From 91c906cfa2782074a4f29a3a562a363266040b16 Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Fri, 6 Nov 2026 06:01:04 +0800 Subject: [PATCH] SpaceWASM support --- .mise/config.coverage.toml | 2 +- .mise/config.mingw.toml | 4 +- .mise/config.msvc.toml | 4 +- .mise/config.rust.toml | 16 + .mise/config.toml | 11 +- .mise/config.windows.toml | 4 +- .mise/mise.lock | 4 +- AGENTS.md | 4 + CHANGELOG.md | 8 + CHECKS.md | 9 +- Cargo.lock | 60 +- Cargo.toml | 16 + config/ast-grep/rules/no-unwrap-or-call.yaml | 25 + config/ast-grep/tests/no-unwrap-or-call.yaml | 21 + config/cargo-features.toml | 1 + config/conftest/policy/jscpd/jscpd.rego | 2 +- config/jscpd-baseline.json | 15 +- libs/core-guest/Cargo.toml | 27 + libs/core-guest/src/abi.rs | 60 ++ libs/core-guest/src/guest.rs | 269 +++++++++ libs/core-guest/src/lib.rs | 39 ++ libs/core-guest/src/wasm.rs | 130 +++++ libs/core-guest/tests/guest.rs | 253 +++++++++ libs/math1/Cargo.toml | 23 + libs/math1/src/lib.rs | 142 +++++ libs/math1/tests/math1.rs | 57 ++ services/firmware-modules/src/lib.rs | 10 + services/firmware-modules/tests/guests.rs | 10 + services/ws-modules/core-math1/.gitignore | 2 + services/ws-modules/core-math1/Cargo.toml | 27 + services/ws-modules/core-math1/src/lib.rs | 84 +++ services/ws-modules/math1-sender/Cargo.toml | 1 + services/ws-modules/math1-sender/src/lib.rs | 6 +- services/ws-modules/math1/Cargo.toml | 2 +- services/ws-modules/math1/src/lib.rs | 85 +-- .../ws-modules/wasi-math1-sender/Cargo.toml | 1 + .../ws-modules/wasi-math1-sender/src/lib.rs | 9 +- services/ws-modules/wasi-math1/Cargo.toml | 2 +- services/ws-modules/wasi-math1/src/lib.rs | 78 +-- services/ws-wasi-runner/Cargo.toml | 18 +- services/ws-wasi-runner/README.md | 36 +- services/ws-wasi-runner/src/convert.rs | 17 +- .../src/core_module/allocator.rs | 88 +++ .../ws-wasi-runner/src/core_module/error.rs | 85 +++ .../ws-wasi-runner/src/core_module/host.rs | 41 ++ .../ws-wasi-runner/src/core_module/imports.rs | 353 ++++++++++++ .../ws-wasi-runner/src/core_module/mod.rs | 20 + .../ws-wasi-runner/src/core_module/run.rs | 146 +++++ .../ws-wasi-runner/src/core_module/stream.rs | 51 ++ services/ws-wasi-runner/src/error.rs | 19 + .../ws-wasi-runner/src/host/core_module.rs | 96 ++++ services/ws-wasi-runner/src/host/mod.rs | 4 + services/ws-wasi-runner/src/host/run.rs | 13 + .../ws-wasi-runner/src/host/wasi_keyvalue.rs | 86 +-- services/ws-wasi-runner/src/host/ws.rs | 78 ++- services/ws-wasi-runner/src/lib.rs | 9 +- services/ws-wasi-runner/tests/core_module.rs | 526 ++++++++++++++++++ services/ws-wasi-runner/tests/modules.rs | 20 +- .../cli/src/module_package_json/generate.rs | 26 +- utilities/cli/tests/module_package_json.rs | 20 + verification/local/input/core-math1.yaml | 17 + .../local/output/core-math1/.gitignore | 7 + .../local/output/core-math1/Dockerfile | 29 + .../output/core-math1/Dockerfile.dockerignore | 87 +++ .../local/output/core-math1/README.md | 183 ++++++ .../local/output/core-math1/compose.yaml | 114 ++++ .../local/output/core-math1/dekit.yaml | 51 ++ verification/local/output/core-math1/k3s.yaml | 331 +++++++++++ .../core-math1/mise.firmware-pico-plus2.toml | 37 ++ .../local/output/core-math1/mise.toml | 60 ++ .../output/core-math1/process-compose.yaml | 53 ++ .../local/output/core-math1/secrets.env | 5 + 72 files changed, 3972 insertions(+), 277 deletions(-) create mode 100644 config/ast-grep/rules/no-unwrap-or-call.yaml create mode 100644 config/ast-grep/tests/no-unwrap-or-call.yaml create mode 100644 libs/core-guest/Cargo.toml create mode 100644 libs/core-guest/src/abi.rs create mode 100644 libs/core-guest/src/guest.rs create mode 100644 libs/core-guest/src/lib.rs create mode 100644 libs/core-guest/src/wasm.rs create mode 100644 libs/core-guest/tests/guest.rs create mode 100644 libs/math1/Cargo.toml create mode 100644 libs/math1/src/lib.rs create mode 100644 libs/math1/tests/math1.rs create mode 100644 services/ws-modules/core-math1/.gitignore create mode 100644 services/ws-modules/core-math1/Cargo.toml create mode 100644 services/ws-modules/core-math1/src/lib.rs create mode 100644 services/ws-wasi-runner/src/core_module/allocator.rs create mode 100644 services/ws-wasi-runner/src/core_module/error.rs create mode 100644 services/ws-wasi-runner/src/core_module/host.rs create mode 100644 services/ws-wasi-runner/src/core_module/imports.rs create mode 100644 services/ws-wasi-runner/src/core_module/mod.rs create mode 100644 services/ws-wasi-runner/src/core_module/run.rs create mode 100644 services/ws-wasi-runner/src/core_module/stream.rs create mode 100644 services/ws-wasi-runner/src/host/core_module.rs create mode 100644 services/ws-wasi-runner/tests/core_module.rs create mode 100644 verification/local/input/core-math1.yaml create mode 100644 verification/local/output/core-math1/.gitignore create mode 100644 verification/local/output/core-math1/Dockerfile create mode 100644 verification/local/output/core-math1/Dockerfile.dockerignore create mode 100644 verification/local/output/core-math1/README.md create mode 100644 verification/local/output/core-math1/compose.yaml create mode 100644 verification/local/output/core-math1/dekit.yaml create mode 100644 verification/local/output/core-math1/k3s.yaml create mode 100644 verification/local/output/core-math1/mise.firmware-pico-plus2.toml create mode 100644 verification/local/output/core-math1/mise.toml create mode 100644 verification/local/output/core-math1/process-compose.yaml create mode 100644 verification/local/output/core-math1/secrets.env diff --git a/.mise/config.coverage.toml b/.mise/config.coverage.toml index 428293d7..e710b39e 100644 --- a/.mise/config.coverage.toml +++ b/.mise/config.coverage.toml @@ -43,7 +43,7 @@ version = "0.9.1" # only be asserted where that path runs. A crate belongs to exactly one list: naming it in both would let a # gap in one pipeline be answered by the other. native_cov_libs = """ -{{ "actix-web-config edge-toolkit et-otlp path sanitise test-helpers " ~ +{{ "actix-web-config core-guest edge-toolkit et-otlp math1 path sanitise test-helpers " ~ "test-nats test-otlp test-recovery ws-runner-common" -}} """ wasm_cov_libs = "wasi-guest web" diff --git a/.mise/config.mingw.toml b/.mise/config.mingw.toml index 596d9673..851db8c8 100644 --- a/.mise/config.mingw.toml +++ b/.mise/config.mingw.toml @@ -34,13 +34,13 @@ version = "16.1.0posix-14.0.0-msvcrt-r3" [[tools.rust]] components = "clippy,rust-analyzer" profile = "minimal" -targets = "wasm32-unknown-unknown,wasm32-wasip2,x86_64-pc-windows-gnu" +targets = "wasm32-unknown-unknown,wasm32-wasip2,wasm32v1-none,x86_64-pc-windows-gnu" version = "stable-x86_64-pc-windows-gnullvm" [[tools.rust]] components = "rust-src,rustfmt" profile = "minimal" -targets = "wasm32-unknown-unknown,wasm32-wasip2,x86_64-pc-windows-gnu" +targets = "wasm32-unknown-unknown,wasm32-wasip2,wasm32v1-none,x86_64-pc-windows-gnu" version = "{{ vars.rust_nightly }}-x86_64-pc-windows-gnullvm" [vars] diff --git a/.mise/config.msvc.toml b/.mise/config.msvc.toml index 9b0f91c6..dc4d719d 100644 --- a/.mise/config.msvc.toml +++ b/.mise/config.msvc.toml @@ -19,13 +19,13 @@ [[tools.rust]] components = "clippy,rust-analyzer" profile = "minimal" -targets = "wasm32-unknown-unknown,wasm32-wasip2,x86_64-pc-windows-msvc" +targets = "wasm32-unknown-unknown,wasm32-wasip2,wasm32v1-none,x86_64-pc-windows-msvc" version = "stable-x86_64-pc-windows-gnullvm" [[tools.rust]] components = "rust-src,rustfmt" profile = "minimal" -targets = "wasm32-unknown-unknown,wasm32-wasip2,x86_64-pc-windows-msvc" +targets = "wasm32-unknown-unknown,wasm32-wasip2,wasm32v1-none,x86_64-pc-windows-msvc" version = "{{ vars.rust_nightly }}-x86_64-pc-windows-gnullvm" [vars] diff --git a/.mise/config.rust.toml b/.mise/config.rust.toml index 02db1ca6..676a4f44 100644 --- a/.mise/config.rust.toml +++ b/.mise/config.rust.toml @@ -106,7 +106,9 @@ shell = "{{ vars.task_shell }}" 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 +set -- "$@" -p et-core-guest -p et-math1 cargo build --lib --target thumbv8m.main-none-eabihf --no-default-features --features "$features" "$@" """ shell = "{{ vars.task_shell }}" @@ -245,6 +247,20 @@ coreutils cp "target/wasm32-wasip2/release/$wasm" "services/ws-modules/wasi-math """ shell = "{{ vars.task_shell }}" +[tasks.build-ws-core-math1-module] +depends = ["build-et-cli"] +description = "Build the Rust core math1 module as a Wasm 1.0 core module, for SpaceWasm" +# Built for wasm32v1-none, whose prebuilt core and alloc are already the Wasm 1.0 that SpaceWasm runs. +# So the module needs no lowering pass. It is not built for coverage: SpaceWasm hosts no file to dump a profile to. +run = """ +cargo build --release -p et-ws-core-math1 --target wasm32v1-none +coreutils mkdir -p services/ws-modules/core-math1/pkg +wasm=et_ws_core_math1.wasm +coreutils cp "target/wasm32v1-none/release/$wasm" "services/ws-modules/core-math1/pkg/$wasm" +{{ vars.et_cli }} module-package-json --module-dir services/ws-modules/core-math1 +""" +shell = "{{ vars.task_shell }}" + [tasks.build-ws-wasi-math1-sender-module] depends = ["build-et-cli"] description = "Build the Rust WASI math1-sender module as a WASI Preview 2 component" diff --git a/.mise/config.toml b/.mise/config.toml index 403e814a..5d29db05 100644 --- a/.mise/config.toml +++ b/.mise/config.toml @@ -230,7 +230,7 @@ zizmor = "latest" components = "clippy,rust-analyzer" os = ["linux", "macos"] profile = "minimal" -targets = "riscv32imac-unknown-none-elf,thumbv8m.main-none-eabihf,wasm32-unknown-unknown,wasm32-wasip2" +targets = "riscv32imac-unknown-none-elf,thumbv8m.main-none-eabihf,wasm32-unknown-unknown,wasm32-wasip2,wasm32v1-none" # Use `stable`, not `latest`, so every lane resolves through the same rustup channel. # mise's `latest` picks the max of rust-lang/rust's GitHub Releases, which are published by separate # automation that lags the `stable` rustup channel the Windows [[tools.rust]] entries resolve through. That @@ -244,7 +244,7 @@ version = "stable" components = "rust-src,rustfmt,llvm-tools" os = ["linux", "macos"] profile = "minimal" -targets = "wasm32-unknown-unknown,wasm32-wasip2" +targets = "wasm32-unknown-unknown,wasm32-wasip2,wasm32v1-none" version = "{{ vars.rust_nightly }}" # The macos/x64 half of vector (see the aqua:vectordotdev/vector note above). @@ -1263,12 +1263,19 @@ meta="$(cargo metadata --format-version 1 --no-deps)" pick='.packages[] | select(any(.dependencies[].name; . as $dep | any($names[]; . == $dep))) | "-p", .name' browser="$(printf '%s' "$meta" | jaq -r --argjson names '["wasm-bindgen"]' "$pick")" wasi="$(printf '%s' "$meta" | jaq -r --argjson names '["wit-bindgen", "et-wasi-guest"]' "$pick")" +# The Wasm 1.0 guests, picked by the target their docs build for, as depending on et-core-guest picks the runner too. +# shellcheck disable=SC2016 +core_pick='.packages[] | select(.metadata.docs.rs."default-target" == "wasm32v1-none") | "-p", .name' +core="$(printf '%s' "$meta" | jaq -r "$core_pick")" rc=0 # Both package lists are newline-separated `-p` / `` words, and rely on word-splitting. # shellcheck disable=SC2086 cargo clippy --keep-going --all-targets --target wasm32-unknown-unknown $browser || rc=1 # shellcheck disable=SC2086 cargo clippy --keep-going --all-targets --target wasm32-wasip2 $wasi || rc=1 +# Only the libraries: a test needs std, which wasm32v1-none does not have. +# shellcheck disable=SC2086 +cargo clippy --keep-going --lib --target wasm32v1-none $core || rc=1 exit "$rc" """ shell = "{{ vars.task_shell }}" diff --git a/.mise/config.windows.toml b/.mise/config.windows.toml index 0ad5b202..210fce9b 100644 --- a/.mise/config.windows.toml +++ b/.mise/config.windows.toml @@ -106,13 +106,13 @@ url = "https://github.com/edge-toolkit/core/releases/download/gnupg-w32-v1/2.5.2 [[tools.rust]] components = "clippy,rust-analyzer" profile = "minimal" -targets = "riscv32imac-unknown-none-elf,thumbv8m.main-none-eabihf,wasm32-unknown-unknown,wasm32-wasip2" +targets = "riscv32imac-unknown-none-elf,thumbv8m.main-none-eabihf,wasm32-unknown-unknown,wasm32-wasip2,wasm32v1-none" version = "stable-{{ vars.win_rust_host }}" [[tools.rust]] components = "rust-src,rustfmt" profile = "minimal" -targets = "wasm32-unknown-unknown,wasm32-wasip2" +targets = "wasm32-unknown-unknown,wasm32-wasip2,wasm32v1-none" version = "{{ vars.rust_nightly }}-{{ vars.win_rust_host }}" [vars] diff --git a/.mise/mise.lock b/.mise/mise.lock index 04f5d6f6..f8755994 100644 --- a/.mise/mise.lock +++ b/.mise/mise.lock @@ -2056,7 +2056,7 @@ specifiers = ["nightly-2026-08-05"] [tools.rust.options] components = "llvm-tools,rust-src,rustfmt" profile = "minimal" -targets = "wasm32-unknown-unknown,wasm32-wasip2" +targets = "wasm32-unknown-unknown,wasm32-wasip2,wasm32v1-none" [[tools.rust]] version = "stable" @@ -2066,7 +2066,7 @@ specifiers = ["stable"] [tools.rust.options] components = "clippy,rust-analyzer" profile = "minimal" -targets = "riscv32imac-unknown-none-elf,thumbv8m.main-none-eabihf,wasm32-unknown-unknown,wasm32-wasip2" +targets = "riscv32imac-unknown-none-elf,thumbv8m.main-none-eabihf,wasm32-unknown-unknown,wasm32-wasip2,wasm32v1-none" [[tools.taplo]] version = "0.10.0" diff --git a/AGENTS.md b/AGENTS.md index 7bec8481..8df2ea31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -561,6 +561,10 @@ Languages: from `pkg/` as a static asset; the guest fetches it via the `storage` host import because componentize-py 0.23 doesn't bundle non-Python data files), then runs inference through ONNX Runtime via `wasi:nn/graph.load` + `inference.compute` and verifies the predicted class. +- **Rust -> Wasm 1.0 core module (`wasm32v1-none`)**: core-math1 -- runs in `et-ws-wasi-runner` on NASA's SpaceWasm + interpreter rather than wasmtime, which the runner picks from the module's header. It imports the `et` functions + `et-core-guest` defines in place of a WIT world, and is built by `cargo build --target wasm32v1-none`, whose + prebuilt `core` and `alloc` are already Wasm 1.0, then `et-cli module-package-json`. ### Libraries (`libs/`) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ed93394..2604d339 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ Changelog 1.1.0. ### Added +- `et-ws-wasi-runner` runs Wasm 1.0 core modules beside WASI components, on NASA's SpaceWasm interpreter, with its + new `core-modules` feature, on by default. It picks the engine from the fetched module's header, so a scenario's + `runner: wasi` covers both. A core module reaches the hub through the `et` imports, which the new `et-core-guest` + 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`. +- `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: every operation, message and shared schema, with the side of the hub that sends each message. They are rendered from `rest.yaml` and `ws.yaml` and regenerated with them. diff --git a/CHECKS.md b/CHECKS.md index 2686e727..81267203 100644 --- a/CHECKS.md +++ b/CHECKS.md @@ -1,11 +1,11 @@ # Checks -- **Generated:** 2026-11-05 +- **Generated:** 2026-11-06 - **Checks:** 83 - **Tools:** 48 - **Shell-script checks:** 10 - **Checks with a repair pass:** 22 -- **Rules:** 368 custom local rules, or non-default strict linter settings +- **Rules:** 369 custom local rules, or non-default strict linter settings - **Meta rules:** 20 of those rules are rules about rules Every check is listed across every `MISE_ENV`, filed under the tool it runs. Each entry names the env whose config @@ -268,6 +268,11 @@ Rules, from `config/ast-grep/rules`: - `no-uds-listener` -- HTTP servers here listen on TCP only. A Unix-domain-socket listener gives requests no peer address, and the handlers take the client address from the socket peer rather than from client-supplied `Forwarded` / `X-Forwarded-For` headers -- so `ws_handler` refuses every request arriving on one. Bind a TCP address instead. +- `no-unwrap-or-call` -- `.unwrap_or(...)` evaluates its argument even when the value it stands in for is there, so a + call written there runs every time. Reach the fallback another way: `let Some(value) = option else { ... }` or a + `match`, or `.unwrap_or_else(...)` when the fallback is real work -- clippy's `unnecessary_lazy_evaluations` objects + to a closure around a plain constructor. This mirrors `DeepSource RS-W1031`. clippy's `or_fun_call` lets an enum or + tuple-struct constructor such as `Ok(None)` through as cheap, so a constructor here gets past clippy alone. - `org-identity-lives-in-et-org` -- This constant spells out the organisation name or the crate prefix. Both belong to `et-org`, which is the one place either literal is written: take the value from `et_org::ORG`, `et_org::CRATE_PREFIX`, `et_org::NPM_SCOPE`, `et_org::REPOSITORY_URL` or `et_org::IMAGE_REGISTRY`, or build a longer string from them, rather diff --git a/Cargo.lock b/Cargo.lock index 622de6e8..83c837ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4435,6 +4435,17 @@ dependencies = [ "libloading 0.8.9", ] +[[package]] +name = "dlmalloc" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad5208a115eaba24916f7456929832e310a81518c641f93fee4f89aa93aa3675" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "dlopen2" version = "0.6.1" @@ -5753,6 +5764,16 @@ dependencies = [ "toml", ] +[[package]] +name = "et-core-guest" +version = "0.1.0" +dependencies = [ + "dlmalloc", + "edge-toolkit", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "et-firmware-modules" version = "0.1.0" @@ -5884,6 +5905,16 @@ dependencies = [ "tungstenite", ] +[[package]] +name = "et-math1" +version = "0.1.0" +dependencies = [ + "et-ws-test-server", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "et-modules-service" version = "0.3.0" @@ -6170,6 +6201,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "et-ws-core-math1" +version = "0.1.0" +dependencies = [ + "edge-toolkit", + "et-core-guest", + "et-math1", + "serde_json", +] + [[package]] name = "et-ws-data1" version = "0.2.0" @@ -6287,11 +6328,11 @@ dependencies = [ name = "et-ws-math1" version = "0.2.0" dependencies = [ + "et-math1", "et-rest-client", "et-web", "et-ws-wasm-agent", "futures-util", - "serde", "serde_json", "tracing", "wasm-bindgen", @@ -6304,6 +6345,7 @@ dependencies = [ name = "et-ws-math1-sender" version = "0.2.0" dependencies = [ + "et-math1", "et-path", "et-rest-client", "et-web", @@ -6562,8 +6604,8 @@ name = "et-ws-wasi-math1" version = "0.2.0" dependencies = [ "document-features", + "et-math1", "et-wasi-guest", - "serde", "serde_json", ] @@ -6572,6 +6614,7 @@ name = "et-ws-wasi-math1-sender" version = "0.2.0" dependencies = [ "document-features", + "et-math1", "et-path", "et-wasi-guest", ] @@ -6588,6 +6631,7 @@ dependencies = [ "edge-ws", "embassy-sync 0.8.0", "embedded-io-async 0.7.0", + "et-core-guest", "et-org", "et-otlp", "et-rest-client", @@ -6606,6 +6650,7 @@ dependencies = [ "secrecy", "serde_json", "serde_path_to_error", + "spacewasm", "spin 0.12.3", "tempfile", "thiserror 2.0.21", @@ -6613,9 +6658,11 @@ dependencies = [ "tokio-tungstenite", "tracing", "wasi-webgpu-wasmtime", + "wasmparser 0.258.3", "wasmtime", "wasmtime-wasi", "wasmtime-wasi-nn", + "wat", "wgpu-core", "wgpu-types", ] @@ -12708,6 +12755,15 @@ dependencies = [ "url", ] +[[package]] +name = "spacewasm" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53f8bfaf73590037468fc77f1a5d4baa3389e3aa73f72f18cc0f463c42c74d2e" +dependencies = [ + "libm", +] + [[package]] name = "spin" version = "0.9.9" diff --git a/Cargo.toml b/Cargo.toml index 8c1c6103..80dca61c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,8 +7,10 @@ rust-version = "1.99.0" [workspace] members = [ "libs/actix-web-config", + "libs/core-guest", "libs/edge-toolkit", "libs/et-otlp", + "libs/math1", "libs/org", "libs/path", "libs/sanitise", @@ -22,6 +24,7 @@ members = [ "services/ws-modules/audio1", "services/ws-modules/bluetooth", "services/ws-modules/comm1", + "services/ws-modules/core-math1", "services/ws-modules/data1", "services/ws-modules/except1", "services/ws-modules/face-detection", @@ -104,6 +107,7 @@ deno_resolver = "0.91" # instead of panicking when no SnapshotOptions struct is in OpState -- without # baking our own startup snapshot we'd otherwise hit the panic on first run. deno_runtime = { version = "0.268", features = ["transpile", "hmr"] } +dlmalloc = { version = "0.2", features = ["global"] } document-features = "0.2" edge-toolkit = { path = "libs/edge-toolkit", version = "0.4.0", default-features = false } edge-ws = { version = "0.8", default-features = false, features = ["io"] } @@ -124,8 +128,10 @@ esp-radio = "1.0.0-beta.1" esp-rtos = "0.4" et-actix-web-config = { path = "libs/actix-web-config", version = "0.1.0" } et-cli = { path = "utilities/cli", version = "0.2.0" } +et-core-guest = { path = "libs/core-guest", version = "0.1.0" } et-firmware-modules = { path = "services/firmware-modules", version = "0.1.0" } et-hub-backplane = { path = "services/hub-backplane", version = "0.2.0" } +et-math1 = { path = "libs/math1", version = "0.1.0" } et-modules-service = { path = "services/modules", version = "0.3.0", default-features = false } et-onnx = { path = "utilities/onnx", version = "0.2.0" } et-org = { path = "libs/org", version = "0.2.0" } @@ -270,6 +276,8 @@ serde_with = { version = "3.24", default-features = false, features = ["macros"] serde_yaml = "0.9" sha2 = { version = "0.11", default-features = false } shlex = "2" +# NASA JPL's Wasm 1.0 interpreter, which et-ws-wasi-runner runs core modules on. +spacewasm = { version = "0.7", default-features = false } spin = { version = "0.12", default-features = false, features = ["spin_mutex"] } static_cell = "2" strum = { version = "0.28", features = ["derive"] } @@ -548,12 +556,20 @@ ignore = [ # jni-sys requirement ^0.3.0 (used 0.3.1, latest 0.4.1); pulled by ndk-sys, an Android-only dep. # Reached via wgpu-hal (deno_webgpu -> wgpu); never compiled on our target platforms. "jni-sys", + # Upstream timothee-haudebourg/khronos-egl has been quiet three years; 6.0.0 still requires libloading ^0.8. + # Pulled by wgpu-hal's GLES backend, Linux and Android only, through wgpu-core for deno_webgpu and the wasi runner's + # wasi:webgpu host. wgpu-hal 30 still requires khronos-egl ^6, so no wgpu upgrade drops it either. + "khronos-egl", # Unmaintained upstream at rust-lang-nursery/lazy-static.rs. # Pulled by actix-web-thiserror-derive (build proc-macro). "lazy_static", # ndk-sys unmaintained upstream; Android-only, reached via wgpu-hal (deno_webgpu -> wgpu). # Never compiled on our target platforms. "ndk-sys", + # Upstream dignifiedquire/num-bigint reached a year without a commit on 2026-11-06; 0.8.6 still requires rand ^0.8. + # Pulled by rsa 0.9 (deno_crypto, deno_node_crypto), dsa and deno_node_crypto, all under et-ws-web-runner's Deno. + # rsa 0.10, still a release candidate, drops it for crypto-bigint; this goes once deno_crypto moves to rsa 0.10. + "num-bigint-dig", # Unmaintained upstream at rust-num/num-complex. # Pulled by nalgebra (via wgpu / naga). Stable foundational math crate. "num-complex", diff --git a/config/ast-grep/rules/no-unwrap-or-call.yaml b/config/ast-grep/rules/no-unwrap-or-call.yaml new file mode 100644 index 00000000..0c5af568 --- /dev/null +++ b/config/ast-grep/rules/no-unwrap-or-call.yaml @@ -0,0 +1,25 @@ +id: no-unwrap-or-call +language: Rust +severity: error +message: | + `.unwrap_or(...)` evaluates its argument even when the value it stands in for is there, so a call written there runs + every time. Reach the fallback another way: `let Some(value) = option else { ... }` or a `match`, or + `.unwrap_or_else(...)` when the fallback is real work -- clippy's `unnecessary_lazy_evaluations` objects to a closure + around a plain constructor. This mirrors `DeepSource RS-W1031`. clippy's `or_fun_call` lets an enum or tuple-struct + constructor such as `Ok(None)` through as cheap, so a constructor here gets past clippy alone. +# Check-only, because the rewrite depends on what the call is. +# A constructor wants a `let`-`else` or a `match`, and real work a closure, which a syntax tree cannot tell apart. +# Matched by node kind: a method call named `unwrap_or` whose argument is itself a call. +rule: + kind: call_expression + all: + - has: + field: function + kind: field_expression + has: + field: field + regex: "^unwrap_or$" + - has: + field: arguments + has: + kind: call_expression diff --git a/config/ast-grep/tests/no-unwrap-or-call.yaml b/config/ast-grep/tests/no-unwrap-or-call.yaml new file mode 100644 index 00000000..7a8ea063 --- /dev/null +++ b/config/ast-grep/tests/no-unwrap-or-call.yaml @@ -0,0 +1,21 @@ +id: no-unwrap-or-call +valid: + - | + let value = option.unwrap_or(0); + - | + let value = option.unwrap_or(usize::MAX); + - | + let value = option.unwrap_or(fallback); + - | + let value = option.unwrap_or_else(|| compute()); + - | + let value = option.unwrap_or_default(); + - | + let value = option.map_or(compute(), |found| found); +invalid: + - | + let value = inbox.pop_front().unwrap_or(Ok(None)); + - | + let value = option.unwrap_or(String::new()); + - | + let value = option.unwrap_or(compute()); diff --git a/config/cargo-features.toml b/config/cargo-features.toml index db5a718e..4d637acb 100644 --- a/config/cargo-features.toml +++ b/config/cargo-features.toml @@ -7,6 +7,7 @@ [features] board = "Runs on a microcontroller board: `no_std` + `alloc`, served or driven over the board's own sockets." +core-modules = "Runs Wasm 1.0 core modules beside components, on NASA's SpaceWasm interpreter." coverage = "Internal: builds in what the repo's coverage workflow measures with, such as minicov or the `/cov` preopen." cuda = "Runs `wasi:nn` inference on an NVIDIA GPU, through ONNX Runtime's CUDA provider." default = "Cargo's own: the features a dependent gets unless it sets `default-features = false`." diff --git a/config/conftest/policy/jscpd/jscpd.rego b/config/conftest/policy/jscpd/jscpd.rego index 3ae386e8..02540aa3 100644 --- a/config/conftest/policy/jscpd/jscpd.rego +++ b/config/conftest/policy/jscpd/jscpd.rego @@ -28,7 +28,7 @@ package jscpd # Ceiling on the clone fingerprints recorded in config/jscpd-baseline.json. # May only ever be lowered. See the header for what raising it implies. -declared_clones := 360 +declared_clones := 355 # How far under the ceiling the baseline may sit before the ceiling has to be banked downward. max_slack := 20 diff --git a/config/jscpd-baseline.json b/config/jscpd-baseline.json index c3d1425b..4b9ed8de 100644 --- a/config/jscpd-baseline.json +++ b/config/jscpd-baseline.json @@ -10,27 +10,25 @@ "03e4e07acff327e2": 1, "04199950b57990a0": 1, "04daef50421ed235": 1, - "0690b4ae1dc710a4": 1, "07378241fe562817": 1, "075c84a635548077": 1, "077a1f0f3040c9ff": 1, + "07980264def2218a": 1, "084d33784cf81a99": 1, "0875c25e5ebb12cc": 1, "0c04b2e97c016262": 1, - "0c2432c487251d55": 1, "0c8f78b8c0d0f6b4": 1, "0ea84f1f5db88fe7": 1, "0f033dd72cb08a76": 1, "0f10e9243723cb05": 3, + "0f217d26c774a6df": 1, "100c4984075d3662": 1, "100c5a39e529a691": 1, "106569d682a6a53f": 1, "109b672c3d6939d9": 1, - "10b898032781bd2b": 1, "1235e9f0b50beb4c": 1, "12bdba43f82ce591": 1, "136c57b4180f7238": 1, - "13d42a6bd52e4560": 1, "151943582f4d3a1c": 1, "15f330210837288e": 1, "161f873e1c1bf887": 1, @@ -42,6 +40,7 @@ "1a1e1611e4db4656": 1, "1a46175d923bf850": 3, "1a97e5c72a30152e": 1, + "1ac2e9fe36333da4": 1, "1c64414236b8476c": 1, "1c8cf13e10b67513": 1, "1ce17b70419170cb": 1, @@ -80,6 +79,7 @@ "3aec07afda461f58": 1, "3afe9805243f94d2": 1, "3b30dfa6b363682d": 2, + "3b747fc4fc906fbf": 1, "3c0efbffa8bb6f9b": 1, "3c49721a9a85b54b": 1, "3c5c4bdf7be7387e": 1, @@ -184,7 +184,6 @@ "8390cc689d64efcb": 1, "84b3a552aa5c21c2": 1, "84e106866c27d5cb": 1, - "869ce6d0bb9475d9": 1, "87009c1cbed26049": 1, "87566acb3f2550bf": 1, "879bfe322b3d676b": 1, @@ -257,7 +256,6 @@ "b07cc7ec51bc1602": 1, "b29077918336343b": 1, "b32ec6344a29f094": 1, - "b3b79293ad6e0885": 1, "b4803673dd3c6d6b": 1, "b4bba4c77bcedb60": 1, "b5cf8f89d849de0b": 1, @@ -284,7 +282,6 @@ "c479ded037f23e52": 1, "c48839dee3d0d4b9": 1, "c4cbfbcff13bc988": 1, - "c52048c7791487fb": 1, "c66014a52ad17acd": 1, "c6ad80475453274a": 1, "c6e1e53b3f01fafb": 1, @@ -318,14 +315,13 @@ "da812f4e54fefbf1": 1, "db4fb9ed218e4d66": 1, "dd162080de996936": 1, - "dd41701c3663b9f3": 1, "ddca47a4caded0fd": 1, "de1a3233f93e1695": 1, "dfab26504ac7abe4": 1, "e152738c60f43b0e": 1, + "e1badf6d4dbe69d3": 1, "e25456ad9cc7f9f9": 1, "e4b9e7313fc22777": 1, - "e5085d0d3af0235b": 1, "e517097fd06d5cb5": 1, "e5481f802d5a9f84": 1, "e579cd730cd60fb8": 1, @@ -336,6 +332,7 @@ "ebb8f005bcebfcee": 1, "ec5c408a8f2c368d": 1, "ec83e5f612762cae": 1, + "ecffd4667336099d": 1, "edfb18c73a139873": 1, "ef69568ef92f1b79": 1, "ef6dd6376769473d": 1, diff --git a/libs/core-guest/Cargo.toml b/libs/core-guest/Cargo.toml new file mode 100644 index 00000000..85d9d2e7 --- /dev/null +++ b/libs/core-guest/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "et-core-guest" +publish = true +description = "The `et` imports through which a Wasm 1.0 core module reaches its hub, and a typed guest API over them" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +# Built for the guests' own target, so the docs show the imports, heap and `export!` a guest links. +[package.metadata.docs.rs] +default-target = "wasm32v1-none" + +[lib] +doctest = false + +[dependencies] +edge-toolkit.workspace = true +serde_json = { workspace = true, features = ["alloc"] } +thiserror.workspace = true + +# What only a guest built for a Wasm 1.0 target links: its heap, which grows the guest's linear memory as it needs. +[target.'cfg(all(target_arch = "wasm32", target_os = "none"))'.dependencies] +dlmalloc.workspace = true + +[lints] +workspace = true diff --git a/libs/core-guest/src/abi.rs b/libs/core-guest/src/abi.rs new file mode 100644 index 00000000..cde44071 --- /dev/null +++ b/libs/core-guest/src/abi.rs @@ -0,0 +1,60 @@ +//! The names and codes of the `et` imports, which the host that provides them and the guest that calls them share. + +/// The import module every `et` function is imported from. +pub const MODULE: &str = "et"; +/// The export the host calls to run the guest. +pub const RUN: &str = "run"; + +/// Open the websocket and register with the hub. +pub const CONNECT: &str = "connect"; +/// Put the agent id the hub assigned in the result slot. +pub const AGENT_ID: &str = "agent_id"; +/// Send one `ClientMessage`. +pub const SEND: &str = "send"; +/// Wait for one `ServerMessage`, putting it in the result slot. +pub const RECV: &str = "recv"; +/// Copy the result slot into the guest's memory. +pub const TAKE: &str = "take"; +/// Close the websocket. +pub const DISCONNECT: &str = "disconnect"; +/// Put the value stored under a key in a bucket in the result slot. +pub const KV_GET: &str = "kv_get"; +/// Store a value under a key in a bucket. +pub const KV_SET: &str = "kv_set"; +/// Record one log entry. +pub const LOG: &str = "log"; + +/// The call succeeded, or `run` did. +pub const SUCCESS: i32 = 0; +/// `run`: the guest failed for a reason of its own, which it logged. +pub const FAILED: i32 = 1; +/// `recv`: no message arrived within the timeout. +pub const NOTHING: i32 = 0; +/// `kv_get`: the bucket holds nothing under the key. +pub const ABSENT: i32 = 0; +/// `kv_get`: the value is in the result slot. +pub const FOUND: i32 = 1; + +/// The websocket failed, or the hub could not be reached. +pub const TRANSPORT: i32 = -1; +/// A message was not the JSON of the message it had to be. +pub const DECODE: i32 = -2; +/// The websocket is not open. +pub const NOT_CONNECTED: i32 = -3; +/// The websocket is already open. +pub const ALREADY_CONNECTED: i32 = -4; +/// The hub's storage refused the request, or failed. +pub const STORE: i32 = -5; + +/// `log`: the `wasi:logging` level trace. +pub const LOG_TRACE: i32 = 0; +/// `log`: the `wasi:logging` level debug. +pub const LOG_DEBUG: i32 = 1; +/// `log`: the `wasi:logging` level info. +pub const LOG_INFO: i32 = 2; +/// `log`: the `wasi:logging` level warn. +pub const LOG_WARN: i32 = 3; +/// `log`: the `wasi:logging` level error. +pub const LOG_ERROR: i32 = 4; +/// `log`: the `wasi:logging` level critical. +pub const LOG_CRITICAL: i32 = 5; diff --git a/libs/core-guest/src/guest.rs b/libs/core-guest/src/guest.rs new file mode 100644 index 00000000..d6b50ba9 --- /dev/null +++ b/libs/core-guest/src/guest.rs @@ -0,0 +1,269 @@ +//! The typed guest API over the `et` imports. + +use alloc::string::{FromUtf8Error, String, ToString as _}; +use alloc::vec; +use alloc::vec::Vec; + +use edge_toolkit::ws::{ClientMessage, ServerMessage}; + +use crate::abi; + +/// The `et` imports as a guest calls them, each returning what its import returns. +/// +/// Built for a Wasm 1.0 target, `wasm::Host` is the imports themselves; anything else stands in for them. +pub trait Imports { + /// `connect`. + fn connect(&mut self) -> i32; + /// `agent_id`. + fn agent_id(&mut self) -> i32; + /// `send` of the `ClientMessage` JSON `message`. + fn send(&mut self, message: &[u8]) -> i32; + /// `recv`, waiting up to `timeout_ms`. + fn recv(&mut self, timeout_ms: u32) -> i32; + /// `take` into `buffer`. + fn take(&mut self, buffer: &mut [u8]) -> i32; + /// `disconnect`. + fn disconnect(&mut self); + /// `kv_get` of `key` in `bucket`. + fn kv_get(&mut self, bucket: &str, key: &str) -> i32; + /// `kv_set` of `value` under `key` in `bucket`. + fn kv_set(&mut self, bucket: &str, key: &str, value: &[u8]) -> i32; + /// `log` of `message` at the level `level`, under `context`. + fn log(&mut self, level: i32, context: &str, message: &str); +} + +/// Why a guest's call failed. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum GuestError { + /// The websocket failed, or the hub could not be reached. + #[error("the websocket failed, or the hub could not be reached")] + Transport, + /// A message was not the JSON of the message it had to be. + #[error("the host could not decode the message")] + Decode, + /// The websocket is not open. + #[error("the websocket is not open")] + NotConnected, + /// The websocket is already open. + #[error("the websocket is already open")] + AlreadyConnected, + /// The hub's storage refused the request, or failed. + #[error("the hub's storage refused the request, or failed")] + Store, + /// The host answered with a code no `et` import returns. + #[error("the host answered {0}, which no `et` import returns")] + Unknown(i32), + /// What the host handed back was not the JSON of the message it had to be. + #[error(transparent)] + Json(#[from] serde_json::Error), + /// What the host handed back as text was not UTF-8. + #[error(transparent)] + Utf8(#[from] FromUtf8Error), + /// The guest's own work failed, for the reason given. + #[error("{0}")] + Runtime(String), +} + +impl GuestError { + /// The code a guest's `run` returns when it fails with this error. + #[must_use] + pub const fn code(&self) -> i32 { + match self { + Self::Transport => abi::TRANSPORT, + Self::Decode => abi::DECODE, + Self::NotConnected => abi::NOT_CONNECTED, + Self::AlreadyConnected => abi::ALREADY_CONNECTED, + Self::Store => abi::STORE, + Self::Unknown(code) => *code, + Self::Json(_) | Self::Utf8(_) | Self::Runtime(_) => abi::FAILED, + } + } +} + +/// A `wasi:logging` level, as `log` takes it. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Level { + /// Trace. + Trace, + /// Debug. + Debug, + /// Info. + Info, + /// Warn. + Warn, + /// Error. + Error, + /// Critical. + Critical, +} + +impl Level { + /// The level's code, as `log` takes it. + #[must_use] + pub const fn code(self) -> i32 { + match self { + Self::Trace => abi::LOG_TRACE, + Self::Debug => abi::LOG_DEBUG, + Self::Info => abi::LOG_INFO, + Self::Warn => abi::LOG_WARN, + Self::Error => abi::LOG_ERROR, + Self::Critical => abi::LOG_CRITICAL, + } + } +} + +/// A guest's typed view of its imports, logging under the context it was made with. +#[derive(Debug)] +pub struct Guest { + /// The imports every call goes through. + imports: I, + /// What every log entry is recorded under: the guest's name. + context: String, +} + +impl Guest { + /// A guest calling `imports`, logging under `context`. + #[must_use] + pub fn new(imports: I, context: &str) -> Self { + Self { + imports, + context: context.to_string(), + } + } + + /// Log that the guest started, connect to the hub, and return the agent id it assigned. + /// + /// # Errors + /// + /// When the hub could not be reached, or the id it assigned could not be read. + pub fn start(&mut self) -> Result { + self.info("entered run()"); + self.connect()?; + self.agent_id() + } + + /// Open the websocket and register with the hub. + /// + /// # Errors + /// + /// When the hub could not be reached, or the websocket is already open. + pub fn connect(&mut self) -> Result<(), GuestError> { + let _code: i32 = status(self.imports.connect())?; + Ok(()) + } + + /// The agent id the hub assigned. + /// + /// # Errors + /// + /// When the websocket is not open, or the id is not UTF-8. + pub fn agent_id(&mut self) -> Result { + let _len: i32 = status(self.imports.agent_id())?; + Ok(String::from_utf8(self.take()?)?) + } + + /// Send `message` to the hub. + /// + /// # Errors + /// + /// When the websocket is not open or failed. + pub fn send(&mut self, message: &ClientMessage) -> Result<(), GuestError> { + let json = serde_json::to_vec(message)?; + let _code: i32 = status(self.imports.send(&json))?; + Ok(()) + } + + /// The next message from the hub, waiting up to `timeout_ms`; `None` when none arrived in that time. + /// + /// # Errors + /// + /// When the websocket is not open or failed, or the message could not be read. + pub fn recv(&mut self, timeout_ms: u32) -> Result, GuestError> { + if status(self.imports.recv(timeout_ms))? == abi::NOTHING { + return Ok(None); + } + Ok(Some(serde_json::from_slice(&self.take()?)?)) + } + + /// Close the websocket. + pub fn disconnect(&mut self) { + self.imports.disconnect(); + } + + /// The value stored under `key` in `bucket`; `None` when the bucket holds nothing under it. + /// + /// # Errors + /// + /// When the hub's storage refused the request, or failed. + pub fn get(&mut self, bucket: &str, key: &str) -> Result>, GuestError> { + if status(self.imports.kv_get(bucket, key))? == abi::ABSENT { + return Ok(None); + } + Ok(Some(self.take()?)) + } + + /// Store `value` under `key` in `bucket`, replacing what was there. + /// + /// # Errors + /// + /// When the hub's storage refused the request, or failed. + pub fn set(&mut self, bucket: &str, key: &str, value: &[u8]) -> Result<(), GuestError> { + let _code: i32 = status(self.imports.kv_set(bucket, key, value))?; + Ok(()) + } + + /// Record `message` at `level`. + pub fn log(&mut self, level: Level, message: &str) { + self.imports.log(level.code(), &self.context, message); + } + + /// Record `message` at [`Level::Info`]. + pub fn info(&mut self, message: &str) { + self.log(Level::Info, message); + } + + /// The code the guest's `run` returns for `result`, logging why when it failed. + pub fn finish(&mut self, result: Result<(), GuestError>) -> i32 { + match result { + Ok(()) => abi::SUCCESS, + Err(error) => { + self.log(Level::Error, &error.to_string()); + error.code() + } + } + } + + /// Copy the host's result slot out: ask its length, then take it into a buffer that size. + fn take(&mut self) -> Result, GuestError> { + let len = length(self.imports.take(&mut []))?; + let mut buffer = vec![0; len]; + let copied = self.imports.take(&mut buffer); + if length(copied)? == len { + Ok(buffer) + } else { + Err(GuestError::Unknown(copied)) + } + } +} + +/// What an import's return `code` says: the non-negative result, or the failure a negative code names. +const fn status(code: i32) -> Result { + match code { + abi::TRANSPORT => Err(GuestError::Transport), + abi::DECODE => Err(GuestError::Decode), + abi::NOT_CONNECTED => Err(GuestError::NotConnected), + abi::ALREADY_CONNECTED => Err(GuestError::AlreadyConnected), + abi::STORE => Err(GuestError::Store), + ..0 => Err(GuestError::Unknown(code)), + _ => Ok(code), + } +} + +/// The length an import's return `code` gives, or the failure a negative code names. +fn length(code: i32) -> Result { + // A non-negative `i32` fits a `usize` on every target a guest or its tests build for, so the fallback is never + // taken; it stands in for a conversion the standard library offers only as fallible. + Ok(usize::try_from(status(code)?).unwrap_or(usize::MAX)) +} diff --git a/libs/core-guest/src/lib.rs b/libs/core-guest/src/lib.rs new file mode 100644 index 00000000..f27e29d9 --- /dev/null +++ b/libs/core-guest/src/lib.rs @@ -0,0 +1,39 @@ +//! The `et` imports through which a Wasm 1.0 core module reaches its hub, and a typed guest API over them. +//! +//! A core module cannot import a WIT world, so it reaches the hub through the operations of `et:ws-wasi` flattened +//! into the import module `et`, every parameter and result an `i32`. A pointer and a length name bytes in the guest's +//! linear memory, and every message crosses as the JSON of an `edge_toolkit::ws` message, the hub's own wire format. +//! A call that produces bytes leaves them in the host's result slot, and `take` copies them out. `et-ws-wasi-runner` +//! hosts these imports for a core module, which it runs on NASA's `SpaceWasm` interpreter rather than wasmtime. +//! +//! | Import | Parameters | Result | +//! | ------------ | ------------------------------------------------- | ------------------------------------------- | +//! | `connect` | none | [`abi::SUCCESS`] once registered | +//! | `agent_id` | none | the id's length, the id in the slot | +//! | `send` | `ClientMessage` JSON: pointer, length | [`abi::SUCCESS`] | +//! | `recv` | timeout in milliseconds | a `ServerMessage`'s length, in the slot | +//! | `take` | buffer: pointer, capacity | the slot's length, copied out if it fits | +//! | `disconnect` | none | none | +//! | `kv_get` | bucket: pointer, length; key: the same | [`abi::FOUND`], the value in the slot | +//! | `kv_set` | bucket, key, value: each pointer, length | [`abi::SUCCESS`] | +//! | `log` | level 0-5; context, message: each pointer, length | none | +//! +//! `recv` returns [`abi::NOTHING`] when no message arrived in time, `kv_get` returns [`abi::ABSENT`] for a key the +//! bucket holds nothing under, and `take` empties the slot once it has copied it out. A failed call returns one of the +//! negative codes in [`abi`], and the host logs why. A bad pointer, text that is not UTF-8 or an unknown level traps +//! the guest. The guest exports [`abi::RUN`], taking nothing and returning an `i32`, which is [`abi::SUCCESS`] when it +//! succeeded. +//! +//! [`Guest`] is the typed API, over any [`Imports`]. Built for a Wasm 1.0 target, `wasm32v1-none`, the crate also +//! provides the guest's imports, its heap and its panic handler, and its `export!` macro defines the guest's `run`. + +#![no_std] + +extern crate alloc; + +pub mod abi; +mod guest; +#[cfg(all(target_arch = "wasm32", target_os = "none"))] +pub mod wasm; + +pub use self::guest::{Guest, GuestError, Imports, Level}; diff --git a/libs/core-guest/src/wasm.rs b/libs/core-guest/src/wasm.rs new file mode 100644 index 00000000..778c1c36 --- /dev/null +++ b/libs/core-guest/src/wasm.rs @@ -0,0 +1,130 @@ +//! What a guest built for a Wasm 1.0 target links: the `et` imports themselves, its heap, and its panic handler. +//! +//! None of it builds for a host, where nothing provides the imports. Each import is one call forwarded, and everything +//! that decides anything is in [`crate::Guest`], which takes any [`Imports`]. + +#![expect(unsafe_code, reason = "the `et` imports are declared in an `unsafe extern` block")] + +use core::panic::PanicInfo; + +use dlmalloc::GlobalDlmalloc; + +use crate::{Guest, GuestError, Imports}; + +/// The heap every allocation the guest makes comes from, growing the guest's linear memory as it runs out. +/// +/// No fixed size, so a guest takes what its input needs, as one built for WASI does; the host bounds the memory. +#[global_allocator] +static HEAP: GlobalDlmalloc = GlobalDlmalloc; + +// The `et` imports, as the host provides them, each named here for the import it is. +// +// Each is declared `safe`: it reads or writes only the bytes its pointer and length name, and the host traps the guest +// on a pointer that is out of bounds rather than touching anything else. +#[link(wasm_import_module = "et")] +unsafe extern "C" { + #[link_name = "connect"] + safe fn et_connect() -> i32; + #[link_name = "agent_id"] + safe fn et_agent_id() -> i32; + #[link_name = "send"] + safe fn et_send(message: *const u8, message_len: usize) -> i32; + #[link_name = "recv"] + safe fn et_recv(timeout_ms: u32) -> i32; + #[link_name = "take"] + safe fn et_take(buffer: *mut u8, capacity: usize) -> i32; + #[link_name = "disconnect"] + safe fn et_disconnect(); + #[link_name = "kv_get"] + safe fn et_kv_get(bucket: *const u8, bucket_len: usize, key: *const u8, key_len: usize) -> i32; + #[link_name = "kv_set"] + safe fn et_kv_set( + bucket: *const u8, + bucket_len: usize, + key: *const u8, + key_len: usize, + value: *const u8, + value_len: usize, + ) -> i32; + #[link_name = "log"] + safe fn et_log(level: i32, context: *const u8, context_len: usize, message: *const u8, message_len: usize); +} + +/// The `et` imports the host provides. +#[derive(Debug)] +#[non_exhaustive] +pub struct Host; + +impl Imports for Host { + fn connect(&mut self) -> i32 { + et_connect() + } + + fn agent_id(&mut self) -> i32 { + et_agent_id() + } + + fn send(&mut self, message: &[u8]) -> i32 { + et_send(message.as_ptr(), message.len()) + } + + fn recv(&mut self, timeout_ms: u32) -> i32 { + et_recv(timeout_ms) + } + + fn take(&mut self, buffer: &mut [u8]) -> i32 { + et_take(buffer.as_mut_ptr(), buffer.len()) + } + + fn disconnect(&mut self) { + et_disconnect(); + } + + fn kv_get(&mut self, bucket: &str, key: &str) -> i32 { + et_kv_get(bucket.as_ptr(), bucket.len(), key.as_ptr(), key.len()) + } + + fn kv_set(&mut self, bucket: &str, key: &str, value: &[u8]) -> i32 { + et_kv_set( + bucket.as_ptr(), + bucket.len(), + key.as_ptr(), + key.len(), + value.as_ptr(), + value.len(), + ) + } + + fn log(&mut self, level: i32, context: &str, message: &str) { + et_log(level, context.as_ptr(), context.len(), message.as_ptr(), message.len()); + } +} + +/// A panic traps the guest, which the host reports as the guest's failure. +#[panic_handler] +fn panic(_info: &PanicInfo<'_>) -> ! { + core::arch::wasm32::unreachable() +} + +/// What the guest's `run` returns: `body` run on a [`Guest`] logging under `context`. +pub fn run(context: &str, body: F) -> i32 +where + F: FnOnce(&mut Guest) -> Result<(), GuestError>, +{ + let mut guest = Guest::new(Host, context); + let result = body(&mut guest); + guest.finish(result) +} + +/// Define the guest's `run` export as `body`, a function taking the guest's [`Guest`] and returning its result. +#[macro_export] +macro_rules! export { + ($body:path) => { + /// The guest's `run`, which the host calls once: [`et_core_guest::wasm::run`] of the guest's work. + #[expect(unsafe_code, reason = "the host finds `run` by its unmangled export name")] + #[unsafe(no_mangle)] + pub extern "C" fn run() -> i32 { + $crate::wasm::run(env!("CARGO_PKG_NAME"), $body) + } + }; +} diff --git a/libs/core-guest/tests/guest.rs b/libs/core-guest/tests/guest.rs new file mode 100644 index 00000000..5cf2f61f --- /dev/null +++ b/libs/core-guest/tests/guest.rs @@ -0,0 +1,253 @@ +//! Covers the typed guest API over a stand-in for the `et` imports: every call, each code an import answers with, and +//! copying the host's result slot out. +#![cfg(test)] + +use std::collections::VecDeque; + +use edge_toolkit::ws::{ClientMessage, ServerMessage}; +use et_core_guest::{Guest, GuestError, Imports, Level, abi}; + +/// The imports as a script: what each call answers, what the slot holds, and a record of what the guest did. +#[derive(Default)] +struct Script { + /// What each call answers in order, a `take` included only when the slot does not fit its buffer. + answers: VecDeque, + /// The host's result slot. + slot: Vec, + /// What a `take` that copies answers instead of the slot's length, when set. + copied: Option, + /// Every call made other than `take` and `log`, by import name. + calls: Vec<&'static str>, + /// Every message sent. + sent: Vec>, + /// Every value stored, with its bucket and key. + stored: Vec<(String, String, Vec)>, + /// Every log entry: level, context, message. + logged: Vec<(i32, String, String)>, +} + +impl Script { + /// A script answering `answers` in order, with `slot` in the result slot. + fn new(answers: &[i32], slot: &[u8]) -> Self { + Self { + answers: answers.iter().copied().collect(), + slot: slot.to_vec(), + ..Self::default() + } + } + + /// The next answer, recording the call `name`. + fn answer(&mut self, name: &'static str) -> i32 { + self.calls.push(name); + self.answers.pop_front().unwrap() + } + + /// A guest over this script, logging as `twin`; the script is read back once the guest is done with it. + fn guest(&mut self) -> Guest<&mut Self> { + Guest::new(self, "twin") + } +} + +impl Imports for &mut Script { + fn connect(&mut self) -> i32 { + self.answer(abi::CONNECT) + } + + fn agent_id(&mut self) -> i32 { + self.answer(abi::AGENT_ID) + } + + fn send(&mut self, message: &[u8]) -> i32 { + self.sent.push(message.to_vec()); + self.answer(abi::SEND) + } + + fn recv(&mut self, _timeout_ms: u32) -> i32 { + self.answer(abi::RECV) + } + + /// Copy the slot out when `buffer` holds it; otherwise the next scripted answer, or the slot's length. + fn take(&mut self, buffer: &mut [u8]) -> i32 { + let len = i32::try_from(self.slot.len()).unwrap(); + if let Some(target) = buffer.get_mut(..self.slot.len()) { + target.copy_from_slice(&self.slot); + return self.copied.unwrap_or(len); + } + self.answers.pop_front().unwrap_or(len) + } + + fn disconnect(&mut self) { + self.calls.push(abi::DISCONNECT); + } + + fn kv_get(&mut self, _bucket: &str, _key: &str) -> i32 { + self.answer(abi::KV_GET) + } + + fn kv_set(&mut self, bucket: &str, key: &str, value: &[u8]) -> i32 { + self.stored.push((bucket.to_owned(), key.to_owned(), value.to_vec())); + self.answer(abi::KV_SET) + } + + fn log(&mut self, level: i32, context: &str, message: &str) { + self.logged.push((level, context.to_owned(), message.to_owned())); + } +} + +#[test] +fn start_logs_connects_and_returns_the_agent_id() { + let mut script = Script::new(&[abi::SUCCESS, 7_i32], b"agent-1"); + assert_eq!(script.guest().start().unwrap(), "agent-1"); + assert_eq!(script.calls, [abi::CONNECT, abi::AGENT_ID]); + assert_eq!( + script.logged, + [(abi::LOG_INFO, "twin".to_owned(), "entered run()".to_owned())] + ); +} + +#[test] +fn each_failure_code_names_its_error() { + let cases = [ + (abi::TRANSPORT, "the websocket failed, or the hub could not be reached"), + (abi::DECODE, "the host could not decode the message"), + (abi::NOT_CONNECTED, "the websocket is not open"), + (abi::ALREADY_CONNECTED, "the websocket is already open"), + (abi::STORE, "the hub's storage refused the request, or failed"), + (-99_i32, "the host answered -99, which no `et` import returns"), + ]; + for (code, text) in cases { + let error = Script::new(&[code], b"").guest().connect().unwrap_err(); + assert_eq!(error.code(), code); + assert_eq!(error.to_string(), text); + } +} + +#[test] +fn an_agent_id_that_is_not_utf8_fails() { + let error = Script::new(&[2_i32], &[0xff, 0xfe]).guest().agent_id().unwrap_err(); + assert!(matches!(error, GuestError::Utf8(_))); + assert_eq!(error.code(), abi::FAILED); +} + +#[test] +fn send_hands_the_host_the_message_json() { + let message = ClientMessage::RelayText { + content: "hello".to_owned(), + }; + let mut script = Script::new(&[abi::SUCCESS, abi::NOT_CONNECTED], b""); + let mut guest = script.guest(); + guest.send(&message).unwrap(); + assert!(matches!(guest.send(&message), Err(GuestError::NotConnected))); + assert_eq!(script.sent.first().unwrap(), &serde_json::to_vec(&message).unwrap()); +} + +#[test] +fn recv_reads_the_message_in_the_slot() { + let message = ServerMessage::RelayText { + content: "hello".to_owned(), + }; + let json = serde_json::to_vec(&message).unwrap(); + let len = i32::try_from(json.len()).unwrap(); + let mut script = Script::new(&[len], &json); + let received = script.guest().recv(100).unwrap(); + assert!(matches!(received, Some(ServerMessage::RelayText { content }) if content == "hello")); +} + +#[test] +fn recv_with_nothing_arrived_is_none() { + assert!(Script::new(&[abi::NOTHING], b"").guest().recv(100).unwrap().is_none()); +} + +#[test] +fn recv_of_a_slot_that_is_not_a_message_fails() { + let error = Script::new(&[3_i32], b"???").guest().recv(100).unwrap_err(); + assert!(matches!(error, GuestError::Json(_))); + assert_eq!(error.code(), abi::FAILED); +} + +#[test] +fn get_reads_a_found_value_and_reports_an_absent_one() { + assert_eq!( + Script::new(&[abi::FOUND], b"value") + .guest() + .get("bucket", "key") + .unwrap() + .unwrap(), + b"value" + ); + assert!( + Script::new(&[abi::ABSENT], b"") + .guest() + .get("bucket", "key") + .unwrap() + .is_none() + ); + assert!(matches!( + Script::new(&[abi::STORE], b"").guest().get("bucket", "key"), + Err(GuestError::Store) + )); +} + +#[test] +fn set_stores_the_value_under_its_key() { + let mut script = Script::new(&[abi::SUCCESS, abi::STORE], b""); + let mut guest = script.guest(); + guest.set("bucket", "key", b"value").unwrap(); + assert!(matches!(guest.set("bucket", "key", b"value"), Err(GuestError::Store))); + let first = script.stored.first().unwrap(); + assert_eq!(first, &("bucket".to_owned(), "key".to_owned(), b"value".to_vec())); +} + +#[test] +fn disconnect_closes_the_websocket() { + let mut script = Script::new(&[], b""); + script.guest().disconnect(); + assert_eq!(script.calls, [abi::DISCONNECT]); +} + +#[test] +fn each_level_logs_with_its_code() { + let levels = [ + (Level::Trace, abi::LOG_TRACE), + (Level::Debug, abi::LOG_DEBUG), + (Level::Info, abi::LOG_INFO), + (Level::Warn, abi::LOG_WARN), + (Level::Error, abi::LOG_ERROR), + (Level::Critical, abi::LOG_CRITICAL), + ]; + for (level, code) in levels { + let mut script = Script::new(&[], b""); + script.guest().log(level, "message"); + assert_eq!(script.logged, [(code, "twin".to_owned(), "message".to_owned())]); + } +} + +#[test] +fn finish_returns_ok_for_success_and_logs_and_codes_a_failure() { + let mut script = Script::new(&[], b""); + let mut guest = script.guest(); + assert_eq!(guest.finish(Ok(())), abi::SUCCESS); + assert_eq!( + guest.finish(Err(GuestError::Runtime("no input".to_owned()))), + abi::FAILED + ); + assert_eq!(guest.finish(Err(GuestError::Transport)), abi::TRANSPORT); + assert_eq!( + script.logged.first().unwrap(), + &(abi::LOG_ERROR, "twin".to_owned(), "no input".to_owned()) + ); +} + +#[test] +fn a_failed_length_probe_fails_the_take() { + // `agent_id` answers the id's length, then the probe for the slot's length answers a failure instead. + let mut script = Script::new(&[2_i32, abi::DECODE], b"id"); + assert!(matches!(script.guest().agent_id(), Err(GuestError::Decode))); +} + +#[test] +fn a_take_that_copies_a_different_length_fails() { + let mut script = Script::new(&[2_i32], b"id"); + script.copied = Some(1_i32); + assert!(matches!(script.guest().agent_id(), Err(GuestError::Unknown(1_i32)))); +} diff --git a/libs/math1/Cargo.toml b/libs/math1/Cargo.toml new file mode 100644 index 00000000..4c6c59f2 --- /dev/null +++ b/libs/math1/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "et-math1" +publish = true +description = "The math1 family's FedAvg kernel and storage protocol, shared by its Rust twins and triggers" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +doctest = false + +[dependencies] +serde = { workspace = true, features = ["alloc"] } +serde_json = { workspace = true, features = ["alloc"] } +thiserror.workspace = true + +[dev-dependencies] +et-ws-test-server.workspace = true +serde_json = { workspace = true, features = ["std"] } + +[lints] +workspace = true diff --git a/libs/math1/src/lib.rs b/libs/math1/src/lib.rs new file mode 100644 index 00000000..6d1a178b --- /dev/null +++ b/libs/math1/src/lib.rs @@ -0,0 +1,142 @@ +//! The math1 family's Rust: the `FedAvg` kernel, and the storage protocol its twins and triggers speak. +//! +//! A trigger stores the canonical input JSON as [`INPUT_FILENAME`] in its own bucket and relays the frame +//! [`pointer_frame`] builds, naming it. Each twin waits for that frame, reads the input from storage, runs +//! [`fed_avg`], and stores what [`output_json`] renders as [`OUTPUT_FILENAME`] in its own bucket. The kernel uses only +//! `+ - * /` on f64 in a fixed order, so every runtime that follows IEEE 754 computes the same model to the bit, the +//! twins in other languages included. +//! +//! `no_std` with `alloc`, so a twin built for a target with no standard library uses it as a browser or WASI one does. + +#![no_std] +#![expect(clippy::float_arithmetic, reason = "the FedAvg kernel is float math by design")] + +extern crate alloc; + +use alloc::string::{String, ToString as _}; +use alloc::vec::Vec; + +use serde::Deserialize; +use serde_json::{Value, json}; + +/// The `type` of the pointer frame a trigger relays. +pub const INPUT_TYPE: &str = "math1-input"; +/// Storage object name of the canonical input, inside the trigger's bucket. +pub const INPUT_FILENAME: &str = "math1-input.json"; +/// Storage object name each twin stores its global model as, inside its own bucket. +pub const OUTPUT_FILENAME: &str = "math1-output.json"; + +/// The canonical input: per-client (feature, target) samples plus the training hyperparameters. +#[derive(Debug, Deserialize)] +#[non_exhaustive] +pub struct Math1Input { + /// Each client's (feature, target) samples. + pub clients: Vec>, + /// How many rounds of local training and merging to run. + pub rounds: u32, + /// How many full-batch gradient-descent epochs each client runs per round. + pub epochs: u32, + /// The gradient-descent step size. + pub learning_rate: f64, +} + +/// Where a trigger stored the input: the bucket and file a pointer frame names. +#[derive(Clone, Debug, Deserialize)] +#[non_exhaustive] +pub struct InputPointer { + /// The trigger's bucket. + pub bucket: String, + /// The input's name in that bucket. + pub filename: String, +} + +/// Why a text frame is not the math1 input pointer. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum PointerError { + /// The frame is not JSON, or claims to be the pointer but does not name a bucket and file. + #[error(transparent)] + Json(#[from] serde_json::Error), + /// The frame is JSON, but a message other than the pointer. + #[error("not a math1-input frame")] + OtherType, +} + +/// A global model, fitting `target = weight * feature + bias`. +#[derive(Clone, Copy, Debug, PartialEq)] +#[non_exhaustive] +pub struct Model { + /// The fitted weight. + pub weight: f64, + /// The fitted bias. + pub bias: f64, +} + +/// The pointer frame a trigger relays, naming [`INPUT_FILENAME`] in `bucket`. +#[must_use] +pub fn pointer_frame(bucket: &str) -> String { + json!({ "type": INPUT_TYPE, "bucket": bucket, "filename": INPUT_FILENAME }).to_string() +} + +/// The input pointer the text frame `text` carries. +/// +/// # Errors +/// +/// When `text` is not JSON, is a message other than the pointer, or is a pointer naming no bucket and file. +pub fn parse_pointer(text: &str) -> Result { + let value: Value = serde_json::from_str(text)?; + if value.get("type").and_then(Value::as_str) != Some(INPUT_TYPE) { + return Err(PointerError::OtherType); + } + Ok(serde_json::from_value(value)?) +} + +/// Sample count as f64, accumulated additively to avoid an integer-to-float cast. +fn sample_count(samples: &[(f64, f64)]) -> f64 { + samples.iter().fold(0.0_f64, |count, _| count + 1.0_f64) +} + +/// Run the `FedAvg` simulation on `input`, returning the final global model. +/// +/// Each round, every client starts from the global model, runs its local epochs of full-batch gradient descent, and +/// the results are merged as an average weighted by each client's sample count. +#[must_use] +pub fn fed_avg(input: &Math1Input) -> Model { + let mut weight = 0.0_f64; + let mut bias = 0.0_f64; + let total_samples: f64 = input + .clients + .iter() + .fold(0.0_f64, |acc, samples| acc + sample_count(samples)); + for _ in 0_u32..input.rounds { + let mut merged_weight = 0.0_f64; + let mut merged_bias = 0.0_f64; + for samples in &input.clients { + let count = sample_count(samples); + let mut client_weight = weight; + let mut client_bias = bias; + for _ in 0_u32..input.epochs { + let mut grad_weight = 0.0_f64; + let mut grad_bias = 0.0_f64; + for &(feature, target) in samples { + let residual = client_weight * feature + client_bias - target; + grad_weight += residual * feature; + grad_bias += residual; + } + client_weight -= input.learning_rate * (2.0_f64 * grad_weight / count); + client_bias -= input.learning_rate * (2.0_f64 * grad_bias / count); + } + merged_weight += client_weight * count; + merged_bias += client_bias * count; + } + weight = merged_weight / total_samples; + bias = merged_bias / total_samples; + } + Model { weight, bias } +} + +/// The JSON a twin stores as [`OUTPUT_FILENAME`]: the name of the `module` that computed `model`, and the model. +#[must_use] +pub fn output_json(module: &str, model: Model) -> String { + json!({ "module": module, "weight": model.weight, "bias": model.bias }).to_string() +} diff --git a/libs/math1/tests/math1.rs b/libs/math1/tests/math1.rs new file mode 100644 index 00000000..6ffc0085 --- /dev/null +++ b/libs/math1/tests/math1.rs @@ -0,0 +1,57 @@ +//! Covers the kernel against the canonical model, each way a frame is or is not the input pointer, and the output. +#![cfg(test)] + +use et_math1::{ + INPUT_FILENAME, INPUT_TYPE, Math1Input, PointerError, fed_avg, output_json, parse_pointer, pointer_frame, +}; +use et_ws_test_server::math1::{MATH1_INPUT_JSON, verify_math1_model}; +use serde_json::Value; + +#[test] +fn fed_avg_computes_the_canonical_model() { + let input: Math1Input = serde_json::from_str(MATH1_INPUT_JSON).unwrap(); + let model = fed_avg(&input); + verify_math1_model(model.weight, model.bias).unwrap(); +} + +#[test] +fn the_pointer_frame_parses_back_to_the_input_it_names() { + let frame = pointer_frame("bucket-1"); + let value: Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(value["type"], INPUT_TYPE); + let pointer = parse_pointer(&frame).unwrap(); + assert_eq!(pointer.bucket, "bucket-1"); + assert_eq!(pointer.filename, INPUT_FILENAME); +} + +#[test] +fn a_frame_that_is_not_json_is_not_a_pointer() { + let error = parse_pointer("not json").unwrap_err(); + let expected = serde_json::from_str::("not json").unwrap_err().to_string(); + assert!(matches!(error, PointerError::Json(_))); + assert_eq!(error.to_string(), expected); +} + +#[test] +fn another_message_is_not_a_pointer() { + let error = parse_pointer(r#"{"type":"et-connect-ack"}"#).unwrap_err(); + assert!(matches!(error, PointerError::OtherType)); + assert_eq!(error.to_string(), "not a math1-input frame"); +} + +#[test] +fn a_pointer_naming_no_file_is_malformed() { + let error = parse_pointer(r#"{"type":"math1-input","bucket":"bucket-1"}"#).unwrap_err(); + assert!(matches!(error, PointerError::Json(_))); + assert!(error.to_string().contains("filename")); +} + +#[test] +fn the_output_names_the_module_and_carries_the_model() { + let input: Math1Input = serde_json::from_str(MATH1_INPUT_JSON).unwrap(); + let model = fed_avg(&input); + let output: Value = serde_json::from_str(&output_json("twin", model)).unwrap(); + assert_eq!(output["module"], "twin"); + assert_eq!(output["weight"].as_f64(), Some(model.weight)); + assert_eq!(output["bias"].as_f64(), Some(model.bias)); +} diff --git a/services/firmware-modules/src/lib.rs b/services/firmware-modules/src/lib.rs index af9a7ff2..6b8628c6 100644 --- a/services/firmware-modules/src/lib.rs +++ b/services/firmware-modules/src/lib.rs @@ -86,6 +86,8 @@ pub enum ModulesError { 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")] Unreadable { path: PathBuf, @@ -340,6 +342,14 @@ fn checked_guest<'pair>( }); } 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) => { diff --git a/services/firmware-modules/tests/guests.rs b/services/firmware-modules/tests/guests.rs index 83cb1860..20aeead7 100644 --- a/services/firmware-modules/tests/guests.rs +++ b/services/firmware-modules/tests/guests.rs @@ -140,6 +140,16 @@ fn a_component_that_is_not_webassembly_is_refused() { assert!(matches!(refused, ModulesError::Unreadable { path, .. } if path == dir.join("guest.wasm"))); } +#[test] +fn a_core_module_is_refused() { + let (_root, path) = component_module(r#"(module (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); +} + #[test] fn a_guest_importing_what_the_board_does_not_host_is_refused() { let (_root, path) = component_module( diff --git a/services/ws-modules/core-math1/.gitignore b/services/ws-modules/core-math1/.gitignore new file mode 100644 index 00000000..133547d6 --- /dev/null +++ b/services/ws-modules/core-math1/.gitignore @@ -0,0 +1,2 @@ +/pkg/*.wasm +/pkg/package.json diff --git a/services/ws-modules/core-math1/Cargo.toml b/services/ws-modules/core-math1/Cargo.toml new file mode 100644 index 00000000..d926dc9e --- /dev/null +++ b/services/ws-modules/core-math1/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "et-ws-core-math1" +publish = true +description = "math1 twin as a Wasm 1.0 core module" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[package.metadata.docs.rs] +default-target = "wasm32v1-none" + +[lib] +crate-type = ["cdylib"] +doctest = false +test = false + +# Scoped to the Wasm 1.0 target the guest builds for, as its lib body is. +# A host `cargo check --workspace` then builds an empty cdylib. +[target.'cfg(all(target_arch = "wasm32", target_os = "none"))'.dependencies] +edge-toolkit.workspace = true +et-core-guest.workspace = true +et-math1.workspace = true +serde_json = { workspace = true, features = ["alloc"] } + +[lints] +workspace = true diff --git a/services/ws-modules/core-math1/src/lib.rs b/services/ws-modules/core-math1/src/lib.rs new file mode 100644 index 00000000..639f1b56 --- /dev/null +++ b/services/ws-modules/core-math1/src/lib.rs @@ -0,0 +1,84 @@ +//! Wasm 1.0 core-module twin of the math1 `FedAvg` module, which `et-ws-wasi-runner` runs on NASA's `SpaceWasm`. +//! +//! The storage-driven exchange `wasi-math1` runs, through the `et` imports rather than the `et:ws-wasi` world: it +//! waits for the relayed `math1-input` pointer, reads the input it names from storage, runs `FedAvg`, and stores the +//! global model as `math1-output.json` in its own bucket. +//! +//! Built for `wasm32v1-none`, the Wasm 1.0 target `SpaceWasm` runs, so it is `no_std`. The crate is gated on that +//! target, so a host `cargo check --workspace` builds an empty cdylib. + +#![cfg(all(target_arch = "wasm32", target_os = "none"))] +#![no_std] + +extern crate alloc; + +use alloc::format; +use alloc::string::ToString as _; + +use edge_toolkit::ws::ServerMessage; +use et_core_guest::wasm::Host; +use et_core_guest::{Guest, GuestError}; +use et_math1::{InputPointer, Math1Input, OUTPUT_FILENAME, fed_avg, output_json, parse_pointer}; + +/// How many windows to wait for the pointer in before giving up. +const POINTER_WAITS: u32 = 100; +/// How long each window lasts, short enough that one of the trigger's re-broadcasts lands in it. +const POINTER_WAIT_MS: u32 = 100; + +/// Wait for the input pointer, fit the model to the input it names, and store the model in this agent's bucket. +#[expect( + clippy::single_call_fn, + reason = "the guest's work, which `export!` makes its run; named apart from the export it is wrapped in" +)] +fn twin(guest: &mut Guest) -> Result<(), GuestError> { + let agent_id = guest.start()?; + guest.info("waiting for the math1-input pointer broadcast"); + let pointer = wait_for_pointer(guest)?; + + guest.info(&format!( + "reading input from bucket={} key={}", + pointer.bucket, pointer.filename + )); + let input_bytes = guest + .get(&pointer.bucket, &pointer.filename)? + .ok_or_else(|| GuestError::Runtime(format!("input {} not found", pointer.filename)))?; + let input: Math1Input = serde_json::from_slice(&input_bytes)?; + + guest.info(&format!( + "running FedAvg - {} clients x {} rounds x {} local epochs", + input.clients.len(), + input.rounds, + input.epochs + )); + let model = fed_avg(&input); + guest.info(&format!("global model weight={} bias={}", model.weight, model.bias)); + + guest.set(&agent_id, OUTPUT_FILENAME, output_json("core-math1", model).as_bytes())?; + guest.info(&format!("stored the global model to {OUTPUT_FILENAME}")); + + guest.disconnect(); + guest.info("workflow complete"); + Ok(()) +} + +/// The relayed `math1-input` pointer, waited for in windows of [`POINTER_WAIT_MS`]. +/// +/// The trigger re-broadcasts the pointer until the output lands, so each window only has to catch one of them. +#[expect( + clippy::single_call_fn, + reason = "the inbox-draining step of twin, named for what it waits on" +)] +fn wait_for_pointer(guest: &mut Guest) -> Result { + for _ in 0..POINTER_WAITS { + if let Some(ServerMessage::RelayText { content }) = guest.recv(POINTER_WAIT_MS)? + && let Ok(pointer) = parse_pointer(&content) + { + return Ok(pointer); + } + } + Err(GuestError::Runtime( + "did not receive the math1-input pointer".to_string(), + )) +} + +et_core_guest::export!(twin); diff --git a/services/ws-modules/math1-sender/Cargo.toml b/services/ws-modules/math1-sender/Cargo.toml index 725ea46a..ef316245 100644 --- a/services/ws-modules/math1-sender/Cargo.toml +++ b/services/ws-modules/math1-sender/Cargo.toml @@ -17,6 +17,7 @@ doctest = false test = false [dependencies] +et-math1.workspace = true et-rest-client.workspace = true et-web.workspace = true et-ws-wasm-agent.workspace = true diff --git a/services/ws-modules/math1-sender/src/lib.rs b/services/ws-modules/math1-sender/src/lib.rs index 972b05e4..860fad6f 100644 --- a/services/ws-modules/math1-sender/src/lib.rs +++ b/services/ws-modules/math1-sender/src/lib.rs @@ -13,6 +13,7 @@ reason = "browser WASM module: JsFuture is !Send; module-local helpers are single-use by design" )] +use et_math1::{INPUT_FILENAME, pointer_frame}; use et_web::{JsResultExt as _, sleep_ms, websocket_url}; use et_ws_wasm_agent::{WsClient, WsClientConfig, append_to_textarea, wait_for_connected}; use tracing::info; @@ -20,8 +21,6 @@ use wasm_bindgen::prelude::*; /// The canonical input bytes, embedded from the committed file so there is one source of truth. const MATH1_INPUT_JSON: &str = include_str!(env!("ET_MATH1_INPUT_PATH")); -/// Storage object name the twins' broadcast pointer names, matching the harnesses. -const INPUT_FILENAME: &str = "math1-input.json"; /// How many one-second-spaced pointer broadcasts to send before completing. const BROADCASTS: u32 = 60; @@ -31,6 +30,7 @@ pub fn init() { info!("math1-sender module initialized"); } +/// Store the canonical input in this agent's bucket, then broadcast the pointer naming it once a second. #[wasm_bindgen] pub async fn run() -> Result<(), JsValue> { let msg = "math1-sender: entered run()"; @@ -64,7 +64,7 @@ pub async fn run() -> Result<(), JsValue> { log(&msg); set_module_status(&msg)?; - let pointer = format!(r#"{{"type":"math1-input","bucket":"{agent_id}","filename":"{INPUT_FILENAME}"}}"#); + let pointer = pointer_frame(&agent_id); let msg = format!("math1-sender: broadcasting the math1-input pointer every second, {BROADCASTS} times"); log(&msg); set_module_status(&msg)?; diff --git a/services/ws-modules/math1/Cargo.toml b/services/ws-modules/math1/Cargo.toml index 3125c90b..33282418 100644 --- a/services/ws-modules/math1/Cargo.toml +++ b/services/ws-modules/math1/Cargo.toml @@ -17,11 +17,11 @@ doctest = false test = false [dependencies] +et-math1.workspace = true et-rest-client.workspace = true et-web.workspace = true et-ws-wasm-agent.workspace = true futures-util.workspace = true -serde = { workspace = true, features = ["std"] } serde_json = { workspace = true, features = ["std"] } tracing = { workspace = true, features = ["std"] } tracing-wasm.workspace = true diff --git a/services/ws-modules/math1/src/lib.rs b/services/ws-modules/math1/src/lib.rs index 9dfb1809..ce132f0f 100644 --- a/services/ws-modules/math1/src/lib.rs +++ b/services/ws-modules/math1/src/lib.rs @@ -9,88 +9,28 @@ //! and verifies it. The math1 twins in the other guest languages run the same protocol and kernel. #![expect( - clippy::float_arithmetic, clippy::future_not_send, clippy::single_call_fn, - reason = "browser WASM module: JsFuture is !Send; the FedAvg kernel is float math; helpers are single-use" + reason = "browser WASM module: JsFuture is !Send; helpers are single-use" )] use std::cell::RefCell; use std::rc::Rc; +use et_math1::{InputPointer, Math1Input, OUTPUT_FILENAME, fed_avg, output_json, parse_pointer}; use et_web::{JsResultExt as _, sleep_ms, websocket_url}; use et_ws_wasm_agent::{WsClient, WsClientConfig, append_to_textarea, wait_for_connected}; use futures_util::StreamExt as _; -use serde::Deserialize; use tracing::info; use wasm_bindgen::prelude::*; -/// The canonical input: per-client (feature, target) samples plus the training hyperparameters. -#[derive(Deserialize)] -struct Math1Input { - clients: Vec>, - rounds: u32, - epochs: u32, - learning_rate: f64, -} - -/// The broadcast pointer naming the storage bucket + filename the input JSON was injected at. -#[derive(Clone, Deserialize)] -struct InputPointer { - bucket: String, - filename: String, -} - #[wasm_bindgen(start)] pub fn init() { tracing_wasm::set_as_global_default(); info!("math1 FedAvg module initialized"); } -/// Sample count as f64, accumulated additively to avoid an integer-to-float cast. -fn sample_count(samples: &[(f64, f64)]) -> f64 { - samples.iter().fold(0.0_f64, |count, _| count + 1.0_f64) -} - -/// Runs the `FedAvg` simulation on `input` and returns the final global (weight, bias). -#[expect( - clippy::suboptimal_flops, - reason = "mul_add rounds once where `a * b + c` rounds twice; the kernel must stay bit-identical to its twins" -)] -fn fed_avg(input: &Math1Input) -> (f64, f64) { - let mut weight = 0.0_f64; - let mut bias = 0.0_f64; - let total_samples: f64 = input - .clients - .iter() - .fold(0.0_f64, |acc, samples| acc + sample_count(samples)); - for _ in 0_u32..input.rounds { - let mut merged_weight = 0.0_f64; - let mut merged_bias = 0.0_f64; - for samples in &input.clients { - let count = sample_count(samples); - let mut client_weight = weight; - let mut client_bias = bias; - for _ in 0_u32..input.epochs { - let mut grad_weight = 0.0_f64; - let mut grad_bias = 0.0_f64; - for &(feature, target) in samples { - let residual = client_weight * feature + client_bias - target; - grad_weight += residual * feature; - grad_bias += residual; - } - client_weight -= input.learning_rate * (2.0_f64 * grad_weight / count); - client_bias -= input.learning_rate * (2.0_f64 * grad_bias / count); - } - merged_weight += client_weight * count; - merged_bias += client_bias * count; - } - weight = merged_weight / total_samples; - bias = merged_bias / total_samples; - } - (weight, bias) -} - +/// Wait for the input pointer, fit the model to the input it names, and store the model in this agent's bucket. #[wasm_bindgen] #[expect( clippy::too_many_lines, @@ -113,14 +53,8 @@ pub async fn run() -> Result<(), JsValue> { let on_message = Closure::wrap(Box::new({ let pointer_slot = Rc::clone(&pointer_slot); move |value: JsValue| { - let Some(data) = value.as_string() else { - return; - }; - let Ok(json) = serde_json::from_str::(&data) else { - return; - }; - if json.get("type").and_then(serde_json::Value::as_str) == Some("math1-input") - && let Ok(pointer) = serde_json::from_value::(json) + if let Some(data) = value.as_string() + && let Ok(pointer) = parse_pointer(&data) { *pointer_slot.borrow_mut() = Some(pointer); } @@ -164,17 +98,16 @@ pub async fn run() -> Result<(), JsValue> { ); log(&msg); set_module_status(&msg)?; - let (weight, bias) = fed_avg(&input); - let msg = format!("math1: global model weight={weight} bias={bias}"); + let model = fed_avg(&input); + let msg = format!("math1: global model weight={} bias={}", model.weight, model.bias); log(&msg); set_module_status(&msg)?; - let output = serde_json::json!({ "module": "math1", "weight": weight, "bias": bias }).to_string(); let _put_response = rest - .put_file(&agent_id, "math1-output.json", output) + .put_file(&agent_id, OUTPUT_FILENAME, output_json("math1", model)) .await .js_context("output PUT failed")?; - let msg = format!("math1: stored the global model to /storage/{agent_id}/math1-output.json"); + let msg = format!("math1: stored the global model to /storage/{agent_id}/{OUTPUT_FILENAME}"); log(&msg); set_module_status(&msg)?; diff --git a/services/ws-modules/wasi-math1-sender/Cargo.toml b/services/ws-modules/wasi-math1-sender/Cargo.toml index fff73d2b..6954e163 100644 --- a/services/ws-modules/wasi-math1-sender/Cargo.toml +++ b/services/ws-modules/wasi-math1-sender/Cargo.toml @@ -24,6 +24,7 @@ docs = ["dep:document-features"] [target.'cfg(target_os = "wasi")'.dependencies] document-features = { workspace = true, optional = true } +et-math1.workspace = true et-wasi-guest.workspace = true # Build script (runs on the host) emits ET_MATH1_INPUT_PATH for the embedded canonical input. diff --git a/services/ws-modules/wasi-math1-sender/src/lib.rs b/services/ws-modules/wasi-math1-sender/src/lib.rs index 1c29824e..4a0b07b3 100644 --- a/services/ws-modules/wasi-math1-sender/src/lib.rs +++ b/services/ws-modules/wasi-math1-sender/src/lib.rs @@ -23,6 +23,7 @@ #![cfg_attr(feature = "docs", doc = "## Feature flags")] #![cfg_attr(feature = "docs", doc = document_features::document_features!())] +use et_math1::{INPUT_FILENAME, pointer_frame}; use et_wasi_guest::et::ws_messages::messages::{ClientMessage, RelayTextPayload}; use et_wasi_guest::et::ws_wasi::ws; use et_wasi_guest::exports::et::ws_wasi::entry::{EntryError, Guest}; @@ -34,9 +35,6 @@ const LOG_CONTEXT: &str = env!("CARGO_PKG_NAME"); /// The canonical input bytes, embedded from the committed file so there is one source of truth. const MATH1_INPUT_JSON: &str = include_str!(env!("ET_MATH1_INPUT_PATH")); -/// Storage object name the twins' broadcast pointer names, matching the harnesses. -const INPUT_FILENAME: &str = "math1-input.json"; - /// How many one-second-spaced pointer broadcasts to send before completing. /// /// Matches the browser sender's window. A twin only has to catch one of them, so the count is really a bound on @@ -50,6 +48,7 @@ const BROADCAST_INTERVAL_MS: u64 = 1_000; struct Component; impl Guest for Component { + /// Store the canonical input in this agent's bucket, then broadcast the pointer naming it once a second. #[expect( clippy::unused_async_trait_impl, reason = "entry.run is an async export of the WIT world, so the generated trait fixes the signature" @@ -60,9 +59,7 @@ impl Guest for Component { own_bucket.set(INPUT_FILENAME, MATH1_INPUT_JSON.as_bytes())?; info(&format!("uploaded {INPUT_FILENAME} to bucket={agent_id}")); - // Built by hand rather than through serde_json, as the browser sender's identical pointer is: one - // fixed-shape object with no parsing anywhere in this module, so a JSON dependency would earn nothing. - let pointer = format!(r#"{{"type":"math1-input","bucket":"{agent_id}","filename":"{INPUT_FILENAME}"}}"#); + let pointer = pointer_frame(&agent_id); info(&format!( "broadcasting the math1-input pointer every {BROADCAST_INTERVAL_MS}ms, {BROADCASTS} times" diff --git a/services/ws-modules/wasi-math1/Cargo.toml b/services/ws-modules/wasi-math1/Cargo.toml index cc2ad703..e620d432 100644 --- a/services/ws-modules/wasi-math1/Cargo.toml +++ b/services/ws-modules/wasi-math1/Cargo.toml @@ -27,8 +27,8 @@ docs = ["dep:document-features"] # so on the host target the crate compiles to an empty cdylib. [target.'cfg(target_os = "wasi")'.dependencies] document-features = { workspace = true, optional = true } +et-math1.workspace = true et-wasi-guest.workspace = true -serde = { workspace = true, features = ["derive", "std"] } serde_json = { workspace = true, features = ["std"] } [lints] diff --git a/services/ws-modules/wasi-math1/src/lib.rs b/services/ws-modules/wasi-math1/src/lib.rs index 7e406fc8..e157ea42 100644 --- a/services/ws-modules/wasi-math1/src/lib.rs +++ b/services/ws-modules/wasi-math1/src/lib.rs @@ -16,81 +16,20 @@ #![cfg(target_os = "wasi")] #![cfg_attr(feature = "docs", doc = "## Feature flags")] #![cfg_attr(feature = "docs", doc = document_features::document_features!())] -#![expect(clippy::float_arithmetic, reason = "the FedAvg kernel is float math by design")] +use et_math1::{InputPointer, Math1Input, OUTPUT_FILENAME, fed_avg, output_json, parse_pointer}; use et_wasi_guest::et::ws_messages::messages::ServerMessage; use et_wasi_guest::et::ws_wasi::ws; use et_wasi_guest::exports::et::ws_wasi::entry::{EntryError, Guest}; use et_wasi_guest::wasi::keyvalue::store; use et_wasi_guest::{info, start}; -use serde::Deserialize; const LOG_CONTEXT: &str = env!("CARGO_PKG_NAME"); -/// The canonical input: per-client (feature, target) samples plus the training hyperparameters. -#[derive(Deserialize)] -struct Math1Input { - clients: Vec>, - rounds: u32, - epochs: u32, - learning_rate: f64, -} - -/// The broadcast pointer naming the storage bucket + filename the input JSON was injected at. -#[derive(Deserialize)] -struct InputPointer { - bucket: String, - filename: String, -} - -/// Sample count as f64, accumulated additively to avoid an integer-to-float cast. -fn sample_count(samples: &[(f64, f64)]) -> f64 { - samples.iter().fold(0.0_f64, |count, _| count + 1.0_f64) -} - -/// Runs the `FedAvg` simulation on `input` and returns the final global (weight, bias). -#[expect( - clippy::single_call_fn, - clippy::suboptimal_flops, - reason = "a step kept apart from the ws workflow; mul_add's single rounding would break bit-identity with its twins" -)] -fn fed_avg(input: &Math1Input) -> (f64, f64) { - let mut weight = 0.0_f64; - let mut bias = 0.0_f64; - let total_samples: f64 = input - .clients - .iter() - .fold(0.0_f64, |acc, samples| acc + sample_count(samples)); - for _ in 0_u32..input.rounds { - let mut merged_weight = 0.0_f64; - let mut merged_bias = 0.0_f64; - for samples in &input.clients { - let count = sample_count(samples); - let mut client_weight = weight; - let mut client_bias = bias; - for _ in 0_u32..input.epochs { - let mut grad_weight = 0.0_f64; - let mut grad_bias = 0.0_f64; - for &(feature, target) in samples { - let residual = client_weight * feature + client_bias - target; - grad_weight += residual * feature; - grad_bias += residual; - } - client_weight -= input.learning_rate * (2.0_f64 * grad_weight / count); - client_bias -= input.learning_rate * (2.0_f64 * grad_bias / count); - } - merged_weight += client_weight * count; - merged_bias += client_bias * count; - } - weight = merged_weight / total_samples; - bias = merged_bias / total_samples; - } - (weight, bias) -} - struct Component; impl Guest for Component { + /// Wait for the input pointer, fit the model to the input it names, and store the model in this agent's bucket. #[expect( clippy::unused_async_trait_impl, reason = "entry.run is an async export of the WIT world, so the generated trait fixes the signature" @@ -120,13 +59,12 @@ impl Guest for Component { input.rounds, input.epochs )); - let (weight, bias) = fed_avg(&input); - info(&format!("global model weight={weight} bias={bias}")); + let model = fed_avg(&input); + info(&format!("global model weight={} bias={}", model.weight, model.bias)); let own_bucket = store::open(&agent_id)?; - let output = serde_json::json!({ "module": "wasi-math1", "weight": weight, "bias": bias }).to_string(); - own_bucket.set("math1-output.json", output.as_bytes())?; - info("stored the global model to math1-output.json"); + own_bucket.set(OUTPUT_FILENAME, output_json("wasi-math1", model).as_bytes())?; + info(&format!("stored the global model to {OUTPUT_FILENAME}")); ws::disconnect(); info("workflow complete"); @@ -147,9 +85,7 @@ impl Guest for Component { fn wait_for_pointer() -> Option { for _ in 0_u32..100 { if let Ok(Some(ServerMessage::RelayText(payload))) = ws::recv(100) - && let Ok(json) = serde_json::from_str::(&payload.content) - && json.get("type").and_then(serde_json::Value::as_str) == Some("math1-input") - && let Ok(pointer) = serde_json::from_value::(json) + && let Ok(pointer) = parse_pointer(&payload.content) { return Some(pointer); } diff --git a/services/ws-wasi-runner/Cargo.toml b/services/ws-wasi-runner/Cargo.toml index 8d83ece8..f09ff3ee 100644 --- a/services/ws-wasi-runner/Cargo.toml +++ b/services/ws-wasi-runner/Cargo.toml @@ -37,6 +37,10 @@ required-features = ["board", "precompile", "std"] name = "board_net" required-features = ["board", "precompile", "std"] +[[test]] +name = "core_module" +required-features = ["core-modules", "std"] + [[test]] name = "modules" required-features = ["std"] @@ -61,7 +65,7 @@ required-features = ["std"] #! Each standardised WASI interface hosted beyond `et:ws-wasi` and WASI Preview 2 is a feature, all on by default. #! A guest that imports an interface the build left out fails to instantiate, naming the import. -default = ["nn", "std", "webgpu"] +default = ["core-modules", "nn", "std", "webgpu"] ## The runner for a host with an operating system. ## It fetches a component from the hub, compiles it, and hosts WASI Preview 2 through `wasmtime-wasi`. Without it the ## crate is `no_std`: the message conversions, and with `board` the runner for a board. @@ -77,6 +81,7 @@ std = [ "dep:tokio", "dep:tokio-tungstenite", "dep:tracing", + "dep:wasmparser", "dep:wasmtime-wasi", "edge-toolkit/std", "embedded-io-async?/std", @@ -92,6 +97,10 @@ precompile = ["wasmtime/cranelift", "wasmtime/pulley", "wasmtime/std"] ## Pulley bytecode, and keeps the board's storage buckets under a spin lock, since a board has no `std::sync::Mutex`. ## Its `board::net` reaches a hub on another machine over the board's own sockets. board = ["dep:edge-ws", "dep:embassy-sync", "dep:embedded-io-async", "dep:spin", "wasmtime/pulley"] +## Runs a Wasm 1.0 core module beside components, on NASA's SpaceWasm interpreter rather than wasmtime. +## The module's header picks the engine. A core module imports the `et` functions `et-core-guest` defines in place of +## the `et:ws-wasi` world, and needs no `std`, so a board can host one too. +core-modules = ["dep:et-core-guest", "dep:spacewasm"] ## Hosts `wasi:nn` (graph, tensor, inference) on ONNX Runtime through `wasmtime-wasi-nn`. ## Guests can then load an ONNX model and run inference on it. nn = ["dep:ort", "dep:wasmtime-wasi-nn", "std"] @@ -120,8 +129,12 @@ edge-toolkit.workspace = true edge-ws = { workspace = true, optional = true } embassy-sync = { workspace = true, optional = true } embedded-io-async = { workspace = true, optional = true } +# The `et` imports a core module is hosted with. +et-core-guest = { workspace = true, optional = true } serde_json = { workspace = true, features = ["alloc"] } serde_path_to_error.workspace = true +# The interpreter a core module runs on. +spacewasm = { workspace = true, optional = true } # The lock a board's guests share their buckets under, when there is no `std::sync::Mutex`. spin = { workspace = true, optional = true } thiserror.workspace = true @@ -136,6 +149,8 @@ reqwest = { workspace = true, features = ["json", "rustls", "stream"], optional tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "sync", "time"], optional = true } tokio-tungstenite = { workspace = true, features = ["connect"], optional = true } tracing = { workspace = true, features = ["std"], optional = true } +# Tells a fetched core module from a component, to pick the engine that runs it. +wasmparser = { workspace = true, optional = true } wasmtime-wasi = { workspace = true, optional = true } # wasi-nn standardised ML inference. # The workspace pin drops `openvino` + `winml` (which we don't use) and keeps `onnx`. @@ -172,6 +187,7 @@ rstest.workspace = true secrecy.workspace = true serde_json = { workspace = true, features = ["std"] } tempfile.workspace = true +wat.workspace = true [lints] workspace = true diff --git a/services/ws-wasi-runner/README.md b/services/ws-wasi-runner/README.md index a0ca4562..532e9176 100644 --- a/services/ws-wasi-runner/README.md +++ b/services/ws-wasi-runner/README.md @@ -1,8 +1,10 @@ # et-ws-wasi-runner -Runs edge-toolkit ws-modules compiled to **WASI Preview 2 components**, rather than browser WASM modules. It fetches -the module's `pkg/package.json` from the ws-server, downloads the `.wasm` named by the `wasi-main` field, -instantiates it under `wasmtime` with async support, and calls the exported `entry.run` function. +Runs edge-toolkit ws-modules built as WebAssembly for a host rather than for a browser. It fetches the module's +`pkg/package.json` from the ws-server, downloads the `.wasm` its `main` field names, and picks the engine from the +module's header: a **WASI Preview 2 component** is instantiated under `wasmtime` with async support and its exported +`entry.run` called, and a **Wasm 1.0 core module** runs on NASA's SpaceWasm interpreter, which calls its exported +`run`. ```sh RUNNER_MODULE=@edge-toolkit/et-ws-wasi-math1 WS_SERVER_URL=ws://localhost:8080/ws et-ws-wasi-runner @@ -14,13 +16,13 @@ variable the runner reads, with its type and default. ## Host imports -Defined in `generated/specs/wit/world.wit` of the edge-toolkit repository, package `et:ws-wasi`: +A component imports the `runner` world in `generated/specs/wit/world.wit` of the edge-toolkit repository: -- `log` -- `log` and `set-status` for guest output -- `clock` -- `sleep-ms`, `now-ms` -- `storage` -- `put-file`/`get-file` proxied to the ws-server's storage service via reqwest -- `ws` -- websocket client backed by `tokio-tungstenite`; mirrors the wire format of `et-ws-wasm-agent` so events - look the same on the server +- `et:ws-wasi/ws` -- websocket client backed by `tokio-tungstenite`; mirrors the wire format of `et-ws-wasm-agent` so + events look the same on the server +- `wasi:keyvalue/store` -- the agent's own storage bucket, and a module's files read-only, proxied to the ws-server's + storage and modules services through its REST API +- `wasi:logging/logging` -- the guest's log, routed into `tracing` Plus, attached to the same `Linker` but defined by external WIT packages, each behind the cargo feature named first: @@ -38,6 +40,22 @@ Plus, attached to the same `Linker` but defined by external WIT packages, each b the same shape of calls Spin / wasmCloud / Fermyon production wasi-nn workloads use. Inference runs on the CPU unless the `cuda` feature is on. +## Core modules + +The `runner` world exists only through the component model, so a core module cannot import it. With the +`core-modules` feature, on by default, the runner runs one on [SpaceWasm](https://github.com/nasa/spacewasm), NASA +JPL's Wasm 1.0 interpreter for flight software, in place of wasmtime. SpaceWasm runs Wasm 1.0 with mutable globals +and nothing later, which is what Rust's `wasm32v1-none` target builds; a `wasm32-wasip1` or `wasm32-wasip2` build +uses bulk memory, sign extension, multi-value and reference types, which it rejects. A core module imports the same +operations as the world -- the websocket, the storage buckets and the log -- flattened into the `et` functions that +`et-core-guest` defines, and `core-math1` is one. + +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. + ## On a board Without its default `std` feature the crate is `no_std` + `alloc`, and with its `board` feature its `board` module diff --git a/services/ws-wasi-runner/src/convert.rs b/services/ws-wasi-runner/src/convert.rs index 0abb304c..047c7de1 100644 --- a/services/ws-wasi-runner/src/convert.rs +++ b/services/ws-wasi-runner/src/convert.rs @@ -37,20 +37,29 @@ pub enum Frame { Binary(Vec), } -/// The frame a guest's `client-message` goes out as. +/// The frame a guest's `client-message` goes out as, which is the frame [`frame_of`] gives its message. +/// +/// # Errors +/// +/// When the message's opaque JSON or event time does not parse, or the message does not serialise. +pub fn client_frame(message: WitClientMessage) -> Result { + frame_of(wit_to_client_message(message)?) +} + +/// The frame `message` goes out as. /// /// Relay variants travel as raw text / binary frames (no JSON envelope) -- that's what the ws-server hub forwards /// verbatim to other agents. Typed variants serialise through standard tagged JSON. /// /// # Errors /// -/// When the message's opaque JSON or event time does not parse, or the message does not serialise. -pub fn client_frame(message: WitClientMessage) -> Result { +/// When the message does not serialise. +pub fn frame_of(message: ClientMessage) -> Result { #[expect( clippy::wildcard_enum_match_arm, reason = "every non-relay ClientMessage variant takes the standard tagged-JSON path" )] - Ok(match wit_to_client_message(message)? { + Ok(match message { ClientMessage::RelayText { content } => Frame::Text(content), ClientMessage::RelayBinary { content } => Frame::Binary(content), typed => Frame::Text(serde_json::to_string(&typed)?), diff --git a/services/ws-wasi-runner/src/core_module/allocator.rs b/services/ws-wasi-runner/src/core_module/allocator.rs new file mode 100644 index 00000000..bc5b3b68 --- /dev/null +++ b/services/ws-wasi-runner/src/core_module/allocator.rs @@ -0,0 +1,88 @@ +//! Where `SpaceWasm`'s memory comes from: the global allocator, for its own structures and for a guest's linear memory. +//! +//! `SpaceWasm` allocates through two functions its embedder exports, rather than through the global allocator. These +//! forward to it, which every thread can share, so engines on different threads never contend for anything `SpaceWasm` +//! itself holds. + +#![expect( + unsafe_code, + reason = "`SpaceWasm` allocates through exported functions and raw pointers, which only unsafe code can provide" +)] + +use alloc::alloc::{alloc, dealloc, realloc}; +use core::alloc::Layout; +use core::ptr::{NonNull, null_mut, without_provenance_mut}; + +use spacewasm::{AllocError, WasmMemoryAllocator}; + +/// `SpaceWasm`'s allocation hook: `size` bytes aligned to `align`, or null with the failure written to `err`. +/// +/// A zero-sized allocation is a dangling pointer of that alignment, as `SpaceWasm` takes null for a failure. +/// +/// # Safety +/// +/// `err` must be valid to write a `u32` to, as `SpaceWasm` always hands it. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn __spacewasm_alloc(size: usize, align: usize, err: *mut u32) -> *mut u8 { + let Ok(layout) = Layout::from_size_align(size, align) else { + // SAFETY: the caller hands a pointer valid to write the failure to. + unsafe { + err.write(u32::from(AllocError::AllocationFailed)); + } + return null_mut(); + }; + if size == 0 { + return without_provenance_mut(align); + } + // SAFETY: `layout` has a non-zero size. + let pointer = unsafe { alloc(layout) }; + if pointer.is_null() { + // SAFETY: the caller hands a pointer valid to write the failure to. + unsafe { + err.write(u32::from(AllocError::OutOfMemory)); + } + } + pointer +} + +/// `SpaceWasm`'s deallocation hook: free what [`__spacewasm_alloc`] gave it for `size` bytes aligned to `align`. +/// +/// # Safety +/// +/// `ptr` must be what `__spacewasm_alloc` returned for the same `size` and `align`, not yet freed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn __spacewasm_dealloc(ptr: *mut u8, size: usize, align: usize) { + if size == 0 { + return; + } + if let Ok(layout) = Layout::from_size_align(size, align) { + // SAFETY: the caller frees only what `__spacewasm_alloc` gave it for this layout, which was non-zero sized. + unsafe { dealloc(ptr, layout) } + } +} + +/// A guest's linear memory, on the global allocator. +/// +/// `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; + +impl WasmMemoryAllocator for LinearMemory { + /// A new linear memory of `layout`, or why there is none. + fn allocate(&self, layout: Layout) -> Result, AllocError> { + // SAFETY: `SpaceWasm` asks only for a memory of at least one page. + NonNull::new(unsafe { alloc(layout) }).ok_or(AllocError::OutOfMemory) + } + + /// The memory at `ptr` grown from `old_layout` to `layout`, its contents kept, or why it could not grow. + fn reallocate(&self, ptr: NonNull, old_layout: Layout, layout: Layout) -> Result, AllocError> { + // SAFETY: `ptr` is this memory's, allocated here for `old_layout`; a grow keeps its alignment and is non-zero. + NonNull::new(unsafe { realloc(ptr.as_ptr(), old_layout, layout.size()) }).ok_or(AllocError::OutOfMemory) + } + + /// Free the memory at `ptr`, of `layout`. + fn deallocate(&self, ptr: NonNull, layout: Layout) { + // SAFETY: `ptr` is this memory's, allocated here for `layout`. + unsafe { dealloc(ptr.as_ptr(), layout) } + } +} diff --git a/services/ws-wasi-runner/src/core_module/error.rs b/services/ws-wasi-runner/src/core_module/error.rs new file mode 100644 index 00000000..7c2868cb --- /dev/null +++ b/services/ws-wasi-runner/src/core_module/error.rs @@ -0,0 +1,85 @@ +//! Why a core module did not run to completion. + +/// Why a core module did not run to completion. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum CoreError { + /// `SpaceWasm` could not allocate what it needed. + #[error("SpaceWasm could not allocate: {0:?}")] + Alloc(spacewasm::AllocError), + /// `SpaceWasm` could not set up its engine. + #[error("SpaceWasm could not set up its engine: {0:?}")] + Engine(spacewasm::MemoryError), + /// The `et` imports could not be named. + #[error("the `et` imports could not be named: {0:?}")] + ImportName(spacewasm::HostNameError), + /// The `et` imports could not be declared. + #[error("the `et` imports could not be declared: {0:?}")] + Import(spacewasm::HostFunctionError), + /// The module is not Wasm 1.0 `SpaceWasm` accepts, or imports what the `et` module does not provide. + #[error("the module did not decode, validate or link: {0:?}")] + Decode(spacewasm::ParseError), + /// The module exports no `run` function of its own. + #[error("the module exports no `run` function of its own")] + NoRun, + /// The module's `run` does not take nothing and return an `i32`. + #[error("the module's `run` must take nothing and return an i32")] + RunSignature, + /// The engine could not start a function. + #[error("the engine could not start the guest: {0:?}")] + Invoke(spacewasm::InvokeError), + /// The engine could not resume the guest after an import. + #[error("the engine could not resume the guest: {0:?}")] + Resume(spacewasm::ResumeError), + /// The guest trapped. + #[error("the guest trapped: {0:?}")] + Trap(spacewasm::TrapReason), + /// The guest's `run` returned a failure, after logging why. + #[error("the guest's run returned {0}")] + Guest(i32), + /// The guest's `run` finished without a result. + #[error("the guest's run returned no result")] + NoResult, +} + +impl From for CoreError { + fn from(error: spacewasm::AllocError) -> Self { + Self::Alloc(error) + } +} + +impl From for CoreError { + fn from(error: spacewasm::MemoryError) -> Self { + Self::Engine(error) + } +} + +impl From for CoreError { + fn from(error: spacewasm::HostNameError) -> Self { + Self::ImportName(error) + } +} + +impl From for CoreError { + fn from(error: spacewasm::HostFunctionError) -> Self { + Self::Import(error) + } +} + +impl From for CoreError { + fn from(error: spacewasm::ParseError) -> Self { + Self::Decode(error) + } +} + +impl From for CoreError { + fn from(error: spacewasm::InvokeError) -> Self { + Self::Invoke(error) + } +} + +impl From for CoreError { + fn from(error: spacewasm::ResumeError) -> Self { + Self::Resume(error) + } +} diff --git a/services/ws-wasi-runner/src/core_module/host.rs b/services/ws-wasi-runner/src/core_module/host.rs new file mode 100644 index 00000000..cb62fda4 --- /dev/null +++ b/services/ws-wasi-runner/src/core_module/host.rs @@ -0,0 +1,41 @@ +//! What a core module's `et` imports are carried out on. + +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use edge_toolkit::ws::{ClientMessage, ServerMessage}; + +use crate::bindings::et::ws_wasi::ws::WsError; +use crate::bindings::wasi::keyvalue::store::Error as StoreError; +use crate::bindings::wasi::logging::logging::Level; + +/// The hub and its storage as a core module reaches them, and where its log goes. +/// +/// These are the operations of the `et:ws-wasi` world a component imports, taking and giving the hub's own message +/// types rather than the world's, since a core module's messages cross as their JSON. A host's `HostState` carries +/// them out as it does a component's. +pub trait CoreHost { + /// Open the websocket and register with the hub. + fn connect(&mut self) -> impl Future>; + + /// The agent id the hub assigned, empty while the websocket is not open. + fn agent_id(&mut self) -> impl Future; + + /// Send `message` to the hub. + fn send(&mut self, message: ClientMessage) -> impl Future>; + + /// The next message from the hub, waiting up to `timeout_ms`; `None` when none arrived in that time. + fn recv(&mut self, timeout_ms: u32) -> impl Future, WsError>>; + + /// Close the websocket. + fn disconnect(&mut self) -> impl Future; + + /// The value stored under `key` in the bucket named `bucket`; `None` when it holds nothing under that key. + fn get(&mut self, bucket: &str, key: &str) -> impl Future>, StoreError>>; + + /// Store `value` under `key` in the bucket named `bucket`, replacing what was there. + fn set(&mut self, bucket: &str, key: &str, value: Vec) -> impl Future>; + + /// Record one log entry from the guest. + fn log(&mut self, level: Level, context: &str, message: &str) -> impl Future; +} diff --git a/services/ws-wasi-runner/src/core_module/imports.rs b/services/ws-wasi-runner/src/core_module/imports.rs new file mode 100644 index 00000000..1a10fa78 --- /dev/null +++ b/services/ws-wasi-runner/src/core_module/imports.rs @@ -0,0 +1,353 @@ +//! The `et` host module a core module imports, and carrying out the calls that pause it. +//! +//! An import that has to wait on the hub cannot wait inside the interpreter, which is synchronous. It reads its +//! arguments out of the guest's memory, records the call in [`Pending`], and pauses the interpreter; the run loop then +//! has [`resume`] carry the call out on the [`CoreHost`] and resume the guest with its result. `take` waits on +//! nothing, so it copies the result slot out inline. + +#![expect( + clippy::future_not_send, + clippy::single_call_fn, + reason = "SpaceWasm's engine is not Send, so no future driving it is; each import and call is a step named apart" +)] + +use alloc::format; +use alloc::rc::Rc; +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; +use core::cell::RefCell; +use core::fmt::Debug; +use core::ops::ControlFlow; + +use edge_toolkit::ws::ClientMessage; +use et_core_guest::abi; +use spacewasm::{Engine, HostFunction, HostFunctionBreak, HostModule, HostName, HostValList, Memory, Value}; + +use super::{CoreError, CoreHost}; +use crate::bindings::et::ws_wasi::ws::WsError; +use crate::bindings::wasi::logging::logging::Level; + +/// What a guest's log context is when the runner itself logs why one of its calls failed. +const LOG_CONTEXT: &str = env!("CARGO_PKG_NAME"); + +/// What the guest's imports and the run loop share: the call that paused the guest, and the host's result slot. +#[derive(Default)] +pub(super) struct Pending { + /// The call the guest is paused in, until [`resume`] takes it. + call: Option, + /// The bytes the last call that produced any left for `take`. + slot: Vec, +} + +/// [`Pending`], shared by every import and the run loop, all on the one thread the engine runs on. +pub(super) type Shared = Rc>; + +/// An import that waits on the host, with the arguments read out of the guest's memory. +enum Call { + /// `connect`. + Connect, + /// `agent_id`. + AgentId, + /// `send` of the message JSON. + Send(Vec), + /// `recv`, waiting up to this many milliseconds. + Recv(u32), + /// `disconnect`. + Disconnect, + /// `kv_get`. + KvGet { + /// The bucket's name. + bucket: String, + /// The key in it. + key: String, + }, + /// `kv_set`. + KvSet { + /// The bucket's name. + bucket: String, + /// The key in it. + key: String, + /// The value to store. + value: Vec, + }, + /// `log`. + Log { + /// The entry's level. + level: Level, + /// The entry's context. + context: String, + /// The entry itself. + message: String, + }, +} + +/// An import the guest called with arguments that cannot be read, which traps the guest. +/// +/// That is a pointer out of bounds, text that is not UTF-8, or an unknown log level. +struct Fault; + +/// How an import that pauses reads its call out of the guest's memory and its arguments. +type ReadCall = fn(&Memory, &[Value]) -> Result; + +/// The `et` host module, every import recording into or reading from `shared`. +pub(super) fn module(shared: &Shared) -> Result { + let functions = spacewasm::Vec::from_array([ + pausing(shared, abi::CONNECT, "", "i", |_, _| Ok(Call::Connect))?, + pausing(shared, abi::AGENT_ID, "", "i", |_, _| Ok(Call::AgentId))?, + pausing(shared, abi::SEND, "ii", "i", |memory, args| { + Ok(Call::Send(bytes(memory, args, 0, 1)?)) + })?, + pausing(shared, abi::RECV, "i", "i", |_, args| { + Ok(Call::Recv(word(args, 0)?.cast_unsigned())) + })?, + take(shared)?, + pausing(shared, abi::DISCONNECT, "", "", |_, _| Ok(Call::Disconnect))?, + pausing(shared, abi::KV_GET, "iiii", "i", |memory, args| { + Ok(Call::KvGet { + bucket: text(memory, args, 0, 1)?, + key: text(memory, args, 2, 3)?, + }) + })?, + pausing(shared, abi::KV_SET, "iiiiii", "i", |memory, args| { + Ok(Call::KvSet { + bucket: text(memory, args, 0, 1)?, + key: text(memory, args, 2, 3)?, + value: bytes(memory, args, 4, 5)?, + }) + })?, + pausing(shared, abi::LOG, "iiiii", "", |memory, args| { + Ok(Call::Log { + level: level(word(args, 0)?)?, + context: text(memory, args, 1, 2)?, + message: text(memory, args, 3, 4)?, + }) + })?, + ])?; + Ok(HostModule { + name: HostName::try_from_str(abi::MODULE)?, + globals: spacewasm::Vec::zero(), + functions, + memory: spacewasm::Vec::zero(), + table: spacewasm::Vec::zero(), + }) +} + +/// The import `name`, taking `params` and returning `results`, which records the call `read` reads and pauses. +fn pausing( + shared: &Shared, + name: &str, + params: &str, + results: &str, + read: ReadCall, +) -> Result { + let shared = Rc::clone(shared); + let signature = (HostValList::try_new(params)?, HostValList::try_new(results)?); + Ok(HostFunction::try_new( + HostName::try_from_str(name)?, + signature.0, + signature.1, + move |engine, args| match read(&engine.memory, args) { + Ok(call) => { + shared.borrow_mut().call = Some(call); + ControlFlow::Break(HostFunctionBreak::Pause) + } + Err(Fault) => ControlFlow::Break(HostFunctionBreak::Trap), + }, + )?) +} + +/// `take`, which copies the result slot into the guest's buffer when it fits, and returns the slot's length. +fn take(shared: &Shared) -> Result { + let shared = Rc::clone(shared); + Ok(HostFunction::try_new( + HostName::try_from_str(abi::TAKE)?, + HostValList::try_new("ii")?, + HostValList::try_new("i")?, + move |engine, args| match copy_slot(&engine.memory, args, &mut shared.borrow_mut().slot) { + Ok(len) => ControlFlow::Continue(Some(Value::I32(len))), + Err(Fault) => ControlFlow::Break(HostFunctionBreak::Trap), + }, + )?) +} + +/// Copy `slot` to the buffer `args` names in `memory` and empty it, when the buffer holds it; its length either way. +fn copy_slot(memory: &Memory, args: &[Value], slot: &mut Vec) -> Result { + let len = i32::try_from(slot.len()).or(Err(Fault))?; + if address(args, 1)? >= slot.len() { + memory.store(address(args, 0)?, slot).or(Err(Fault))?; + slot.clear(); + } + Ok(len) +} + +/// The `i32` argument at `index`. +fn word(args: &[Value], index: usize) -> Result { + match args.get(index) { + Some(Value::I32(value)) => Ok(*value), + _ => Err(Fault), + } +} + +/// The argument at `index`, an address or a length in the guest's memory. +fn address(args: &[Value], index: usize) -> Result { + usize::try_from(word(args, index)?.cast_unsigned()).or(Err(Fault)) +} + +/// The bytes in `memory` at the address the argument `pointer` gives, as many as the argument `length` gives. +fn bytes(memory: &Memory, args: &[Value], pointer: usize, length: usize) -> Result, Fault> { + let loaded = memory.load(address(args, pointer)?, address(args, length)?); + Ok(loaded.or(Err(Fault))?.to_vec()) +} + +/// The UTF-8 text [`bytes`] reads. +fn text(memory: &Memory, args: &[Value], pointer: usize, length: usize) -> Result { + String::from_utf8(bytes(memory, args, pointer, length)?).or(Err(Fault)) +} + +/// The `wasi:logging` level `code` names. +const fn level(code: i32) -> Result { + match code { + abi::LOG_TRACE => Ok(Level::Trace), + abi::LOG_DEBUG => Ok(Level::Debug), + abi::LOG_INFO => Ok(Level::Info), + abi::LOG_WARN => Ok(Level::Warn), + abi::LOG_ERROR => Ok(Level::Error), + abi::LOG_CRITICAL => Ok(Level::Critical), + _ => Err(Fault), + } +} + +/// Carry out on `host` the call the guest on `engine` paused in, and resume the guest with what the import returns. +/// +/// # Errors +/// +/// When the engine refuses the result, which it does only for one of another type than the import declared. +pub(super) async fn resume(host: &mut H, shared: &Shared, engine: &mut Engine) -> Result<(), CoreError> +where + H: CoreHost, +{ + let call = shared.borrow_mut().call.take(); + match call { + Some(call) => perform(call, host, shared, engine).await, + None => Ok(engine.resume(None)?), + } +} + +/// Carry `call` out on `host`, and resume the guest paused in it, on `engine`, with what the import returns. +async fn perform(call: Call, host: &mut H, shared: &Shared, engine: &mut Engine) -> Result<(), CoreError> +where + H: CoreHost, +{ + let code = match call { + Call::Connect => { + let connected = host.connect().await; + ws_status(host, abi::CONNECT, connected).await + } + Call::AgentId => { + let agent_id = host.agent_id().await; + fill(shared, agent_id.into_bytes()) + } + Call::Send(json) => send(host, &json).await, + Call::Recv(timeout_ms) => recv(host, shared, timeout_ms).await, + Call::Disconnect => { + host.disconnect().await; + return Ok(engine.resume(None)?); + } + Call::KvGet { bucket, key } => match host.get(&bucket, &key).await { + Ok(Some(value)) => { + let _len = fill(shared, value); + abi::FOUND + } + Ok(None) => abi::ABSENT, + Err(error) => failed(host, abi::KV_GET, &error, abi::STORE).await, + }, + Call::KvSet { bucket, key, value } => match host.set(&bucket, &key, value).await { + Ok(()) => abi::SUCCESS, + Err(error) => failed(host, abi::KV_SET, &error, abi::STORE).await, + }, + Call::Log { + level, + context, + message, + } => { + host.log(level, &context, &message).await; + return Ok(engine.resume(None)?); + } + }; + Ok(engine.resume(Some(Value::I32(code)))?) +} + +/// `send` of the message `json` is: the message, unless it is not a `ClientMessage`. +async fn send(host: &mut H, json: &[u8]) -> i32 +where + H: CoreHost, +{ + match serde_json::from_slice::(json) { + Ok(message) => { + let sent = host.send(message).await; + ws_status(host, abi::SEND, sent).await + } + Err(error) => failed(host, abi::SEND, &error, abi::DECODE).await, + } +} + +/// `recv`: the next message's JSON into the slot, and its length, or [`abi::NOTHING`] when none arrived. +async fn recv(host: &mut H, shared: &Shared, timeout_ms: u32) -> i32 +where + H: CoreHost, +{ + match host.recv(timeout_ms).await { + Ok(Some(message)) => match serde_json::to_vec(&message) { + Ok(json) => fill(shared, json), + Err(error) => failed(host, abi::RECV, &error, abi::DECODE).await, + }, + Ok(None) => abi::NOTHING, + Err(error) => { + let code = ws_code(&error); + failed(host, abi::RECV, &error, code).await + } + } +} + +/// Leave `bytes` in the slot for `take`, returning their length. +fn fill(shared: &Shared, bytes: Vec) -> i32 { + // A guest's memory is at most 4 GiB, so a slot it has to fit fits an `i32` length or could never be taken. + let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX); + shared.borrow_mut().slot = bytes; + len +} + +/// What a websocket call's `result` returns to the guest. +async fn ws_status(host: &mut H, name: &str, result: Result<(), WsError>) -> i32 +where + H: CoreHost, +{ + match result { + Ok(()) => abi::SUCCESS, + Err(error) => { + let code = ws_code(&error); + failed(host, name, &error, code).await + } + } +} + +/// The code a websocket `error` returns to the guest. +const fn ws_code(error: &WsError) -> i32 { + match error { + WsError::Transport(_) => abi::TRANSPORT, + WsError::Decode(_) => abi::DECODE, + WsError::NotConnected => abi::NOT_CONNECTED, + WsError::AlreadyConnected => abi::ALREADY_CONNECTED, + } +} + +/// Log why the import `name` failed with `error`, and return `code` to the guest, which is told no more than that. +async fn failed(host: &mut H, name: &str, error: &E, code: i32) -> i32 +where + H: CoreHost, + E: Debug, +{ + host.log(Level::Warn, LOG_CONTEXT, &format!("{name} failed: {error:?}")) + .await; + code +} diff --git a/services/ws-wasi-runner/src/core_module/mod.rs b/services/ws-wasi-runner/src/core_module/mod.rs new file mode 100644 index 00000000..df2e4fcb --- /dev/null +++ b/services/ws-wasi-runner/src/core_module/mod.rs @@ -0,0 +1,20 @@ +//! 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 +//! instructions, yielding between them, so a guest computing for a long time never keeps its executor from everything +//! else. +//! +//! `no_std` with `alloc`, so that a board can host a core module as a host does. + +mod allocator; +mod error; +mod host; +mod imports; +mod run; +mod stream; + +pub use self::error::CoreError; +pub use self::host::CoreHost; +pub use self::run::run; diff --git a/services/ws-wasi-runner/src/core_module/run.rs b/services/ws-wasi-runner/src/core_module/run.rs new file mode 100644 index 00000000..15569c9f --- /dev/null +++ b/services/ws-wasi-runner/src/core_module/run.rs @@ -0,0 +1,146 @@ +//! Decode, link and run a core module, driving the interpreter between the imports that pause it. + +#![expect( + clippy::future_not_send, + reason = "SpaceWasm's engine is neither Send nor Sync, so no future driving it can be; a host gives it a thread" +)] + +use core::pin::Pin; +use core::task::{Context, Poll}; + +use et_core_guest::abi; +use spacewasm::{ + CodeBuilder, CompilerOptions, Engine, ExportDesc, Interpreter, InterpreterResult, InterpreterRunner as _, Module, + Ref, ValType, WasmRef, +}; + +use super::allocator::LinearMemory; +use super::imports::{self, Shared}; +use super::stream::ModuleStream; +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. +const STACK_WORDS: usize = 0x10_0000; +/// The deepest nesting of blocks the decoder validates a function with. +const MAX_CONTROL_FRAMES: usize = 256; +/// The deepest operand stack the decoder validates a function with. +const MAX_STACK_DEPTH: usize = 1024; +/// The most pages of compiled code a module may compile to. +const MAX_CODE_PAGES: usize = 0x1_0000; +/// What the store names the guest's module. +const MODULE_NAME: &str = "main"; + +/// Run the core module `wasm` until its `run` returns, carrying its imports out on `host`. +/// +/// The module's start function, when it has one, runs first. Between imports the interpreter runs in slices of +/// instructions, yielding to the executor after each. +/// +/// # Errors +/// +/// 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> +where + H: CoreHost, +{ + let shared = Shared::default(); + let mut code = CodeBuilder::new(CompilerOptions { + allow_memory_grow: true, + 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 module = Module::new::( + MODULE_NAME, + &mut ModuleStream::new(wasm), + &mut engine.store, + &mut code, + memory, + )?; + let entry = entry(&module)?; + let module_ref = engine.push_module(module)?; + if let Some(start) = engine.module_start(module_ref) { + engine.invoke(start, &[])?; + drive(&mut engine, &code, &shared, &mut host).await?; + } + engine.invoke( + WasmRef { + module: module_ref, + index: entry, + }, + &[], + )?; + drive(&mut engine, &code, &shared, &mut host).await?; + match engine.result.map(|result| result.read_i32()) { + Some(abi::SUCCESS) => Ok(()), + Some(failure) => Err(CoreError::Guest(failure)), + None => Err(CoreError::NoResult), + } +} + +/// The index of `module`'s own `run` function, checking it takes nothing and returns an `i32`. +#[expect( + clippy::single_call_fn, + reason = "the export lookup of run, named apart from the decoding around it" +)] +fn entry(module: &Module) -> Result { + let Some(ExportDesc::Func(index)) = module + .exports + .iter() + .find(|export| export.name == abi::RUN) + .map(|export| &export.desc) + else { + return Err(CoreError::NoRun); + }; + let Some(Ref::Module(local)) = module.get_func_ref(*index) else { + return Err(CoreError::NoRun); + }; + let signature = module + .functions + .get(usize::from(local)) + .and_then(|function| module.types.get(usize::try_from(function.ty.0).ok()?)); + match signature { + Some(signature) if signature.params.is_empty() && *signature.returns == [ValType::I32] => Ok(local), + _ => Err(CoreError::RunSignature), + } +} + +/// Run the function the engine was last invoked with to its end, carrying out each import that pauses it. +async fn drive(engine: &mut Engine, code: &CodeBuilder, shared: &Shared, host: &mut H) -> Result<(), CoreError> +where + H: CoreHost, +{ + loop { + match Interpreter.run(code.pages(), engine, FUEL_SLICE) { + InterpreterResult::Finished => return Ok(()), + InterpreterResult::Trap(reason) => return Err(CoreError::Trap(reason)), + InterpreterResult::OutOfFuel => YieldNow::default().await, + InterpreterResult::Pause => imports::resume(host, shared, engine).await?, + } + } +} + +/// Yields to the executor once, so that whatever else it runs makes progress between slices of the guest. +#[derive(Default)] +struct YieldNow { + /// Whether this future has yielded already. + yielded: bool, +} + +impl Future for YieldNow { + type Output = (); + + /// Pending the first time, waking the task at once, and ready the second. + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> { + if self.yielded { + Poll::Ready(()) + } else { + self.yielded = true; + cx.waker().wake_by_ref(); + Poll::Pending + } + } +} diff --git a/services/ws-wasi-runner/src/core_module/stream.rs b/services/ws-wasi-runner/src/core_module/stream.rs new file mode 100644 index 00000000..218badf9 --- /dev/null +++ b/services/ws-wasi-runner/src/core_module/stream.rs @@ -0,0 +1,51 @@ +//! A module's bytes as the stream `SpaceWasm` decodes it from: the whole module, as one chunk. + +#![expect( + unsafe_code, + reason = "SpaceWasm takes each chunk as raw parts, which only unsafe code can build" +)] + +#[cfg(not(feature = "std"))] +use alloc::vec::Vec; + +use spacewasm::{InnerVec, WasmStream}; + +/// The module's bytes, owned so that the one chunk handed out stays valid until the decoder hands it back. +pub(super) struct ModuleStream { + /// The module, which the chunk points into. + bytes: Vec, + /// Whether the chunk has been handed out, after which the stream has ended. + read: bool, +} + +impl ModuleStream { + /// A stream of a copy of `wasm`. + #[expect( + clippy::single_call_fn, + reason = "the stream's constructor, called where the module is decoded" + )] + pub(super) fn new(wasm: &[u8]) -> Self { + Self { + bytes: wasm.to_vec(), + read: false, + } + } +} + +impl WasmStream for ModuleStream { + /// The whole module the first time, and the end of the stream after that. + fn read(&mut self) -> Result>, u8> { + if core::mem::replace(&mut self.read, true) { + return Ok(None); + } + let len = self.bytes.len(); + // SAFETY: `bytes` is this stream's own allocation of `len` initialised bytes, which neither the decoder nor + // `InnerVec` frees or writes, and which outlives the chunk: the decoder hands it back before the stream drops. + Ok(Some(unsafe { + InnerVec::from_raw_parts(self.bytes.as_mut_ptr(), len, len) + })) + } + + /// Nothing to reclaim: the chunk points into the stream's own bytes, which drop with it. + fn return_(&mut self, _chunk: InnerVec) {} +} diff --git a/services/ws-wasi-runner/src/error.rs b/services/ws-wasi-runner/src/error.rs index fe97bbad..e02236bf 100644 --- a/services/ws-wasi-runner/src/error.rs +++ b/services/ws-wasi-runner/src/error.rs @@ -24,6 +24,25 @@ pub enum RunnerError { #[error("module run() returned err: {0:?}")] Guest(#[from] EntryError), + + #[cfg(feature = "core-modules")] + #[error(transparent)] + Core(#[from] crate::core_module::CoreError), + + /// The thread a core module runs on could not be started. + #[cfg(feature = "core-modules")] + #[error("the core module's thread could not be started: {0}")] + CoreThread(#[from] std::io::Error), + + /// The thread a core module runs on ended without reporting how the module ended, which only a panic does. + #[cfg(feature = "core-modules")] + #[error("the core module's thread ended without a result")] + CoreLost(#[from] tokio::sync::oneshot::error::RecvError), + + /// The module is a core module, which only a runner built with the `core-modules` feature runs. + #[cfg(not(feature = "core-modules"))] + #[error("the module is a core module, which this runner, built without the `core-modules` feature, cannot run")] + CoreModulesDisabled, } impl From for RunnerError { diff --git a/services/ws-wasi-runner/src/host/core_module.rs b/services/ws-wasi-runner/src/host/core_module.rs new file mode 100644 index 00000000..b343b982 --- /dev/null +++ b/services/ws-wasi-runner/src/host/core_module.rs @@ -0,0 +1,96 @@ +//! Run a core module on `SpaceWasm`, its `et` imports carried out on the same [`HostState`] a component's are. + +use edge_toolkit::ws::{ClientMessage, ServerMessage}; +use tracing::{Instrument as _, Span}; + +use super::wasi_keyvalue::{bucket_get, bucket_set}; +use super::ws::{next_message, send_frame}; +use crate::bindings::et::ws_wasi::ws::{self, WsError}; +use crate::bindings::wasi::keyvalue::store::Error as StoreError; +use crate::bindings::wasi::logging::logging::{self, Level}; +use crate::convert::frame_of; +use crate::core_module::{self, CoreHost}; +use crate::keyvalue::bucket_from_identifier; +use crate::{HostState, RunnerError}; + +impl CoreHost for HostState { + /// Open the websocket as a component's `connect` does. + async fn connect(&mut self) -> Result<(), WsError> { + ws::Host::connect(self).await + } + + /// The agent id, as a component's `agent-id` gives it. + async fn agent_id(&mut self) -> String { + ws::Host::agent_id(self).await + } + + /// Send `message` as the frame it goes out as. + async fn send(&mut self, message: ClientMessage) -> Result<(), WsError> { + send_frame(&self.ws, 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> { + next_message(&self.ws, timeout_ms).await + } + + /// Close the websocket as a component's `disconnect` does. + async fn disconnect(&mut self) { + ws::Host::disconnect(self).await; + } + + /// The value under `key` in the bucket `bucket` names, read as a component's `get` reads it. + async fn get(&mut self, bucket: &str, key: &str) -> Result>, StoreError> { + bucket_get(&self.rest, &bucket_from_identifier(bucket)?, key).await + } + + /// Store `value` under `key` in the bucket `bucket` names, as a component's `set` does. + async fn set(&mut self, bucket: &str, key: &str, value: Vec) -> Result<(), StoreError> { + bucket_set(&self.rest, &bucket_from_identifier(bucket)?, key, value).await + } + + /// Record the entry as a component's `wasi:logging` call is. + async fn log(&mut self, level: Level, context: &str, message: &str) { + logging::Host::log(self, level, context.to_owned(), message.to_owned()).await; + } +} + +/// Run the core module `wasm` on `SpaceWasm` until its `run` returns, its imports carried out on `host`. +/// +/// `SpaceWasm`'s engine cannot move between threads, so the module runs on a thread of its own, driven by the current +/// runtime's handle. That keeps this future `Send`, keeps a guest that computes for a long time off the runtime's +/// workers, and leaves nothing for the runtime to wait on at shutdown when the runner gives up on the guest. +/// +/// Dropping this future stops the module, as dropping a component's run does: the thread stops at the guest's next +/// slice of instructions, or in the middle of a host call, and drops `host` without calling it again. +/// +/// # Errors +/// +/// When the thread cannot be started, the module fails as [`core_module::run`] describes, or the thread dies first. +#[expect( + clippy::integer_division_remainder_used, + reason = "tokio::select! expands to remainder operations" +)] +pub async fn run_core_module(wasm: Vec, host: H) -> Result<(), RunnerError> +where + H: CoreHost + Send + 'static, +{ + let runtime = tokio::runtime::Handle::current(); + let span = Span::current(); + let (mut sender, receiver) = tokio::sync::oneshot::channel(); + let _thread = std::thread::Builder::new() + .name("spacewasm".to_owned()) + .spawn(move || { + runtime.block_on(async move { + tokio::select! { + ended = core_module::run(&wasm, host).instrument(span) => { + // The receiver can go between the run ending and this send, leaving nobody to tell. + let _unheard: Result<(), _> = sender.send(ended); + } + // The runner stopped waiting, so the run is dropped where it stands, and `host` with it. + () = sender.closed() => {} + } + }); + })?; + Ok(receiver.await??) +} diff --git a/services/ws-wasi-runner/src/host/mod.rs b/services/ws-wasi-runner/src/host/mod.rs index 6dc897cb..860a633c 100644 --- a/services/ws-wasi-runner/src/host/mod.rs +++ b/services/ws-wasi-runner/src/host/mod.rs @@ -5,6 +5,8 @@ //! used by the storage interface, the ws connection state, and the wgpu device //! used by the gfx interface. +#[cfg(feature = "core-modules")] +mod core_module; mod error; mod log; mod run; @@ -14,6 +16,8 @@ pub mod wasi_keyvalue; pub mod wasi_nn; pub mod ws; +#[cfg(feature = "core-modules")] +pub use self::core_module::run_core_module; pub use self::run::run_module; pub use self::state::HostState; pub use self::ws::WsBackend; diff --git a/services/ws-wasi-runner/src/host/run.rs b/services/ws-wasi-runner/src/host/run.rs index 4c0c6f21..da75fa75 100644 --- a/services/ws-wasi-runner/src/host/run.rs +++ b/services/ws-wasi-runner/src/host/run.rs @@ -3,6 +3,7 @@ use et_ws_runner_common::{derive_http_base, fetch_main_field}; use tracing::{Instrument as _, info, info_span}; +use wasmparser::Parser; use wasmtime::component::{Component, HasSelf, Linker}; use wasmtime::{Config, Engine, Store}; @@ -49,6 +50,18 @@ async fn run_module_inner( .instrument(info_span!("fetch_component", module = module_name, file = %main)) .await?; + // A core module cannot import the `et:ws-wasi` world, so it runs on SpaceWasm with the `et` imports instead. + if Parser::is_core_wasm(&wasm_bytes) { + #[cfg(feature = "core-modules")] + { + info!(module = module_name, "running the core module on SpaceWasm"); + let host_state = HostState::new(&http_base, ws_url.to_string(), connect_ack_timeout, coverage); + return super::core_module::run_core_module(wasm_bytes, host_state).await; + } + #[cfg(not(feature = "core-modules"))] + return Err(RunnerError::CoreModulesDisabled); + } + let mut config = Config::new(); #[expect( unused_results, diff --git a/services/ws-wasi-runner/src/host/wasi_keyvalue.rs b/services/ws-wasi-runner/src/host/wasi_keyvalue.rs index d96951cf..02fedfcb 100644 --- a/services/ws-wasi-runner/src/host/wasi_keyvalue.rs +++ b/services/ws-wasi-runner/src/host/wasi_keyvalue.rs @@ -11,10 +11,7 @@ use crate::keyvalue::{Bucket, bucket_from_identifier, kv_not_implemented}; /// Drain a progenitor `ByteStream` into a `Vec`. /// /// Used by both bucket kinds since the wasi:keyvalue/store interface returns whole values. -#[expect( - clippy::single_call_fn, - reason = "named helper; used once by ::get" -)] +#[expect(clippy::single_call_fn, reason = "named helper; used once by bucket_get")] async fn collect_stream(mut stream: et_rest_client::ByteStream) -> Result, Error> { let mut out = Vec::new(); while let Some(chunk) = stream.next().await { @@ -36,44 +33,71 @@ impl Host for HostState { } } +/// The value stored under `key` in `bucket`, asked of the hub through `rest`; `None` when it holds nothing under it. +/// +/// A function of the client rather than a method, so that the WIT `get` can call it while it holds the bucket from +/// its resource table. +/// +/// # Errors +/// +/// When the hub could not be reached, or refused the request. +pub async fn bucket_get(rest: &et_rest_client::Client, bucket: &Bucket, key: &str) -> Result>, Error> { + match bucket { + Bucket::Storage { agent_id } => match rest.get_file(agent_id, key).await { + Ok(response) => Ok(Some(collect_stream(response.into_inner()).await?)), + // The OpenAPI spec gives the endpoint a 404 variant, so progenitor surfaces "no such key" as + // `Error::ErrorResponse`. + Err(et_rest_client::Error::ErrorResponse(_)) => Ok(None), + Err(e) => Err(Error::Other(format!("GET {key}: {e}"))), + }, + // Off the typed call, which would percent-encode the module name as one path segment. A module + // is served under the name it publishes under, so that name carries the owner scope and holds a + // `/` and an `@` -- encoded, the request asks for a module no hub serves. + Bucket::Modules { module_name } => match et_ws_runner_common::fetch_module_file(rest, module_name, key).await { + Ok(bytes) => Ok(Some(bytes)), + // Same "no such key" as above, told from the status because a request that did not go + // through the generated client has no typed `ErrorResponse` variant to match on. + Err(e) if et_ws_runner_common::is_module_file_missing(&e) => Ok(None), + Err(e) => Err(Error::Other(format!("GET {key}: {e}"))), + }, + } +} + +/// Store `value` under `key` in `bucket` through `rest`, replacing what was there. +/// +/// A function of the client for the reason [`bucket_get`] is. +/// +/// # Errors +/// +/// When `bucket` is a module's, which is read-only, or the hub could not be reached or refused the request. +pub async fn bucket_set( + rest: &et_rest_client::Client, + bucket: &Bucket, + key: &str, + value: Vec, +) -> Result<(), Error> { + let Bucket::Storage { agent_id } = bucket else { + return Err(Error::AccessDenied); + }; + let _response = rest.put_file(agent_id, key, value).await?; + Ok(()) +} + #[expect( clippy::unused_async_trait_impl, reason = "generated HostBucket trait declares every method async; the not-implemented ones never await" )] impl HostBucket for HostState { + /// The value under `key` in the bucket `self_` holds open. async fn get(&mut self, self_: Resource, key: String) -> Result>, Error> { let bucket = self.resource_table.get(&self_)?; - match bucket { - Bucket::Storage { agent_id } => match self.rest.get_file(agent_id, &key).await { - Ok(response) => Ok(Some(collect_stream(response.into_inner()).await?)), - // The OpenAPI spec gives the endpoint a 404 variant, so progenitor surfaces "no such key" as - // `Error::ErrorResponse`. - Err(et_rest_client::Error::ErrorResponse(_)) => Ok(None), - Err(e) => Err(Error::Other(format!("GET {key}: {e}"))), - }, - // Off the typed call, which would percent-encode the module name as one path segment. A module - // is served under the name it publishes under, so that name carries the owner scope and holds a - // `/` and an `@` -- encoded, the request asks for a module no hub serves. - Bucket::Modules { module_name } => { - match et_ws_runner_common::fetch_module_file(&self.rest, module_name, &key).await { - Ok(bytes) => Ok(Some(bytes)), - // Same "no such key" as above, told from the status because a request that did not go - // through the generated client has no typed `ErrorResponse` variant to match on. - Err(e) if et_ws_runner_common::is_module_file_missing(&e) => Ok(None), - Err(e) => Err(Error::Other(format!("GET {key}: {e}"))), - } - } - } + bucket_get(&self.rest, bucket, &key).await } + /// Store `value` under `key` in the bucket `self_` holds open. async fn set(&mut self, self_: Resource, key: String, value: Vec) -> Result<(), Error> { let bucket = self.resource_table.get(&self_)?; - let agent_id = match bucket { - Bucket::Storage { agent_id } => agent_id.clone(), - Bucket::Modules { .. } => return Err(Error::AccessDenied), - }; - let _response = self.rest.put_file(&agent_id, &key, value).await?; - Ok(()) + bucket_set(&self.rest, bucket, &key, value).await } async fn delete(&mut self, _self_: Resource, _key: String) -> Result<(), Error> { diff --git a/services/ws-wasi-runner/src/host/ws.rs b/services/ws-wasi-runner/src/host/ws.rs index 053b29c7..dd82787b 100644 --- a/services/ws-wasi-runner/src/host/ws.rs +++ b/services/ws-wasi-runner/src/host/ws.rs @@ -23,6 +23,47 @@ use crate::bindings::et::ws_messages::messages::{ use crate::bindings::et::ws_wasi::ws::{Host, State, WsError}; use crate::convert::{Frame, client_frame, server_message, server_message_to_wit}; +/// Send `frame` on the websocket open in `ws`. +/// +/// # Errors +/// +/// When the websocket is not open, or the frame could not be sent. +pub async fn send_frame(ws: &Mutex>, frame: Frame) -> Result<(), WsError> { + let outgoing = match frame { + Frame::Text(text) => tungstenite::Message::text(text), + Frame::Binary(bytes) => tungstenite::Message::binary(bytes), + }; + // Clone the sink Arc and release the outer lock before awaiting the inner send -- keeping `ws` locked across the + // await would block every other ws-method call. + let sink = ws + .lock() + .await + .as_ref() + .map(|backend| Arc::clone(&backend.sink)) + .ok_or(WsError::NotConnected)?; + Ok(sink.lock().await.send(outgoing).await?) +} + +/// The next message the hub sent on the websocket open in `ws`, waiting up to `timeout_ms`. +/// +/// `None` when none arrived in that time. +/// +/// # Errors +/// +/// When the websocket is not open. +pub async fn next_message(ws: &Mutex>, timeout_ms: u32) -> Result, WsError> { + // Same lock-handoff pattern as `send_frame`: grab the inbox Arc, drop the outer lock, then await. + let inbox = ws + .lock() + .await + .as_ref() + .map(|backend| Arc::clone(&backend.inbox)) + .ok_or(WsError::NotConnected)?; + let mut rx = inbox.lock().await; + let timeout = Duration::from_millis(u64::from(timeout_ms)); + Ok(tokio::time::timeout(timeout, rx.recv()).await.ok().flatten()) +} + // The `et:ws-messages/messages` interface only declares types -- no functions. wasmtime-bindgen still requires a `Host` // impl so the linker has somewhere to anchor the interface. The trait body is empty. impl crate::bindings::et::ws_messages::messages::Host for HostState {} @@ -192,40 +233,17 @@ impl Host for HostState { } } + /// Send the guest's `message` as the frame it goes out as. async fn send(&mut self, message: WitClientMessage) -> Result<(), WsError> { - let outgoing = match client_frame(message)? { - Frame::Text(text) => tungstenite::Message::text(text), - Frame::Binary(bytes) => tungstenite::Message::binary(bytes), - }; - // Clone the sink Arc and release the outer lock before awaiting the inner send -- keeping `self.ws` locked - // across the await would block every other ws-method call. - let sink = self - .ws - .lock() - .await - .as_ref() - .map(|backend| Arc::clone(&backend.sink)) - .ok_or(WsError::NotConnected)?; - Ok(sink.lock().await.send(outgoing).await?) + send_frame(&self.ws, client_frame(message)?).await } + /// The next message, waiting up to `timeout_ms`, in the world's types. async fn recv(&mut self, timeout_ms: u32) -> Result, WsError> { - // Same lock-handoff pattern as `send`: grab the inbox Arc, drop the outer lock, then await. - let inbox = self - .ws - .lock() - .await - .as_ref() - .map(|backend| Arc::clone(&backend.inbox)) - .ok_or(WsError::NotConnected)?; - let parsed = { - let mut rx = inbox.lock().await; - match tokio::time::timeout(Duration::from_millis(u64::from(timeout_ms)), rx.recv()).await { - Ok(Some(value)) => value, - Ok(None) | Err(_) => return Ok(None), - } - }; - Ok(Some(server_message_to_wit(parsed)?)) + match next_message(&self.ws, timeout_ms).await? { + Some(message) => Ok(Some(server_message_to_wit(message)?)), + None => Ok(None), + } } async fn disconnect(&mut self) { diff --git a/services/ws-wasi-runner/src/lib.rs b/services/ws-wasi-runner/src/lib.rs index 804c4a0f..acf5611f 100644 --- a/services/ws-wasi-runner/src/lib.rs +++ b/services/ws-wasi-runner/src/lib.rs @@ -3,7 +3,12 @@ #![cfg_attr(feature = "docs", doc = document_features::document_features!())] #![cfg_attr(not(feature = "std"), no_std)] -#[cfg(any(not(feature = "std"), feature = "board", feature = "precompile"))] +#[cfg(any( + feature = "board", + feature = "core-modules", + feature = "precompile", + not(feature = "std") +))] extern crate alloc; pub mod bindings; @@ -12,6 +17,8 @@ pub mod board; #[cfg(feature = "std")] pub mod config; pub mod convert; +#[cfg(feature = "core-modules")] +pub mod core_module; #[cfg(any(feature = "board", feature = "precompile"))] pub mod engine; #[cfg(feature = "std")] diff --git a/services/ws-wasi-runner/tests/core_module.rs b/services/ws-wasi-runner/tests/core_module.rs new file mode 100644 index 00000000..65e8c329 --- /dev/null +++ b/services/ws-wasi-runner/tests/core_module.rs @@ -0,0 +1,526 @@ +//! Covers running a Wasm 1.0 core module on `SpaceWasm`: what a module must export, each `et` import and every answer +//! it gives the guest, the traps a bad call raises, and stopping a run that is dropped. Guests are inline WAT, run +//! against a scripted host; the last case runs one against a real hub through the runner's own `HostState`. +#![cfg(test)] + +use std::collections::{BTreeMap, VecDeque}; +use std::time::Duration; + +use edge_toolkit::ws::{ClientMessage, ServerMessage}; +use et_core_guest::abi; +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::host::run_core_module; +use spacewasm::TrapReason; +use tokio::sync::oneshot::{self, error::RecvError}; +use tokio::time::error::Elapsed; + +/// Every `et` import, declared as a guest declares them. +const IMPORTS: &str = r#" + (import "et" "connect" (func $connect (result i32))) + (import "et" "agent_id" (func $agent_id (result i32))) + (import "et" "send" (func $send (param i32 i32) (result i32))) + (import "et" "recv" (func $recv (param i32) (result i32))) + (import "et" "take" (func $take (param i32 i32) (result i32))) + (import "et" "disconnect" (func $disconnect)) + (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))) + (memory (export "memory") 1) +"#; + +/// The `et-list-agents` message as WAT data, 25 bytes once parsed. +const LIST_AGENTS: &str = r#"{\"type\":\"et-list-agents\"}"#; + +/// Sending the 25 bytes of [`LIST_AGENTS`] placed at address 0. +const SEND_LIST_AGENTS: &str = "(call $send (i32.const 0) (i32.const 25))"; + +/// Whether a run ended with the error a case expects. +type Expected = fn(&CoreError) -> bool; + +/// The host as a script: what it answers each call with, and a record of what the guest did. +#[derive(Default)] +struct Script { + /// Whether the websocket is open. + connected: bool, + /// What `send` fails with, when set. + send_error: Option, + /// What each `recv` answers, in order; a timeout once they run out. + inbox: VecDeque, WsError>>, + /// Whether every storage request fails. + store_fails: bool, + /// The storage buckets: `(bucket, key)` to value. + stored: BTreeMap<(String, String), Vec>, + /// Every message sent. + sent: Vec, + /// Every log entry: level, context, message. + logged: Vec<(Level, String, String)>, + /// Whether the guest disconnected. + disconnected: bool, +} + +impl Script { + /// The value the test expects stored under `key` in `bucket`. + fn value(&self, bucket: &str, key: &str) -> &[u8] { + &self.stored[&(bucket.to_owned(), key.to_owned())] + } +} + +#[expect( + clippy::unused_async_trait_impl, + reason = "CoreHost's methods return futures, which a script answers at once" +)] +impl CoreHost for &mut Script { + /// Open the websocket, which a second open finds already open. + async fn connect(&mut self) -> Result<(), WsError> { + if self.connected { + return Err(WsError::AlreadyConnected); + } + self.connected = true; + Ok(()) + } + + async fn agent_id(&mut self) -> String { + "agent-1".to_owned() + } + + /// Record `message`, unless the script fails the send. + async fn send(&mut self, message: ClientMessage) -> Result<(), WsError> { + if let Some(error) = self.send_error.take() { + return Err(error); + } + self.sent.push(message); + Ok(()) + } + + /// The inbox's next answer, or a timeout once it is empty. + async fn recv(&mut self, _timeout_ms: u32) -> Result, WsError> { + let Some(answer) = self.inbox.pop_front() else { + return Ok(None); + }; + answer + } + + async fn disconnect(&mut self) { + self.disconnected = true; + } + + /// The stored value, unless the script fails storage. + async fn get(&mut self, bucket: &str, key: &str) -> Result>, StoreError> { + if self.store_fails { + return Err(StoreError::AccessDenied); + } + Ok(self.stored.get(&(bucket.to_owned(), key.to_owned())).cloned()) + } + + /// Store the value, unless the script fails storage. + async fn set(&mut self, bucket: &str, key: &str, value: Vec) -> Result<(), StoreError> { + if self.store_fails { + return Err(StoreError::AccessDenied); + } + let _replaced = self.stored.insert((bucket.to_owned(), key.to_owned()), value); + Ok(()) + } + + async fn log(&mut self, level: Level, context: &str, message: &str) { + self.logged.push((level, context.to_owned(), message.to_owned())); + } +} + +/// A host a run owns, whose `recv` never answers, and whose dropping the test sees. +struct Held { + /// Never sent on, so its receiver fails exactly when the host is dropped. + _released: oneshot::Sender<()>, +} + +#[expect( + clippy::unused_async_trait_impl, + reason = "CoreHost's methods return futures, which this host answers at once, all but recv" +)] +impl CoreHost for Held { + async fn connect(&mut self) -> Result<(), WsError> { + Ok(()) + } + + async fn agent_id(&mut self) -> String { + String::default() + } + + async fn send(&mut self, _message: ClientMessage) -> Result<(), WsError> { + Ok(()) + } + + /// Wait for a message that never comes. + async fn recv(&mut self, _timeout_ms: u32) -> Result, WsError> { + std::future::pending().await + } + + async fn disconnect(&mut self) {} + + async fn get(&mut self, _bucket: &str, _key: &str) -> Result>, StoreError> { + Ok(None) + } + + async fn set(&mut self, _bucket: &str, _key: &str, _value: Vec) -> Result<(), StoreError> { + Ok(()) + } + + async fn log(&mut self, _level: Level, _context: &str, _message: &str) {} +} + +/// A core module with every `et` import and one page of memory, plus `body`. +fn module(body: &str) -> Vec { + wat::parse_str(format!("(module {IMPORTS} {body})")).unwrap() +} + +/// A core module whose `run` is `instructions`, with `data` placed at address 0. +fn guest(data: &str, instructions: &str) -> Vec { + module(&format!( + r#"(data (i32.const 0) "{data}") (func (export "run") (result i32) {instructions})"# + )) +} + +/// Run `wasm` against `script`. +#[expect( + clippy::future_not_send, + reason = "SpaceWasm's engine is neither Send nor Sync, and each test awaits it on its own runtime" +)] +async fn run_on(wasm: &[u8], script: &mut Script) -> Result<(), CoreError> { + run(wasm, script).await +} + +#[tokio::test] +async fn run_returning_success_succeeds() { + run_on(&guest("", "(i32.const 0)"), &mut Script::default()) + .await + .unwrap(); +} + +#[tokio::test] +async fn run_returning_a_failure_is_the_guests_failure() { + let ended = run_on(&guest("", "(i32.const 7)"), &mut Script::default()).await; + assert!(matches!(ended, Err(CoreError::Guest(7_i32)))); +} + +#[tokio::test] +async fn a_module_needs_a_run_function_of_its_own() { + let unnamed = module(r#"(func (export "main") (result i32) (i32.const 0))"#); + let memory = module(r#"(export "run" (memory 0))"#); + let imported = module(r#"(export "run" (func $connect))"#); + for wasm in [unnamed, memory, imported] { + assert!(matches!( + run_on(&wasm, &mut Script::default()).await, + Err(CoreError::NoRun) + )); + } +} + +#[tokio::test] +async fn run_must_take_nothing_and_return_an_i32() { + let taking = module(r#"(func (export "run") (param i32) (result i32) (i32.const 0))"#); + let returning_nothing = module(r#"(func (export "run"))"#); + for wasm in [taking, returning_nothing] { + let ended = run_on(&wasm, &mut Script::default()).await; + assert!(matches!(ended, Err(CoreError::RunSignature))); + } +} + +#[tokio::test] +async fn the_start_function_runs_before_run() { + let wasm = module( + r#"(func $start (drop (call $connect))) (start $start) + (func (export "run") (result i32) (call $connect))"#, + ); + let mut script = Script::default(); + let ended = run_on(&wasm, &mut script).await; + assert!(matches!(ended, Err(CoreError::Guest(abi::ALREADY_CONNECTED)))); + let (level, context, message) = script.logged.first().unwrap(); + assert_eq!(*level, Level::Warn); + assert_eq!(context, "et-ws-wasi-runner"); + assert!(message.starts_with("connect failed")); +} + +#[tokio::test] +async fn a_module_that_cannot_run_to_its_end_fails() { + let trapping_start = module( + r#"(func $start unreachable) (start $start) + (func (export "run") (result i32) (i32.const 0))"#, + ); + // Bulk memory is a proposal past Wasm 1.0. + let bulk = guest( + "", + "(memory.copy (i32.const 0) (i32.const 8) (i32.const 8)) (i32.const 0)", + ); + // Ahead of `IMPORTS`, since WAT takes no import after a memory. + let missing = r#"(import "et" "missing" (func))"#; + let unlinked = wat::parse_str(format!( + r#"(module {missing} {IMPORTS} (func (export "run") (result i32) (i32.const 0)))"# + )) + .unwrap(); + let cases: [(Vec, Expected); 4] = [ + (trapping_start, |error| { + matches!(error, CoreError::Trap(TrapReason::Unreachable)) + }), + (guest("", "unreachable"), |error| { + matches!(error, CoreError::Trap(TrapReason::Unreachable)) + }), + (bulk, |error| matches!(error, CoreError::Decode(_))), + (unlinked, |error| matches!(error, CoreError::Decode(_))), + ]; + for (wasm, expected) in cases { + let error = run_on(&wasm, &mut Script::default()).await.unwrap_err(); + assert!(expected(&error)); + } +} + +#[tokio::test] +async fn a_long_computation_runs_to_its_end() { + let counting = guest( + "", + "(local $left i32) (local.set $left (i32.const 1000000)) + (loop $more (local.set $left (i32.sub (local.get $left) (i32.const 1))) (br_if $more (local.get $left))) + (local.get $left)", + ); + run_on(&counting, &mut Script::default()).await.unwrap(); +} + +#[tokio::test] +async fn the_agent_id_is_taken_out_of_the_slot() { + // The id goes into memory at 100 and becomes the bucket the guest stores "v" under "k" in. + let wasm = guest( + "kv", + "(local $len i32) (local.set $len (call $agent_id)) + (if (i32.ne (call $take (i32.const 100) (i32.const 0)) (local.get $len)) (then (return (i32.const 10)))) + (if (i32.ne (call $take (i32.const 100) (local.get $len)) (local.get $len)) (then (return (i32.const 11)))) + (call $kv_set (i32.const 100) (local.get $len) (i32.const 0) (i32.const 1) (i32.const 1) (i32.const 1))", + ); + let mut script = Script::default(); + run_on(&wasm, &mut script).await.unwrap(); + assert_eq!(script.value("agent-1", "k"), b"v"); +} + +#[tokio::test] +async fn send_hands_the_host_a_typed_message() { + let mut script = Script::default(); + run_on(&guest(LIST_AGENTS, SEND_LIST_AGENTS), &mut script) + .await + .unwrap(); + assert!(matches!(script.sent.as_slice(), [ClientMessage::ListAgents])); +} + +#[tokio::test] +async fn send_of_json_that_is_no_client_message_is_a_decode_failure() { + let wasm = guest( + r#"{\"type\":\"et-bogus\"}"#, + "(call $send (i32.const 0) (i32.const 19))", + ); + let ended = run_on(&wasm, &mut Script::default()).await; + assert!(matches!(ended, Err(CoreError::Guest(abi::DECODE)))); +} + +#[tokio::test] +async fn send_that_the_host_cannot_deliver_is_a_transport_failure() { + let mut script = Script { + send_error: Some(WsError::Transport("down".to_owned())), + ..Script::default() + }; + let ended = run_on(&guest(LIST_AGENTS, SEND_LIST_AGENTS), &mut script).await; + assert!(matches!(ended, Err(CoreError::Guest(abi::TRANSPORT)))); +} + +#[tokio::test] +async fn recv_puts_the_message_json_in_the_slot() { + // The message is taken into memory at 100, then stored under "k" in bucket "b" so the test can read it back. + let wasm = guest( + "bk", + "(local $len i32) (local.set $len (call $recv (i32.const 100))) + (drop (call $take (i32.const 100) (local.get $len))) + (call $kv_set (i32.const 0) (i32.const 1) (i32.const 1) (i32.const 1) (i32.const 100) (local.get $len))", + ); + let message = ServerMessage::RelayText { + content: "hello".to_owned(), + }; + let mut script = Script { + inbox: VecDeque::from([Ok(Some(message.clone()))]), + ..Script::default() + }; + run_on(&wasm, &mut script).await.unwrap(); + assert_eq!(script.value("b", "k"), serde_json::to_vec(&message).unwrap()); +} + +#[tokio::test] +async fn recv_with_nothing_arrived_answers_nothing_and_a_failure_its_code() { + let wasm = guest("", "(call $recv (i32.const 100))"); + run_on(&wasm, &mut Script::default()).await.unwrap(); + let decode = Err(WsError::Decode("bad".to_owned())); + let failures = [ + (Err(WsError::Transport("down".to_owned())), abi::TRANSPORT), + (decode, abi::DECODE), + (Err(WsError::NotConnected), abi::NOT_CONNECTED), + ]; + for (answer, code) in failures { + let mut script = Script { + inbox: VecDeque::from([answer]), + ..Script::default() + }; + let ended = run_on(&wasm, &mut script).await; + assert!(matches!(ended, Err(CoreError::Guest(failure)) if failure == code)); + } +} + +#[tokio::test] +async fn kv_get_finds_a_value_and_take_copies_it_only_into_a_buffer_it_fits() { + // "value" is found under "k" in "b", a 2-byte take copies nothing, a 5-byte one copies it, and it is stored back + // under "c" in "b". + let wasm = guest( + "bkc", + "(if (i32.ne (call $kv_get (i32.const 0) (i32.const 1) (i32.const 1) (i32.const 1)) (i32.const 1)) + (then (return (i32.const 10)))) + (if (i32.ne (call $take (i32.const 100) (i32.const 2)) (i32.const 5)) (then (return (i32.const 11)))) + (if (i32.ne (call $take (i32.const 100) (i32.const 5)) (i32.const 5)) (then (return (i32.const 12)))) + (call $kv_set (i32.const 0) (i32.const 1) (i32.const 2) (i32.const 1) (i32.const 100) (i32.const 5))", + ); + let mut script = Script::default(); + let _none = script + .stored + .insert(("b".to_owned(), "k".to_owned()), b"value".to_vec()); + run_on(&wasm, &mut script).await.unwrap(); + assert_eq!(script.value("b", "c"), b"value"); +} + +#[tokio::test] +async fn kv_get_of_an_absent_key_answers_absent() { + let wasm = guest( + "bk", + "(call $kv_get (i32.const 0) (i32.const 1) (i32.const 1) (i32.const 1))", + ); + run_on(&wasm, &mut Script::default()).await.unwrap(); +} + +#[tokio::test] +async fn a_storage_failure_is_a_store_failure() { + let getting = guest( + "bk", + "(call $kv_get (i32.const 0) (i32.const 1) (i32.const 1) (i32.const 1))", + ); + let setting = guest( + "bk", + "(call $kv_set (i32.const 0) (i32.const 1) (i32.const 1) (i32.const 1) (i32.const 0) (i32.const 2))", + ); + for wasm in [getting, setting] { + let mut script = Script { + store_fails: true, + ..Script::default() + }; + let ended = run_on(&wasm, &mut script).await; + assert!(matches!(ended, Err(CoreError::Guest(abi::STORE)))); + } +} + +#[tokio::test] +async fn log_records_each_level() { + let wasm = guest( + "cm", + "(call $log (i32.const 0) (i32.const 0) (i32.const 1) (i32.const 1) (i32.const 1)) + (call $log (i32.const 1) (i32.const 0) (i32.const 1) (i32.const 1) (i32.const 1)) + (call $log (i32.const 2) (i32.const 0) (i32.const 1) (i32.const 1) (i32.const 1)) + (call $log (i32.const 3) (i32.const 0) (i32.const 1) (i32.const 1) (i32.const 1)) + (call $log (i32.const 4) (i32.const 0) (i32.const 1) (i32.const 1) (i32.const 1)) + (call $log (i32.const 5) (i32.const 0) (i32.const 1) (i32.const 1) (i32.const 1)) + (i32.const 0)", + ); + let mut script = Script::default(); + run_on(&wasm, &mut script).await.unwrap(); + let levels: Vec = script.logged.iter().map(|(level, ..)| *level).collect(); + let expected = [ + Level::Trace, + Level::Debug, + Level::Info, + Level::Warn, + Level::Error, + Level::Critical, + ]; + assert_eq!(levels, expected); + assert!( + script + .logged + .iter() + .all(|(_, context, message)| context == "c" && message == "m") + ); +} + +#[tokio::test] +async fn disconnect_closes_the_websocket() { + let mut script = Script::default(); + run_on(&guest("", "(call $disconnect) (i32.const 0)"), &mut script) + .await + .unwrap(); + assert!(script.disconnected); +} + +#[tokio::test] +async fn a_call_the_host_cannot_read_traps_the_guest() { + let calls = [ + // A message past the end of the guest's one page of memory. + "(call $send (i32.const 70000) (i32.const 4))", + // A bucket name that is not UTF-8. + "(call $kv_get (i32.const 0) (i32.const 1) (i32.const 1) (i32.const 1))", + // A log level that does not exist. + "(call $log (i32.const 9) (i32.const 0) (i32.const 1) (i32.const 0) (i32.const 1)) (i32.const 0)", + // A buffer that fits the slot, past the end of memory. + "(drop (call $agent_id)) (call $take (i32.const 70000) (i32.const 100))", + ]; + for instructions in calls { + let ended = run_on(&guest("\\ff", instructions), &mut Script::default()).await; + assert!(matches!(ended, Err(CoreError::Trap(TrapReason::Host)))); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn dropping_a_run_stops_its_module_and_releases_its_host() { + // One guest computes forever and one waits forever on the host; dropping the run has to stop either. + let stuck = [ + "(loop $forever (br $forever)) (i32.const 0)", + "(call $recv (i32.const 100))", + ]; + for instructions in stuck { + let (released, dropped) = oneshot::channel::<()>(); + let run = run_core_module(guest("", instructions), Held { _released: released }); + let _gave_up: Elapsed = tokio::time::timeout(Duration::from_millis(200), run).await.unwrap_err(); + let _released: RecvError = tokio::time::timeout(Duration::from_secs(10), dropped) + .await + .unwrap() + .unwrap_err(); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_core_module_reaches_a_real_hub_through_the_runners_host() { + // Connect, send `et-list-agents`, take the ack the hub registered the guest with, store "v" under "k" in the + // guest's own bucket, read it back, and disconnect, failing with a distinct code at each step that goes wrong. + let wasm = guest( + &format!("kv{LIST_AGENTS}"), + "(local $len i32) + (if (call $connect) (then (return (i32.const 10)))) + (if (call $send (i32.const 2) (i32.const 25)) (then (return (i32.const 11)))) + (if (i32.eqz (call $recv (i32.const 10000))) (then (return (i32.const 12)))) + (local.set $len (call $agent_id)) + (drop (call $take (i32.const 1000) (local.get $len))) + (if (call $kv_set (i32.const 1000) (local.get $len) (i32.const 0) (i32.const 1) (i32.const 1) (i32.const 1)) + (then (return (i32.const 13)))) + (if (i32.ne (call $kv_get (i32.const 1000) (local.get $len) (i32.const 0) (i32.const 1)) (i32.const 1)) + (then (return (i32.const 14)))) + (if (i32.ne (call $take (i32.const 2000) (i32.const 1)) (i32.const 1)) (then (return (i32.const 15)))) + (if (i32.ne (i32.load8_u (i32.const 2000)) (i32.const 118)) (then (return (i32.const 16)))) + (call $log (i32.const 2) (i32.const 0) (i32.const 1) (i32.const 1) (i32.const 1)) + (call $disconnect) + (i32.const 0)", + ); + let server = et_ws_test_server::start(); + let http_base = et_ws_runner_common::derive_http_base(&server.ws_url).unwrap(); + let host = HostState::new(&http_base, server.ws_url.clone(), None, false); + run(&wasm, host).await.unwrap(); +} diff --git a/services/ws-wasi-runner/tests/modules.rs b/services/ws-wasi-runner/tests/modules.rs index 6c5c5a11..12243336 100644 --- a/services/ws-wasi-runner/tests/modules.rs +++ b/services/ws-wasi-runner/tests/modules.rs @@ -53,25 +53,29 @@ fn module_runs_successfully(#[case] module: &str, #[case] language: Language) { .unwrap_or_else(|error| panic!("{module} runner failed: {error}")); } -/// Run wasi-math1 through the storage-driven exchange and verify its stored model. +/// Run a math1 twin through the storage-driven exchange and verify its stored model. /// /// The fake-agent side lives in `et_ws_test_server::math1`: it injects the canonical input JSON -/// into storage, broadcasts the `math1-input` pointer, and reads back the component's +/// into storage, broadcasts the `math1-input` pointer, and reads back the twin's /// `math1-output.json`, which is verified against the expected weights for that input. The -/// runner's own exit status is asserted too. +/// runner's own exit status is asserted too. wasi-math1 is a component the runner runs on wasmtime; +/// core-math1 is a Wasm 1.0 core module it runs on `SpaceWasm`. +#[rstest] +#[case::wasi("et-ws-wasi-math1")] +#[cfg_attr(feature = "core-modules", case::core("et-ws-core-math1"))] #[tokio::test(flavor = "current_thread")] #[cfg_attr( windows, ignore = "pkg/package.json 404 on Windows -- see module_runs_successfully's comment" )] -async fn wasi_math1_stores_verified_model() { +async fn math1_twin_stores_verified_model(#[case] module: &str) { if !mise_env_includes(Language::Rust) { return; } let server = et_ws_test_server::start(); let bin = env!("CARGO_BIN_EXE_et-ws-wasi-runner"); let mut runner = std::process::Command::new(bin) - .env("RUNNER_MODULE", served("et-ws-wasi-math1")) + .env("RUNNER_MODULE", served(module)) .env("WS_SERVER_URL", &server.ws_url) .env("ET_TEST_WS_WASI_RUNNER_FAST_EXIT", "1") .spawn() @@ -85,9 +89,9 @@ async fn wasi_math1_stores_verified_model() { // Reap the runner regardless of the exchange outcome; it exits on its own once the component // completes. let status = wait_for_runner_exit(&mut runner); - let (weight, bias) = outcome.unwrap_or_else(|err| panic!("wasi-math1: {err}")); - et_ws_test_server::math1::verify_math1_model(weight, bias).unwrap_or_else(|err| panic!("wasi-math1: {err}")); - assert!(status.success(), "wasi-math1 runner exited {status:?}"); + let (weight, bias) = outcome.unwrap_or_else(|err| panic!("{module}: {err}")); + et_ws_test_server::math1::verify_math1_model(weight, bias).unwrap_or_else(|err| panic!("{module}: {err}")); + assert!(status.success(), "{module} runner exited {status:?}"); } /// Poll the spawned runner until it exits, killing it if it overstays the bound. diff --git a/utilities/cli/src/module_package_json/generate.rs b/utilities/cli/src/module_package_json/generate.rs index cdf1caf0..f45558dd 100644 --- a/utilities/cli/src/module_package_json/generate.rs +++ b/utilities/cli/src/module_package_json/generate.rs @@ -18,8 +18,11 @@ use crate::error::{CliError, parse_json, parse_toml, serialize_json_pretty}; /// The crate prefix, or the organisation name itself, as `edge-toolkit` is named like its Rust crate. const OWN_MODULE_PREFIXES: [&str; 2] = [et_org::CRATE_PREFIX, et_org::ORG]; -/// The runtime dependency names that identify a WASI component guest. -const WASI_GUEST_MARKERS: [&str; 2] = ["et-wasi-guest", "wit-bindgen"]; +/// The runtime dependency names that identify a guest a runner loads as `.wasm`. +/// +/// A WASI component reaches its bindings through `et-wasi-guest` or `wit-bindgen`; a Wasm 1.0 core module imports the +/// `et` functions through `et-core-guest`. +const WASM_GUEST_MARKERS: [&str; 3] = ["et-core-guest", "et-wasi-guest", "wit-bindgen"]; /// The name a module publishes under, given the name its crate or project declares. /// @@ -324,7 +327,9 @@ fn repository_json(url: &str) -> Value { json!({ "type": "git", "url": url }) } -/// Whether a module is built as a WASI Preview 2 component or as JS that browser/Pyodide loads. +/// Whether a module is built as `.wasm` the WASI runner loads, or as JS that browser/Pyodide loads. +/// +/// The WASI runner loads a WASI Preview 2 component or a Wasm 1.0 core module. #[derive(Clone, Copy)] enum ModuleKind { Wasi, @@ -352,13 +357,14 @@ fn detect_python_kind(module_dir: &Path) -> ModuleKind { } } -/// WASI Rust modules reach the component bindings one of two ways; wasm-pack browser modules do neither. +/// Whether a Rust module's runtime dependencies make it a `.wasm` guest rather than a wasm-pack browser module. /// -/// `et-wasi-guest` generates the bindings once and shares them, so a guest that depends on it names no -/// `wit-bindgen` of its own -- and a guest predating that crate, or generating its own for some reason, still -/// names `wit-bindgen` directly. Either marker identifies a component, and missing one is not cosmetic: the kind -/// picks the extension the entry file is looked up by, so a component read as a browser module goes looking for a -/// `.js` that was never built and the module's `package.json` cannot be written at all. +/// WASI Rust modules reach the component bindings one of two ways, core modules reach the `et` imports a third way, +/// and wasm-pack browser modules take none of them. `et-wasi-guest` generates the bindings once and shares them, so a +/// guest that depends on it names no `wit-bindgen` of its own -- and a guest predating that crate, or generating its +/// own for some reason, still names `wit-bindgen` directly. Each marker identifies a `.wasm` guest, and missing one is +/// not cosmetic: the kind picks the extension the entry file is looked up by, so a guest read as a browser module goes +/// looking for a `.js` that was never built and the module's `package.json` cannot be written at all. /// /// Only a runtime dependency counts -- `[dependencies]` or `[target..dependencies]`. The name turning up in /// a comment, in `[package.metadata]`, in a `[patch]` pin, or under `dev-`/`build-dependencies` says nothing @@ -370,7 +376,7 @@ fn detect_cargo_kind(cargo_toml: &CargoToml) -> ModuleKind { .dependencies .keys() .chain(target_deps) - .any(|name| WASI_GUEST_MARKERS.contains(&name.as_str())) + .any(|name| WASM_GUEST_MARKERS.contains(&name.as_str())) { ModuleKind::Wasi } else { diff --git a/utilities/cli/tests/module_package_json.rs b/utilities/cli/tests/module_package_json.rs index 418a0ce6..91ae55ce 100644 --- a/utilities/cli/tests/module_package_json.rs +++ b/utilities/cli/tests/module_package_json.rs @@ -153,6 +153,26 @@ et-wasi-guest = { workspace = true } assert_eq!(package["main"], "et_ws_wasi_shared.wasm"); } +/// A Wasm 1.0 core module, which imports the `et` functions through `et-core-guest`, is a `.wasm` guest too. +#[test] +fn module_package_json_derives_core_module_main_via_the_core_guest_crate() { + let package = generated_package( + "Cargo.toml", + r#"[package] +name = "et-ws-core-demo" +version = "0.1.0" +edition = "2024" + +[target.'cfg(all(target_arch = "wasm32", target_os = "none"))'.dependencies] +et-core-guest = { workspace = true } +"#, + "et_ws_core_demo.wasm", + &[], + ); + + assert_eq!(package["main"], "et_ws_core_demo.wasm"); +} + /// A browser module stays a browser module however often the guest markers appear outside its runtime deps. /// /// Detection reads the parsed dependency tables, not the manifest text, so none of the spellings below is a diff --git a/verification/local/input/core-math1.yaml b/verification/local/input/core-math1.yaml new file mode 100644 index 00000000..a26e9da9 --- /dev/null +++ b/verification/local/input/core-math1.yaml @@ -0,0 +1,17 @@ +cluster_name: "core-math1" +deployment_type: "mise" + +# The math1 exchange with a Wasm 1.0 core-module twin, driven by the WASI trigger. Both agents are `runner: wasi`, and +# the runner reads each module's header to pick its engine: the trigger is a component, which runs on wasmtime, and +# the twin is a core module, which runs on NASA's SpaceWasm interpreter. One runner kind hosting both engines is what +# distinguishes this from `wasi-math1`, where both ends are components. +agents: + - name: "math1-trigger" + runner: "wasi" + resources: + - type: "wasi-math1-sender" + + - name: "core-math1-twin" + runner: "wasi" + resources: + - type: "core-math1" diff --git a/verification/local/output/core-math1/.gitignore b/verification/local/output/core-math1/.gitignore new file mode 100644 index 00000000..4b22f893 --- /dev/null +++ b/verification/local/output/core-math1/.gitignore @@ -0,0 +1,7 @@ +# Written by `et-cli`. +# What the hub writes into the directory it runs from: its self-signed TLS pair (the key is a +# credential too), the agent registry it saves on shutdown, and agent storage. +cert.pem +key.pem +registry.yaml +storage/ diff --git a/verification/local/output/core-math1/Dockerfile b/verification/local/output/core-math1/Dockerfile new file mode 100644 index 00000000..cee77776 --- /dev/null +++ b/verification/local/output/core-math1/Dockerfile @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1 + +# AUTO-GENERATED by `et-cli` for the `core-math1` scenario -- do not edit. +# Regenerate with `mise run regen-verification`. +# +# The hub image (services/ws-server/Dockerfile) carries no workflow modules at all, because which +# modules a deployment serves is a per-cluster decision. This image is that decision made concrete: it +# stages exactly the modules this cluster's agents declare, plus the packages those modules depend on, +# into the `/app` paths the hub already scans. +# +# The sibling `Dockerfile.dockerignore` is what makes the COPYs below possible. The repository's root +# `.dockerignore` drops every `*.wasm` and `*.onnx` so the CI images built from `./Dockerfile` stay +# small; BuildKit uses this Dockerfile's own ignore file instead, which re-admits the built `pkg/` +# directories and nothing else. + +# `hub` is a named build context, not a stage defined here. +# The generated compose file wires it to its `hub-image-build` service, so compose builds the hub image +# first and hands the result over under this name. That is also why the version-tag rule is +# suppressed on the next line: hadolint reads `hub` as an untagged image, but there is no tag to give a +# build context, and the image it resolves to is pinned by the hub Dockerfile rather than here. +# hadolint ignore=DL3006 +FROM hub + +LABEL org.opencontainers.image.source="https://github.com/edge-toolkit/core" + +COPY --chown=10001:10001 services/ws-modules/core-math1/pkg /app/services/ws-modules/core-math1/pkg +COPY --chown=10001:10001 services/ws-modules/wasi-math1-sender/pkg /app/services/ws-modules/wasi-math1-sender/pkg + +USER 10001 diff --git a/verification/local/output/core-math1/Dockerfile.dockerignore b/verification/local/output/core-math1/Dockerfile.dockerignore new file mode 100644 index 00000000..42c1e067 --- /dev/null +++ b/verification/local/output/core-math1/Dockerfile.dockerignore @@ -0,0 +1,87 @@ +# AUTO-GENERATED by `et-cli` for the `core-math1` scenario -- do not edit. +# Regenerate with `mise run regen-verification`. +# +# BuildKit reads this file instead of the repository root `.dockerignore` when building the +# Dockerfile beside it, so the root file keeps governing the CI images built from `./Dockerfile` +# and only this build sees the re-includes at the end. + +# AUTO-GENERATED from .gitignore by 'mise run gen:dockerignore' -- do not edit. +# Docker reads only this file; patterns are **/-prefixed to match at any depth +# like .gitignore. Edit .gitignore and regenerate. + +# `.dockerignore` is generated from `.gitignore` by `mise run gen:dockerignore`. +# After editing `.gitignore`, regenerate it and commit both, or the dockerignore-check task fails on drift. +**/.claude/ +**/*.wasm +**/*.onnx +**/target/ +**/.DS_Store +services/ws-wasm-agent/pkg/ +services/ws-modules/pywasm1/pkg/ +services/ws-modules/rdata1/pkg/webr/ +services/ws-modules/rcomm1/pkg/webr/ +services/ws-modules/rmath1/pkg/webr/ +services/ws-modules/js-data1/pkg/et_ws_js_data1.js +services/ws-server/static/models/ +**/.zig-cache/ +**/zig-out/ +**/*.o +**/*.pem +**/mprocs.log +**/__pycache__/ +**/.pytest_cache/ +**/.python-version +**/node_modules/ +**/.venv/ +# .NET build output. +# `obj/` is safe globally (nothing tracked is named obj/), but `bin/` is scoped to the module so it never +# matches a Rust crate's `src/bin/` (e.g. utilities/int-gen/src/bin/). +**/obj/ +services/ws-modules/dotnet-data1/bin/ +# Gradle project caches and build output plus the Kotlin session dir, all regenerated by any Gradle run. +**/.gradle/ +**/.kotlin/ +services/ws-modules/kotlin-data1/build/ +services/ws-modules/kotlin-math1/build/ +# Coverage-report artifacts produced by the coverage workflow, uploaded to Codecov and never committed. +# Covers cargo-llvm-cov's lcov, pytest-cov's Cobertura xml, and pytest-cov's .coverage data file. +lcov.info +**/coverage-python.xml +**/.coverage +# minicov/llvm raw profiles dropped by instrumented binaries run during coverage builds. +**/*.profraw +# Editor dir, tool caches, and the ws-server's runtime file storage. +# The .vscode dir is ignored except its shared recommended-extensions list (negated below). +.vscode/* +!.vscode/extensions.json +**/.ruff_cache/ +**/.ryl_cache/ +**/.lycheecache +# Dart pub tool dir, regenerated by `dart pub get`; one global rule for the whole workspace (root + members). +**/.dart_tool/ +services/ws-server/storage/ +# One-off Markdown an agent writes on request, such as an email draft or notes, which is never committed. +**/*.local.md +# Lock files a generated scenario leaves behind when it is run. +# `lockfile = true` makes mise write one beside whichever config it resolved tools for, so running a scenario +# drops `mise.lock` into that scenario's output directory. It is a by-product of running the deployment rather +# than part of it: `regen-verification` never writes one, the drift check never reads one, and what it pins is +# whatever the machine that ran it happened to resolve. One rule here rather than a `.gitignore` generated into +# each scenario, so a scenario added later is covered without anything having to be written into it. +verification/**/*.lock +# The npm dependency graphs `mise lock` records beside that lockfile, a by-product for the same reason. +verification/**/.mise/locks/ +# No rule for the per-scenario credential file, deliberately. +# Under verification/ it is committed like every other generated artifact: the password is derived from an +# input this repository already carries, so the file reproduces from public material rather than keeping a +# secret, and the drift check has to be able to read it. A deployment generated anywhere else gets its own +# `.gitignore` written beside it by `et-cli`, which is what keeps a real credential out of a real repository. +**/.git/ +**/Dockerfile* +*.md + +# Re-admit the built module artifacts this scenario serves. +# The rules above drop every `*.wasm` and `*.onnx`, which is what keeps the CI images small; these +# negations put back the `pkg/` directories this image copies, and nothing else. +!services/ws-modules/core-math1/pkg/** +!services/ws-modules/wasi-math1-sender/pkg/** diff --git a/verification/local/output/core-math1/README.md b/verification/local/output/core-math1/README.md new file mode 100644 index 00000000..1d16296e --- /dev/null +++ b/verification/local/output/core-math1/README.md @@ -0,0 +1,183 @@ +# core-math1 + +This directory contains generated deployment configs for the `core-math1` scenario. +Files: `mise.toml`, `dekit.yaml`, `process-compose.yaml`, `compose.yaml`, `k3s.yaml`, `mise.firmware-pico-plus2.toml`. + +The scenario exposes these workflow modules: core-math1, wasi-math1-sender. + +`secrets.env` holds the scenario's derived OpenObserve and OTLP credentials. It is derived from the +scenario input, so regenerating this deployment rewrites it; if it is missing, regenerate before +starting the stack. A deployment generated outside the repository is written with a `.gitignore` +covering it, so its credential is not committed by whatever repository it lands in. + +## Lock The Tools + +Before the first run, lock the tools `mise.toml` names: + +```bash +mise lock +``` + +Commit `mise.lock` alongside the deployment, together with the `.mise/locks/` directory when +mise writes one -- it holds the dependency graph of each npm tool, which `mise.lock` refers to. +The two record the version and checksum every `latest` in `mise.toml` resolved to, for each of +mise's common platforms, so every later install -- on any machine -- fetches the same binaries +rather than whatever is newest that day. Move to newer releases deliberately, with +`mise lock --bump`, and commit the result. + +## Run With Mise + +From this directory, start the scenario with: + +```bash +mise run generated-scenario +``` + +That task starts both OpenObserve and `ws-server` for this scenario. + +### Open The OpenObserve UI + +From this directory, open the OpenObserve UI with: + +```bash +mise run open-o2 +``` + +## Run With dekit + +From this directory, start the scenario and watch each process in its own pane with: + +```bash +dekit up +dekit attach +``` + +Each task runs one task of `mise.toml`, so these are the processes `mise run generated-scenario` +starts, but in order: `ws-server` waits for OpenObserve to report healthy, and every runner +waits for `ws-server`. The tasks keep running after the terminal closes; stop them with: + +```bash +dekit down +``` + +Open the OpenObserve UI, once it is up, with: + +```bash +dekit start open-o2 +``` + +## Run With process-compose + +From this directory, install the process-compose `mise.toml` declares, then start the scenario +and watch each process with: + +```bash +mise install +process-compose up --no-server -f process-compose.yaml +``` + +Each process runs one task of `mise.toml`, in order: `ws-server` waits for OpenObserve to report +healthy, and every runner waits for `ws-server`. A scheduled agent's runner is started on each +tick of its schedule and runs its module once; a tick that finds the previous run still going is +skipped. `--no-server` keeps process-compose's own API off port 8080, where `ws-server` listens. +Quitting the TUI stops every process. Open the OpenObserve UI, once it is up, by starting the +`open-o2` process from the TUI. + +## Run With Docker Compose + +From this directory, start the scenario with: + +```bash +docker compose up --build +``` + +The compose stack starts OpenObserve and builds `ws-server` in two layers: the module-less hub +image from the repository's `services/ws-server/Dockerfile`, then the `Dockerfile` in this +directory, which stages this scenario's modules onto it. +`ws-server` runs with host networking so it advertises the same LAN IP as the `mise` deployment. + +### Open The UIs + +OpenObserve is available at . +`ws-server` is available at and . + +Stop the scenario with: + +```bash +docker compose down +``` + +## Run With k3s + +The manifests reference images by name and never build them, so build and import each one first. +This scenario's image layers its modules onto the module-less hub image and takes that hub as a +_named build context_ rather than building it, so the hub has to exist first -- a plain +`docker build` of the scenario Dockerfile fails on `FROM hub`. From the repository root: + +```bash +scenario=core-math1 +hub=et-ws-server-hub:latest +image="et-ws-server-$scenario:latest" +dockerfile="verification/local/output/$scenario/Dockerfile" +docker build -t "$hub" -f services/ws-server/Dockerfile . +docker build --build-context "hub=docker-image://$hub" -t "$image" -f "$dockerfile" . +docker save "$image" | sudo k3s ctr images import - +``` + +Then this scenario's runner images, which need no build context of their own: + +```bash +docker build -t et-ws-wasi-runner:latest -f services/ws-wasi-runner/Dockerfile . +docker save et-ws-wasi-runner:latest | sudo k3s ctr images import - +``` + +### Load The Credential + +The credential reaches the pods as a `Secret` created from `secrets.env`, rather than written into +`k3s.yaml` where it would be committed alongside the manifests. From this directory: + +```bash +ns=et-core-math1 +kubectl create namespace "$ns" --save-config +kubectl create secret generic "$ns-secrets" --from-env-file=secrets.env -n "$ns" +``` + +### Apply + +```bash +kubectl apply -f k3s.yaml +``` + +The runners exit and restart until the hub reports ready, so `CrashLoopBackOff` while the hub +starts is expected here rather than a fault. Watch it settle with: + +```bash +kubectl get pods -n "$ns" --watch +``` + +## Run As Pimoroni Pico Plus 2 W Firmware + +The board runs the hub, and beside it each wasi runner the scenario places on it, compiled into the +firmware from its module's component. The build refuses a runner that imports what the board +does not host, or runners needing more memory than it holds. +The board serves this scenario's modules from its 16 MiB of flash. +Build those modules first, since the firmware takes each one's built package. +Set `WIFI_NETWORK` and `WIFI_PASSWORD` to the Wi-Fi network the board joins. +Then hold the board's BOOTSEL button while plugging it in. +From this directory, build the firmware and load it with: + +```bash +MISE_ENV=firmware-pico-plus2 mise run firmware-flash +``` + +To check what the board will serve without one, serve the same modules from this host and fetch +each, as a page would: + +```bash +MISE_ENV=firmware-pico-plus2 mise run firmware-host-test +``` + +The board takes an address over DHCP and serves the page at port 8080 of it. npm packages the +modules use that are too big for its flash, such as onnxruntime-web, are not built in: the board +redirects a request for one to the same path under `https://cdn.jsdelivr.net/npm`, which the browser has to be +able to reach. diff --git a/verification/local/output/core-math1/compose.yaml b/verification/local/output/core-math1/compose.yaml new file mode 100644 index 00000000..5bdc4c5b --- /dev/null +++ b/verification/local/output/core-math1/compose.yaml @@ -0,0 +1,114 @@ +services: + openobserve: + image: openobserve/openobserve:v1.1.2 + healthcheck: + test: + - CMD + - /openobserve + - node + - status + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + ports: + - 127.0.0.1:5080:5080 + env_file: + - secrets.env + environment: + RUST_LOG: warn + ZO_ROOT_USER_EMAIL: root@example.com + ZO_DATA_DIR: /data + volumes: + - openobserve-data:/data + deploy: + resources: + limits: + cpus: "2" + memory: 2g + cpu_shares: 102 + hub-image-build: + build: + context: ../../../.. + dockerfile: services/ws-server/Dockerfile + scale: 0 + deploy: + resources: + limits: + cpus: "2" + memory: 2g + cpu_shares: 102 + ws-server: + healthcheck: + test: + - CMD + - curl + - -fsS + - http://localhost:8080/health + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + env_file: + - secrets.env + build: + context: ../../../.. + dockerfile: verification/local/output/core-math1/Dockerfile + additional_contexts: + hub: service:hub-image-build + network_mode: host + environment: + MODULES_PATHS: "/app/services/ws-modules/core-math1, + /app/services/ws-modules/wasi-math1-sender" + MODULES_ROOT: "" + OTLP_AUTH_USERNAME: root@example.com + OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 + STORAGE_URL: file:///app/storage + volumes: + - ws-server-storage:/app/storage + depends_on: + openobserve: + condition: service_healthy + deploy: + resources: + limits: + cpus: "2" + memory: 2g + cpu_shares: 102 + math1-trigger: + build: + context: ../../../.. + dockerfile: services/ws-wasi-runner/Dockerfile + network_mode: host + environment: + RUNNER_MODULE: "@edge-toolkit/et-ws-wasi-math1-sender" + WS_SERVER_URL: ws://localhost:8080/ws + depends_on: + ws-server: + condition: service_healthy + deploy: + resources: + limits: + cpus: "2" + memory: 2g + cpu_shares: 102 + core-math1-twin: + build: + context: ../../../.. + dockerfile: services/ws-wasi-runner/Dockerfile + network_mode: host + environment: + RUNNER_MODULE: "@edge-toolkit/et-ws-core-math1" + WS_SERVER_URL: ws://localhost:8080/ws + depends_on: + ws-server: + condition: service_healthy + deploy: + resources: + limits: + cpus: "2" + memory: 2g + cpu_shares: 102 +volumes: + openobserve-data: {} + ws-server-storage: {} diff --git a/verification/local/output/core-math1/dekit.yaml b/verification/local/output/core-math1/dekit.yaml new file mode 100644 index 00000000..70bb466c --- /dev/null +++ b/verification/local/output/core-math1/dekit.yaml @@ -0,0 +1,51 @@ +defaults: + stop: SIGINT +tasks: + openobserve: + cmd: + - mise + - run + - openobserve + autostart: true + ready: + http: http://localhost:5080/healthz + stop: + cmd: + - docker + - stop + - openobserve-core-math1 + timeout: 30s + ws-server: + cmd: + - mise + - run + - ws-server + deps: + - openobserve + autostart: true + ready: + http: http://localhost:8080/health + math1-trigger: + cmd: + - mise + - run + - math1-trigger + deps: + - ws-server + autostart: true + core-math1-twin: + cmd: + - mise + - run + - core-math1-twin + deps: + - ws-server + autostart: true + open-o2: + type: job + cmd: + - mise + - run + - open-o2 + deps: + - openobserve diff --git a/verification/local/output/core-math1/k3s.yaml b/verification/local/output/core-math1/k3s.yaml new file mode 100644 index 00000000..2437829d --- /dev/null +++ b/verification/local/output/core-math1/k3s.yaml @@ -0,0 +1,331 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: et-core-math1 +--- +apiVersion: v1 +kind: ConfigMap +data: + RUST_LOG: warn + ZO_DATA_DIR: /data + ZO_ROOT_USER_EMAIL: root@example.com +metadata: + name: openobserve-config + namespace: et-core-math1 +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: openobserve-data + namespace: et-core-math1 +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: openobserve + name: openobserve + namespace: et-core-math1 +spec: + replicas: 1 + selector: + matchLabels: + app: openobserve + template: + metadata: + labels: + app: openobserve + spec: + containers: + - envFrom: + - configMapRef: + name: openobserve-config + - secretRef: + name: et-core-math1-secrets + image: openobserve/openobserve:v1.1.2 + imagePullPolicy: IfNotPresent + name: openobserve + ports: + - containerPort: 5080 + readinessProbe: + exec: + command: + - /openobserve + - node + - status + failureThreshold: 20 + periodSeconds: 5 + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /data + name: openobserve-data + - mountPath: /tmp + name: openobserve-tmp + securityContext: + fsGroup: 10001 + runAsGroup: 10001 + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + volumes: + - name: openobserve-data + persistentVolumeClaim: + claimName: openobserve-data + - emptyDir: {} + name: openobserve-tmp +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app: openobserve + name: openobserve + namespace: et-core-math1 +spec: + ports: + - name: http + port: 5080 + targetPort: 5080 + selector: + app: openobserve +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: ws-server-storage + namespace: et-core-math1 +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: ws-server + name: ws-server + namespace: et-core-math1 +spec: + replicas: 1 + selector: + matchLabels: + app: ws-server + template: + metadata: + labels: + app: ws-server + spec: + containers: + - env: + - name: MODULES_PATHS + value: /app/services/ws-modules/core-math1,/app/services/ws-modules/wasi-math1-sender + - name: MODULES_ROOT + value: "" + - name: OTLP_AUTH_USERNAME + value: root@example.com + - name: OTLP_COLLECTOR_URL + value: http://openobserve:5080/api/default/v1 + - name: STORAGE_URL + value: file:///app/storage + - name: TLS_CERT_FILE + value: /app/runtime/cert.pem + - name: TLS_KEY_FILE + value: /app/runtime/key.pem + - name: WS_AGENT_REGISTRY + value: /app/runtime/registry.yaml + envFrom: + - secretRef: + name: et-core-math1-secrets + image: et-ws-server-core-math1:latest + imagePullPolicy: IfNotPresent + name: ws-server + ports: + - containerPort: 8080 + - containerPort: 8443 + readinessProbe: + failureThreshold: 20 + httpGet: + path: /health + port: 8080 + periodSeconds: 5 + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /app/storage + name: ws-server-storage + - mountPath: /app/runtime + name: ws-server-runtime + securityContext: + fsGroup: 10001 + runAsGroup: 10001 + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + volumes: + - name: ws-server-storage + persistentVolumeClaim: + claimName: ws-server-storage + - emptyDir: {} + name: ws-server-runtime +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app: ws-server + name: ws-server + namespace: et-core-math1 +spec: + ports: + - name: ws + port: 8080 + targetPort: 8080 + - name: wss + port: 8443 + targetPort: 8443 + selector: + app: ws-server +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: math1-trigger + name: math1-trigger + namespace: et-core-math1 +spec: + replicas: 1 + selector: + matchLabels: + app: math1-trigger + template: + metadata: + labels: + app: math1-trigger + spec: + containers: + - env: + - name: RUNNER_MODULE + value: "@edge-toolkit/et-ws-wasi-math1-sender" + - name: WS_SERVER_URL + value: ws://ws-server:8080/ws + image: et-ws-wasi-runner:latest + imagePullPolicy: IfNotPresent + name: math1-trigger + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /tmp + name: runner-tmp + securityContext: + fsGroup: 10001 + runAsGroup: 10001 + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + volumes: + - emptyDir: {} + name: runner-tmp +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: core-math1-twin + name: core-math1-twin + namespace: et-core-math1 +spec: + replicas: 1 + selector: + matchLabels: + app: core-math1-twin + template: + metadata: + labels: + app: core-math1-twin + spec: + containers: + - env: + - name: RUNNER_MODULE + value: "@edge-toolkit/et-ws-core-math1" + - name: WS_SERVER_URL + value: ws://ws-server:8080/ws + image: et-ws-wasi-runner:latest + imagePullPolicy: IfNotPresent + name: core-math1-twin + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /tmp + name: runner-tmp + securityContext: + fsGroup: 10001 + runAsGroup: 10001 + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + volumes: + - emptyDir: {} + name: runner-tmp diff --git a/verification/local/output/core-math1/mise.firmware-pico-plus2.toml b/verification/local/output/core-math1/mise.firmware-pico-plus2.toml new file mode 100644 index 00000000..8bdcaf6d --- /dev/null +++ b/verification/local/output/core-math1/mise.firmware-pico-plus2.toml @@ -0,0 +1,37 @@ +[tasks.firmware-build] +description = "Build the Pimoroni Pico Plus 2 W firmware serving core-math1" +dir = "../../../.." +run = """ +MODULES_PATHS="$PWD/services/ws-modules/core-math1, $PWD/services/ws-modules/wasi-math1-sender" +MODULES_PATHS="$MODULES_PATHS, $PWD/services/ws-server/static, $PWD/services/ws-wasm-agent" +export MODULES_PATHS +mise -E rust run build-firmware-pico-plus2w +""" + +[tasks.firmware-build.env] +MODULES_ELSEWHERE = "onnxruntime-web, stats-gl" +MODULES_ELSEWHERE_URL = "https://cdn.jsdelivr.net/npm" +MODULES_ROOT = "@edge-toolkit/et-ws-server-static" +RUNNER_GUESTS = "math1-trigger=@edge-toolkit/et-ws-wasi-math1-sender, core-math1-twin=@edge-toolkit/et-ws-core-math1" + +[tasks.firmware-flash] +depends = ["firmware-build"] +description = "Load the Pimoroni Pico Plus 2 W firmware onto the board, and start it" +dir = "../../../.." +run = "picotool load -x -t elf target/thumbv8m.main-none-eabihf/release/et-ws-server-firmware" + +[tasks.firmware-host-test] +description = "Serve core-math1's modules from this host as the board would, and fetch each" +dir = "../../../.." +run = """ +MODULES_PATHS="$PWD/services/ws-modules/core-math1, $PWD/services/ws-modules/wasi-math1-sender" +MODULES_PATHS="$MODULES_PATHS, $PWD/services/ws-server/static, $PWD/services/ws-wasm-agent" +export MODULES_PATHS +mise run cargo-test-pkg et-int-ws-server-firmware +""" + +[tasks.firmware-host-test.env] +MODULES_ELSEWHERE = "onnxruntime-web, stats-gl" +MODULES_ELSEWHERE_URL = "https://cdn.jsdelivr.net/npm" +MODULES_ROOT = "@edge-toolkit/et-ws-server-static" +RUNNER_GUESTS = "math1-trigger=@edge-toolkit/et-ws-wasi-math1-sender, core-math1-twin=@edge-toolkit/et-ws-core-math1" diff --git a/verification/local/output/core-math1/mise.toml b/verification/local/output/core-math1/mise.toml new file mode 100644 index 00000000..f8a981ce --- /dev/null +++ b/verification/local/output/core-math1/mise.toml @@ -0,0 +1,60 @@ +[env] +OTLP_AUTH_USERNAME = "root@example.com" + +[env._] +file = "secrets.env" + +[tasks.core-math1-twin] +description = "Run @edge-toolkit/et-ws-core-math1 in the wasi runner" +dir = "../../../.." +run = """ +cargo run --quiet -p et-ws-wasi-runner +""" + +[tasks.core-math1-twin.env] +RUNNER_MODULE = "@edge-toolkit/et-ws-core-math1" +WS_SERVER_URL = "ws://localhost:8080/ws" + +[tasks.generated-scenario] +depends = ["core-math1-twin", "math1-trigger", "openobserve", "ws-server"] +description = "Run generated scenario for core-math1" + +[tasks.math1-trigger] +description = "Run @edge-toolkit/et-ws-wasi-math1-sender in the wasi runner" +dir = "../../../.." +run = """ +cargo run --quiet -p et-ws-wasi-runner +""" + +[tasks.math1-trigger.env] +RUNNER_MODULE = "@edge-toolkit/et-ws-wasi-math1-sender" +WS_SERVER_URL = "ws://localhost:8080/ws" + +[tasks.open-o2] +description = "Open the OpenObserve UI" +run = "open http://localhost:5080/" + +[tasks.openobserve] +alias = "o2" +run = """ +image=openobserve/openobserve:v1.1.2 +container=openobserve-core-math1 +settings="-e RUST_LOG=warn -e ZO_ROOT_USER_EMAIL=root@example.com" +# $settings is a word-split flag list by design; do not quote it. +docker run --rm --name "$container" -p 127.0.0.1:5080:5080 $settings -e ZO_ROOT_USER_PASSWORD "$image" +""" + +[tasks.ws-server] +description = "Run the WebSocket server" +dir = "../../../../services/ws-server" +run = """ +MODULES_PATHS="../ws-modules/core-math1, ../ws-modules/wasi-math1-sender" +export MODULES_PATHS +cargo run +""" + +[tasks.ws-server.env] + +[tools] +"aqua:F1bonacc1/process-compose" = "latest" +"cargo:open" = "latest" diff --git a/verification/local/output/core-math1/process-compose.yaml b/verification/local/output/core-math1/process-compose.yaml new file mode 100644 index 00000000..1cefab9d --- /dev/null +++ b/verification/local/output/core-math1/process-compose.yaml @@ -0,0 +1,53 @@ +version: "0.5" +processes: + openobserve: + command: mise run openobserve + shutdown: + signal: 2 + command: docker stop openobserve-core-math1 + timeout_seconds: 30 + readiness_probe: + http_get: + host: localhost + scheme: http + path: /healthz + port: 5080 + period_seconds: 2 + failure_threshold: 1800 + ws-server: + command: mise run ws-server + depends_on: + openobserve: + condition: process_healthy + shutdown: + signal: 2 + readiness_probe: + http_get: + host: localhost + scheme: http + path: /health + port: 8080 + period_seconds: 2 + failure_threshold: 1800 + math1-trigger: + command: mise run math1-trigger + depends_on: + ws-server: + condition: process_healthy + shutdown: + signal: 2 + core-math1-twin: + command: mise run core-math1-twin + depends_on: + ws-server: + condition: process_healthy + shutdown: + signal: 2 + open-o2: + command: mise run open-o2 + depends_on: + openobserve: + condition: process_healthy + shutdown: + signal: 2 + disabled: true diff --git a/verification/local/output/core-math1/secrets.env b/verification/local/output/core-math1/secrets.env new file mode 100644 index 00000000..369b2503 --- /dev/null +++ b/verification/local/output/core-math1/secrets.env @@ -0,0 +1,5 @@ +# Generated from the scenario input -- regenerating this deployment rewrites it. +# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret +ZO_ROOT_USER_PASSWORD=Sint%qui3695 +# skipcq: SCT-A000 -- derived from the scenario input, not an independently generated secret +OTLP_AUTH_PASSWORD=Sint%qui3695