From f6b84840a9d8db0ca3068d01edcb04fec179a70f Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Mon, 2 Nov 2026 16:50:19 +0800 Subject: [PATCH] Deploy WASI runner to firmware --- .github/workflows/check.yaml | 13 +- .github/workflows/no-std.yaml | 30 +- .mise/config.rust.toml | 43 ++- CHANGELOG.md | 40 ++- CHECKS.md | 11 +- Cargo.lock | 10 + Cargo.toml | 7 +- README.md | 18 +- .../rules/no-repeated-wildcard-pattern.yaml | 23 ++ .../tests/no-repeated-wildcard-pattern.yaml | 30 ++ libs/edge-toolkit/Cargo.toml | 9 + libs/edge-toolkit/src/board.rs | 181 ++++++++++++ libs/edge-toolkit/src/lib.rs | 1 + libs/edge-toolkit/tests/board.rs | 148 ++++++++++ services/firmware-modules/Cargo.toml | 4 + services/firmware-modules/src/lib.rs | 277 ++++++++++++++++-- services/firmware-modules/tests/guests.rs | 215 ++++++++++++++ services/ws-server-firmware/Cargo.toml | 19 +- services/ws-server-firmware/README.md | 52 +++- services/ws-server-firmware/build.rs | 57 +++- services/ws-server-firmware/memory.x | 1 + services/ws-server-firmware/src/board/hub.rs | 63 +++- services/ws-server-firmware/src/board/mod.rs | 3 + .../ws-server-firmware/src/board/pico2w.rs | 28 +- services/ws-server-firmware/src/host.rs | 82 +++++- services/ws-server-firmware/src/main.rs | 11 +- services/ws-server-firmware/src/runner.rs | 248 ++++++++++++++++ .../tests/serves_modules.rs | 54 +++- services/ws-wasi-runner/Cargo.toml | 29 +- services/ws-wasi-runner/README.md | 27 +- services/ws-wasi-runner/src/board/host.rs | 49 +++- services/ws-wasi-runner/src/board/memory.rs | 114 +++++++ services/ws-wasi-runner/src/board/mod.rs | 4 +- services/ws-wasi-runner/src/board/run.rs | 30 +- .../ws-wasi-runner/src/board/test_host.rs | 88 +++++- services/ws-wasi-runner/src/board/wasi.rs | 13 +- services/ws-wasi-runner/src/engine.rs | 19 +- services/ws-wasi-runner/src/lib.rs | 3 + services/ws-wasi-runner/tests/board.rs | 76 ++++- services/ws/src/picoserve_hub.rs | 157 +++++++++- services/ws/tests/picoserve_hub.rs | 190 ++++++++++-- utilities/cli/HELP.md | 2 + .../cli/src/deployment_types/firmware.rs | 72 +++-- .../src/deployment_types/scenario_image.rs | 6 +- utilities/cli/src/error.rs | 10 +- utilities/cli/src/input.rs | 43 ++- utilities/cli/src/lib.rs | 21 +- utilities/cli/src/limits.rs | 8 +- utilities/cli/tests/firmware.rs | 100 ++++++- utilities/cli/tests/input.rs | 18 +- utilities/cli/tests/machines.rs | 10 +- utilities/cli/tests/recovery_regeneration.rs | 11 +- verification/local/output/default/README.md | 29 +- .../output/default/mise.firmware-esp32c6.toml | 2 + .../default/mise.firmware-pico-plus2.toml | 35 +++ .../output/default/mise.firmware-pico2.toml | 2 + .../facility-security-scenario/README.md | 29 +- .../mise.firmware-esp32c6.toml | 2 + .../mise.firmware-pico-plus2.toml | 43 +++ .../mise.firmware-pico2.toml | 2 + .../local/output/wasi-math1/README.md | 29 +- .../wasi-math1/mise.firmware-pico-plus2.toml | 37 +++ 62 files changed, 2706 insertions(+), 282 deletions(-) create mode 100644 config/ast-grep/rules/no-repeated-wildcard-pattern.yaml create mode 100644 config/ast-grep/tests/no-repeated-wildcard-pattern.yaml create mode 100644 libs/edge-toolkit/src/board.rs create mode 100644 libs/edge-toolkit/tests/board.rs create mode 100644 services/firmware-modules/tests/guests.rs create mode 100644 services/ws-server-firmware/src/runner.rs create mode 100644 services/ws-wasi-runner/src/board/memory.rs create mode 100644 verification/local/output/default/mise.firmware-pico-plus2.toml create mode 100644 verification/local/output/facility-security-scenario/mise.firmware-pico-plus2.toml create mode 100644 verification/local/output/wasi-math1/mise.firmware-pico-plus2.toml diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index a8c859de..edf4c9fd 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -21,14 +21,9 @@ env: MISE_ENV: dart,dotnet,java,js,kotlin,python,r,rust,zig jobs: - # Each OS runs two lanes: `mise run check`, and the feature powerset (cargo-hack-check) as a lane of its own. + # Each OS runs `mise run check`, and Linux and macOS run the feature powerset (cargo-hack-check) as a lane of its own. # The powerset is several hundred clippy runs, which no shared timeout fits, and as a separate lane it runs in - # parallel rather than lengthening the check. Running it as a step after `mise run check` also fails on Windows, - # where the next `mise` call dies at once with - # mise: recursive shim invocation detected for mise: C:/Users/runneradmin/AppData/Local/mise/shims/mise - # because the check's install-all rewrites mise's own shim, which install-mise-tools had removed. Observed on - # commit https://github.com/edge-toolkit/core/commit/aa260c59248d8eb227e310f78d553ddb1b9445a2 at - # https://github.com/edge-toolkit/core/actions/runs/37386640989/job/112021398135. + # parallel rather than lengthening the check. Windows runs no powerset lane, at the operator's direction. # Each lane is named explicitly so the check lanes keep their existing `check (, )` names. check: name: ${{ matrix.lane }} @@ -64,10 +59,6 @@ jobs: os: macos-latest task: cargo-hack-check timeout: 90 - - lane: features (windows-latest) - os: windows-latest - task: cargo-hack-check - timeout: 180 # Windows runs the checkers under busybox-w32 ash, same as test.yaml's Windows lane. # That's the shell mise's `shell = "bash"` tasks run on; Git Bash's MSYS fork-emulation cygheap breaks # down on long task chains. The path matches the version pin in `.mise/config.windows.toml`'s diff --git a/.github/workflows/no-std.yaml b/.github/workflows/no-std.yaml index 9906370c..2d7feba4 100644 --- a/.github/workflows/no-std.yaml +++ b/.github/workflows/no-std.yaml @@ -72,13 +72,7 @@ jobs: run: | mise run cargo-clippy-check-pico2w mise run build-ws-wasm-agent - src=verification/local/input/default.yaml - dst=target/firmware-default - fmt=firmware-pico2 - cargo run -q -p et-cli -- generate-deployment --input-file "$src" --output-dir "$dst" --output-type "$fmt" - cd "$dst" - MISE_ENV="$fmt" mise run firmware-build - MISE_ENV="$fmt" mise run firmware-host-test + mise run firmware-scenario verification/local/input/default.yaml firmware-pico2 --host-test # The host build the step above tested is the one both boards share, so only the board's own build is left. - name: Lint the ESP32-C6 firmware, then build a generated scenario's @@ -88,9 +82,19 @@ jobs: WIFI_PASSWORD: not-a-real-passphrase run: | mise run cargo-clippy-check-esp32c6 - src=verification/local/input/default.yaml - dst=target/firmware-default - fmt=firmware-esp32c6 - cargo run -q -p et-cli -- generate-deployment --input-file "$src" --output-dir "$dst" --output-type "$fmt" - cd "$dst" - MISE_ENV="$fmt" mise run firmware-build + mise run firmware-scenario verification/local/input/default.yaml firmware-esp32c6 + + # The board with PSRAM runs wasi runners beside the hub, so its scenario is the two math1 runners. + # Their components have to be built before the firmware compiles them for the board. The host build then runs the + # same guests against the same hub. + - name: Lint the Pico Plus 2 W firmware, then build and serve a generated scenario's runners + timeout-minutes: 45 + env: + WIFI_NETWORK: edge-toolkit-ci + WIFI_PASSWORD: not-a-real-passphrase + run: | + mise run cargo-clippy-check-pico-plus2w + mise run build-ws-wasm-agent + mise run build-ws-wasi-math1-module + mise run build-ws-wasi-math1-sender-module + mise run firmware-scenario verification/local/input/wasi-math1.yaml firmware-pico-plus2 --host-test diff --git a/.mise/config.rust.toml b/.mise/config.rust.toml index c29c898d..9ae15828 100644 --- a/.mise/config.rust.toml +++ b/.mise/config.rust.toml @@ -16,7 +16,7 @@ depends = ["fetch-cyw43-firmware-rclone"] description = "Build et-ws-server as Raspberry Pi Pico 2 W firmware" env = { CYW43_FIRMWARE_DIR = "{{ config_root }}/{{ vars.cyw43_dir }}" } -run = "cargo build --release -p et-int-ws-server-firmware --target thumbv8m.main-none-eabihf" +run = "cargo build --release -p et-int-ws-server-firmware --target thumbv8m.main-none-eabihf --features pico2w" # The firmware's body compiles only for the board, so the workspace's host clippy never reaches it; this does. # It needs what build-firmware-pico2w needs. @@ -24,7 +24,44 @@ run = "cargo build --release -p et-int-ws-server-firmware --target thumbv8m.main depends = ["fetch-cyw43-firmware-rclone"] description = "Lint the Raspberry Pi Pico 2 W firmware for its own target" env = { CYW43_FIRMWARE_DIR = "{{ config_root }}/{{ vars.cyw43_dir }}" } -run = "cargo clippy --release -p et-int-ws-server-firmware --target thumbv8m.main-none-eabihf" +run = "cargo clippy --release -p et-int-ws-server-firmware --target thumbv8m.main-none-eabihf --features pico2w" + +# As build-firmware-pico2w, for the Pimoroni Pico Plus 2 W: the same radio on the RP2350B, with PSRAM for the heap. +# It also builds in each runner RUNNER_GUESTS names, compiled for the board from the module MODULES_PATHS provides. +[tasks.build-firmware-pico-plus2w] +depends = ["fetch-cyw43-firmware-rclone"] +description = "Build et-ws-server and its wasi runners as Pimoroni Pico Plus 2 W firmware" +env = { CYW43_FIRMWARE_DIR = "{{ config_root }}/{{ vars.cyw43_dir }}" } +run = "cargo build --release -p et-int-ws-server-firmware --target thumbv8m.main-none-eabihf --features pico-plus2w" + +# Generate a scenario's deployment as one board's firmware, then build it, as CI does for every board. +# The deployment lands in target/, and the build reads WIFI_NETWORK and WIFI_PASSWORD as any board +# build does. With --host-test it then serves the same modules, and runs the same runners, from this host. +[tasks.firmware-scenario] +description = "Generate a scenario's firmware deployment under target/ and build it, optionally testing it on the host" +run = """ +dst="target/$usage_format" +set -- --input-file "$usage_input" --output-dir "$dst" --output-type "$usage_format" +cargo run -q -p et-cli -- generate-deployment "$@" +cd "$dst" +MISE_ENV="$usage_format" mise run firmware-build +if [ "${usage_host_test:-false}" = true ]; then + MISE_ENV="$usage_format" mise run firmware-host-test +fi +""" +shell = "{{ vars.task_shell }}" +usage = """ +arg "" help="The scenario input file" +arg "" help="The board's output type, such as firmware-pico2" +flag "--host-test" help="Also serve the modules and run the runners from this host, checking each" +""" + +# As cargo-clippy-check-pico2w, for the Pico Plus 2 W's body. +[tasks.cargo-clippy-check-pico-plus2w] +depends = ["fetch-cyw43-firmware-rclone"] +description = "Lint the Pimoroni Pico Plus 2 W firmware for its own target" +env = { CYW43_FIRMWARE_DIR = "{{ config_root }}/{{ vars.cyw43_dir }}" } +run = "cargo clippy --release -p et-int-ws-server-firmware --target thumbv8m.main-none-eabihf --features pico-plus2w" # et-ws-server as ESP32-C6 firmware: an ELF that espflash writes to the board, with esp-hal's bootloader before it. # Its radio's blobs come with esp-radio, so the build needs only the network to join, from WIFI_NETWORK and @@ -68,7 +105,7 @@ shell = "{{ vars.task_shell }}" [tasks.build-no-std] description = "Build the no_std crates for a bare-metal target" run = """ -features=et-ws-service/picoserve-hub,et-ws-server/picoserve-server +features=et-ws-service/picoserve-hub,et-ws-server/picoserve-server,et-ws-wasi-runner/board set -- -p et-org -p edge-toolkit -p et-ws-service -p et-ws-server -p et-ws-wasi-runner cargo build --lib --target thumbv8m.main-none-eabihf --no-default-features --features "$features" "$@" """ diff --git a/CHANGELOG.md b/CHANGELOG.md index de7e16d9..34fccbbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,25 +23,34 @@ Changelog 1.1.0. `GET /ws` over HTTP and WebSocket through picoserve on any socket the embedder accepts, such as an `embassy-net` `TcpSocket`. It is a local hub, with no backplane, admin API, idle timeout or registry file. A crate awaiting `Hub::serve` needs `#![recursion_limit = "256"]`. +- `picoserve_hub::Hub::local` joins an agent the embedder runs itself to the hub, with no socket between them: a + `LocalLink` routes what it sends as a connection's frames are, queues what others send it, and disconnects the agent + when dropped. It is how a board's own runner reaches the hub beside it. Its `send_text` and `send_binary` refuse a + frame larger than the hub reads from a socket with `FrameTooLarge`. - `et-ws-server` builds as `no_std` + `alloc` without its new `std` feature. Its `picoserve-server` feature, on by default, adds `picoserve_server::serve`: the hub's `/ws` with `/health` and `/favicon.ico` beside it, over picoserve, and the modules a `picoserve_modules::Modules` builds into the binary under `/modules` and `/`, as the `std` server serves them from disk. A module listed as served elsewhere is redirected to a base URL instead. -- The `et-ws-server-firmware` firmware runs et-ws-server on a Raspberry Pi Pico 2 W or an ESP32-C6, over the board's - Wi-Fi. `MISE_ENV=rust mise run build-firmware-pico2w` fetches the Pico's radio blobs and builds it for the Pico, and - `build-firmware-esp32c6` for the ESP32-C6, each for the network `WIFI_NETWORK` and `WIFI_PASSWORD` name, embedding - the modules `MODULES_PATHS` names and serving `MODULES_ROOT` at `/`. -- `et-cli generate-deployment --output-type firmware-pico2` writes a `mise.firmware-pico2.toml`, and - `firmware-esp32c6` a `mise.firmware-esp32c6.toml`, whose `firmware-flash` task builds that firmware with the - scenario's modules in it and loads it onto a board, with picotool or espflash. The npm - packages the modules use, such as onnxruntime-web, are redirected to jsDelivr rather than built in. A scenario with - a runner, `machines:` or published artifacts is refused, since the board runs one hub and nothing else. Its - `firmware-host-test` task serves the same modules from the host, as the firmware's host build does, and fetches - every module it lists, with no board needed. +- The `et-ws-server-firmware` firmware runs et-ws-server on a Raspberry Pi Pico 2 W, an ESP32-C6 or a Pimoroni Pico + Plus 2 W, over the board's Wi-Fi. `MISE_ENV=rust mise run build-firmware-pico2w` fetches the Pico's radio blobs and + builds it for the Pico, `build-firmware-esp32c6` for the ESP32-C6, and `build-firmware-pico-plus2w` for the Pico Plus + 2 W, each for the network `WIFI_NETWORK` and `WIFI_PASSWORD` name, embedding the modules `MODULES_PATHS` names and + serving `MODULES_ROOT` at `/`. The Pico Plus 2 W makes its 8 MiB of PSRAM the heap, and also runs each wasi runner + `RUNNER_GUESTS` names beside the hub, its guest compiled into the firmware and its websocket a link into the hub. +- `et-cli generate-deployment --output-type firmware-pico2` writes a `mise.firmware-pico2.toml`, `firmware-esp32c6` a + `mise.firmware-esp32c6.toml`, and `firmware-pico-plus2` a `mise.firmware-pico-plus2.toml`, whose `firmware-flash` + task builds that firmware with the scenario's modules in it and loads it onto a board, with picotool or espflash. + The npm packages the modules use, such as onnxruntime-web, are redirected to jsDelivr rather than built in. The + board's firmware decides which runners it runs: the Pico Plus 2 W runs a scenario's `wasi` runners, and every other + runner, or a runner with `limits:`, is refused; the other boards refuse any runner. A scenario with `machines:` or + published artifacts is refused on every board. Its `firmware-host-test` task serves the same modules from the host, + and runs the same runners, as the firmware's host build does, and fetches every module it lists, with no board + needed. - `et-firmware-modules` generates the source a firmware's build script writes to build ws-modules into its image, from the same `MODULES_*` variables, so every board's firmware embeds modules the same way. Its `board` writes what the board a target picks bakes in -- the Wi-Fi network, and the Pico 2 W's radio blobs and memory layout -- and - returns the directives that link it. + returns the directives that link it. Its `guests` compiles each runner's guest for the board, refusing one that + imports what a board does not host, or guests needing more memory than the board holds for them. - `et-ws-wasi-runner` builds as `no_std` + `alloc` without its new default `std` feature, and its `board` module runs a guest on a microcontroller: compiled ahead of time with `engine::precompile` for the board's Pulley target, and hosted through the firmware's `board::Platform` and `board::Transport`. @@ -51,6 +60,13 @@ Changelog 1.1.0. - Recovery supervision reconnects after hub interruptions and restores incident context with explicit observation gaps. Optional dependency checks, fleet action limits, bounded backoff with jitter and sustained-recovery assessment extend the existing restart policy. Reports distinguish observed failure conditions from reasons for withholding actions. +- `edge-toolkit`'s `board` module lists the interfaces a board's runner hosts, and its `Guest` reads what a component + imports and how much memory it starts with, so a guest can be checked against a board before it is built in. +- `et-ws-wasi-runner` builds as `no_std` + `alloc` without its new default `std` feature, and with its new `board` + feature its `board` module runs a guest on a microcontroller: compiled ahead of time with `engine::precompile` (the + `precompile` feature) for the board's Pulley target, and hosted through the firmware's `board::Platform` and + `board::Transport`. Every guest on a board shares its storage in `board::Buckets`, and draws its memories and + tables from one `board::MemoryBudget`, which refuses growth past what the board holds for them. - An opt-in `et-recovery-supervisor` service observes application progress and coordinates bounded Kubernetes recovery. - The Python SDK provides recovery lifecycle messages and integrity-checked checkpoint storage helpers. - A scenario can list `machines:` to span several hosts. The first is the server, running the hub and the collector, diff --git a/CHECKS.md b/CHECKS.md index 52a0d83d..7eeef5fe 100644 --- a/CHECKS.md +++ b/CHECKS.md @@ -1,11 +1,11 @@ # Checks - **Generated:** 2026-11-02 -- **Checks:** 81 +- **Checks:** 82 - **Tools:** 48 - **Shell-script checks:** 10 - **Checks with a repair pass:** 22 -- **Rules:** 360 custom local rules, or non-default strict linter settings +- **Rules:** 361 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 @@ -212,6 +212,11 @@ Rules, from `config/ast-grep/rules`: repository root instead -- `edge_toolkit::config::get_project_root()` (runtime) or `et_path::find_project_root_from_manifest()` (build scripts) -- and join the remainder onto it. (Char literals like `'.'` for splitting are unaffected.) +- `no-repeated-wildcard-pattern` -- Two or more `_` side by side in a tuple or tuple-struct pattern each ignore one + field where `..` ignores them all. Write the run as `..`: `(outcome, _, _, _)` becomes `(outcome, ..)`, `(_, _, path)` + becomes `(.., path)`, and `(outcome, _, _, closed)` becomes `(outcome, .., closed)`. The pattern then says which + fields matter rather than counting the ones that do not, and it keeps compiling when one is added. This mirrors + `DeepSource RS-W1213`; clippy has no equivalent. - `no-result-alias` -- Defining a `type Result<...>` alias shadows `std::result::Result` at file scope and makes every `Result` ambiguous to readers. Spell the error type at each call site instead (`Result`). - `no-result-string-err` -- `Result<_, String>` flattens any typed error to a free-form string at the point of failure @@ -334,6 +339,8 @@ Rules, from `config/ast-grep/rules`: `config/clippy.toml`. - `cargo-clippy-check-esp32c6` (`rust` env; not run by `mise run check`) -- Lint the ESP32-C6 firmware for its own target. Config: `Cargo.toml`, `config/clippy.toml`. +- `cargo-clippy-check-pico-plus2w` (`rust` env; not run by `mise run check`) -- Lint the Pimoroni Pico Plus 2 W firmware + for its own target. Config: `Cargo.toml`, `config/clippy.toml`. - `cargo-clippy-check-pico2w` (`rust` env; not run by `mise run check`) -- Lint the Raspberry Pi Pico 2 W firmware for its own target. Config: `Cargo.toml`, `config/clippy.toml`. - `cargo-clippy-check-pkg` (`default` env; not run by `mise run check`) -- cargo clippy on one or more workspace diff --git a/Cargo.lock b/Cargo.lock index 521149d0..7d96b896 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4672,6 +4672,8 @@ dependencies = [ "tempfile", "thiserror 2.0.21", "tracing", + "wasmparser 0.258.3", + "wat", ] [[package]] @@ -5746,10 +5748,14 @@ dependencies = [ name = "et-firmware-modules" version = "0.1.0" dependencies = [ + "edge-toolkit", + "et-ws-wasi-runner", "fs-err", "serde_json", "tempfile", "thiserror 2.0.21", + "wasmparser 0.258.3", + "wat", ] [[package]] @@ -5839,6 +5845,7 @@ dependencies = [ "cyw43-pio", "defmt 1.2.0", "defmt-rtt", + "document-features", "edge-toolkit", "embassy-executor", "embassy-net", @@ -5855,6 +5862,7 @@ dependencies = [ "et-firmware-modules", "et-ws-server", "et-ws-service", + "et-ws-wasi-runner", "fs-err", "panic-probe", "picoserve", @@ -6572,6 +6580,7 @@ dependencies = [ "et-test-otlp", "et-ws-runner-common", "et-ws-test-server", + "et-ws-wasi-runner", "fs-err", "futures-util", "libc", @@ -6582,6 +6591,7 @@ dependencies = [ "secrecy", "serde_json", "serde_path_to_error", + "spin 0.12.3", "tempfile", "thiserror 2.0.21", "tokio", diff --git a/Cargo.toml b/Cargo.toml index ae76d7fd..e495240a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -142,6 +142,7 @@ et-ws-runner-common = { path = "libs/ws-runner-common", version = "0.3.0" } et-ws-server = { path = "services/ws-server", version = "0.3.0", default-features = false } et-ws-service = { path = "services/ws", version = "0.2.0", default-features = false } et-ws-test-server = { path = "services/ws-test-server", version = "0.2.0" } +et-ws-wasi-runner = { path = "services/ws-wasi-runner", version = "0.3.0", default-features = false } et-ws-wasm-agent = { path = "services/ws-wasm-agent", version = "0.3.0" } fake = "5" fs-err = "3" @@ -301,17 +302,19 @@ wasi-webgpu-wasmtime = "0.4" wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" wasm-bindgen-test = "0.3" +# Held to the release wasmtime parses with, so a guest it reads as importable is one wasmtime loads the same way. +wasmparser = { version = "0.258", default-features = false, features = ["component-model", "std"] } # Keep all three on the same major for ABI compat -- wasmtime-wasi-nn tracks wasmtime's version exactly. # 49.0.5 is the floor, not a preference: RUSTSEC-2026-0352 through RUSTSEC-2026-0364 are fixed only from 49.0.3, # RUSTSEC-2026-0378 / RUSTSEC-2026-0379 only from 49.0.4, and RUSTSEC-2026-0420 and RUSTSEC-2026-0422 through # RUSTSEC-2026-0424 only from 49.0.5, on the 49 line. The major is held at 49 by wasi-webgpu-wasmtime, whose 0.4 # line requires wasmtime ^49. # Without its default features, which need std, so a board's runner can depend on it; a host build turns them on. +# Pulley likewise, which only a board's runner and the compiler for it need, so ws-wasi-runner's features turn it on. wasmtime = { version = "49.0.5", default-features = false, features = [ "async", "component-model", "component-model-async", - "pulley", "runtime", ] } # The generator behind wasmtime's `bindgen!` macro, driven directly by et-int-gen. @@ -322,6 +325,8 @@ wasmtime = { version = "49.0.5", default-features = false, features = [ wasmtime-internal-wit-bindgen = { version = "49.0.5", features = ["async", "component-model-async"] } wasmtime-wasi = "49.0.5" wasmtime-wasi-nn = { version = "49", default-features = false, features = ["onnx"] } +# Writes the components the tests read from WebAssembly text. +wat = "1" web-sys = "0.3" # wgpu-core/wgpu-types come in via wasi-webgpu-wasmtime's re-exports; only the instance handle is ours. wgpu-core = { version = "29", default-features = false } diff --git a/README.md b/README.md index da028338..f47a8339 100644 --- a/README.md +++ b/README.md @@ -427,10 +427,10 @@ for `localhost` only and the runners have no way to be told to trust it. ### Firmware -The `firmware-` output types deploy the hub alone, as firmware for a networked microcontroller board that -serves the scenario's modules itself. Each board has one: `firmware-pico2` builds for the Raspberry Pi Pico 2 W, over -its own Wi-Fi. It writes a `mise.firmware-pico2.toml` beside the other formats, and its `firmware-flash` task builds -the firmware and loads it onto a board in BOOTSEL mode: +The `firmware-` output types deploy the hub as firmware for a networked microcontroller board that serves the +scenario's modules itself. Each board has one: `firmware-pico2` builds for the Raspberry Pi Pico 2 W, over its own +Wi-Fi. It writes a `mise.firmware-pico2.toml` beside the other formats, and its `firmware-flash` task builds the +firmware and loads it onto a board in BOOTSEL mode: ```bash input=verification/local/input/facility-security-scenario.yaml @@ -442,8 +442,14 @@ MISE_ENV=firmware-pico2 mise run firmware-flash A board has no filesystem, so each module from this repository, or from the scenario's `module_paths:`, is built into the firmware from its built package, and the modules have to be built first. The npm packages they use, such as -onnxruntime-web, are bigger than a board's flash: those are redirected to jsDelivr instead. A scenario with a runner, -more than one machine or published artifacts is refused, since the board runs nothing but the hub. +onnxruntime-web, are bigger than a board's flash: those are redirected to jsDelivr instead. A scenario with more than +one machine or published artifacts is refused. + +The board's firmware decides which runners it can run. Only `firmware-pico-plus2`, for the Pimoroni Pico Plus 2 W, +runs any: its 8 MiB of PSRAM holds the memory a guest needs, so it runs each `wasi` runner beside the hub, its guest +compiled into the firmware. The firmware's build refuses a guest that imports what a board does not host, such as +`wasi:webgpu` or `wasi:nn`, or guests needing more memory than the board holds for them. Every other board refuses a +scenario with a runner, and the Pico Plus 2 W refuses any runner but `wasi`, or one with `limits:`. ### Multiple machines diff --git a/config/ast-grep/rules/no-repeated-wildcard-pattern.yaml b/config/ast-grep/rules/no-repeated-wildcard-pattern.yaml new file mode 100644 index 00000000..03562f69 --- /dev/null +++ b/config/ast-grep/rules/no-repeated-wildcard-pattern.yaml @@ -0,0 +1,23 @@ +id: no-repeated-wildcard-pattern +language: Rust +severity: error +message: | + Two or more `_` side by side in a tuple or tuple-struct pattern each ignore one field where `..` ignores them all. + Write the run as `..`: `(outcome, _, _, _)` becomes `(outcome, ..)`, `(_, _, path)` becomes `(.., path)`, and + `(outcome, _, _, closed)` becomes `(outcome, .., closed)`. The pattern then says which fields matter rather than + counting the ones that do not, and it keeps compiling when one is added. This mirrors `DeepSource RS-W1213`; clippy + has no equivalent. +# Check-only, because a pattern holds only one `..`. +# A rewrite would have to collapse one run and leave any other alone, which a single template cannot express. +# Matched on the pattern's own text: the wildcard is an anonymous node that a structural `has` does not reach. +# A pattern already holding `..`, or with two runs and a field between them, is left alone, since `..` can replace at +# most one run there and the rule would ask for a rewrite Rust does not allow. +rule: + any: + - kind: tuple_pattern + - kind: tuple_struct_pattern + regex: '[(,]\s*_\s*,\s*_\s*[,)]' + not: + any: + - regex: '\.\.' + - regex: '_\s*,\s*_\s*,.*[^\s_,()].*,\s*_\s*,\s*_' diff --git a/config/ast-grep/tests/no-repeated-wildcard-pattern.yaml b/config/ast-grep/tests/no-repeated-wildcard-pattern.yaml new file mode 100644 index 00000000..2749b474 --- /dev/null +++ b/config/ast-grep/tests/no-repeated-wildcard-pattern.yaml @@ -0,0 +1,30 @@ +id: no-repeated-wildcard-pattern +valid: + - | + let (outcome, ..) = ran(); + - | + let (outcome, .., closed) = ran(); + - | + let (outcome, _, closed, _) = ran(); + - | + let Frame(_, text) = frame; + - | + let _ = (first, second); + - | + let (_, _, value, _, _) = five(); + - | + let (first, .., _, _) = many(); +invalid: + - | + let (outcome, _, _, _) = ran(); + - | + let (outcome, _, _, closed) = ran(); + - | + for (_, _, path) in &sources {} + - | + let Frame(_, _, text) = frame; + - | + let (outcome, (_, + _)) = ran(); + - | + let (_, _, value, _) = four(); diff --git a/libs/edge-toolkit/Cargo.toml b/libs/edge-toolkit/Cargo.toml index 678d2204..963cfd67 100644 --- a/libs/edge-toolkit/Cargo.toml +++ b/libs/edge-toolkit/Cargo.toml @@ -19,6 +19,10 @@ doctest = false name = "args" required-features = ["std"] +[[test]] +name = "board" +required-features = ["std"] + [[test]] name = "config" required-features = ["std"] @@ -78,6 +82,7 @@ std = [ "dep:serde_yaml", "dep:strum", "dep:tracing", + "dep:wasmparser", "serde/std", "serde_json/std", "thiserror/std", @@ -89,6 +94,8 @@ schema-export = ["dep:asyncapi-rust", "dep:schemars", "schemars/chrono04", "std" ## Derives JSON Schema for the configuration types the binaries read from the environment. ## et-int-gen renders each binary's environment variables into its HELP.md from that schema. env-schema = ["dep:schemars", "std"] +## Internal: a board runner built for coverage also hosts the `/cov` interfaces an instrumented guest writes through. +coverage = [] ## Internal: renders this feature list into the crate documentation, as docs.rs builds it. docs = ["dep:document-features"] @@ -114,6 +121,7 @@ spin.workspace = true strum = { workspace = true, optional = true } thiserror.workspace = true tracing = { workspace = true, features = ["std"], optional = true } +wasmparser = { workspace = true, optional = true } [dev-dependencies] cloudevents-sdk.workspace = true @@ -122,6 +130,7 @@ et-test-helpers.workspace = true et-test-otlp.workspace = true rstest.workspace = true tempfile.workspace = true +wat.workspace = true [lints] workspace = true diff --git a/libs/edge-toolkit/src/board.rs b/libs/edge-toolkit/src/board.rs new file mode 100644 index 00000000..3b3cebc9 --- /dev/null +++ b/libs/edge-toolkit/src/board.rs @@ -0,0 +1,181 @@ +//! What a board's firmware runner hosts for a guest, and the reading of a guest component that checks one against it. +//! +//! A board links only the interfaces below, so a guest importing any other fails to instantiate there. Reading the +//! guest ahead of time lets a deployment refuse it before anything is built or flashed. + +/// The WASI Preview 2 release a board's runner links its `wasi:` interfaces at. +pub const PREVIEW2: &str = "0.2.12"; + +/// Every interface a board's runner links, at the version it links it: all a guest may import to run on a board. +/// +/// The `runner` world's own imports, and the WASI Preview 2 a ws-module guest built for `wasm32-wasip2` imports, the +/// latter at [`PREVIEW2`]. +pub const HOSTED_IMPORTS: [&str; 18] = [ + "et:ws-messages/messages@0.2.0", + "et:ws-wasi/ws@0.2.0", + "wasi:cli/environment@0.2.12", + "wasi:cli/exit@0.2.12", + "wasi:cli/stderr@0.2.12", + "wasi:cli/stdin@0.2.12", + "wasi:cli/stdout@0.2.12", + "wasi:cli/terminal-input@0.2.12", + "wasi:cli/terminal-output@0.2.12", + "wasi:cli/terminal-stderr@0.2.12", + "wasi:cli/terminal-stdin@0.2.12", + "wasi:cli/terminal-stdout@0.2.12", + "wasi:clocks/monotonic-clock@0.2.12", + "wasi:io/error@0.2.12", + "wasi:io/poll@0.2.12", + "wasi:io/streams@0.2.12", + "wasi:keyvalue/store@0.2.0-draft", + "wasi:logging/logging@0.1.0-draft", +]; + +/// What a guest instrumented for coverage imports beyond [`HOSTED_IMPORTS`], to write its profile into `/cov`. +/// +/// A board's runner links these only when built for coverage, which the repo's coverage workflow does to run the +/// instrumented guests; a board's firmware never is. `wall-clock` is imported for the timestamp type alone, which a +/// linker needs no definition of. +pub const COVERAGE_IMPORTS: [&str; 3] = [ + "wasi:clocks/wall-clock@0.2.12", + "wasi:filesystem/preopens@0.2.12", + "wasi:filesystem/types@0.2.12", +]; + +/// Every interface this build's board runner links: [`HOSTED_IMPORTS`]. +#[cfg(not(feature = "coverage"))] +#[expect( + clippy::single_call_fn, + reason = "one of two builds of the list, which `hosts` reads either way" +)] +fn linked() -> impl Iterator { + HOSTED_IMPORTS.iter() +} + +/// Every interface this build's board runner links: [`HOSTED_IMPORTS`], and [`COVERAGE_IMPORTS`] with them. +#[cfg(feature = "coverage")] +#[expect( + clippy::single_call_fn, + reason = "one of two builds of the list, which `hosts` reads either way" +)] +fn linked() -> impl Iterator { + HOSTED_IMPORTS.iter().chain(&COVERAGE_IMPORTS) +} + +/// Whether a board hosts `import`, an interface as a component imports it, `name@version`. +/// +/// It does when the board links the same interface at a version in the same semver series, which is what wasmtime's +/// linker resolves an import to: one that names no version, or another series, fails to instantiate. +#[must_use] +pub fn hosts(import: &str) -> bool { + let Some((name, version)) = import.split_once('@') else { + return false; + }; + linked().any(|hosted| { + hosted + .split_once('@') + .is_some_and(|(hosted_name, linked)| hosted_name == name && series(linked) == series(version)) + }) +} + +/// The part of `version` a compatible one shares, as Cargo's semver rules have it, and wasmtime's linker with them. +/// +/// From `1.0.0` on that is the major version, and below it the minor, so `0.2.0` and `0.2.12` are one series but +/// `0.3.0` another; a `0.0` version, a prerelease or one with build metadata matches only itself. +fn series(version: &str) -> (&str, &str) { + if version.contains(['-', '+']) { + return (version, ""); + } + let Some((major, rest)) = version.split_once('.') else { + return (version, ""); + }; + if major != "0" { + return (major, ""); + } + let minor = rest.split_once('.').map_or(rest, |(minor, _patch)| minor); + if minor == "0" { + return (version, ""); + } + (major, minor) +} + +/// What a guest component asks of the runner that hosts it. +#[cfg(feature = "std")] +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct Guest { + /// The interfaces the component imports, as it names them, in the order it declares them. + pub imports: Vec, + /// The bytes its core modules' memories start at, which the runner allocates before the guest runs. + pub memory: u64, + /// The elements its core modules' tables start with, each a pointer the runner allocates beside the memories. + pub table_elements: u64, +} + +#[cfg(feature = "std")] +impl Guest { + /// Read what `component`, a WebAssembly component's bytes, imports and how much memory it starts with. + /// + /// Only the outer component's imports count, since a nested one's are satisfied inside the component. A memory or + /// table a core module imports is another module's, already counted where it is defined. + /// + /// # Errors + /// + /// When `component` is not a WebAssembly binary. + pub fn read(component: &[u8]) -> wasmparser::Result { + use wasmparser::{Parser, Payload}; + + let mut imports = Vec::new(); + let mut memory: u64 = 0; + let mut table_elements: u64 = 0; + let mut depth: usize = 0; + for payload in Parser::new(0).parse_all(component) { + let payload = payload?; + if matches!( + payload, + Payload::ModuleSection { .. } | Payload::ComponentSection { .. } + ) { + depth = depth.saturating_add(1); + } else if matches!(payload, Payload::End(_)) { + depth = depth.saturating_sub(1); + } else if let Payload::ComponentImportSection(section) = payload { + if depth == 0 { + for import in section { + imports.push(String::from(import?.name.name)); + } + } + } else if let Payload::MemorySection(section) = payload { + for declared in section { + let declared = declared?; + let page_size = 1_u64 << declared.page_size_log2.unwrap_or(16); + memory = memory.saturating_add(declared.initial.saturating_mul(page_size)); + } + } else if let Payload::TableSection(section) = payload { + for declared in section { + table_elements = table_elements.saturating_add(declared?.ty.initial); + } + } else { + // Every other section declares neither an import of the component's, a memory nor a table. + } + } + Ok(Self { + imports, + memory, + table_elements, + }) + } + + /// The bytes the runner allocates for this guest's memories and tables before it runs. + /// + /// A table element is a pointer, `pointer_bytes` wide on the target the guest runs on. + #[must_use] + pub const fn starts_with(&self, pointer_bytes: u64) -> u64 { + self.memory + .saturating_add(self.table_elements.saturating_mul(pointer_bytes)) + } + + /// The imports a board does not host, which would stop this guest instantiating there. + pub fn unhosted(&self) -> impl Iterator { + self.imports.iter().map(String::as_str).filter(|import| !hosts(import)) + } +} diff --git a/libs/edge-toolkit/src/lib.rs b/libs/edge-toolkit/src/lib.rs index 53543b07..39039ab1 100644 --- a/libs/edge-toolkit/src/lib.rs +++ b/libs/edge-toolkit/src/lib.rs @@ -8,6 +8,7 @@ extern crate alloc; #[cfg(feature = "std")] pub mod args; pub mod auth; +pub mod board; #[cfg(feature = "std")] pub mod config; pub mod ports; diff --git a/libs/edge-toolkit/tests/board.rs b/libs/edge-toolkit/tests/board.rs new file mode 100644 index 00000000..0543ce6e --- /dev/null +++ b/libs/edge-toolkit/tests/board.rs @@ -0,0 +1,148 @@ +//! What a board hosts, and what a guest component is read as asking of it. +#![cfg(test)] + +use edge_toolkit::board::{COVERAGE_IMPORTS, Guest, HOSTED_IMPORTS, PREVIEW2, hosts}; + +/// The page size every memory here declares but the one that names its own, 64 KiB. +const PAGE: u64 = 64 * 1024; +/// What the guest below starts with: seventeen pages, and one memory of one byte-sized page. +const DECLARED: u64 = 17 * PAGE + 1; + +/// The bytes of a component written in WebAssembly text. +fn component(text: &str) -> Vec { + wat::parse_str(text).unwrap() +} + +/// A component's bytes with the start of the first occurrence of `found` overwritten by `written`. +fn corrupted(bytes: &[u8], found: &[u8], written: &[u8]) -> Vec { + let start = bytes.windows(found.len()).position(|window| window == found).unwrap(); + let mut corrupted = bytes.to_vec(); + corrupted + .iter_mut() + .skip(start) + .zip(written) + .for_each(|(byte, replacement)| *byte = *replacement); + corrupted +} + +#[test] +fn every_hosted_interface_is_hosted_at_the_version_it_is_linked_at() { + for import in HOSTED_IMPORTS { + assert!(hosts(import)); + } + let wasi = HOSTED_IMPORTS.iter().filter(|import| { + ["wasi:cli/", "wasi:clocks/", "wasi:io/"] + .iter() + .any(|package| import.starts_with(package)) + }); + for import in wasi { + assert!(import.ends_with(&format!("@{PREVIEW2}"))); + } +} + +#[test] +fn an_import_is_hosted_at_any_version_in_the_series_linked() { + for import in ["wasi:io/poll@0.2.0", "wasi:io/poll@0.2.99", "et:ws-wasi/ws@0.2.7"] { + assert!(hosts(import)); + } + for import in [ + "wasi:io/poll", + "wasi:io/poll@0.3.0", + "wasi:io/poll@1.0.0", + "wasi:io/poll@2", + "wasi:io/poll@0.0.12", + "wasi:logging/logging@0.1.1-draft", + "wasi:logging/logging@0.1.0", + "wasi:keyvalue/store@0.2.0-draft+build", + ] { + assert!(!hosts(import)); + } +} + +#[test] +fn an_interface_only_a_host_runner_has_is_not_hosted() { + assert!(!hosts("wasi:webgpu/webgpu@0.3.0-rc.2")); + assert!(!hosts("wasi:nn/graph@0.2.0-rc-2024-10-28")); + assert!(!hosts("wasi:random/random@0.2.12")); +} + +#[test] +fn what_an_instrumented_guest_writes_its_profile_through_is_hosted_only_by_a_coverage_build() { + for import in COVERAGE_IMPORTS { + assert_eq!(hosts(import), cfg!(feature = "coverage")); + assert!(import.ends_with(&format!("@{PREVIEW2}"))); + } +} + +#[test] +fn a_guest_is_read_as_its_imports_and_its_memory() { + let guest = Guest::read(&component( + r#"(component + (import "wasi:io/poll@0.2.12" (instance)) + (import "wasi:webgpu/webgpu@0.3.0-rc.2" (instance)) + (core module (memory 17) (table 3 funcref)) + (core module + (import "env" "shared" (memory 3)) + (import "env" "table" (table 9 funcref)) + (memory 1 (pagesize 1)) + (table 2 externref) + ) + )"#, + )) + .unwrap(); + assert_eq!(guest.imports, ["wasi:io/poll@0.2.12", "wasi:webgpu/webgpu@0.3.0-rc.2"]); + assert_eq!(guest.memory, DECLARED); + assert_eq!(guest.table_elements, 5); + assert_eq!(guest.starts_with(4), DECLARED + 20); + assert_eq!(guest.starts_with(8), DECLARED + 40); + assert_eq!(guest.unhosted().collect::>(), ["wasi:webgpu/webgpu@0.3.0-rc.2"]); +} + +#[test] +fn a_nested_component_s_imports_are_its_own() { + let guest = Guest::read(&component( + r#"(component + (component (import "wasi:nn/graph" (instance))) + (import "wasi:logging/logging@0.1.0-draft" (instance)) + )"#, + )) + .unwrap(); + assert_eq!(guest.imports, ["wasi:logging/logging@0.1.0-draft"]); + assert_eq!(guest.memory, 0); + assert_eq!(guest.unhosted().count(), 0); +} + +#[test] +fn what_is_not_webassembly_is_refused() { + let _refused = Guest::read(b"not wasm").unwrap_err(); +} + +#[test] +fn an_unreadable_import_is_refused() { + let bytes = component(r#"(component (import "wasi:io/poll" (instance)))"#); + let name = b"wasi:io/poll"; + let length = u8::try_from(name.len()).unwrap(); + let mut from = vec![length]; + from.extend_from_slice(name); + let _refused = Guest::read(&corrupted(&bytes, &from, &[0x7f])).unwrap_err(); +} + +#[test] +fn an_unreadable_memory_is_refused() { + let bytes = component("(component (core module (memory 17)))"); + // The memory section: id 5, three bytes, one memory with no limits flags and 17 pages. + let section = [0x05, 0x03, 0x01, 0x00, 0x11]; + // The same memory, its flags byte set to bits no memory may have. + let unreadable = [0x05, 0x03, 0x01, 0x7f]; + let _refused = Guest::read(&corrupted(&bytes, §ion, &unreadable)).unwrap_err(); +} + +#[test] +fn an_unreadable_table_is_refused() { + let bytes = component("(component (core module (table 3 funcref)))"); + // The table section: id 4, four bytes, one funcref table with no limits flags and 3 elements. + let section = [0x04, 0x04, 0x01, 0x70, 0x00, 0x03]; + // The same table, its element type a byte no reference type has. + let unreadable = [0x04, 0x04, 0x01, 0x7f]; + let _refused = Guest::read(&corrupted(&bytes, §ion, &unreadable)).unwrap_err(); +} diff --git a/services/firmware-modules/Cargo.toml b/services/firmware-modules/Cargo.toml index d9ff4ec5..c1811bac 100644 --- a/services/firmware-modules/Cargo.toml +++ b/services/firmware-modules/Cargo.toml @@ -11,12 +11,16 @@ repository.workspace = true doctest = false [dependencies] +edge-toolkit = { workspace = true, features = ["std"] } +et-ws-wasi-runner = { workspace = true, features = ["precompile"] } fs-err.workspace = true serde_json = { workspace = true, features = ["std"] } thiserror = { workspace = true, features = ["std"] } +wasmparser.workspace = true [dev-dependencies] tempfile.workspace = true +wat.workspace = true [lints] workspace = true diff --git a/services/firmware-modules/src/lib.rs b/services/firmware-modules/src/lib.rs index 21516628..0239ba9a 100644 --- a/services/firmware-modules/src/lib.rs +++ b/services/firmware-modules/src/lib.rs @@ -12,6 +12,8 @@ use std::fmt::Write as _; use std::hash::{DefaultHasher, Hash as _, Hasher as _}; use std::path::{Component, Path, PathBuf}; +use edge_toolkit::board::Guest; +use et_ws_wasi_runner::engine::precompile; use fs_err as fs; /// The variables [`board`] reads, which a build script has to rerun when one changes. @@ -31,6 +33,10 @@ const PACKAGE_JSON: &str = "package.json"; const MODULES_API: &str = "et_ws_server::picoserve_modules"; /// The variable the module directories come from, which errors about them name. const MODULES_PATHS: &str = "MODULES_PATHS"; +/// The variable naming the runners a board starts and the modules they run, which errors about them name. +pub const RUNNER_GUESTS: &str = "RUNNER_GUESTS"; +/// The Pulley target of a 32-bit board, whose pointers, and so table elements, are four bytes rather than eight. +const PULLEY32: &str = "pulley32"; /// Why a firmware's modules cannot be built in, or its board's build cannot be generated. #[derive(Debug, thiserror::Error)] @@ -66,6 +72,32 @@ pub enum ModulesError { MissingBlob { variable: String, file: String }, #[error("no board is built for {0}")] NoBoard(String), + #[error("RUNNER_GUESTS holds {0:?}, which is not a runner=package pair")] + NotAGuest(String), + #[error("runner {runner} runs {package}, which none of MODULES_PATHS provides; serving {names:?}")] + UnknownGuest { + runner: String, + package: String, + names: Vec, + }, + #[error("runner {runner} runs {package}, whose main {main:?} is not a WebAssembly component")] + NotAComponent { + runner: String, + package: String, + main: String, + }, + #[error("{path} is not a WebAssembly component")] + Unreadable { + path: PathBuf, + #[source] + source: wasmparser::BinaryReaderError, + }, + #[error("runner {runner} imports {imports:?}, which this board does not host")] + Unhosted { runner: String, imports: Vec }, + #[error("the runners' guests start with {needed} bytes of memories and tables, but this board holds {budget}")] + GuestMemory { needed: u64, budget: u64 }, + #[error("runner {runner}'s guest could not be compiled for the board: {reason}")] + Precompile { runner: String, reason: String }, } /// The source of `modules.rs`, and every file it builds in, which the build has to rerun when one changes. @@ -100,11 +132,7 @@ pub fn generate(paths: &str, root: &str, elsewhere: &str, elsewhere_url: &str) - let path = PathBuf::from(path); // Checked before anything is read, so a relative entry is refused as one, wherever the build happens to run. let _literal = include_path(MODULES_PATHS, &path)?; - let package_dir = if path.join(PACKAGE_DIR).is_dir() { - path.join(PACKAGE_DIR) - } else { - path - }; + let package_dir = package_dir(path); let package = package(&package_dir)?; let _written = writeln!(embedded, " {MODULES_API}::Module::new({:?}, &[", package.name); for file in package.files { @@ -154,6 +182,183 @@ static MODULES: {MODULES_API}::Modules = {MODULES_API}::Modules::new( }) } +/// The source building in a guest for each runner in `guests`, compiled for `target`, and checked against the board. +/// +/// `guests` is a comma-separated list of `runner=package` pairs, as `RUNNER_GUESTS` holds it, each naming a module in +/// `paths` whose `main` is the component to run. A board hosts only what [`edge_toolkit::board::HOSTED_IMPORTS`] lists, +/// and holds `memory` bytes for its guests' memories and tables, so a guest importing anything else, or guests starting +/// with more between them, are refused here rather than when the board instantiates them. A table element is counted +/// at the pointer width of `target`, as the board's runner charges it. Each compiled guest is written to `out_dir`, and +/// the source defines `const GUESTS`, every runner's name and its compiled component, as an array so the firmware can +/// size a task pool by it. +/// +/// # Errors +/// +/// When a pair is malformed, a package is not in `paths` or its `main` is not a readable component, a guest imports +/// what the board does not host, the guests need more than `memory`, or one does not compile for `target`. +#[expect( + clippy::use_debug, + reason = "a str's Debug form is the Rust literal the generated source needs" +)] +pub fn guests(paths: &str, guests: &str, target: &str, memory: u64, out_dir: &Path) -> Result { + // Checked before anything is written, so every compiled guest's path is absolute UTF-8 too. + let _literal = include_path(RUNNER_GUESTS, out_dir)?; + let packages = items(paths) + .map(|path| runnable(&package_dir(PathBuf::from(path)))) + .collect::, _>>()?; + let pointer_bytes = if target == PULLEY32 { 4 } else { 8 }; + let mut entries = String::default(); + let mut components = Vec::new(); + let mut needed: u64 = 0; + for (index, pair) in items(guests).enumerate() { + let (runner, component, bytes, guest) = checked_guest(pair, &packages)?; + needed = needed.saturating_add(guest.starts_with(pointer_bytes)); + // Matched rather than converted, so the error can name the runner whose guest would not compile. + let compiled = match precompile(target, &bytes) { + Ok(compiled) => compiled, + Err(error) => { + return Err(ModulesError::Precompile { + runner: runner.to_owned(), + reason: error.to_string(), + }); + } + }; + let compiled_path = out_dir.join(format!("guest-{index}.cwasm")); + fs::write(&compiled_path, compiled)?; + let literal = compiled_path.display().to_string(); + let _written = writeln!(entries, " ({runner:?}, include_bytes!({literal:?})),"); + components.push(component); + } + if needed > memory { + return Err(ModulesError::GuestMemory { needed, budget: memory }); + } + let count = components.len(); + let source = format!( + "/// The guests the firmware runs beside the hub, each compiled for it, as `RUNNER_GUESTS` named them. +pub(crate) const GUESTS: [(&str, &[u8]); {count}] = [ +{entries}]; +/// The bytes the board holds for its guests' memories, which they draw on as they grow, whatever they start with. +pub(crate) const GUEST_MEMORY: u64 = {budget}; +", + budget = separated(memory) + ); + Ok(Generated { + source, + files: components, + }) +} + +/// `value` as an integer literal with its digits in groups of three, as the firmware's lints ask of a long one. +#[expect( + clippy::single_call_fn, + reason = "the literal step of guests, kept apart from what it writes" +)] +fn separated(value: u64) -> String { + let digits = value.to_string(); + let mut groups: Vec<&[u8]> = digits.as_bytes().rchunks(3).collect(); + groups.reverse(); + groups + .iter() + .map(|group| String::from_utf8_lossy(group)) + .collect::>() + .join("_") +} + +/// Refuse `entry`, a path the manifest at `manifest` names, unless it names something within the package. +/// +/// A root, a drive or a `..` would build in a file from elsewhere. +fn within_package(entry: &str, manifest: &Path) -> Result<(), ModulesError> { + if Path::new(entry) + .components() + .all(|part| matches!(part, Component::Normal(_) | Component::CurDir)) + { + return Ok(()); + } + Err(ModulesError::OutsidePackage { + manifest: manifest.to_path_buf(), + entry: entry.to_owned(), + }) +} + +/// A package a runner may run: its name, the path of its `main`, and `main` as the manifest spells it. +struct Runnable { + name: String, + component: PathBuf, + main: String, +} + +/// The package in `dir`, as a runner would find its component there. +#[expect( + clippy::single_call_fn, + reason = "the per-module step of guests, kept apart from its runners' checks" +)] +fn runnable(dir: &Path) -> Result { + let manifest_path = dir.join(PACKAGE_JSON); + let manifest = manifest(&manifest_path)?; + let name = manifest_name(&manifest, &manifest_path)?; + // No `main` is no component, which is refused for the runner that names the package rather than here. + let main = manifest + .get("main") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + within_package(main, &manifest_path)?; + Ok(Runnable { + name, + component: dir.join(main), + main: main.to_owned(), + }) +} + +/// The runner `pair` names, once a board can host its component. +/// +/// With it, the component's path and bytes, and what the component asks of the board. +#[expect( + clippy::single_call_fn, + reason = "the per-runner check of guests, kept apart from compiling" +)] +fn checked_guest<'pair>( + pair: &'pair str, + packages: &[Runnable], +) -> Result<(&'pair str, PathBuf, Vec, Guest), ModulesError> { + let (runner, package) = pair + .split_once('=') + .map(|(runner, package)| (runner.trim(), package.trim())) + .filter(|(runner, package)| !runner.is_empty() && !package.is_empty()) + .ok_or_else(|| ModulesError::NotAGuest(pair.to_owned()))?; + let Some(found) = packages.iter().find(|candidate| candidate.name == package) else { + return Err(ModulesError::UnknownGuest { + runner: runner.to_owned(), + package: package.to_owned(), + names: packages.iter().map(|candidate| candidate.name.clone()).collect(), + }); + }; + if found.component.extension().and_then(|extension| extension.to_str()) != Some("wasm") { + return Err(ModulesError::NotAComponent { + runner: runner.to_owned(), + package: package.to_owned(), + main: found.main.clone(), + }); + } + let bytes = fs::read(&found.component)?; + let guest = match Guest::read(&bytes) { + Ok(guest) => guest, + Err(source) => { + return Err(ModulesError::Unreadable { + path: found.component.clone(), + source, + }); + } + }; + let unhosted: Vec = guest.unhosted().map(str::to_owned).collect(); + if !unhosted.is_empty() { + return Err(ModulesError::Unhosted { + runner: runner.to_owned(), + imports: unhosted, + }); + } + Ok((runner, found.component.clone(), bytes, guest)) +} + /// `path` as the literal an `include_bytes!` names it by, which has to be absolute; `variable` is where it came from. /// /// A build script runs in its own crate's directory, so a relative path would quietly resolve against that. @@ -178,8 +383,8 @@ pub fn include_path(variable: &str, path: &Path) -> Result /// /// Every board gets `wifi.rs`, the network `WIFI_NETWORK` and `WIFI_PASSWORD` name, each read through `var` as from /// the environment. Bare-metal Arm is the Raspberry Pi Pico 2 W, which also gets its `memory_x` layout and `cyw43.rs`, -/// its radio's blobs from `CYW43_FIRMWARE_DIR`, and links with cortex-m-rt's script; bare-metal RISC-V is the -/// ESP32-C6, which links with esp-hal's. Both link defmt's too. +/// its radio's blobs from `CYW43_FIRMWARE_DIR`, and links with cortex-m-rt's script; bare-metal RISC-V is the ESP32-C6, +/// which links with esp-hal's. Both link defmt's too. /// /// # Errors /// @@ -258,6 +463,36 @@ fn items(list: &str) -> impl Iterator { list.split(',').map(str::trim).filter(|item| !item.is_empty()) } +/// The directory holding the package of the module at `path`: its `pkg/` when it has one, otherwise `path` itself. +fn package_dir(path: PathBuf) -> PathBuf { + if path.join(PACKAGE_DIR).is_dir() { + path.join(PACKAGE_DIR) + } else { + path + } +} + +/// The `package.json` at `path`, parsed. +fn manifest(path: &Path) -> Result { + // Matched rather than converted, so the error can name the manifest that would not parse. + match serde_json::from_str(&fs::read_to_string(path)?) { + Ok(manifest) => Ok(manifest), + Err(source) => Err(ModulesError::Json { + path: path.to_path_buf(), + source, + }), + } +} + +/// The name `manifest`, read from `path`, declares. +fn manifest_name(manifest: &serde_json::Value, path: &Path) -> Result { + Ok(manifest + .get("name") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| ModulesError::NoName(path.to_path_buf()))? + .to_owned()) +} + /// A module package: the name its `package.json` declares, and the files it publishes. struct Package { name: String, @@ -271,21 +506,8 @@ struct Package { #[expect(clippy::single_call_fn, reason = "the per-module step of generate")] fn package(dir: &Path) -> Result { let manifest_path = dir.join(PACKAGE_JSON); - // Matched rather than converted, so the error can name the manifest that would not parse. - let manifest: serde_json::Value = match serde_json::from_str(&fs::read_to_string(&manifest_path)?) { - Ok(manifest) => manifest, - Err(source) => { - return Err(ModulesError::Json { - path: manifest_path, - source, - }); - } - }; - let name = manifest - .get("name") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| ModulesError::NoName(manifest_path.clone()))? - .to_owned(); + let manifest = manifest(&manifest_path)?; + let name = manifest_name(&manifest, &manifest_path)?; let mut files = Vec::new(); match manifest.get("files").and_then(serde_json::Value::as_array) { Some(listed) => { @@ -294,16 +516,7 @@ fn package(dir: &Path) -> Result { let entry = entry .as_str() .ok_or_else(|| ModulesError::FileNotString(manifest_path.clone()))?; - // Only names within the package: a root, a drive or a `..` would build in a file from elsewhere. - if !Path::new(entry) - .components() - .all(|part| matches!(part, Component::Normal(_) | Component::CurDir)) - { - return Err(ModulesError::OutsidePackage { - manifest: manifest_path, - entry: entry.to_owned(), - }); - } + within_package(entry, &manifest_path)?; // Joined without its `.` parts, which would otherwise end up in the path the file is served at. let within: PathBuf = Path::new(entry) .components() diff --git a/services/firmware-modules/tests/guests.rs b/services/firmware-modules/tests/guests.rs new file mode 100644 index 00000000..83cb1860 --- /dev/null +++ b/services/firmware-modules/tests/guests.rs @@ -0,0 +1,215 @@ +//! Covers the `guests.rs` source a firmware build writes: each runner's component found among the modules built in, +//! checked against what a board hosts and holds, and compiled for it, and every way a guest is refused. +#![cfg(test)] + +use std::path::{Path, PathBuf}; + +use edge_toolkit::config::get_project_root; +use et_firmware_modules::{Generated, ModulesError, guests}; +use fs_err as fs; +use tempfile::{TempDir, tempdir}; + +/// The Pulley target a 64-bit host's board engine runs, which the tests compile for. +const TARGET: &str = "pulley64"; +/// More memory than any guest here starts with. +const ROOMY: u64 = 64 * 1024 * 1024; +/// One WebAssembly page, 64 KiB. +const PAGE: u64 = 64 * 1024; +/// Two pages, what the guest declaring `(memory 2)` starts with. +const TWO_PAGES: u64 = 2 * PAGE; +/// A page, and a four-element table on a 64-bit board. +const PAGE_AND_FOUR_POINTERS_64: u64 = PAGE + 4 * 8; +/// A page, and a four-element table on a 32-bit board. +const PAGE_AND_FOUR_POINTERS_32: u64 = PAGE + 4 * 4; + +/// A module in `root` named `name`, its `package.json` naming `main`, with `component` written there. +fn module(root: &Path, name: &str, main: &str, component: &[u8]) -> PathBuf { + let dir = root.join(name); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("package.json"), + format!(r#"{{"name": "{name}", "main": "{main}"}}"#), + ) + .unwrap(); + fs::write(dir.join(main), component).unwrap(); + dir +} + +/// A module holding the component `text` describes, in WebAssembly text, as its `main`. +fn component_module(text: &str) -> (TempDir, String) { + let root = tempdir().unwrap(); + let dir = module(root.path(), "guest", "guest.wasm", &wat::parse_str(text).unwrap()); + (root, dir.display().to_string()) +} + +/// What `guests` returns for one runner, `runner=guest`, of the module at `path`, holding `memory` for it. +fn one_guest(path: &str, memory: u64, out_dir: &Path) -> Result { + guests(path, "runner-1=guest", TARGET, memory, out_dir) +} + +#[test] +fn no_runners_build_in_no_guests() { + let out_dir = tempdir().unwrap(); + let generated = guests("", " , ", TARGET, 0, out_dir.path()).unwrap(); + assert!( + generated + .source + .contains("pub(crate) const GUESTS: [(&str, &[u8]); 0] = [\n];") + ); + assert_eq!(generated.files, Vec::::new()); + assert!(generated.source.contains("pub(crate) const GUEST_MEMORY: u64 = 0;")); +} + +#[test] +fn the_board_s_budget_is_written_for_its_guests_to_draw_on() { + let out_dir = tempdir().unwrap(); + let generated = guests("", "", TARGET, ROOMY, out_dir.path()).unwrap(); + assert!( + generated + .source + .contains("pub(crate) const GUEST_MEMORY: u64 = 67_108_864;") + ); + let generated = guests("", "", TARGET, PAGE, out_dir.path()).unwrap(); + assert!( + generated + .source + .contains("pub(crate) const GUEST_MEMORY: u64 = 65_536;") + ); +} + +#[test] +fn a_runner_s_module_is_compiled_for_the_board_and_built_in() { + let data1 = get_project_root().join("services/ws-modules/wasi-data1"); + let out_dir = tempdir().unwrap(); + let paths = format!("{}", data1.display()); + let generated = guests( + &paths, + "data1 = @edge-toolkit/et-ws-wasi-data1", + TARGET, + ROOMY, + out_dir.path(), + ) + .unwrap(); + let compiled = out_dir.path().join("guest-0.cwasm"); + assert!(compiled.is_file()); + assert!(generated.source.contains("[(&str, &[u8]); 1]")); + assert!(generated.source.contains(&format!( + "(\"data1\", include_bytes!({:?})),", + compiled.display().to_string() + ))); + assert_eq!(generated.files, [data1.join("pkg/et_ws_wasi_data1.wasm")]); +} + +#[test] +fn a_runner_that_is_not_a_pair_is_refused() { + let out_dir = tempdir().unwrap(); + for pair in ["no-package", "=guest", "runner-1= "] { + let pair = pair.trim(); + let refused = guests("", pair, TARGET, ROOMY, out_dir.path()).unwrap_err(); + assert!(matches!(refused, ModulesError::NotAGuest(held) if held == pair)); + } +} + +#[test] +fn a_runner_of_a_module_not_built_in_is_refused() { + let (_root, path) = component_module("(component)"); + let out_dir = tempdir().unwrap(); + let refused = guests(&path, "runner-1=elsewhere", TARGET, ROOMY, out_dir.path()).unwrap_err(); + assert!(matches!( + refused, + ModulesError::UnknownGuest { runner, package, names } + if runner == "runner-1" && package == "elsewhere" && names == ["guest"] + )); +} + +#[test] +fn a_runner_of_a_module_with_no_component_is_refused() { + let root = tempdir().unwrap(); + let dir = module(root.path(), "guest", "guest.js", b"export {};"); + let out_dir = tempdir().unwrap(); + let refused = one_guest(&dir.display().to_string(), ROOMY, out_dir.path()).unwrap_err(); + assert!(matches!(refused, ModulesError::NotAComponent { main, .. } if main == "guest.js")); +} + +#[test] +fn a_component_that_is_not_webassembly_is_refused() { + let root = tempdir().unwrap(); + let dir = module(root.path(), "guest", "guest.wasm", b"not wasm"); + let out_dir = tempdir().unwrap(); + let refused = one_guest(&dir.display().to_string(), ROOMY, out_dir.path()).unwrap_err(); + assert!(matches!(refused, ModulesError::Unreadable { path, .. } if path == dir.join("guest.wasm"))); +} + +#[test] +fn a_guest_importing_what_the_board_does_not_host_is_refused() { + let (_root, path) = component_module( + r#"(component + (import "wasi:io/poll@0.2.12" (instance)) + (import "wasi:webgpu/webgpu@0.3.0-rc.2" (instance)) + )"#, + ); + let out_dir = tempdir().unwrap(); + let refused = one_guest(&path, ROOMY, out_dir.path()).unwrap_err(); + let webgpu = "wasi:webgpu/webgpu@0.3.0-rc.2"; + assert!(matches!( + refused, + ModulesError::Unhosted { runner, imports } if runner == "runner-1" && imports == [webgpu] + )); +} + +#[test] +fn guests_needing_more_memory_than_the_board_holds_are_refused() { + let (_root, path) = component_module("(component (core module (memory 2)))"); + let out_dir = tempdir().unwrap(); + let refused = one_guest(&path, PAGE, out_dir.path()).unwrap_err(); + assert!(matches!( + refused, + ModulesError::GuestMemory { + needed: TWO_PAGES, + budget: PAGE + } + )); + let _fits = one_guest(&path, TWO_PAGES, out_dir.path()).unwrap(); +} + +#[test] +fn a_guest_s_tables_count_at_the_board_s_pointer_width() { + let (_root, path) = component_module("(component (core module (memory 1) (table 4 funcref)))"); + let out_dir = tempdir().unwrap(); + for (target, needed) in [ + ("pulley64", PAGE_AND_FOUR_POINTERS_64), + ("pulley32", PAGE_AND_FOUR_POINTERS_32), + ] { + let refused = guests(&path, "runner-1=guest", target, PAGE, out_dir.path()).unwrap_err(); + assert!(matches!(refused, ModulesError::GuestMemory { needed: held, .. } if held == needed)); + let _fits = guests(&path, "runner-1=guest", target, needed, out_dir.path()).unwrap(); + } +} + +#[test] +fn a_runner_s_main_outside_its_package_is_refused() { + let root = tempdir().unwrap(); + let dir = module(root.path(), "guest", "guest.wasm", b"\0asm"); + fs::write( + dir.join("package.json"), + r#"{"name": "guest", "main": "/elsewhere.wasm"}"#, + ) + .unwrap(); + let out_dir = tempdir().unwrap(); + let refused = one_guest(&dir.display().to_string(), ROOMY, out_dir.path()).unwrap_err(); + assert!(matches!(refused, ModulesError::OutsidePackage { entry, .. } if entry == "/elsewhere.wasm")); +} + +#[test] +fn a_guest_that_does_not_compile_for_the_target_is_refused() { + let (_root, path) = component_module("(component)"); + let out_dir = tempdir().unwrap(); + let refused = guests(&path, "runner-1=guest", "no-such-target", ROOMY, out_dir.path()).unwrap_err(); + assert!(matches!(refused, ModulesError::Precompile { runner, .. } if runner == "runner-1")); +} + +#[test] +fn a_relative_directory_to_compile_into_is_refused() { + let refused = guests("", "", TARGET, ROOMY, Path::new("relative")).unwrap_err(); + assert!(matches!(refused, ModulesError::RelativePath { variable, .. } if variable == "RUNNER_GUESTS")); +} diff --git a/services/ws-server-firmware/Cargo.toml b/services/ws-server-firmware/Cargo.toml index 2bc654d4..00264e7d 100644 --- a/services/ws-server-firmware/Cargo.toml +++ b/services/ws-server-firmware/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true license.workspace = true repository.workspace = true +[package.metadata.docs.rs] +features = ["docs"] + # `#[embassy_executor::main]` expands to `#[cortex_m_rt::entry]`, which cargo-shear's scan cannot see. [package.metadata.cargo-shear] ignored = ["cortex-m-rt"] @@ -18,13 +21,25 @@ name = "et-ws-server-firmware" path = "src/main.rs" test = false -# The hub, its settings and the modules built in are the same wherever the binary is built for. +# The board a bare-metal Arm build is for, each picking its RP2350; a host or RISC-V build needs neither. +[features] +## The Raspberry Pi Pico 2 W, on its RP2350A. +pico2w = ["embassy-rp/rp235xa"] +## The Pimoroni Pico Plus 2 W, on its RP2350B, whose PSRAM holds the heap the wasi runners' guests need. +pico-plus2w = ["embassy-rp/rp235xb"] +## Internal: renders this feature list into the crate documentation, as docs.rs builds it. +docs = ["dep:document-features"] + +# The hub, its settings, the modules built in and the runner its guests run on are the same wherever it is built for. [dependencies] chrono = { workspace = true, features = ["alloc"] } +document-features = { workspace = true, optional = true } edge-toolkit.workspace = true et-ws-server = { workspace = true, features = ["picoserve-server"] } et-ws-service = { workspace = true, features = ["picoserve-hub"] } +et-ws-wasi-runner = { workspace = true, features = ["board"] } picoserve.workspace = true +serde_json = { workspace = true, features = ["alloc"] } # What every board shares once its radio is up: embassy's network stack, executor and clock, and the defmt log. [target.'cfg(target_os = "none")'.dependencies] @@ -42,7 +57,7 @@ cyw43 = { workspace = true, features = ["defmt"] } cyw43-pio = { workspace = true, features = ["defmt"] } defmt-rtt.workspace = true embassy-executor = { workspace = true, features = ["executor-thread", "platform-cortex-m"] } -embassy-rp = { workspace = true, features = ["critical-section-impl", "defmt", "rp235xa", "time-driver"] } +embassy-rp = { workspace = true, features = ["critical-section-impl", "defmt", "time-driver"] } embedded-alloc.workspace = true panic-probe = { workspace = true, features = ["defmt"] } diff --git a/services/ws-server-firmware/README.md b/services/ws-server-firmware/README.md index 07e588c0..3972be48 100644 --- a/services/ws-server-firmware/README.md +++ b/services/ws-server-firmware/README.md @@ -4,16 +4,22 @@ et-ws-server as firmware, built as the `et-ws-server-firmware` binary: the `no_s `/favicon.ico` and the modules built into it beside it, served over a board's own Wi-Fi on port 8080. It joins the network it was built for, takes an address over DHCP, and serves four connections at once. -The target it is built for picks the board: +The target it is built for, and for an Arm board its feature, picks the board: -| Board | Target | Build task | Loaded with | -| --------------------- | ------------------------------ | ------------------------ | ----------- | -| Raspberry Pi Pico 2 W | `thumbv8m.main-none-eabihf` | `build-firmware-pico2w` | picotool | -| ESP32-C6 | `riscv32imac-unknown-none-elf` | `build-firmware-esp32c6` | espflash | +| Board | Target | Feature | Build task | Loaded with | +| ---------------------- | ------------------------------ | ------------- | ---------------------------- | ----------- | +| Raspberry Pi Pico 2 W | `thumbv8m.main-none-eabihf` | `pico2w` | `build-firmware-pico2w` | picotool | +| Pimoroni Pico Plus 2 W | `thumbv8m.main-none-eabihf` | `pico-plus2w` | `build-firmware-pico-plus2w` | picotool | +| ESP32-C6 | `riscv32imac-unknown-none-elf` | | `build-firmware-esp32c6` | espflash | -Everything but bringing up the radio is shared, so both serve the same hub the same way. It is a local hub, with no -backplane, admin API, idle timeout or registry file. A board has no clock, so the times it stamps on messages are its -uptime counted from the Unix epoch. +Everything but bringing up the radio is shared, so every board serves the same hub the same way. It is a local hub, +with no backplane, admin API, idle timeout or registry file. A board has no clock, so the times it stamps on messages +are its uptime counted from the Unix epoch. + +The Pico Plus 2 W is the Pico 2 W's radio on an RP2350B with 8 MiB of PSRAM, which it maps in and makes the heap. +That is room for guests, so it also runs the wasi runners the build compiles in beside the hub, each on +`et-ws-wasi-runner`'s board runner. A runner's websocket is a link into the hub in the same firmware rather than a +socket, and the guests on one board share their storage buckets. ## Building @@ -31,9 +37,14 @@ It serves no modules unless told which to build in, through the variables the `s - `MODULES_ROOT`: the name of the module served at `/`, one of those. - `MODULES_ELSEWHERE` and `MODULES_ELSEWHERE_URL`: module names too big for a board's 4 MiB of flash, listed with the rest at `/modules/`, and the base URL a request for one of their files is redirected to. +- `RUNNER_GUESTS`: the runners to start beside the hub, comma-separated `runner=package` pairs, each naming a module + `MODULES_PATHS` builds in whose `main` is a WebAssembly component. The build compiles each for the board, and + refuses one that imports what a board does not host or guests needing more memory than the board holds for them, + which is 6 MiB of the Pico Plus 2 W's PSRAM and none on the other boards. The guests start with what they declare, + and the same 6 MiB bounds what they grow to as they run. -`et-cli generate-deployment --output-type firmware-pico2` or `firmware-esp32c6` writes the tasks that set all of -these for a scenario. +`et-cli generate-deployment --output-type firmware-pico2`, `firmware-esp32c6` or `firmware-pico-plus2` writes the +tasks that set all of these for a scenario. ### Raspberry Pi Pico 2 W @@ -52,6 +63,15 @@ picotool load -x -t elf target/thumbv8m.main-none-eabihf/release/et-ws-server-fi probe-rs run --chip RP235x target/thumbv8m.main-none-eabihf/release/et-ws-server-firmware ``` +### Pimoroni Pico Plus 2 W + +```sh +MISE_ENV=rust mise run build-firmware-pico-plus2w +``` + +The firmware is the same ELF path, and loads the same ways as the Pico 2 W's. Its log also shows each runner's guest +logging as it runs, and how it ended. + ### ESP32-C6 ```sh @@ -69,13 +89,15 @@ Either board's log names the address it serves on, as `ws://
:8080/ws`. ## Serving from the host -Built for a target with an operating system, the binary is the same hub serving the same modules from the host over -tokio, read from the same `MODULES_*` at build time, so what a board would serve can be explored without one. It -listens on the address its one argument names, or on port 8080 on every interface, and prints where: +Built for a target with an operating system, the binary is the same hub serving the same modules and running the same +runners from the host over tokio, read from the same `MODULES_*` and `RUNNER_GUESTS` at build time, so what a board +would do can be explored without one. It listens on the address its one argument names, or on port 8080 on every +interface, and prints where: ```sh cargo run -p et-int-ws-server-firmware -- 127.0.0.1:8080 ``` -The package's test starts it on a free port and fetches every module it lists, so run with the `MODULES_*` a -scenario's deployment sets, as its `firmware-host-test` task does, it checks what a board would serve. +The package's test starts it on a free port, fetches every module it lists, and waits for every runner it was built +with to join the hub, so run with the `MODULES_*` and `RUNNER_GUESTS` a scenario's deployment sets, as its +`firmware-host-test` task does, it checks what a board would do. diff --git a/services/ws-server-firmware/build.rs b/services/ws-server-firmware/build.rs index bc746c36..734571e0 100644 --- a/services/ws-server-firmware/build.rs +++ b/services/ws-server-firmware/build.rs @@ -1,15 +1,29 @@ //! Generates what the firmware bakes in, read here from the build's environment, and links it for its board. //! -//! Every build gets `modules.rs`, since the host serves the same modules a board would; a board's build also gets -//! what [`board`] writes for it, and links as that says. +//! Every build gets `modules.rs`, since the host serves the same modules a board would, and `guests.rs`, the runners it +//! starts beside the hub; a board's build also gets what [`board`] writes for it, and links as that says. use std::env; use std::error::Error; use std::path::PathBuf; -use et_firmware_modules::{BOARD_VARIABLES, board, generate}; +use et_firmware_modules::{BOARD_VARIABLES, RUNNER_GUESTS, board, generate, guests}; use fs_err as fs; +/// The bytes a board with PSRAM holds for its guests' memories: its 8 MiB, less what wasmtime keeps beside them. +/// +/// That is each guest's compiled code, copied in when it loads, its 64 KiB stack, and the store and instance, all of +/// which share the one heap with the hub's 160 KiB. +const PSRAM_GUEST_MEMORY: u64 = 6 * 1024 * 1024; +/// The flash `memory.x` gives the Pico 2 W, which the Pico Plus 2 W's build replaces with its own. +const PICO2W_FLASH: &str = "FLASH : ORIGIN = 0x10000000, LENGTH = 4096K"; +/// The Pico Plus 2 W's 16 MiB of flash. +const PICO_PLUS2W_FLASH: &str = "FLASH : ORIGIN = 0x10000000, LENGTH = 16384K"; +/// Set by cargo when the firmware is built for the Pimoroni Pico Plus 2 W, the board with PSRAM. +const CARGO_FEATURE_PICO_PLUS2W: &str = "CARGO_FEATURE_PICO_PLUS2W"; +/// The pointer width cargo is building for, which picks the Pulley target the guests are compiled to. +const CARGO_CFG_TARGET_POINTER_WIDTH: &str = "CARGO_CFG_TARGET_POINTER_WIDTH"; + /// The variables `modules.rs` is generated from, each named as the `std` server's modules service or `MODULES_*` reads. const MODULE_VARIABLES: [&str; 4] = [MODULES_PATHS, MODULES_ROOT, MODULES_ELSEWHERE, MODULES_ELSEWHERE_URL]; /// The module directories to build in, comma-separated, as the `std` server's `MODULES_PATHS` lists them. @@ -30,7 +44,11 @@ const OUT_DIR: &str = "OUT_DIR"; /// Write `modules.rs`, and for a board what [`board`] writes, rerunning whenever a variable or file they read changes. fn main() -> Result<(), Box> { println!("cargo:rerun-if-changed=memory.x"); - for variable in BOARD_VARIABLES.into_iter().chain(MODULE_VARIABLES) { + for variable in BOARD_VARIABLES + .into_iter() + .chain(MODULE_VARIABLES) + .chain([RUNNER_GUESTS]) + { println!("cargo:rerun-if-env-changed={variable}"); } let out_dir = PathBuf::from(env::var(OUT_DIR)?); @@ -45,9 +63,36 @@ fn main() -> Result<(), Box> { println!("cargo:rerun-if-changed={}", file.display()); } fs::write(out_dir.join("modules.rs"), modules.source)?; - if env::var(CARGO_CFG_TARGET_OS)? == "none" { + let on_board = env::var(CARGO_CFG_TARGET_OS)? == "none"; + let with_psram = env::var(CARGO_FEATURE_PICO_PLUS2W).is_ok(); + // The host stands in for the board with PSRAM, the one that runs guests; any other board holds none. + let guest_memory = if with_psram || !on_board { PSRAM_GUEST_MEMORY } else { 0 }; + let target = if env::var(CARGO_CFG_TARGET_POINTER_WIDTH)? == "32" { + "pulley32" + } else { + "pulley64" + }; + let runners = guests( + &optional(MODULES_PATHS), + &optional(RUNNER_GUESTS), + target, + guest_memory, + &out_dir, + )?; + for file in &runners.files { + println!("cargo:rerun-if-changed={}", file.display()); + } + fs::write(out_dir.join("guests.rs"), runners.source)?; + if on_board { let arch = env::var(CARGO_CFG_TARGET_ARCH)?; - for directive in board(&arch, &out_dir, include_bytes!("memory.x"), |name| env::var(name))? { + let memory_x = include_str!("memory.x"); + // The Pico Plus 2 W is the same RP2350 layout with four times the flash. + let memory_x = if with_psram { + memory_x.replace(PICO2W_FLASH, PICO_PLUS2W_FLASH) + } else { + memory_x.to_owned() + }; + for directive in board(&arch, &out_dir, memory_x.as_bytes(), |name| env::var(name))? { println!("{directive}"); } } diff --git a/services/ws-server-firmware/memory.x b/services/ws-server-firmware/memory.x index 3428cb96..4fb2f5ec 100644 --- a/services/ws-server-firmware/memory.x +++ b/services/ws-server-firmware/memory.x @@ -1,4 +1,5 @@ /* The RP2350's memory as the Pico 2 W wires it: 4 MiB of flash and 512 KiB of striped SRAM. */ +/* The Pico Plus 2 W's build swaps in its 16 MiB of flash; its PSRAM is the heap's, so is not laid out here. */ MEMORY { FLASH : ORIGIN = 0x10000000, LENGTH = 4096K RAM : ORIGIN = 0x20000000, LENGTH = 512K diff --git a/services/ws-server-firmware/src/board/hub.rs b/services/ws-server-firmware/src/board/hub.rs index b08c2195..b5f9833d 100644 --- a/services/ws-server-firmware/src/board/hub.rs +++ b/services/ws-server-firmware/src/board/hub.rs @@ -1,5 +1,5 @@ //! What every board runs once it is on the network: the hub, served on as many sockets at once as there are connection -//! tasks, from the network stack a board hands to [`serve`]. +//! tasks, from the network stack a board hands to [`serve`], and a task for each guest the build compiled in. #![expect( clippy::future_not_send, @@ -17,21 +17,71 @@ use edge_toolkit::ws_server::MessageRetention; use embassy_executor::Spawner; use embassy_net::tcp::TcpSocket; use embassy_net::{Stack, StackResources}; -use embassy_time::Instant; +use embassy_time::{Instant, Timer}; use et_ws_server::picoserve_server::serve as serve_connection; use et_ws_service::hub::Stamps; use et_ws_service::picoserve_hub::{self, Hub}; +use et_ws_wasi_runner::bindings::wasi::logging::logging; +use et_ws_wasi_runner::board::{Console, Platform}; use picoserve::time::EmbassyTimer; use static_cell::StaticCell; +use crate::runner::{GUEST_POOL, GUESTS, Shared, run_guest}; use crate::{HTTP_BUFFER, MAX_FRAME_SIZE, MAX_QUEUED, MODULES, PORT}; /// How many connections the hub serves at once: one task, and one socket's buffers, each. const CONNECTIONS: usize = 4; /// Each connection's TCP receive and transmit buffer. const TCP_BUFFER: usize = 4 * 1024; -/// The heap the hub's registry, routing and frame buffers allocate from. +/// The heap the hub's registry, routing and frame buffers allocate from, on a board whose heap is its SRAM. +#[cfg(not(feature = "pico-plus2w"))] pub(super) const HEAP_SIZE: usize = 160 * 1024; +/// Nanoseconds in a microsecond: a guest's clock counts the one, the board's the other. +const NANOS_PER_MICRO: u64 = 1000; + +/// The board's clock and its defmt log, which its guests' clock and log are. +#[derive(Clone, Copy)] +struct BoardPlatform; + +impl Platform for BoardPlatform { + /// The board's uptime, in the nanoseconds a guest's clock counts. + fn now(&self) -> u64 { + Instant::now().as_micros().saturating_mul(NANOS_PER_MICRO) + } + + /// Wait on the board's timer until its uptime reaches `deadline`. + async fn sleep_until(&self, deadline: u64) { + Timer::at(Instant::from_micros(deadline.div_euclid(NANOS_PER_MICRO))).await; + } + + /// Log the guest's entry at the defmt level nearest its own. + #[expect( + clippy::missing_asserts_for_indexing, + reason = "the indexing is inside defmt's error! expansion, which an attribute on the call does not reach" + )] + fn log(&self, level: logging::Level, context: &str, message: &str) { + match level { + logging::Level::Critical | logging::Level::Error => defmt::error!("{}: {}", context, message), + logging::Level::Warn => warn!("{}: {}", context, message), + logging::Level::Info => info!("{}: {}", context, message), + logging::Level::Debug | logging::Level::Trace => defmt::debug!("{}: {}", context, message), + } + } + + /// Log what the guest wrote to either standard stream, the board's one console being its defmt log. + fn write(&self, _console: Console, bytes: &[u8]) { + info!( + "{=str}", + core::str::from_utf8(bytes).unwrap_or("(output that is not UTF-8)") + ); + } +} + +/// Run one guest the build compiled in beside the hub, until it ends. +#[embassy_executor::task(pool_size = GUEST_POOL)] +async fn guest_task(hub: &'static Hub, name: &'static str, component: &'static [u8], shared: Shared) { + run_guest(hub, name, component, BoardPlatform, shared).await; +} /// Ids unique across boots -- a random word drawn at boot, then a count -- and the time since boot. /// @@ -110,4 +160,11 @@ pub(super) async fn serve(spawner: Spawner, stack: Stack<'static>, boot: u32) { Err(error) => warn!("Failed to start a connection task: {}", Debug2Format(&error)), } } + let shared = Shared::new(); + for (name, component) in GUESTS { + match guest_task(hub, name, component, shared.clone()) { + Ok(task) => spawner.spawn(task), + Err(error) => warn!("Failed to start runner {=str}: {}", name, Debug2Format(&error)), + } + } } diff --git a/services/ws-server-firmware/src/board/mod.rs b/services/ws-server-firmware/src/board/mod.rs index 4bcabee3..f404c95b 100644 --- a/services/ws-server-firmware/src/board/mod.rs +++ b/services/ws-server-firmware/src/board/mod.rs @@ -1,6 +1,9 @@ //! The firmware on a board: each board's module brings up its radio and joins the network named at build time, and //! [`hub`] serves the hub on it. +#[cfg(all(target_arch = "arm", not(any(feature = "pico-plus2w", feature = "pico2w"))))] +compile_error!("a bare-metal Arm build is for a board: enable the `pico2w` or the `pico-plus2w` feature"); + #[cfg(target_arch = "riscv32")] mod esp32c6; mod hub; diff --git a/services/ws-server-firmware/src/board/pico2w.rs b/services/ws-server-firmware/src/board/pico2w.rs index 77cac655..997bea3b 100644 --- a/services/ws-server-firmware/src/board/pico2w.rs +++ b/services/ws-server-firmware/src/board/pico2w.rs @@ -1,5 +1,8 @@ //! The Raspberry Pi Pico 2 W: bring up the CYW43439's Wi-Fi, join the network named at build time, and serve the hub //! on it. +//! +//! With the `pico-plus2w` feature it is the Pimoroni Pico Plus 2 W instead, the same radio wired the same way to an +//! RP2350B, whose PSRAM it maps in and makes the heap before anything allocates. #![expect( clippy::future_not_send, @@ -22,7 +25,9 @@ use embedded_alloc::LlffHeap as Heap; use static_cell::StaticCell; use {defmt_rtt as _, panic_probe as _}; -use super::hub::{HEAP_SIZE, serve, stack_resources}; +#[cfg(not(feature = "pico-plus2w"))] +use super::hub::HEAP_SIZE; +use super::hub::{serve, stack_resources}; include!(concat!(env!("OUT_DIR"), "/wifi.rs")); include!(concat!(env!("OUT_DIR"), "/cyw43.rs")); @@ -58,10 +63,31 @@ async fn main(spawner: Spawner) { reason = "handing the allocator its memory is unsafe by nature: nothing else may own that memory" )] // SAFETY: this runs once, first, before anything allocates, and `init!` declares the memory it hands over. + #[cfg(not(feature = "pico-plus2w"))] unsafe { embedded_alloc::init!(HEAP, HEAP_SIZE); } let peripherals = embassy_rp::init(embassy_rp::config::Config::default()); + // The Pico Plus 2 W's heap is its 8 MiB of PSRAM instead, which its guests' memories need, mapped in first. + #[cfg(feature = "pico-plus2w")] + { + use embassy_rp::psram::{Config as PsramConfig, Psram}; + use embassy_rp::qmi_cs1::QmiCs1; + + let config = PsramConfig { + clock_hz: embassy_rp::clocks::clk_sys_freq(), + ..PsramConfig::aps6404l() + }; + let psram = Psram::new(QmiCs1::new(peripherals.QMI_CS1, peripherals.PIN_47), config).unwrap(); + #[expect( + unsafe_code, + reason = "handing the allocator its memory is unsafe by nature: nothing else may own that memory" + )] + // SAFETY: nothing has allocated yet, and the PSRAM window is mapped and belongs to nothing else. + unsafe { + HEAP.init(psram.base_address().addr(), psram.size()); + } + } let mut trng = Trng::new(peripherals.TRNG, Irqs, trng::Config::default()); let power = Output::new(peripherals.PIN_23, Level::Low); diff --git a/services/ws-server-firmware/src/host.rs b/services/ws-server-firmware/src/host.rs index 9cf813ab..43a7cebe 100644 --- a/services/ws-server-firmware/src/host.rs +++ b/services/ws-server-firmware/src/host.rs @@ -1,26 +1,76 @@ -//! The firmware's hub and modules, served from the host over tokio rather than from the board over its Wi-Fi. +//! The firmware's hub, modules and runners, served from the host over tokio rather than from the board over its Wi-Fi. //! -//! The hub, its settings and the modules built in are the board's own; only the network stack and the executor -//! differ. It listens on the address its one argument names, or on the board's port on every interface, and says -//! where on its first line of output, so a caller can ask for port 0 and learn the one it got. +//! The hub, its settings, the modules built in and the guests its runners run are the board's own; only the network +//! stack, the executor and the clock differ. It listens on the address its one argument names, or on the board's port +//! on every interface, and says where on its first line of output, so a caller can ask for port 0 and learn the one +//! it got. use std::error::Error; use std::io::Write as _; use std::net::SocketAddr; -use std::sync::Arc; -use std::time::SystemTime; +use std::sync::LazyLock; +use std::time::{Duration, Instant, SystemTime}; use chrono::{DateTime, Utc}; use edge_toolkit::ws_server::MessageRetention; use et_ws_server::picoserve_server::serve; use et_ws_service::hub::Stamps; use et_ws_service::picoserve_hub::{self, Hub}; +use et_ws_wasi_runner::bindings::wasi::logging::logging::Level; +use et_ws_wasi_runner::board::{Console, Platform}; use picoserve::time::TokioTimer; use tokio::net::TcpListener; use tokio::task::LocalSet; +use crate::runner::{GUESTS, Shared, run_guest}; use crate::{HTTP_BUFFER, MAX_FRAME_SIZE, MAX_QUEUED, MODULES, PORT}; +/// When the process started, which the guests' clock counts from. +static STARTED: LazyLock = LazyLock::new(Instant::now); + +/// The host's monotonic clock and its standard streams, standing in for a board's clock and log. +#[derive(Clone, Copy)] +struct HostPlatform; + +impl Platform for HostPlatform { + /// The nanoseconds since the process started, as a board counts its uptime. + fn now(&self) -> u64 { + u64::try_from(STARTED.elapsed().as_nanos()).unwrap_or(u64::MAX) + } + + /// Wait on tokio's clock until [`HostPlatform::now`] reaches `deadline`. + async fn sleep_until(&self, deadline: u64) { + match STARTED.checked_add(Duration::from_nanos(deadline)) { + Some(due) => tokio::time::sleep_until(due.into()).await, + // A deadline past what the clock can count never comes. + None => std::future::pending().await, + } + } + + /// Write the guest's log entry to standard error, its level named. + fn log(&self, level: Level, context: &str, message: &str) { + let level = match level { + Level::Trace => "TRACE", + Level::Debug => "DEBUG", + Level::Info => "INFO", + Level::Warn => "WARN", + Level::Error => "ERROR", + Level::Critical => "CRITICAL", + }; + let mut stderr = std::io::stderr().lock(); + let _written = writeln!(stderr, "{level} {context}: {message}"); + } + + /// Write what the guest wrote to the host's own stream of the same name. + fn write(&self, console: Console, bytes: &[u8]) { + let _written = if matches!(console, Console::Stdout) { + std::io::stdout().lock().write_all(bytes) + } else { + std::io::stderr().lock().write_all(bytes) + }; + } +} + /// Ids `host-1`, `host-2`, ... in turn, and the host's clock, which unlike the board's knows the date. #[derive(Default)] struct HostStamps(u32); @@ -56,23 +106,33 @@ pub(crate) fn main() -> Result<(), Box> { async fn accept(address: SocketAddr) -> Result<(), Box> { let listener = TcpListener::bind(address).await?; let mut stdout = std::io::stdout().lock(); - writeln!(stdout, "Serving http://{}/", listener.local_addr()?)?; + // With how many runners it starts, so a caller knows how many agents of its own to expect. + writeln!( + stdout, + "Serving http://{}/ with {} runners", + listener.local_addr()?, + GUESTS.len() + )?; stdout.flush()?; - let hub = Arc::new(Hub::new( + // Leaked, as the board's lives in a static: it serves until the process ends, and every runner holds it that long. + let hub: &'static Hub = Box::leak(Box::new(Hub::new( HostStamps::default(), MessageRetention::LatestPerSender, MAX_FRAME_SIZE, MAX_QUEUED, - )); + ))); + let shared = Shared::new(); + for (name, component) in GUESTS { + let _running = tokio::task::spawn_local(run_guest(hub, name, component, HostPlatform, shared.clone())); + } loop { let (socket, peer) = listener.accept().await?; - let hub = Arc::clone(&hub); let _serving = tokio::task::spawn_local(async move { // Each connection's own, as each of the board's connection tasks holds one. let config = picoserve_hub::config(); let mut http_buffer = [0; HTTP_BUFFER]; // A connection that ends badly is the client's to report; the hub carries on serving the others. - let _served = serve(&hub, &MODULES, socket, peer.ip(), TokioTimer, &config, &mut http_buffer).await; + let _served = serve(hub, &MODULES, socket, peer.ip(), TokioTimer, &config, &mut http_buffer).await; }); } } diff --git a/services/ws-server-firmware/src/main.rs b/services/ws-server-firmware/src/main.rs index 158417bf..56f85733 100644 --- a/services/ws-server-firmware/src/main.rs +++ b/services/ws-server-firmware/src/main.rs @@ -1,13 +1,15 @@ //! et-ws-server as firmware: the hub's `/ws`, `/health`, `/favicon.ico` and the modules built into it, on port 8080, //! over a board's own Wi-Fi. //! -//! The target picks the board: bare-metal Arm is the Raspberry Pi Pico 2 W, and bare-metal RISC-V the ESP32-C6. Built -//! for any target with an operating system, the same hub serves the same modules from the host, over tokio, so what a -//! board would serve can be explored without one. +//! The target picks the board: bare-metal Arm is the Raspberry Pi Pico 2 W, or the Pimoroni Pico Plus 2 W with its +//! feature, and bare-metal RISC-V the ESP32-C6. The Pico Plus 2 W also runs the wasi runners the build compiled in +//! beside the hub. Built for any target with an operating system, the same hub serves the same modules and runs the +//! same runners from the host, over tokio, so what a board would do can be explored without one. +#![cfg_attr(feature = "docs", doc = "## Feature flags")] +#![cfg_attr(feature = "docs", doc = document_features::document_features!())] #![cfg_attr(target_os = "none", no_std, no_main)] #![recursion_limit = "256"] -#[cfg(target_os = "none")] extern crate alloc; use edge_toolkit::ports::Services; @@ -16,6 +18,7 @@ use edge_toolkit::ports::Services; mod board; #[cfg(not(target_os = "none"))] mod host; +mod runner; #[cfg(not(target_os = "none"))] use self::host::main; diff --git a/services/ws-server-firmware/src/runner.rs b/services/ws-server-firmware/src/runner.rs new file mode 100644 index 00000000..2232bba6 --- /dev/null +++ b/services/ws-server-firmware/src/runner.rs @@ -0,0 +1,248 @@ +//! The wasi runners the firmware starts beside its hub: each guest the build compiled in, run on the board's engine, +//! its websocket a link into the hub in the same process rather than a socket. +//! +//! The board and the host share all of it but the clock and the log, which each hands in as a [`Platform`]. + +#[cfg(target_os = "none")] +use alloc::string::{String, ToString as _}; +use core::future::poll_fn; +use core::net::{IpAddr, Ipv4Addr}; +use core::pin::pin; +use core::ptr::null_mut; +use core::sync::atomic::{AtomicPtr, Ordering}; +use core::task::Poll; + +use et_ws_service::hub::{SessionMessage, Stamps}; +use et_ws_service::picoserve_hub::{FrameTooLarge, Hub, LocalLink}; +use et_ws_wasi_runner::board::{BoardError, Buckets, MemoryBudget, Platform, Transport, run}; +use et_ws_wasi_runner::convert::Frame; +use et_ws_wasi_runner::engine::board_engine; + +include!(concat!(env!("OUT_DIR"), "/guests.rs")); + +/// How many guest tasks a board's executor holds room for. +/// +/// One per guest, and one for a build with none, since a pool cannot be empty. +#[cfg_attr( + not(target_os = "none"), + expect(dead_code, reason = "the host spawns its guests without a pool") +)] +pub(crate) const GUEST_POOL: usize = if GUESTS.is_empty() { 1 } else { GUESTS.len() }; +/// The address a runner's agent is registered from: the board itself. +const LOOPBACK: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST); +/// Nanoseconds in a millisecond, the unit a guest's `recv` timeout comes in. +const NANOS_PER_MILLI: u64 = 1_000_000; + +/// The pointers wasmtime keeps for the call it is in, which with one thread are statics rather than a thread's. +/// +/// Slot 0 is its runtime state, and slot 1 its component-model-async state; it asks for no other. +static WASMTIME_TLS: [AtomicPtr; 2] = [AtomicPtr::new(null_mut()), AtomicPtr::new(null_mut())]; + +/// wasmtime's read of thread-local `slot`, which a build without `std` asks the embedder for. +#[expect( + unsafe_code, + reason = "wasmtime links against this name, so it has to be exported unmangled; nothing else defines it" +)] +#[unsafe(no_mangle)] +extern "C" fn wasmtime_tls_get(slot: usize) -> *mut u8 { + WASMTIME_TLS + .get(slot) + .map_or(null_mut(), |pointer| pointer.load(Ordering::Relaxed)) +} + +/// wasmtime's write of thread-local `slot`, which a build without `std` asks the embedder for. +#[expect( + unsafe_code, + reason = "wasmtime links against this name, so it has to be exported unmangled; nothing else defines it" +)] +#[unsafe(no_mangle)] +extern "C" fn wasmtime_tls_set(slot: usize, pointer: *mut u8) { + if let Some(stored) = WASMTIME_TLS.get(slot) { + stored.store(pointer, Ordering::Relaxed); + } +} + +/// Why a guest's link to the hub refused a call; nothing between them can break, so only the guest's own use. +#[derive(Debug)] +pub(crate) enum LinkError { + /// The link was used while closed. + NotOpen, + /// A frame larger than the hub reads from a socket, which it holds the board's own agents to too. + TooLarge(FrameTooLarge), +} + +impl From for LinkError { + fn from(refused: FrameTooLarge) -> Self { + Self::TooLarge(refused) + } +} + +impl core::fmt::Display for LinkError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::NotOpen => f.write_str("the link to the hub is not open"), + Self::TooLarge(refused) => refused.fmt(f), + } + } +} + +/// Whether a guest's link to the hub is open, and the link when it is. +enum State { + Closed, + Open(LocalLink<'static, S>), +} + +/// A guest's websocket to the hub it runs beside: a link in process, opened on the guest's `connect`. +/// +/// Its waits are on the guest's own clock, `platform`'s. It needs no keep-alive, since nothing idles out a link. +pub(crate) struct HubTransport { + hub: &'static Hub, + platform: P, + state: State, +} + +impl HubTransport { + /// A transport into `hub`, waiting on `platform`'s clock, not yet open. + #[expect( + clippy::single_call_fn, + reason = "the transport's constructor, called where a guest is started" + )] + pub(crate) const fn new(hub: &'static Hub, platform: P) -> Self { + Self { + hub, + platform, + state: State::Closed, + } + } +} + +#[expect( + clippy::unused_async_trait_impl, + reason = "the Transport trait declares every method async, and a link in process never waits to open or send" +)] +impl Transport for HubTransport +where + S: Stamps + Send + 'static, + P: Platform, +{ + type Error = LinkError; + + /// Join the hub as an agent of the board's own, which never fails, since nothing lies between them. + async fn connect(&mut self) -> Result<(), LinkError> { + self.state = State::Open(self.hub.local(LOOPBACK)); + Ok(()) + } + + /// Route `frame` through the hub, as a connection's frame would be, refusing one larger than a connection's may be. + async fn send(&mut self, frame: Frame) -> Result<(), LinkError> { + let State::Open(link) = &mut self.state else { + return Err(LinkError::NotOpen); + }; + match frame { + Frame::Text(text) => link.send_text(&text)?, + Frame::Binary(bytes) => link.send_binary(bytes.into())?, + // Every kind of frame a guest sends is one of the two above. + _ => {} + } + Ok(()) + } + + /// The next message for the guest, as the frame a socket would have carried, if one comes within `timeout_ms`. + async fn recv(&mut self, timeout_ms: u32) -> Result, LinkError> { + let State::Open(link) = &mut self.state else { + return Err(LinkError::NotOpen); + }; + let deadline = self + .platform + .now() + .saturating_add(u64::from(timeout_ms).saturating_mul(NANOS_PER_MILLI)); + let Ok(message) = first(link.next(), self.platform.sleep_until(deadline)).await else { + return Ok(None); + }; + Ok(Some(match message { + SessionMessage::Json(message) => Frame::Text(serde_json::to_string(&message).unwrap_or_default()), + SessionMessage::Text(text) => Frame::Text(text), + SessionMessage::Binary(bytes) => Frame::Binary(bytes.to_vec()), + _ => Frame::Text(String::default()), + })) + } + + /// Leave the hub, which drops the link and so disconnects the guest's agent. + async fn close(&mut self) { + self.state = State::Closed; + } +} + +/// That a wait's deadline passed before what it waited for arrived. +struct Elapsed; + +/// What `wanted` resolves to, or [`Elapsed`] once `deadline` has passed first. +#[expect( + clippy::single_call_fn, + reason = "the timeout of recv, a race of two futures kept apart from the frames" +)] +async fn first(wanted: W, deadline: D) -> Result +where + W: Future, + D: Future, +{ + let mut wanted = pin!(wanted); + let mut deadline = pin!(deadline); + poll_fn(|context| { + if let Poll::Ready(output) = wanted.as_mut().poll(context) { + return Poll::Ready(Ok(output)); + } + deadline.as_mut().poll(context).map(|()| Err(Elapsed)) + }) + .await +} + +/// What every guest on the board shares: its storage, and the memory the build holds for the guests. +#[derive(Clone)] +pub(crate) struct Shared { + buckets: Buckets, + memory: MemoryBudget, +} + +impl Shared { + /// The board's storage, empty, and all of [`GUEST_MEMORY`], none of it drawn on yet. + #[expect( + clippy::single_call_fn, + reason = "made once, where the board's guests are started, as every guest has to share the one" + )] + pub(crate) fn new() -> Self { + Self { + buckets: Buckets::default(), + memory: MemoryBudget::new(usize::try_from(GUEST_MEMORY).unwrap_or(usize::MAX)), + } + } +} + +/// Run the guest `name` until it ends, against `hub`, on `platform`, with what it shares with the board's other guests. +/// +/// `component` is the guest as the build compiled it in. How it ended is logged rather than returned, since nothing +/// waits on a runner: the hub carries on without it. +#[expect( + clippy::single_call_fn, + reason = "shared by the board's guest task and the host's, each of which a build has only one of" +)] +pub(crate) async fn run_guest(hub: &'static Hub, name: &str, component: &[u8], platform: P, shared: Shared) +where + S: Stamps + Send + Sync + 'static, + P: Platform + Clone + 'static, +{ + let transport = HubTransport::new(hub, platform.clone()); + let Shared { buckets, memory } = shared; + let outcome = match board_engine() { + Ok(engine) => run(&engine, component, platform.clone(), transport, buckets, memory).await, + Err(error) => Err(BoardError::Wasmtime(error)), + }; + let ended = match outcome { + Ok(()) => "finished".to_string(), + Err(error) => error.to_string(), + }; + platform.write( + et_ws_wasi_runner::board::Console::Stderr, + alloc::format!("runner {name}: {ended}\n").as_bytes(), + ); +} diff --git a/services/ws-server-firmware/tests/serves_modules.rs b/services/ws-server-firmware/tests/serves_modules.rs index 28ae5c47..7e155c19 100644 --- a/services/ws-server-firmware/tests/serves_modules.rs +++ b/services/ws-server-firmware/tests/serves_modules.rs @@ -1,9 +1,9 @@ //! Explores the host build of the firmware the way a page or a runner would: every module it lists, fetched. //! -//! It asks the server what it serves rather than being told, so it holds for whatever modules the build put in: none -//! in a plain test run, and a whole scenario's when that scenario's `firmware-host-test` task builds it. A module built -//! in has to serve its `package.json` and its `main`, and answer a revalidation without the body; one served elsewhere -//! has to redirect to the same path under its base, which is not fetched, since it is not this server's to serve. +//! It asks the server what it serves rather than being told, so it holds for whatever modules the build put in: none in +//! a plain test run, and a whole scenario's when that scenario's `firmware-host-test` task builds it. A module built in +//! has to serve its `package.json` and its `main`, and answer a revalidation without the body; one served elsewhere has +//! to redirect to the same path under its base, which is not fetched, since it is not this server's to serve. #![cfg(test)] use std::io::{BufRead as _, BufReader, Read as _, Write as _}; @@ -17,6 +17,8 @@ use tungstenite::Message; struct Server { child: Child, address: SocketAddr, + /// How many runners it said it starts: none in a plain test run, a scenario's in its own `firmware-host-test`. + runners: usize, } impl Server { @@ -32,13 +34,15 @@ impl Server { let _read = BufReader::new(child.stdout.take().unwrap()) .read_line(&mut line) .unwrap(); - let address = line + let (address, runners) = line .trim() .strip_prefix("Serving http://") .unwrap() - .trim_end_matches('/'); + .split_once("/ with ") + .unwrap(); Self { address: address.parse().unwrap(), + runners: runners.strip_suffix(" runners").unwrap().parse().unwrap(), child, } } @@ -136,6 +140,8 @@ fn it_serves_every_module_it_lists() { } /// Send `message` to the hub over `socket`, and decode its reply. +/// +/// What other agents send in the meantime is passed over: a runner the build compiled in may be broadcasting. fn exchange( socket: &mut tungstenite::WebSocket>, message: &ClientMessage, @@ -143,7 +149,38 @@ fn exchange( socket .send(Message::text(serde_json::to_string(message).unwrap())) .unwrap(); - ServerMessage::from_text_frame(socket.read().unwrap().to_text().unwrap()).unwrap() + loop { + let reply = ServerMessage::from_text_frame(socket.read().unwrap().to_text().unwrap()).unwrap(); + if !matches!(reply, ServerMessage::AgentMessage { .. }) { + return reply; + } + } +} + +#[test] +fn every_runner_the_build_compiled_in_joins_the_hub() { + let server = Server::start(); + let runners = server.runners; + let (mut socket, _response) = tungstenite::connect(format!("ws://{}/ws", server.address)).unwrap(); + let ServerMessage::ConnectAck { agent_id, .. } = exchange(&mut socket, &ClientMessage::Connect { agent_id: None }) + else { + panic!("the connect was not acknowledged"); + }; + let mut joined = || { + let ServerMessage::ListAgentsResponse { agents } = exchange(&mut socket, &ClientMessage::ListAgents) else { + panic!("the agents were not listed"); + }; + agents.iter().filter(|agent| agent.agent_id != agent_id).count() + }; + let mut others = joined(); + for _ in 0..100_u32 { + if others >= runners { + break; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + others = joined(); + } + assert_eq!(others, runners); } #[test] @@ -153,7 +190,8 @@ fn an_agent_is_given_an_id_and_told_the_time() { let (mut socket, _response) = tungstenite::connect(format!("ws://{}/ws", server.address)).unwrap(); let connected = exchange(&mut socket, &ClientMessage::Connect { agent_id: None }); - assert!(matches!(connected, ServerMessage::ConnectAck { agent_id, .. } if agent_id == "host-1")); + // Not necessarily `host-1`: a runner the build compiled in may have connected first. + assert!(matches!(connected, ServerMessage::ConnectAck { agent_id, .. } if agent_id.starts_with("host-"))); let alive = ClientMessage::Alive { timestamp: "now".to_owned(), }; diff --git a/services/ws-wasi-runner/Cargo.toml b/services/ws-wasi-runner/Cargo.toml index ffad541c..a9bebc81 100644 --- a/services/ws-wasi-runner/Cargo.toml +++ b/services/ws-wasi-runner/Cargo.toml @@ -27,9 +27,11 @@ required-features = ["std"] # The tests of the `std` runner, and of the board's, which compiles its guests as only a host can. # `server_message_to_wit`, the test of the conversions a board shares, runs with `--no-default-features` as well. +# The board's test also needs `board` and `precompile`, which the crate's dev-dependency on itself turns on, so it +# runs whatever features the crate is otherwise built with. [[test]] name = "board" -required-features = ["std"] +required-features = ["board", "precompile", "std"] [[test]] name = "modules" @@ -58,7 +60,7 @@ required-features = ["std"] default = ["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`, and runs a component compiled ahead of time for a board through its `board` module. +## crate is `no_std`: the message conversions, and with `board` the runner for a board. std = [ "chrono/std", "dep:et-otlp", @@ -73,13 +75,17 @@ std = [ "dep:tracing", "dep:wasmtime-wasi", "edge-toolkit/std", - "precompile", "serde_json/std", "thiserror/std", "wasmtime/default", ] ## Compiles a component ahead of time for a board's engine, as a firmware's build script does. -precompile = ["wasmtime/cranelift", "wasmtime/std"] +## The board runs Pulley bytecode, so this compiles to it; a host runner, which runs native code, has no need of it. +precompile = ["wasmtime/cranelift", "wasmtime/pulley", "wasmtime/std"] +## The runner for a board with no operating system, `board::run`, which a firmware runs its guests on. +## It hosts a component compiled ahead of time on the clock, log and websocket the firmware provides, interpreting its +## Pulley bytecode, and keeps the board's storage buckets under a spin lock, since a board has no `std::sync::Mutex`. +board = ["dep:spin", "wasmtime/pulley"] ## 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"] @@ -95,7 +101,7 @@ cuda = ["nn", "wasmtime-wasi-nn/onnx-cuda"] ## Internal: compiles in the `/cov` preopen for the repo's coverage workflow. ## Mapped to `target/wasi-cov`, it is where instrumented WASI guests dump their minicov `.profraw`. ## `ET_TEST_COVERAGE` still gates the preopen at runtime. -coverage = ["et-ws-runner-common?/coverage", "std"] +coverage = ["edge-toolkit/coverage", "et-ws-runner-common?/coverage", "std"] ## Internal: renders this feature list into the crate documentation, as docs.rs builds it. docs = ["dep:document-features"] @@ -106,6 +112,8 @@ document-features = { workspace = true, optional = true } edge-toolkit.workspace = true serde_json = { workspace = true, features = ["alloc"] } serde_path_to_error.workspace = 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 wasmtime.workspace = true # The rest is the `std` runner's. @@ -114,10 +122,6 @@ et-rest-client = { workspace = true, features = ["tracing"], optional = true } et-ws-runner-common = { workspace = true, optional = true } fs-err = { workspace = true, optional = true } futures-util = { workspace = true, optional = true } -# libc::_exit for the macOS test-only fast exit (see main.rs). -# std::process::exit still runs atexit handlers, which is exactly the path that races libc++ during -# ORT teardown -- _exit skips them. -libc = { workspace = true, optional = true } reqwest = { workspace = true, features = ["json", "rustls", "stream"], optional = true } tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "sync", "time"], optional = true } tokio-tungstenite = { workspace = true, features = ["connect"], optional = true } @@ -138,12 +142,19 @@ wasi-webgpu-wasmtime = { workspace = true, optional = true } wgpu-core = { workspace = true, optional = true } wgpu-types = { workspace = true, optional = true } +# libc::_exit for the macOS test-only fast exit (see main.rs), the one platform that needs it. +# std::process::exit still runs atexit handlers, which is exactly the path that races libc++ during +# ORT teardown -- _exit skips them. +[target.'cfg(target_os = "macos")'.dependencies] +libc = { workspace = true, optional = true } + [dev-dependencies] command-error.workspace = true et-org.workspace = true et-test-helpers.workspace = true et-test-otlp.workspace = true et-ws-test-server.workspace = true +et-ws-wasi-runner = { workspace = true, features = ["board", "precompile"] } reqwest = { workspace = true, features = ["blocking"] } retry.workspace = true rstest.workspace = true diff --git a/services/ws-wasi-runner/README.md b/services/ws-wasi-runner/README.md index c2746562..a0ca4562 100644 --- a/services/ws-wasi-runner/README.md +++ b/services/ws-wasi-runner/README.md @@ -40,16 +40,21 @@ Plus, attached to the same `Linker` but defined by external WIT packages, each b ## On a board -Without its default `std` feature the crate is `no_std` + `alloc`, and its `board` module runs a guest on a -microcontroller with no operating system. A board cannot compile WebAssembly, so its engine runs Pulley bytecode -instead: a firmware's build script compiles the guest ahead of time with `engine::precompile` (the `precompile` -feature) for the board's Pulley target, `pulley32` on a 32-bit board, and builds the result in. +Without its default `std` feature the crate is `no_std` + `alloc`, and with its `board` feature its `board` module +runs a guest on a microcontroller with no operating system. A board cannot compile WebAssembly, so its engine runs +Pulley bytecode instead: a firmware's build script compiles the guest ahead of time with `engine::precompile` (the +`precompile` feature) for the board's Pulley target, `pulley32` on a 32-bit board, and builds the result in. `board::run` hosts the `runner` world on what the firmware provides through two traits: a `Platform`, the board's -clock and log, and a `Transport`, its websocket to the hub. Guest buckets live in the board's memory. Of WASI -Preview 2 it hosts only what the ws-module guests import -- their standard streams, an empty environment, the -monotonic clock and polling -- since `wasmtime-wasi` needs `std`; `wasi:nn` and `wasi:webgpu` are host-only. - -wasmtime asks the firmware for one thing more, a thread-local pointer through `wasmtime_tls_get` and -`wasmtime_tls_set`, which on a single core is a static. A guest's linear memory is its declared size, about 1 MiB -for the Rust guests, so a board needs external RAM such as PSRAM to hold it. +clock and log, and a `Transport`, its websocket to the hub. Guest buckets live in the board's memory, in the +`Buckets` the firmware hands every guest it runs, so one reads what another wrote. Their memories and tables draw on +the one `MemoryBudget` the firmware hands them too: growth past what is left fails in the guest, as `memory.grow` +returning -1, rather than the board running out of heap, and what a guest held is returned when it ends. Of WASI +Preview 2 it hosts only +what the ws-module guests import -- their standard streams, an empty environment, the monotonic clock and polling -- +since `wasmtime-wasi` needs `std`; `wasi:nn` and `wasi:webgpu` are host-only. + +wasmtime asks the firmware for one thing more, two thread-local pointers through `wasmtime_tls_get` and +`wasmtime_tls_set`, which on a single core are statics. A guest's linear memory is its declared size, about 1 MiB +for the Rust guests, so a board needs external RAM such as PSRAM to hold it: et-ws-server-firmware runs guests on the +Pimoroni Pico Plus 2 W, whose PSRAM is its heap. diff --git a/services/ws-wasi-runner/src/board/host.rs b/services/ws-wasi-runner/src/board/host.rs index 24003ded..0458430b 100644 --- a/services/ws-wasi-runner/src/board/host.rs +++ b/services/ws-wasi-runner/src/board/host.rs @@ -1,5 +1,5 @@ -//! A board's store data, and its hosting of the `runner` world: the guest's log through the board, its buckets in -//! the board's memory, and its websocket over the board's transport. +//! A board's store data, and its hosting of the `runner` world: the guest's log through the board, its buckets in the +//! board's memory, and its websocket over the board's transport. use alloc::collections::{BTreeMap, VecDeque}; #[cfg(not(feature = "std"))] @@ -11,6 +11,7 @@ use alloc::{ use edge_toolkit::ws::{ClientMessage, ServerMessage}; use wasmtime::component::{Resource, ResourceTable}; +use super::memory::{Allowance, MemoryBudget}; use super::platform::{Platform, Transport}; use crate::bindings::et::ws_messages::messages::{ ClientMessage as WitClientMessage, ServerMessage as WitServerMessage, @@ -25,6 +26,16 @@ use crate::keyvalue::{Bucket, bucket_from_identifier, kv_not_implemented}; /// How long `connect` waits for each frame while it waits for the hub's `et-connect-ack`. const CONNECT_ACK_TIMEOUT_MS: u32 = 10_000; +/// Every storage bucket the guests on one board have written, by agent id, each a map from key to value. +/// +/// A handle: its clones are the same buckets, so every guest a board runs is handed one and can read what another +/// wrote, as agents share the hub's storage elsewhere. +#[derive(Clone, Default)] +pub struct Buckets(alloc::sync::Arc>>); + +/// What one storage bucket holds, a map from key to value. +type Stored = BTreeMap>; + /// The store data a board runs a guest with. pub struct BoardHost { /// The board's clock, log and console. @@ -33,8 +44,10 @@ pub struct BoardHost { table: ResourceTable, /// The websocket to the hub. transport: T, - /// Every storage bucket the guest has written, by its agent id, each a map from key to value. - buckets: BTreeMap>>, + /// The board's storage, which the guest's storage buckets are in. + buckets: Buckets, + /// The guest's draw on the board's memory, which wasmtime asks before each memory or table grows. + allowance: Allowance, /// Messages that arrived before the guest asked for them: the connect ack, and any that came ahead of it. inbox: VecDeque, /// The id the hub assigned, empty until it has. @@ -44,7 +57,7 @@ pub struct BoardHost { } impl BoardHost { - /// A guest's store data, with nothing yet stored and no websocket open. + /// A guest's store data, keeping its storage in `buckets` and its memories in `memory`, with no websocket open. #[cfg_attr( not(test), expect( @@ -52,18 +65,24 @@ impl BoardHost { reason = "the store data's constructor, called where run makes the store" ) )] - pub(super) fn new(platform: P, transport: T) -> Self { + pub(super) fn new(platform: P, transport: T, buckets: Buckets, memory: MemoryBudget) -> Self { Self { platform, table: ResourceTable::new(), transport, - buckets: BTreeMap::new(), + buckets, + allowance: Allowance::new(memory), inbox: VecDeque::new(), agent_id: String::default(), state: State::Closed, } } + /// The guest's draw on the board's memory, which the store's limiter is. + pub(super) const fn allowance(&mut self) -> &mut Allowance { + &mut self.allowance + } + /// The board's clock, log and console, which the WASI functions use. pub(super) const fn platform(&self) -> &P { &self.platform @@ -133,7 +152,13 @@ impl wasi::keyvalue::store::Host for BoardHost impl wasi::keyvalue::store::HostBucket for BoardHost { async fn get(&mut self, self_: Resource, key: String) -> Result>, StoreError> { Ok(match self.table.get(&self_)? { - Bucket::Storage { agent_id } => self.buckets.get(agent_id).and_then(|stored| stored.get(&key)).cloned(), + Bucket::Storage { agent_id } => self + .buckets + .0 + .lock() + .get(agent_id) + .and_then(|stored| stored.get(&key)) + .cloned(), // A board serves no module's files, so none of them holds a key. Bucket::Modules { .. } => None, }) @@ -143,7 +168,13 @@ impl wasi::keyvalue::store::HostBucket for BoardHost< let Bucket::Storage { agent_id } = self.table.get(&self_)? else { return Err(StoreError::AccessDenied); }; - let _previous = self.buckets.entry(agent_id.clone()).or_default().insert(key, value); + let _previous = self + .buckets + .0 + .lock() + .entry(agent_id.clone()) + .or_default() + .insert(key, value); Ok(()) } diff --git a/services/ws-wasi-runner/src/board/memory.rs b/services/ws-wasi-runner/src/board/memory.rs new file mode 100644 index 00000000..ee8a4893 --- /dev/null +++ b/services/ws-wasi-runner/src/board/memory.rs @@ -0,0 +1,114 @@ +//! What a board holds for its guests' memories, and each guest's draw on it as its memories and tables grow. + +use alloc::sync::Arc; +use core::sync::atomic::{AtomicUsize, Ordering}; + +use wasmtime::ResourceLimiter; + +/// The bytes a board holds for its guests' memories and tables, which every guest it runs draws on as they grow. +/// +/// A handle: its clones are the same bytes, so every guest a board runs is handed one. A guest whose growth would spend +/// more than is left is refused it, so its `memory.grow` fails in the guest rather than the board running out of heap, +/// and a guest starting with more than is left fails to instantiate. What a guest held is returned when it ends. +#[derive(Clone, Debug)] +pub struct MemoryBudget(Arc); + +impl MemoryBudget { + /// A budget of `bytes`, none of it yet drawn on. + #[must_use] + pub fn new(bytes: usize) -> Self { + Self(Arc::new(AtomicUsize::new(bytes))) + } + + /// The bytes not yet drawn on. + #[must_use] + pub fn left(&self) -> usize { + self.0.load(Ordering::Acquire) + } + + /// Draw `bytes` from the budget, if that many are left. + fn take(&self, bytes: usize) -> bool { + self.0 + .try_update(Ordering::AcqRel, Ordering::Acquire, |left| left.checked_sub(bytes)) + .is_ok() + } + + /// Return `bytes` drawn earlier. + fn give_back(&self, bytes: usize) { + let _before = self.0.fetch_add(bytes, Ordering::AcqRel); + } +} + +/// One guest's draw on its board's [`MemoryBudget`]: what it holds, and what its latest growth took. +pub(super) struct Allowance { + budget: MemoryBudget, + /// Every byte this guest has drawn and not returned. + held: usize, + /// What the latest growth drew, returned if wasmtime then fails to grow after all. + latest: usize, +} + +impl Allowance { + /// A guest's draw on `budget`, holding nothing yet. + #[expect( + clippy::single_call_fn, + reason = "the allowance's constructor, called where each guest's store data is made" + )] + pub(super) const fn new(budget: MemoryBudget) -> Self { + Self { + budget, + held: 0, + latest: 0, + } + } + + /// Draw what growing from `current` bytes to `desired` takes, and whether the budget had it. + fn grow(&mut self, current: usize, desired: usize) -> bool { + let wanted = desired.saturating_sub(current); + if !self.budget.take(wanted) { + return false; + } + self.held = self.held.saturating_add(wanted); + self.latest = wanted; + true + } + + /// Return what the latest growth drew, since it did not happen. + fn refund_latest(&mut self) { + self.budget.give_back(self.latest); + self.held = self.held.saturating_sub(self.latest); + self.latest = 0; + } +} + +impl ResourceLimiter for Allowance { + /// A memory's growth, or its first allocation at instantiation, allowed only if the budget has it. + fn memory_growing(&mut self, current: usize, desired: usize, _maximum: Option) -> wasmtime::Result { + Ok(self.grow(current, desired)) + } + + /// Return what a memory's allowed growth drew, since wasmtime could not grow it after all. + fn memory_grow_failed(&mut self, _error: wasmtime::Error) -> wasmtime::Result<()> { + self.refund_latest(); + Ok(()) + } + + /// A table's growth, counted at the pointer wasmtime keeps for each element. + fn table_growing(&mut self, current: usize, desired: usize, _maximum: Option) -> wasmtime::Result { + let pointer = size_of::(); + Ok(self.grow(current.saturating_mul(pointer), desired.saturating_mul(pointer))) + } + + /// Return what a table's allowed growth drew, since wasmtime could not grow it after all. + fn table_grow_failed(&mut self, _error: wasmtime::Error) -> wasmtime::Result<()> { + self.refund_latest(); + Ok(()) + } +} + +impl Drop for Allowance { + /// Return everything this guest held, as it ends. + fn drop(&mut self) { + self.budget.give_back(self.held); + } +} diff --git a/services/ws-wasi-runner/src/board/mod.rs b/services/ws-wasi-runner/src/board/mod.rs index 09a33537..d9476f63 100644 --- a/services/ws-wasi-runner/src/board/mod.rs +++ b/services/ws-wasi-runner/src/board/mod.rs @@ -6,12 +6,14 @@ #[cfg(feature = "coverage")] mod cov; mod host; +mod memory; mod platform; mod run; #[cfg(test)] mod test_host; mod wasi; -pub use self::host::BoardHost; +pub use self::host::{BoardHost, Buckets}; +pub use self::memory::MemoryBudget; pub use self::platform::{Console, Platform, Transport}; pub use self::run::{BoardError, run}; diff --git a/services/ws-wasi-runner/src/board/run.rs b/services/ws-wasi-runner/src/board/run.rs index 97b01481..3aca7d70 100644 --- a/services/ws-wasi-runner/src/board/run.rs +++ b/services/ws-wasi-runner/src/board/run.rs @@ -3,7 +3,8 @@ use wasmtime::component::{Component, HasSelf, Linker}; use wasmtime::{Engine, Store}; -use super::host::BoardHost; +use super::host::{BoardHost, Buckets}; +use super::memory::MemoryBudget; use super::platform::{Platform, Transport}; use super::wasi; use crate::bindings; @@ -27,16 +28,24 @@ impl From for BoardError { } } -/// Run `component` on `engine` until its `entry.run` returns, hosting it on `platform` and `transport`. +/// Run `component` on `engine` until its `entry.run` returns, hosting it on what the board hands it. /// -/// `engine` is [`crate::engine::board_engine`]'s, and `component` what [`crate::engine::precompile`] compiled for -/// it: a firmware builds its guest in, compiled by its build script. +/// `engine` is [`crate::engine::board_engine`]'s, and `component` what `engine::precompile` compiled for it: a firmware +/// builds its guest in, compiled by its build script. The guest runs on `platform` and `transport`, and every guest a +/// board runs is handed the same `buckets` for its storage and the same `memory` for its memories and tables. /// /// # Errors /// -/// When `component` was not compiled for `engine`, the guest imports something a board does not host, it traps, -/// or its `entry.run` returns an error. -pub async fn run(engine: &Engine, component: &[u8], platform: P, transport: T) -> Result<(), BoardError> +/// When `component` was not compiled for `engine`, the guest imports something a board does not host, starts with more +/// memory than `memory` has left, traps, or its `entry.run` returns an error. +pub async fn run( + engine: &Engine, + component: &[u8], + platform: P, + transport: T, + buckets: Buckets, + memory: MemoryBudget, +) -> Result<(), BoardError> where P: Platform + 'static, T: Transport + 'static, @@ -45,13 +54,14 @@ where unsafe_code, reason = "a precompiled component is native code to wasmtime, so loading one is unsafe by nature" )] - // SAFETY: the bytes are what `precompile` produced for this engine's settings, built into the firmware by its - // build script rather than received from anywhere at run time. + // SAFETY: the bytes are what `precompile` produced for this engine's settings, built into the firmware by its build + // script rather than received from anywhere at run time. let component = unsafe { Component::deserialize(engine, component) }?; let mut linker = Linker::new(engine); wasi::add_to_linker(&mut linker)?; bindings::Runner::add_to_linker::, HasSelf>>(&mut linker, |host| host)?; - let mut store = Store::new(engine, BoardHost::new(platform, transport)); + let mut store = Store::new(engine, BoardHost::new(platform, transport, buckets, memory)); + store.limiter(|host| host.allowance()); let runner = bindings::Runner::instantiate_async(&mut store, &component, &linker).await?; // `entry.run` is an async export, which only the concurrent API can call. match store diff --git a/services/ws-wasi-runner/src/board/test_host.rs b/services/ws-wasi-runner/src/board/test_host.rs index c9eef57b..b3050b17 100644 --- a/services/ws-wasi-runner/src/board/test_host.rs +++ b/services/ws-wasi-runner/src/board/test_host.rs @@ -1,5 +1,5 @@ -//! Covers the `runner` world calls no shipped guest makes on a board: the websocket used out of order, and the -//! bucket calls a board refuses. +//! Covers the `runner` world calls no shipped guest makes on a board: the websocket used out of order, and the bucket +//! calls a board refuses. #![cfg(test)] #[cfg(not(feature = "std"))] @@ -8,9 +8,11 @@ use core::pin::pin; use core::task::{Context, Poll, Waker}; use edge_toolkit::ws::{ConnectStatus, ServerMessage}; +use wasmtime::ResourceLimiter as _; use wasmtime::component::Resource; -use super::host::BoardHost; +use super::host::{BoardHost, Buckets}; +use super::memory::MemoryBudget; use super::platform::{Console, Platform, Transport}; use crate::bindings::et::ws_messages::messages::ClientMessage; use crate::bindings::et::ws_wasi::ws::{Host as _, State, WsError}; @@ -19,6 +21,9 @@ use crate::bindings::wasi::logging::logging::Level; use crate::convert::Frame; use crate::keyvalue::Bucket; +/// A WebAssembly page, the unit a memory grows by. +const PAGE: usize = 64 * 1024; + /// A board with nothing to say: its clock stands still and its log goes nowhere. struct Quiet; @@ -76,7 +81,17 @@ where /// A guest's store data on a quiet board with an acking hub. fn host() -> BoardHost { - BoardHost::new(Quiet, Acking) + on_board(Buckets::default(), roomy()) +} + +/// A guest's store data on a quiet board with an acking hub, sharing `buckets` and `memory` with the board's others. +fn on_board(buckets: Buckets, memory: MemoryBudget) -> BoardHost { + BoardHost::new(Quiet, Acking, buckets, memory) +} + +/// A budget no guest here spends. +fn roomy() -> MemoryBudget { + MemoryBudget::new(usize::MAX) } /// The bucket `identifier` opens. @@ -125,6 +140,26 @@ fn a_module_bucket_holds_nothing_and_cannot_be_written() { now(HostBucket::drop(&mut host, bucket)).unwrap(); } +#[test] +fn guests_handed_the_same_buckets_read_what_each_other_wrote() { + let buckets = Buckets::default(); + let mut writer = on_board(buckets.clone(), roomy()); + let mut reader = on_board(buckets, roomy()); + let written = open(&mut writer, "agent-1"); + now(writer.set(written, "math1-input.json".to_owned(), vec![7])).unwrap(); + let read = open(&mut reader, "agent-1"); + assert_eq!( + now(reader.get(read, "math1-input.json".to_owned())).unwrap(), + Some(vec![7]) + ); + let mut other_board = host(); + let elsewhere = open(&mut other_board, "agent-1"); + assert_eq!( + now(other_board.get(elsewhere, "math1-input.json".to_owned())).unwrap(), + None + ); +} + #[test] fn a_bucket_refuses_the_calls_a_board_does_not_implement() { let mut host = host(); @@ -150,3 +185,48 @@ fn a_bucket_refuses_the_calls_a_board_does_not_implement() { ] ); } + +#[test] +fn guests_grow_only_into_what_the_board_has_left() { + let memory = MemoryBudget::new(3 * PAGE); + let mut first = on_board(Buckets::default(), memory.clone()); + let mut second = on_board(Buckets::default(), memory.clone()); + assert!(first.allowance().memory_growing(0, 2 * PAGE, None).unwrap()); + assert!(!second.allowance().memory_growing(0, 2 * PAGE, None).unwrap()); + assert!(second.allowance().memory_growing(0, PAGE, None).unwrap()); + assert_eq!(memory.left(), 0); + assert!(!first.allowance().memory_growing(2 * PAGE, 3 * PAGE, None).unwrap()); + drop(first); + assert_eq!(memory.left(), 2 * PAGE); + drop(second); + assert_eq!(memory.left(), 3 * PAGE); +} + +#[test] +fn a_growth_that_fails_after_all_returns_what_it_drew() { + let memory = MemoryBudget::new(4 * PAGE); + let mut guest = on_board(Buckets::default(), memory.clone()); + assert!(guest.allowance().memory_growing(0, PAGE, None).unwrap()); + assert!( + guest + .allowance() + .memory_growing(PAGE, 3 * PAGE, Some(2 * PAGE)) + .unwrap() + ); + guest + .allowance() + .memory_grow_failed(wasmtime::Error::msg("past the maximum")) + .unwrap(); + assert_eq!(memory.left(), 3 * PAGE); + + let pointer = size_of::(); + assert!(guest.allowance().table_growing(0, 8, None).unwrap()); + assert_eq!(memory.left(), 3 * PAGE - 8 * pointer); + guest + .allowance() + .table_grow_failed(wasmtime::Error::msg("past the maximum")) + .unwrap(); + assert_eq!(memory.left(), 3 * PAGE); + drop(guest); + assert_eq!(memory.left(), 4 * PAGE); +} diff --git a/services/ws-wasi-runner/src/board/wasi.rs b/services/ws-wasi-runner/src/board/wasi.rs index e1257147..d8fe3343 100644 --- a/services/ws-wasi-runner/src/board/wasi.rs +++ b/services/ws-wasi-runner/src/board/wasi.rs @@ -1,11 +1,11 @@ -//! The WASI Preview 2 a board hosts: what a guest built for `wasm32-wasip2` imports even when it never uses it, -//! defined by hand, since `wasmtime-wasi` needs `std`. +//! The WASI Preview 2 a board hosts: what a guest built for `wasm32-wasip2` imports even when it never uses it, defined +//! by hand, since `wasmtime-wasi` needs `std`. //! //! Every ws-module guest imports the same few functions: its standard streams and environment, `exit`, the terminal //! queries, and the monotonic clock and polling it sleeps with. A component imports only the functions it calls, so -//! these are all a guest needs; one importing any other fails to instantiate, naming it. Its standard streams go to -//! the board's console, its environment is empty, it has no terminal, and a pollable is ready at once or at a -//! deadline on the board's clock. +//! these are all a guest needs; one importing any other fails to instantiate, naming it. Its standard streams go to the +//! board's console, its environment is empty, it has no terminal, and a pollable is ready at once or at a deadline on +//! the board's clock. #![expect( clippy::integer_division_remainder_used, reason = "wasmtime's ComponentType derive, on StreamError, aligns with `%` in the impls it generates" @@ -14,14 +14,13 @@ #[cfg(not(feature = "std"))] use alloc::{boxed::Box, format, string::String, vec::Vec}; +pub(super) use edge_toolkit::board::PREVIEW2; use wasmtime::StoreContextMut; use wasmtime::component::{ComponentType, Lift, Linker, Lower, Resource, ResourceType}; use super::host::BoardHost; use super::platform::{Console, Platform, Transport}; -/// The WASI Preview 2 release the guests import, which any later 0.2 import also resolves to. -pub(super) const PREVIEW2: &str = "0.2.12"; /// How many bytes `check-write` lets the guest write at once; the board's console takes any amount. const WRITE_BUDGET: u64 = 4096; diff --git a/services/ws-wasi-runner/src/engine.rs b/services/ws-wasi-runner/src/engine.rs index 4d4cf026..3426ff0c 100644 --- a/services/ws-wasi-runner/src/engine.rs +++ b/services/ws-wasi-runner/src/engine.rs @@ -1,8 +1,8 @@ //! The engine a board runs a component with, and compiling a component for it ahead of time. //! -//! A board has no compiler and no virtual memory, so its engine runs Pulley bytecode a host compiled for it, keeps -//! each linear memory to what it holds, and checks bounds and traps explicitly rather than through signals. The host -//! has to compile with the same settings the board loads with, so both come from [`board_config`]. +//! A board has no compiler and no virtual memory, so its engine runs Pulley bytecode a host compiled for it, keeps each +//! linear memory to what it holds, and checks bounds and traps explicitly rather than through signals. The host has to +//! compile with the same settings the board loads with, so both come from [`board_config`]. #[cfg(all(feature = "precompile", not(feature = "std")))] use alloc::vec::Vec; @@ -10,6 +10,7 @@ use alloc::vec::Vec; use wasmtime::{Config, Engine}; /// The Pulley target a board with this build's pointer width runs. +#[cfg(feature = "board")] pub const NATIVE_PULLEY: &str = if cfg!(target_pointer_width = "64") { "pulley64" } else { @@ -17,8 +18,8 @@ pub const NATIVE_PULLEY: &str = if cfg!(target_pointer_width = "64") { }; /// The stack each guest call runs on, the most a board gives one. /// -/// The interpreter and the host functions a guest calls run on it too, and unoptimised their frames are several -/// times larger, so a debug build gives it four times the room. +/// The interpreter and the host functions a guest calls run on it too, and unoptimised their frames are several times +/// larger, so a debug build gives it four times the room. const ASYNC_STACK_SIZE: usize = if cfg!(debug_assertions) { // 64 KiB overflowed on Windows, on `override (msvc)` at commit // https://github.com/edge-toolkit/core/commit/0a3156acdc5f4405cb318451a58ace3c53922d5c @@ -47,6 +48,9 @@ pub fn board_config(target: &str) -> wasmtime::Result { config.target(target)?; config.wasm_component_model_async(true); config.signals_based_traps(false); + // Copy-on-write memory images need virtual memory, which a build without `std` has none of; left on, loading a + // component there fails with `virtual memory disabled at compile time -- cannot enable CoW`. + config.memory_init_cow(false); config.memory_reservation(0); config.memory_reservation_for_growth(0); config.memory_guard_size(0); @@ -56,16 +60,17 @@ pub fn board_config(target: &str) -> wasmtime::Result { Ok(config) } -/// The engine a board runs components with, which loads only what [`precompile`] compiled for this build's target. +/// The engine a board runs components with, which loads only what `precompile` compiled for this build's target. /// /// # Errors /// /// When wasmtime rejects the settings, as it would one that this build cannot run. +#[cfg(feature = "board")] pub fn board_engine() -> wasmtime::Result { Engine::new(&board_config(NATIVE_PULLEY)?) } -/// `component`, compiled for a board running `target`, as that board's [`board_engine`] loads it. +/// `component`, compiled for a board running `target`, as that board's `board_engine` loads it. /// /// # Errors /// diff --git a/services/ws-wasi-runner/src/lib.rs b/services/ws-wasi-runner/src/lib.rs index 90fc47b2..804c4a0f 100644 --- a/services/ws-wasi-runner/src/lib.rs +++ b/services/ws-wasi-runner/src/lib.rs @@ -3,13 +3,16 @@ #![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"))] extern crate alloc; pub mod bindings; +#[cfg(feature = "board")] pub mod board; #[cfg(feature = "std")] pub mod config; pub mod convert; +#[cfg(any(feature = "board", feature = "precompile"))] pub mod engine; #[cfg(feature = "std")] pub mod error; diff --git a/services/ws-wasi-runner/tests/board.rs b/services/ws-wasi-runner/tests/board.rs index 77ae8c8b..3e7714a6 100644 --- a/services/ws-wasi-runner/tests/board.rs +++ b/services/ws-wasi-runner/tests/board.rs @@ -7,6 +7,7 @@ use std::net::{IpAddr, Ipv4Addr}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; +use edge_toolkit::board::Guest; use edge_toolkit::config::get_project_root; use edge_toolkit::ws::{ AgentConnectionState, AgentSummary, ClientMessage, ConnectStatus, MessageDeliveryStatus, MessageScope, @@ -15,7 +16,7 @@ use edge_toolkit::ws::{ use et_ws_wasi_runner::bindings::et::ws_wasi::ws::WsError; use et_ws_wasi_runner::bindings::exports::et::ws_wasi::entry::EntryError; use et_ws_wasi_runner::bindings::wasi::logging::logging::Level; -use et_ws_wasi_runner::board::{BoardError, Console, Platform, Transport, run}; +use et_ws_wasi_runner::board::{BoardError, Buckets, Console, MemoryBudget, Platform, Transport, run}; use et_ws_wasi_runner::convert::Frame; use et_ws_wasi_runner::engine::{NATIVE_PULLEY, board_engine, precompile}; use rstest::rstest; @@ -153,31 +154,52 @@ fn text_frame(message: &ServerMessage) -> Frame { Frame::Text(serde_json::to_string(message).unwrap()) } -/// `module`'s component as its build left it, compiled for the board engine this build has. -fn compiled(module: &str) -> Vec { +/// `module`'s component as its build left it. +fn component(module: &str) -> Vec { let file = format!("et_ws_{}.wasm", module.replace('-', "_")); - let wasm = fs_err::read( + fs_err::read( get_project_root() .join("services/ws-modules") .join(module) .join("pkg") .join(file), ) - .unwrap(); - precompile(NATIVE_PULLEY, &wasm).unwrap() + .unwrap() +} + +/// `module`'s component as its build left it, compiled for the board engine this build has. +fn compiled(module: &str) -> Vec { + precompile(NATIVE_PULLEY, &component(module)).unwrap() } /// What running `component` against `hub` left. /// /// That is how the run ended, every line it logged, every frame it sent, and whether it closed the websocket. async fn ran_on(component: &[u8], hub: Hub) -> (Result<(), BoardError>, Vec, Vec, bool) { + ran_within(component, hub, MemoryBudget::new(usize::MAX)).await +} + +/// What running `component` against `hub` left, its memories and tables drawn from `memory`. +async fn ran_within( + component: &[u8], + hub: Hub, + memory: MemoryBudget, +) -> (Result<(), BoardError>, Vec, Vec, bool) { let lines = Arc::new(Mutex::new(Vec::new())); let host = Host { clock: AtomicU64::new(0), lines: Arc::clone(&lines), }; let (sent, closed) = (Arc::clone(&hub.sent), Arc::clone(&hub.closed)); - let outcome = run(&board_engine().unwrap(), component, host, hub).await; + let outcome = run( + &board_engine().unwrap(), + component, + host, + hub, + Buckets::default(), + memory, + ) + .await; let lines = lines.lock().unwrap().clone(); let sent = sent.lock().unwrap().clone(); let closed = *closed.lock().unwrap(); @@ -209,7 +231,7 @@ async fn failed(module: &str, hub: Hub) -> EntryError { /// The error `module`'s `entry.run` returned against `hub`, and whether the websocket was closed. async fn failed_closing(module: &str, hub: Hub) -> (EntryError, bool) { - let (outcome, _, _, closed) = ran_on(&compiled(module), hub).await; + let (outcome, .., closed) = ran_on(&compiled(module), hub).await; let Err(BoardError::Guest(error)) = outcome else { panic!("{module} did not fail: {outcome:?}"); }; @@ -328,6 +350,44 @@ async fn comm1_fails_when_its_roster_request_cannot_be_sent() { assert!(matches!(error, EntryError::Ws(WsError::Transport(reason)) if reason == "broken pipe")); } +/// Every guest the tests above run on the board imports only what the hosted list names. +/// +/// So a deployment that checks a guest against the list refuses none the board can run. +#[rstest] +#[case::comm1("wasi-comm1")] +#[case::data1("wasi-data1")] +#[case::math1("wasi-math1")] +#[case::math1_sender("wasi-math1-sender")] +fn a_guest_the_board_runs_imports_only_what_it_hosts(#[case] module: &str) { + let guest = Guest::read(&component(module)).unwrap(); + assert_eq!(guest.unhosted().count(), 0); + assert!(guest.memory > 0); +} + +#[test] +fn a_guest_that_needs_a_gpu_or_a_model_imports_what_the_board_does_not_host() { + let guest = Guest::read(&component("wasi-graphics-info")).unwrap(); + let unhosted = guest.unhosted().collect::>(); + assert!(unhosted.iter().any(|import| import.starts_with("wasi:webgpu/"))); + assert!(unhosted.iter().any(|import| import.starts_with("wasi:nn/"))); +} + +#[tokio::test] +async fn a_guest_runs_within_the_board_s_memory_and_returns_it_when_it_ends() { + let module = "wasi-data1"; + let starts_with = usize::try_from(Guest::read(&component(module)).unwrap().memory).unwrap(); + let roomy = MemoryBudget::new(starts_with.saturating_mul(4)); + let (outcome, ..) = ran_within(&compiled(module), Hub::default(), roomy.clone()).await; + outcome.unwrap(); + assert_eq!(roomy.left(), starts_with.saturating_mul(4)); + + let short = MemoryBudget::new(starts_with.saturating_sub(1)); + let (outcome, lines, sent, _) = ran_within(&compiled(module), Hub::default(), short.clone()).await; + assert!(matches!(outcome, Err(BoardError::Wasmtime(_)))); + assert!(lines.is_empty() && sent.is_empty()); + assert_eq!(short.left(), starts_with.saturating_sub(1)); +} + #[tokio::test] async fn bytes_that_were_not_compiled_for_the_board_do_not_load() { let (outcome, lines, sent, _) = ran_on(b"not a component", Hub::default()).await; diff --git a/services/ws/src/picoserve_hub.rs b/services/ws/src/picoserve_hub.rs index 485dc6d1..08543c78 100644 --- a/services/ws/src/picoserve_hub.rs +++ b/services/ws/src/picoserve_hub.rs @@ -1,9 +1,9 @@ //! A `no_std` hub serving `/ws` over HTTP and WebSocket through picoserve, on whatever sockets the embedder accepts. //! -//! The embedder owns the network stack and the executor. It accepts each connection on a socket picoserve can drive -//! -- an `embassy-net` `TcpSocket` on a microcontroller, a tokio `TcpStream` on a host -- and runs [`Hub::serve`] for -//! it, typically as a task of its own. Every connection shares the one [`Hub`], so agents on different sockets reach -//! each other through its registry, with [`Link`] deciding what goes where. +//! The embedder owns the network stack and the executor. It accepts each connection on a socket picoserve can drive -- +//! an `embassy-net` `TcpSocket` on a microcontroller, a tokio `TcpStream` on a host -- and runs [`Hub::serve`] for it, +//! typically as a task of its own. Every connection shares the one [`Hub`], so agents on different sockets reach each +//! other through its registry, with [`Link`] deciding what goes where. //! //! It is a local hub: there is no backplane, admin API, idle timeout or registry file, and a frame larger than //! [`Hub::new`]'s `max_frame_size` ends its connection. @@ -36,9 +36,9 @@ const CONNECTION_LIFETIME: Duration = Duration::from_secs(24 * 60 * 60); /// The picoserve configuration a hub connection needs. /// /// picoserve bounds every read of a request by `read_request`, counted from the moment the request starts and still -/// running after the upgrade, so its default of three seconds would close each agent's WebSocket three seconds after -/// it connected. This lifts that bound to a day and leaves the other timeouts at picoserve's defaults; an embedder -/// wanting another maximum connection lifetime builds its own. +/// running after the upgrade, so its default of three seconds would close each agent's WebSocket three seconds after it +/// connected. This lifts that bound to a day and leaves the other timeouts at picoserve's defaults; an embedder wanting +/// another maximum connection lifetime builds its own. #[must_use] pub const fn config() -> Config { Config::new(Timeouts { @@ -147,13 +147,24 @@ where &self.registry } + /// Refuse a frame of `size` bytes from an agent in process if a connection could not send one that large. + const fn fits(&self, size: usize) -> Result<(), FrameTooLarge> { + if size > self.max_frame_size { + return Err(FrameTooLarge { + size, + max: self.max_frame_size, + }); + } + Ok(()) + } + /// Serve one accepted connection from `client_ip` until it closes. /// /// `GET /ws` upgrades to the hub's WebSocket protocol, as [`Hub::routes`] describes; every other request is /// answered 404. `config` is usually [`config`], and `http_buffer` holds the request head. /// - /// picoserve's futures nest deeply enough that the crate awaiting this one needs `#![recursion_limit = "256"]` - /// for rustc to lay out the future; rustc says so when it is missing. + /// picoserve's futures nest deeply enough that the crate awaiting this one needs `#![recursion_limit = "256"]` for + /// rustc to lay out the future; rustc says so when it is missing. #[expect( clippy::future_not_send, reason = "picoserve's futures hold Cells; its executors run each connection on one thread" @@ -195,6 +206,21 @@ where ) } + /// An agent the embedder runs itself, joined to the hub with no socket between them. + /// + /// The hub sees it as connected from `client_ip`. What it sends is routed as a connection's frames are, and what + /// others send it waits in its outbox with the hub's replies, as a connection's would. It is how a board's own + /// runner reaches the hub it runs beside. Unlike a connection, it is not refused for a blocked `client_ip`, since + /// nothing outside the embedder reaches it. + #[must_use] + pub fn local(&self, client_ip: IpAddr) -> LocalLink<'_, T> { + LocalLink { + hub: self, + link: Link::new(client_ip, Outbox::new(self.max_queued), self.retention), + replies: VecDeque::new(), + } + } + /// Route one frame from `link`'s agent. fn route(&self, link: &mut Link, frame: Frame) -> Routing { let mut stamps = self.stamps.lock(); @@ -231,6 +257,119 @@ where } } +/// A frame an agent in process sent that is larger than the hub reads from a socket. +/// +/// Held to the same bound a connection is, so an agent on the board cannot fill other agents' outboxes, and the heap, +/// with frames no socket could carry. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct FrameTooLarge { + /// The frame's size in bytes. + pub size: usize, + /// The largest frame the hub reads. + pub max: usize, +} + +impl core::fmt::Display for FrameTooLarge { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!( + f, + "a {}-byte frame is larger than the {} bytes the hub reads", + self.size, self.max + ) + } +} + +/// An agent joined to a [`Hub`] in process, as [`Hub::local`] makes one; dropping it disconnects the agent. +pub struct LocalLink<'hub, T> { + hub: &'hub Hub, + link: Link, + /// The hub's replies to what this agent sent, not yet taken. + /// + /// Kept apart from the outbox, so the replies to the latest request are never lost to what others send: the `et- + /// connect-ack`, say, that reconnecting with more messages waiting than the outbox holds would otherwise lose. A + /// connection writes its replies to its socket at once, so has no such queue. It holds the hub's `max_queued`, as + /// an outbox does, or the latest request's replies when they are more: an agent that keeps sending without reading, + /// as one broadcasting on a timer does, loses its oldest replies rather than taking the heap with it. + replies: VecDeque, +} + +impl LocalLink<'_, T> +where + T: Stamps, +{ + /// Route `message` from this agent, queueing the hub's replies to it. + pub fn send(&mut self, message: ClientMessage) { + let routing = self.hub.route(&mut self.link, Frame::Message(message)); + self.deliver(routing); + } + + /// Route `text` from this agent, as a text frame from a connection is: decoded, or dropped if it is no message. + /// + /// # Errors + /// + /// When `text` is larger than the hub reads from a socket, a frame that would end a connection. + pub fn send_text(&mut self, text: &str) -> Result<(), FrameTooLarge> { + self.hub.fits(text.len())?; + match ClientMessage::from_text_frame(text) { + Ok(message) => self.send(message), + Err(err) => warn!( + "Decode error for et-* frame from {}: {}", + self.link.current_agent_id(), + err + ), + } + Ok(()) + } + + /// Relay `bytes` from this agent, as a binary frame from a connection is. + /// + /// # Errors + /// + /// When `bytes` is larger than the hub reads from a socket, a frame that would end a connection. + pub fn send_binary(&mut self, bytes: Bytes) -> Result<(), FrameTooLarge> { + self.hub.fits(bytes.len())?; + let routing = self.hub.route(&mut self.link, Frame::Binary(bytes)); + self.deliver(routing); + Ok(()) + } + + /// The next message for this agent, once there is one: the hub's replies first, then what others sent it. + pub async fn next(&mut self) -> SessionMessage { + match self.replies.pop_front() { + Some(reply) => reply, + None => self.link.session().next().await, + } + } + + /// Carry out `routing`: other agents' messages go to their outboxes, and the replies to this agent's own queue. + /// + /// Older replies make room for these, never these for older ones. + fn deliver(&mut self, routing: Routing) { + for (session, message) in routing.deliveries { + session.send(message); + } + let room = self.hub.max_queued.saturating_sub(routing.replies.len()); + let unread = self.replies.len().saturating_sub(room); + if unread > 0 { + warn!("Dropped the {unread} oldest replies to an in-process agent that is not reading"); + for _ in 0..unread { + let _oldest = self.replies.pop_front(); + } + } + self.replies + .extend(routing.replies.into_iter().map(SessionMessage::Json)); + } +} + +impl Drop for LocalLink<'_, T> { + /// Disconnect the agent, unless another session has taken its id over since. + fn drop(&mut self) { + let link = &self.link; + let _released = link.release(&self.hub.registry, &|session| session.same_connection(link.session())); + } +} + /// A frame from an agent the hub routes. enum Frame { Message(ClientMessage), diff --git a/services/ws/tests/picoserve_hub.rs b/services/ws/tests/picoserve_hub.rs index da1647a2..a43b82f3 100644 --- a/services/ws/tests/picoserve_hub.rs +++ b/services/ws/tests/picoserve_hub.rs @@ -1,5 +1,5 @@ -//! The picoserve hub over real TCP sockets, with tokio standing in for an embedded executor and network stack: -//! agents connect over HTTP, upgrade to WebSocket, and reach each other through the one hub. +//! The picoserve hub over real TCP sockets, with tokio standing in for an embedded executor and network stack: agents +//! connect over HTTP, upgrade to WebSocket, and reach each other through the one hub. #![cfg(test)] #![recursion_limit = "256"] @@ -7,10 +7,13 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::Arc; use std::time::Duration; -use edge_toolkit::ws::{ClientMessage, ConnectStatus, MessageDeliveryStatus, MessageScope, ServerMessage}; +use bytes::Bytes; +use edge_toolkit::ws::{ + AgentConnectionState, ClientMessage, ConnectStatus, MessageDeliveryStatus, MessageScope, ServerMessage, +}; use edge_toolkit::ws_server::MessageRetention; use et_ws_service::hub::{SessionMessage, Stamps}; -use et_ws_service::picoserve_hub::{Hub, Outbox, config}; +use et_ws_service::picoserve_hub::{Hub, LocalLink, Outbox, config}; use futures_util::{SinkExt as _, StreamExt as _}; use picoserve::time::TokioTimer; use serde_json::json; @@ -121,8 +124,23 @@ async fn receive(client: &mut Client) -> ServerMessage { ServerMessage::from_text_frame(next_frame(client).await.to_text().unwrap()).unwrap() } -/// Whether a direct message `client` sent was answered with `status`. -async fn answered_with(client: &mut Client, expected: &MessageDeliveryStatus) -> bool { +/// Whether `message`, sent by `client` directly to the agent `to_agent_id`, was answered as delivered. +async fn delivered(client: &mut Client, to_agent_id: &str, message: serde_json::Value) -> bool { + sent_directly(client, to_agent_id, message, &MessageDeliveryStatus::Delivered).await +} + +/// Whether `message`, sent by `client` directly to the agent `to_agent_id`, was answered with `expected`. +async fn sent_directly( + client: &mut Client, + to_agent_id: &str, + message: serde_json::Value, + expected: &MessageDeliveryStatus, +) -> bool { + let direct = ClientMessage::SendAgentMessage { + to_agent_id: to_agent_id.to_owned(), + message, + }; + send(client, &direct).await; matches!(receive(client).await, ServerMessage::MessageStatus { status, .. } if status == *expected) } @@ -134,12 +152,7 @@ async fn agents_on_separate_sockets_reach_each_other() { let (mut bob, _bob_status) = connect(address, "bob").await; assert_eq!(alice_status, ConnectStatus::Assigned); - let direct = ClientMessage::SendAgentMessage { - to_agent_id: "bob".to_owned(), - message: json!({"hello": "bob"}), - }; - send(&mut alice, &direct).await; - assert!(answered_with(&mut alice, &MessageDeliveryStatus::Delivered).await); + assert!(delivered(&mut alice, "bob", json!({"hello": "bob"})).await); assert!(matches!( receive(&mut bob).await, ServerMessage::AgentMessage { from_agent_id, scope: MessageScope::Direct, .. } if from_agent_id == "alice" @@ -170,12 +183,7 @@ async fn a_message_waits_for_a_recipient_that_has_gone() { // The close is answered once the hub has released bob's id. while bob.next().await.is_some() {} - let direct = ClientMessage::SendAgentMessage { - to_agent_id: "bob".to_owned(), - message: json!(null), - }; - send(&mut alice, &direct).await; - assert!(answered_with(&mut alice, &MessageDeliveryStatus::Queued).await); + assert!(sent_directly(&mut alice, "bob", json!(null), &MessageDeliveryStatus::Queued).await); let (mut bob, status) = connect(address, "bob").await; assert_eq!(status, ConnectStatus::Reconnected); @@ -236,6 +244,152 @@ async fn a_blocked_address_is_refused_and_other_paths_are_not_found() { .await; } +/// The next message `link`'s agent is sent, as a protocol message. +async fn local_receive(link: &mut LocalLink<'_, Counter>) -> ServerMessage { + let SessionMessage::Json(message) = tokio::time::timeout(PATIENCE, link.next()).await.unwrap() else { + panic!("a raw frame arrived where a message was expected"); + }; + message +} + +#[tokio::test] +async fn an_agent_in_process_reaches_one_on_a_socket_and_leaves_when_dropped() { + on_one_thread(async || { + let (hub, address) = served_hub().await; + let (mut alice, _alice_status) = connect(address, "alice").await; + let mut board = hub.local(IpAddr::V4(Ipv4Addr::LOCALHOST)); + board.send(ClientMessage::Connect { + agent_id: Some("board".to_owned()), + }); + assert!(matches!( + local_receive(&mut board).await, + ServerMessage::ConnectAck { agent_id, status: ConnectStatus::Assigned } if agent_id == "board" + )); + + board.send(ClientMessage::SendAgentMessage { + to_agent_id: "alice".to_owned(), + message: json!({"from": "the board"}), + }); + assert!(matches!( + local_receive(&mut board).await, + ServerMessage::MessageStatus { + status: MessageDeliveryStatus::Delivered, + .. + } + )); + assert!(matches!( + receive(&mut alice).await, + ServerMessage::AgentMessage { from_agent_id, .. } if from_agent_id == "board" + )); + + assert!(delivered(&mut alice, "board", json!({"from": "alice"})).await); + assert!(matches!( + local_receive(&mut board).await, + ServerMessage::AgentMessage { from_agent_id, .. } if from_agent_id == "alice" + )); + + board.send_binary(Bytes::from_static(&[4, 5, 6])).unwrap(); + assert_eq!(next_frame(&mut alice).await, Message::binary(vec![4, 5, 6])); + + drop(board); + send(&mut alice, &ClientMessage::ListAgents).await; + let ServerMessage::ListAgentsResponse { agents } = receive(&mut alice).await else { + panic!("the agents were not listed"); + }; + let board_state = agents + .iter() + .find(|agent| agent.agent_id == "board") + .map(|agent| &agent.state); + assert_eq!(board_state, Some(&AgentConnectionState::Disconnected)); + }) + .await; +} + +#[tokio::test] +async fn an_agent_in_process_is_held_to_the_frames_a_connection_may_send() { + let hub = Hub::new(Counter::default(), MessageRetention::All, MAX_FRAME_SIZE, MAX_QUEUED); + let mut board = hub.local(IpAddr::V4(Ipv4Addr::LOCALHOST)); + let oversized = MAX_FRAME_SIZE.saturating_add(1); + let refused = board.send_binary(Bytes::from(vec![0; oversized])).unwrap_err(); + assert_eq!((refused.size, refused.max), (oversized, MAX_FRAME_SIZE)); + let refused = board.send_text(&" ".repeat(oversized)).unwrap_err(); + assert_eq!( + refused.to_string(), + "a 1025-byte frame is larger than the 1024 bytes the hub reads" + ); + + // Text that is no message is dropped, as a connection's is, so the first reply is the connect's. + board.send_text("not a message").unwrap(); + let connect = serde_json::to_string(&ClientMessage::Connect { agent_id: None }).unwrap(); + board.send_text(&connect).unwrap(); + assert!(matches!( + local_receive(&mut board).await, + ServerMessage::ConnectAck { .. } + )); +} + +#[tokio::test] +async fn an_agent_in_process_that_never_reads_keeps_only_its_latest_replies() { + let hub = Hub::new(Counter::default(), MessageRetention::All, MAX_FRAME_SIZE, MAX_QUEUED); + let mut board = hub.local(IpAddr::V4(Ipv4Addr::LOCALHOST)); + board.send(ClientMessage::Connect { agent_id: None }); + for _ in 0..MAX_QUEUED.saturating_mul(3) { + board.send(ClientMessage::Alive { + timestamp: "now".to_owned(), + }); + } + board.send(ClientMessage::ListAgents); + for _ in 1..MAX_QUEUED { + assert!(matches!( + local_receive(&mut board).await, + ServerMessage::Response { .. } + )); + } + assert!(matches!( + local_receive(&mut board).await, + ServerMessage::ListAgentsResponse { .. } + )); + let _nothing_more = tokio::time::timeout(Duration::from_millis(100), board.next()) + .await + .unwrap_err(); +} + +#[tokio::test] +async fn an_agent_in_process_reconnecting_to_more_than_its_outbox_holds_is_still_acknowledged() { + on_one_thread(async || { + let (hub, address) = served_hub().await; + let (mut alice, _alice_status) = connect(address, "alice").await; + let loopback = IpAddr::V4(Ipv4Addr::LOCALHOST); + let connect = || ClientMessage::Connect { + agent_id: Some("board".to_owned()), + }; + let mut board = hub.local(loopback); + board.send(connect()); + drop(board); + // One more than the outbox holds, all waiting for the board while it is away. + for _ in 0..=MAX_QUEUED { + assert!(sent_directly(&mut alice, "board", json!(null), &MessageDeliveryStatus::Queued).await); + } + + let mut board = hub.local(loopback); + board.send(connect()); + assert!(matches!( + local_receive(&mut board).await, + ServerMessage::ConnectAck { + status: ConnectStatus::Reconnected, + .. + } + )); + for _ in 0..=MAX_QUEUED { + assert!(matches!( + local_receive(&mut board).await, + ServerMessage::AgentMessage { from_agent_id, .. } if from_agent_id == "alice" + )); + } + }) + .await; +} + #[tokio::test] async fn a_full_outbox_drops_its_oldest_message() { let outbox = Outbox::new(2); diff --git a/utilities/cli/HELP.md b/utilities/cli/HELP.md index afafb766..5779f883 100644 --- a/utilities/cli/HELP.md +++ b/utilities/cli/HELP.md @@ -49,6 +49,8 @@ Generate deployment config from a cluster input YAML The hub alone, as firmware for a Raspberry Pi Pico 2 W - `firmware-esp32c6`: The hub alone, as firmware for an ESP32-C6 + - `firmware-pico-plus2`: + The hub and the scenario's wasi runners, as firmware for a Pimoroni Pico Plus 2 W ## `et-cli regen-verification` diff --git a/utilities/cli/src/deployment_types/firmware.rs b/utilities/cli/src/deployment_types/firmware.rs index 00fad4ff..d080cff0 100644 --- a/utilities/cli/src/deployment_types/firmware.rs +++ b/utilities/cli/src/deployment_types/firmware.rs @@ -7,7 +7,7 @@ use toml::{Table, Value}; use super::mise::{mise_task, registry_and_modules, wrap_module_paths}; use crate::error::CliError; use crate::input::{ArtifactSource, Board, ClusterInput}; -use crate::{ModuleSource, ScenarioModules, cluster_module_names, resolve_cluster_modules}; +use crate::{ModuleSource, ScenarioModules, cluster_module_names, resolve_cluster_modules, scenario_runners}; /// The task that builds the firmware with the scenario's modules in it. const BUILD_TASK: &str = "firmware-build"; @@ -18,32 +18,55 @@ pub(crate) const HOST_TEST_TASK: &str = "firmware-host-test"; /// Where npm packages too big for the board are fetched from instead: a CDN mirroring the npm registry by package path. pub(crate) const ELSEWHERE_URL: &str = "https://cdn.jsdelivr.net/npm"; -/// Refuse a scenario `board` cannot run: it holds one hub and the modules built into it, and nothing else. +/// Refuse a scenario `board` cannot run. /// -/// A runner needs an OS to run on, a second machine a backplane, and a published deployment modules staged from a -/// release, none of which the firmware has. A second hub needs a second machine, so is refused with it. Checked before -/// anything is written, as every format's refusals are. +/// A board holds one hub, the modules built into it, and the runners its firmware hosts, and nothing else. A second +/// machine needs a backplane, and a published deployment modules staged from a release, neither of which the firmware +/// has. A second hub needs a second machine, so is refused with it. A runner is refused unless the board's firmware +/// runs its kind; whether the guest itself fits, the firmware's build decides. Checked before anything is written, as +/// every format's refusals are. pub(crate) fn validate_firmware(cluster: &ClusterInput, board: Board) -> Result<(), CliError> { - let reason = if cluster.agents.iter().any(|agent| agent.runner.is_some()) { - "starts a runner" - } else if !cluster.machines.is_empty() { - "spans several machines" - } else if matches!(cluster.artifact_source, ArtifactSource::Published) { - "uses published artifacts, since the firmware is built from this repository" - } else { - return Ok(()); + let spec = board.spec(); + let refused = |reason| { + Err(CliError::BoardCannotRun { + board: spec.title, + reason, + }) }; - Err(CliError::BoardCannotRun { - board: board.spec().title, - reason, - }) + if !cluster.machines.is_empty() { + return refused("spans several machines"); + } + if matches!(cluster.artifact_source, ArtifactSource::Published) { + return refused("uses published artifacts, since the firmware is built from this repository"); + } + for agent in &cluster.agents { + let Some(runner) = agent.runner.as_deref().map(str::trim).filter(|kind| !kind.is_empty()) else { + continue; + }; + if spec.runners.is_empty() { + return refused("starts a runner, since the board has no memory to run one in"); + } + if !agent.limits.is_unset() { + return refused("sets limits on a runner, since a board has no way to hold one to them"); + } + if !spec.runners.contains(&runner) { + return Err(CliError::BoardRunnerUnsupported { + board: spec.title, + agent: agent.name.clone(), + runner: runner.to_owned(), + supported: spec.runners.join(", "), + }); + } + } + Ok(()) } /// Write the `mise.firmware-.toml` that builds the scenario's hub as `board` firmware and loads it onto one. /// /// The board serves the modules the scenario names, and what they depend on, from flash: each module from this /// repository or the scenario's own `module_paths:` is built into the firmware, and each npm package a mise tool -/// stages is redirected to a CDN instead, since onnxruntime-web alone is bigger than a board's flash. Selected with +/// stages is redirected to a CDN instead, since onnxruntime-web alone is bigger than a board's flash. A board +/// that runs runners also starts one per runner the scenario declares, on its module's component. Selected with /// `MISE_ENV=firmware-`, so it sits beside a `mise.toml` without either loading the other's tasks unasked. pub(crate) fn generate_firmware_deployment( cluster: &ClusterInput, @@ -109,7 +132,11 @@ pub(crate) fn generate_firmware_deployment( Ok(()) } -/// The `MODULES_PATHS` assignments naming the modules built in, and the rest of the `MODULES_*` environment. +/// The `MODULES_PATHS` assignments naming the modules built in, and the rest of the build's environment. +/// +/// That is the other `MODULES_*` variables, and `RUNNER_GUESTS`: a `name=package` pair for each runner the board +/// starts, naming the module built in whose component the firmware compiles for it. Set even when empty, so a build +/// never picks runners up from the shell it was started in. #[expect( clippy::single_call_fn, reason = "the module-resolving step of generate_firmware_deployment, kept apart from writing the file" @@ -117,8 +144,12 @@ pub(crate) fn generate_firmware_deployment( fn module_env(cluster: &ClusterInput, workspace_root: &Path) -> Result<(String, Table), CliError> { let ws_server_dir = workspace_root.join("services/ws-server"); let module_names = cluster_module_names(cluster); - // With no runner allowed, every agent is one the page runs, so the board always serves the page. + // The page is served whether or not the board runs agents of its own, since a browser can still join it. let scenario = ScenarioModules::new(&ws_server_dir, &module_names, true).with_module_paths(&cluster.module_paths); + let guests = scenario_runners(cluster)? + .into_iter() + .map(|runner| format!("{}={}", runner.name, runner.module)) + .collect::>(); let (registry, modules) = registry_and_modules(&scenario); let mut embedded = Vec::new(); @@ -140,5 +171,6 @@ fn module_env(cluster: &ClusterInput, workspace_root: &Path) -> Result<(String, set("MODULES_ROOT", super::hub_root_module(&ws_server_dir)); set("MODULES_ELSEWHERE", elsewhere.join(", ")); set("MODULES_ELSEWHERE_URL", ELSEWHERE_URL.to_owned()); + set("RUNNER_GUESTS", guests.join(", ")); Ok((wrap_module_paths(&embedded), env)) } diff --git a/utilities/cli/src/deployment_types/scenario_image.rs b/utilities/cli/src/deployment_types/scenario_image.rs index 72e84b4a..086ea82d 100644 --- a/utilities/cli/src/deployment_types/scenario_image.rs +++ b/utilities/cli/src/deployment_types/scenario_image.rs @@ -359,7 +359,7 @@ fn render_dockerfile(cluster_name: &str, published: bool, sources: &ImageSources if !sources.released.is_empty() { out.push_str("COPY --from=released --chown=10001:10001 /staged/app /app\n"); } - for (_, _, docker_path) in &sources.mise_tools { + for (.., docker_path) in &sources.mise_tools { let _write_result = writeln!( out, "COPY --from=deps --chown=10001:10001 /staged{docker_path} {docker_path}" @@ -445,13 +445,13 @@ fn render_released_stage(released: &[(String, String)]) -> String { fn render_deps_stage(mise_tools: &[(String, String, String)]) -> String { let tools = mise_tools .iter() - .map(|(tool, _, _)| tool.as_str()) + .map(|(tool, ..)| tool.as_str()) .collect::>() .join(" "); let mut stage = String::default(); stage.push_str(DEPS_BASE_STAGE); let _write_result = writeln!(stage, "ENV MISE_ENV={DEPS_MISE_ENV}"); - if mise_tools.iter().any(|(tool, _, _)| tool.starts_with("npm:")) { + if mise_tools.iter().any(|(tool, ..)| tool.starts_with("npm:")) { // mise's npm backend shells out to npm, and refuses an `npm:` tool whose configured `node` dependency is // not installed yet ("requires configured install dependency 'node@22', but its selected version is not // installed"). Its version comes from the repo's own [tools] pin, like every other tool staged here. The diff --git a/utilities/cli/src/error.rs b/utilities/cli/src/error.rs index 5bdd8879..0fa1076a 100644 --- a/utilities/cli/src/error.rs +++ b/utilities/cli/src/error.rs @@ -172,9 +172,17 @@ pub enum CliError { )] ScheduleUnsupported { agent: String, output_type: String }, - #[error("The {board} firmware runs one hub and nothing else, so it cannot deploy a scenario that {reason}")] + #[error("The {board} firmware cannot deploy a scenario that {reason}")] BoardCannotRun { board: &'static str, reason: &'static str }, + #[error("Agent {agent:?} names the {runner:?} runner, but the {board} firmware runs only {supported}")] + BoardRunnerUnsupported { + board: &'static str, + agent: String, + runner: String, + supported: String, + }, + #[error("Module path {0:?} has a name a shell would interpret or a filesystem would not hold; rename it")] UnsafeModulePath(PathBuf), diff --git a/utilities/cli/src/input.rs b/utilities/cli/src/input.rs index 807ff584..13e83735 100644 --- a/utilities/cli/src/input.rs +++ b/utilities/cli/src/input.rs @@ -50,18 +50,23 @@ pub enum OutputType { /// The hub alone, as firmware for an ESP32-C6. #[serde(rename = "firmware-esp32c6", alias = "firmware_esp32c6")] FirmwareEsp32c6, + /// The hub and the scenario's wasi runners, as firmware for a Pimoroni Pico Plus 2 W. + #[serde(rename = "firmware-pico-plus2", alias = "firmware_pico_plus2")] + FirmwarePicoPlus2, } /// A board a `firmware-*` format builds the hub for, each the board of exactly one format. /// -/// Only boards with a network of their own are supported, so a board's name leaves out the suffix its vendor gives -/// the networked variant: `pico2` is the Pico 2 W. +/// Only boards with a network of their own are supported, so a board's name leaves out the suffix its vendor gives the +/// networked variant: `pico2` is the Pico 2 W. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum Board { /// The Raspberry Pi Pico 2 W, over its own Wi-Fi. Pico2, /// Any ESP32-C6 board, over the chip's own Wi-Fi. Esp32c6, + /// The Pimoroni Pico Plus 2 W, over its own Wi-Fi, with the PSRAM a guest's memory needs. + PicoPlus2, } /// What building, loading and describing one board's firmware takes. @@ -85,11 +90,16 @@ pub(crate) struct BoardSpec { pub network_setup: &'static str, /// How big the board's flash is, which bounds what can be built in. pub flash: &'static str, + /// The runner kinds the board's firmware runs beside the hub, none for a board without the memory a guest needs. + /// + /// What a guest may import and how much memory it may take, the firmware's build decides, reading the guest it + /// builds in: the deployment is generated before the module is necessarily built, so this is only the kind. + pub runners: &'static [&'static str], } impl Board { /// Every board, each with a `firmware-*` format of its own. - pub(crate) const ALL: &'static [Self] = &[Self::Pico2, Self::Esp32c6]; + pub(crate) const ALL: &'static [Self] = &[Self::Pico2, Self::Esp32c6, Self::PicoPlus2]; /// What building, loading and describing this board's firmware takes. #[must_use] @@ -105,6 +115,7 @@ impl Board { load_preparation: "hold the board's BOOTSEL button while plugging it in", network_setup: WIFI_SETUP, flash: "4 MiB", + runners: &[], }, Self::Esp32c6 => BoardSpec { output_type: OutputType::FirmwareEsp32c6, @@ -116,6 +127,19 @@ impl Board { load_preparation: "plug the board in over USB, where espflash finds it", network_setup: WIFI_SETUP, flash: "4 MiB", + runners: &[], + }, + Self::PicoPlus2 => BoardSpec { + output_type: OutputType::FirmwarePicoPlus2, + title: "Pimoroni Pico Plus 2 W", + build_task: "build-firmware-pico-plus2w", + package: FIRMWARE_PACKAGE, + image: "target/thumbv8m.main-none-eabihf/release/et-ws-server-firmware", + load_command: "picotool load -x -t elf", + load_preparation: "hold the board's BOOTSEL button while plugging it in", + network_setup: WIFI_SETUP, + flash: "16 MiB", + runners: &["wasi"], }, } } @@ -125,9 +149,9 @@ impl OutputType { /// Every format, in the order a generated README lists their files and run sections. /// /// The order is what running each takes and what it offers. The three that need no container runtime come first: - /// mise, a plain CLI; dekit, a TUI; then process-compose, a TUI that can also schedule. Compose follows because it - /// needs Docker, and k3s because it needs a Kubernetes cluster such as Rancher Desktop's. The firmware is last, - /// because it needs a board, and because it deploys only the hub. + /// mise, a plain CLI; dekit, a TUI; then process-compose, a TUI that can also schedule. Compose follows because + /// it needs Docker, and k3s because it needs a Kubernetes cluster such as Rancher Desktop's. The firmware is last, + /// because it needs a board, and because it deploys the hub and at most the runners the board can hold. pub const ALL: &'static [Self] = &[ Self::Mise, Self::Dekit, @@ -136,6 +160,7 @@ impl OutputType { Self::K3s, Self::FirmwarePico2, Self::FirmwareEsp32c6, + Self::FirmwarePicoPlus2, ]; /// The file this format is written to, beside the others in the deployment directory. @@ -149,6 +174,7 @@ impl OutputType { Self::ProcessCompose => "process-compose.yaml", Self::FirmwarePico2 => "mise.firmware-pico2.toml", Self::FirmwareEsp32c6 => "mise.firmware-esp32c6.toml", + Self::FirmwarePicoPlus2 => "mise.firmware-pico-plus2.toml", } } @@ -163,18 +189,19 @@ impl OutputType { Self::ProcessCompose => "process-compose", Self::FirmwarePico2 => "firmware-pico2", Self::FirmwareEsp32c6 => "firmware-esp32c6", + Self::FirmwarePicoPlus2 => "firmware-pico-plus2", } } /// Whether this format can start an agent on its `schedule:`. /// /// mise and dekit have no scheduler of their own, so a scheduled agent is refused rather than run continuously, and - /// the board runs no agents at all. + /// a board starts what it runs once, at boot. #[must_use] pub const fn runs_schedules(self) -> bool { !matches!( self, - Self::Mise | Self::Dekit | Self::FirmwarePico2 | Self::FirmwareEsp32c6 + Self::Mise | Self::Dekit | Self::FirmwarePico2 | Self::FirmwareEsp32c6 | Self::FirmwarePicoPlus2 ) } diff --git a/utilities/cli/src/lib.rs b/utilities/cli/src/lib.rs index c9d29a52..3cc24585 100644 --- a/utilities/cli/src/lib.rs +++ b/utilities/cli/src/lib.rs @@ -655,6 +655,7 @@ fn generate_format( } OutputType::FirmwarePico2 => generate_firmware_deployment(cluster, output_dir, Board::Pico2), OutputType::FirmwareEsp32c6 => generate_firmware_deployment(cluster, output_dir, Board::Esp32c6), + OutputType::FirmwarePicoPlus2 => generate_firmware_deployment(cluster, output_dir, Board::PicoPlus2), } } @@ -1134,15 +1135,28 @@ fn generated_run_instructions( OutputType::K3s => k3s_run_instructions(cluster, image_build, runners, artifacts, build_contexts), OutputType::FirmwarePico2 => firmware_run_instructions(Board::Pico2.spec()), OutputType::FirmwareEsp32c6 => firmware_run_instructions(Board::Esp32c6.spec()), + OutputType::FirmwarePicoPlus2 => firmware_run_instructions(Board::PicoPlus2.spec()), } } /// Render the README section that builds the hub as firmware for `board` and loads it onto one. fn firmware_run_instructions(board: BoardSpec) -> String { + let runs = if board.runners.is_empty() { + "The board runs the hub alone and".to_owned() + } else { + format!( + concat!( + "The board runs the hub, and beside it each {} runner the scenario declares, compiled into the\n", + "firmware from its module's component. The build refuses a runner that imports what the board\n", + "does not host, or runners needing more memory than it holds.\nThe board" + ), + board.runners.join(", ") + ) + }; format!( concat!( "## Run As {title} Firmware\n\n", - "The board runs the hub alone and serves this scenario's modules from its {flash} of flash.\n", + "{runs} serves this scenario's modules from its {flash} of flash.\n", "Build those modules first, since the firmware takes each one's built package.\n", "{network}\n", "Then {preparation}.\n", @@ -1161,6 +1175,7 @@ fn firmware_run_instructions(board: BoardSpec) -> String { "able to reach.\n" ), title = board.title, + runs = runs, flash = board.flash, network = board.network_setup, preparation = board.load_preparation, @@ -1273,7 +1288,9 @@ fn machine_instructions(output_type: OutputType, cluster: &ClusterInput) -> Stri ), OutputType::K3s => return k3s_machine_instructions(cluster, server), // Refused before anything is written: the board is one machine. - OutputType::FirmwarePico2 | OutputType::FirmwareEsp32c6 => return String::default(), + OutputType::FirmwarePico2 | OutputType::FirmwareEsp32c6 | OutputType::FirmwarePicoPlus2 => { + return String::default(); + } }; let roles = machine_roles(cluster, server); let loopback = loopback_note(cluster); diff --git a/utilities/cli/src/limits.rs b/utilities/cli/src/limits.rs index d96067ea..df237ec4 100644 --- a/utilities/cli/src/limits.rs +++ b/utilities/cli/src/limits.rs @@ -540,8 +540,12 @@ pub(crate) fn limits_note(output_type: OutputType, cluster: &ClusterInput) -> St // Named per format, because every format's section lands in the one README and markdownlint rejects a repeated // heading. let heading = match output_type { - // The board runs no agent, and only the one hub, which has nothing else to be limited against. - OutputType::DockerCompose | OutputType::K3s | OutputType::FirmwarePico2 | OutputType::FirmwareEsp32c6 => { + // A board runs no agent, or refuses a runner that sets limits, since it has no way to hold one to them. + OutputType::DockerCompose + | OutputType::K3s + | OutputType::FirmwarePico2 + | OutputType::FirmwareEsp32c6 + | OutputType::FirmwarePicoPlus2 => { return String::default(); } OutputType::Mise => "Mise", diff --git a/utilities/cli/tests/firmware.rs b/utilities/cli/tests/firmware.rs index 81d4e4b3..50a576db 100644 --- a/utilities/cli/tests/firmware.rs +++ b/utilities/cli/tests/firmware.rs @@ -1,6 +1,6 @@ //! Covers the firmware formats, mostly through `firmware-pico2`: which modules a firmware build embeds and which it -//! redirects, the scenarios it refuses because a board runs one hub and nothing else, and what `firmware-esp32c6` -//! builds and loads differently. +//! redirects, the scenarios a board refuses, what `firmware-esp32c6` builds and loads differently, and the wasi runners +//! `firmware-pico-plus2` starts beside its hub. #![cfg(test)] use std::path::PathBuf; @@ -17,6 +17,19 @@ agents: - type: "math1" "#; +/// Two wasi runners, the math1 sender and the twin it feeds, which a board with the memory runs beside its hub. +const WASI: &str = r#"cluster_name: "board" +agents: + - name: "math1-trigger" + runner: "wasi" + resources: + - type: "wasi-math1-sender" + - name: "wasi-math1-twin" + runner: "wasi" + resources: + - type: "wasi-math1" +"#; + /// The file `firmware-pico2` writes. const CONFIG_FILE: &str = "mise.firmware-pico2.toml"; @@ -85,6 +98,7 @@ fn it_embeds_the_repository_modules_and_redirects_the_npm_packages() { assert_eq!(build.env["MODULES_ROOT"], "@edge-toolkit/et-ws-server-static"); assert_eq!(build.env["MODULES_ELSEWHERE"], "onnxruntime-web, stats-gl"); assert_eq!(build.env["MODULES_ELSEWHERE_URL"], "https://cdn.jsdelivr.net/npm"); + assert_eq!(build.env["RUNNER_GUESTS"], ""); assert_eq!(config.tasks.flash.depends, ["firmware-build"]); assert_eq!(config.tasks.flash.dir, build.dir); @@ -132,8 +146,8 @@ fn it_embeds_a_module_the_scenario_keeps_beside_it() { r#"{"name": "mine", "main": "mine.js"}"#, ) .unwrap(); - // Named relative to the scenario, as `module_paths:` is meant to be; the temporary directory holding both, which - // on Windows runs through `RUNNER~1`, is not the scenario author's to name. + // Named relative to the scenario, as `module_paths:` is meant to be; the temporary directory holding both, which on + // Windows runs through `RUNNER~1`, is not the scenario author's to name. let input = format!("{BROWSER}module_paths: [\"mine\"]\n").replace(r#"type: "math1""#, r#"type: "mine""#); let module_path = module_dir.display().to_string(); @@ -147,17 +161,73 @@ fn it_refuses_what_the_board_cannot_run() { let machines = format!("{BROWSER}machines:\n - name: \"server\"\n address: \"10.0.0.1\"\n"); let published = format!("{BROWSER}artifact_source: \"published\"\n"); for (input, reason) in [ - (runner, "starts a runner"), - (machines, "spans several machines"), - (published, "uses published artifacts"), + (&runner, "starts a runner"), + (&WASI.to_owned(), "starts a runner"), + (&machines, "spans several machines"), + (&published, "uses published artifacts"), ] { - let (_root, output_dir, outcome) = generate_in(tempdir().unwrap(), &input, OutputType::FirmwarePico2); - assert!(outcome.starts_with("The Raspberry Pi Pico 2 W firmware runs one hub and nothing else")); + let (_root, output_dir, outcome) = generate_in(tempdir().unwrap(), input, OutputType::FirmwarePico2); + assert!(outcome.starts_with("The Raspberry Pi Pico 2 W firmware cannot deploy a scenario that")); assert!(outcome.contains(reason)); assert!(!output_dir.join(CONFIG_FILE).exists()); } } +#[test] +fn a_pico_plus_2_w_starts_each_wasi_runner_beside_its_hub() { + let (_root, config, readme) = generated_in(tempdir().unwrap(), WASI, OutputType::FirmwarePicoPlus2); + let build = &config.tasks.build; + assert_eq!( + build.env["RUNNER_GUESTS"], + "math1-trigger=@edge-toolkit/et-ws-wasi-math1-sender, wasi-math1-twin=@edge-toolkit/et-ws-wasi-math1" + ); + for module in [ + "services/ws-modules/wasi-math1", + "services/ws-modules/wasi-math1-sender", + ] { + assert!(build.run.contains(&format!("$PWD/{module}"))); + } + assert!(build.run.contains("mise -E rust run build-firmware-pico-plus2w")); + assert_eq!(config.tasks.host_test.env, build.env); + assert!(config.tasks.flash.run.starts_with("picotool load")); + assert!(readme.contains("## Run As Pimoroni Pico Plus 2 W Firmware")); + assert!(readme.contains("The board runs the hub, and beside it each wasi runner the scenario declares")); + assert!(readme.contains("MISE_ENV=firmware-pico-plus2 mise run firmware-flash")); +} + +#[test] +fn a_pico_plus_2_w_refuses_a_runner_its_firmware_does_not_host() { + let web = WASI.replacen(r#"runner: "wasi""#, r#"runner: "web""#, 1); + let limited = WASI.replacen( + " resources:", + " limits:\n memory: \"64m\"\n resources:", + 1, + ); + let published = format!("{WASI}artifact_source: \"published\"\n"); + for (input, refusal) in [ + ( + web, + r#"Agent "math1-trigger" names the "web" runner, but the Pimoroni Pico Plus 2 W firmware runs only wasi"#, + ), + ( + limited, + "The Pimoroni Pico Plus 2 W firmware cannot deploy a scenario that sets limits on a runner", + ), + ( + published, + "The Pimoroni Pico Plus 2 W firmware cannot deploy a scenario that uses published artifacts", + ), + ] { + let (_root, output_dir, outcome) = generate_in(tempdir().unwrap(), &input, OutputType::FirmwarePicoPlus2); + assert!(outcome.starts_with(refusal)); + assert!( + !output_dir + .join(OutputType::FirmwarePicoPlus2.output_file_name()) + .exists() + ); + } +} + #[test] fn verification_renders_it_only_for_a_scenario_the_board_can_run() { let test_root = tempdir().unwrap(); @@ -168,13 +238,21 @@ fn verification_renders_it_only_for_a_scenario_the_board_can_run() { let headless = BROWSER.replace(" resources:", " runner: \"web\"\n resources:"); fs::write(input_dir.join("headless.yaml"), headless).unwrap(); + fs::write(input_dir.join("wasi.yaml"), WASI).unwrap(); + let _regenerated = regenerate_verification(&verification_root, None).unwrap(); let output = verification_root.join("local/output"); let esp_file = OutputType::FirmwareEsp32c6.output_file_name(); + let plus_file = OutputType::FirmwarePicoPlus2.output_file_name(); assert!(output.join("browser").join(CONFIG_FILE).is_file()); assert!(output.join("browser").join(esp_file).is_file()); + assert!(output.join("browser").join(plus_file).is_file()); assert!(output.join("browser/mise.toml").is_file()); - assert!(!output.join("headless").join(CONFIG_FILE).exists()); - assert!(!output.join("headless").join(esp_file).exists()); + for unsupported in [CONFIG_FILE, esp_file, plus_file] { + assert!(!output.join("headless").join(unsupported).exists()); + } assert!(output.join("headless/mise.toml").is_file()); + assert!(!output.join("wasi").join(CONFIG_FILE).exists()); + assert!(!output.join("wasi").join(esp_file).exists()); + assert!(output.join("wasi").join(plus_file).is_file()); } diff --git a/utilities/cli/tests/input.rs b/utilities/cli/tests/input.rs index 88f63c4b..c90603a8 100644 --- a/utilities/cli/tests/input.rs +++ b/utilities/cli/tests/input.rs @@ -121,6 +121,16 @@ fn every_deployment_type_spelling_deserializes_to_its_own_output_file() { OutputType::FirmwareEsp32c6, "mise.firmware-esp32c6.toml", ), + ( + "firmware-pico-plus2", + OutputType::FirmwarePicoPlus2, + "mise.firmware-pico-plus2.toml", + ), + ( + "firmware_pico_plus2", + OutputType::FirmwarePicoPlus2, + "mise.firmware-pico-plus2.toml", + ), ] { let cluster = parse(&format!("{ONLY_A_NAME}deployment_type: {spelling}\n")); @@ -131,11 +141,15 @@ fn every_deployment_type_spelling_deserializes_to_its_own_output_file() { cluster.deployment_type.runs_mise_tasks(), !matches!( expected, - OutputType::DockerCompose | OutputType::K3s | OutputType::FirmwarePico2 | OutputType::FirmwareEsp32c6 + OutputType::DockerCompose + | OutputType::K3s + | OutputType::FirmwarePico2 + | OutputType::FirmwareEsp32c6 + | OutputType::FirmwarePicoPlus2 ) ); } - assert_eq!(OutputType::ALL.len(), 7, "and the list of them is complete"); + assert_eq!(OutputType::ALL.len(), 8, "and the list of them is complete"); } #[test] diff --git a/utilities/cli/tests/machines.rs b/utilities/cli/tests/machines.rs index 7832d4ac..0adc2b16 100644 --- a/utilities/cli/tests/machines.rs +++ b/utilities/cli/tests/machines.rs @@ -2,8 +2,8 @@ //! //! The fixture splits the math1 pair the way `verification/local/input/math1-multi.yaml` does -- the trigger on the //! server with the hub, the twin on `edge` -- plus a third agent no machine lists, which has to land on the server. -//! The single-host half of each test is what keeps the option opt-in: a scenario without `machines:` must render -//! none of it. +//! The single-host half of each test is what keeps the option opt-in: a scenario without `machines:` must render none +//! of it. #![cfg(test)] use std::collections::BTreeMap; @@ -288,7 +288,11 @@ fn the_readme_says_how_to_run_each_machine_share() { let readme = read(&dir, "README.md"); // Every format but the boards', which refuse a scenario spanning machines. - let boards = [OutputType::FirmwarePico2, OutputType::FirmwareEsp32c6]; + let boards = [ + OutputType::FirmwarePico2, + OutputType::FirmwareEsp32c6, + OutputType::FirmwarePicoPlus2, + ]; assert_eq!( readme.matches("### Run Across Machines With").count(), OutputType::ALL.len() - boards.len() diff --git a/utilities/cli/tests/recovery_regeneration.rs b/utilities/cli/tests/recovery_regeneration.rs index dea5b670..36a737c3 100644 --- a/utilities/cli/tests/recovery_regeneration.rs +++ b/utilities/cli/tests/recovery_regeneration.rs @@ -38,11 +38,14 @@ fn verification_generation_limits_recovery_scenarios_to_supported_formats() { assert!(readme.contains("services/recovery-supervisor/Dockerfile")); } -/// Every format a scenario with runners renders: all but the boards', which run none. +/// Every format this scenario's runners render: all but the boards', which run none of them or no pyo3 runner. fn runner_formats() -> impl Iterator { - OutputType::ALL - .iter() - .filter(|kind| !matches!(kind, OutputType::FirmwarePico2 | OutputType::FirmwareEsp32c6)) + OutputType::ALL.iter().filter(|kind| { + !matches!( + kind, + OutputType::FirmwarePico2 | OutputType::FirmwareEsp32c6 | OutputType::FirmwarePicoPlus2 + ) + }) } /// Start with actual outputs from every generator, rather than fabricated files that may not match their layout. diff --git a/verification/local/output/default/README.md b/verification/local/output/default/README.md index 7eae0e29..4b930de3 100644 --- a/verification/local/output/default/README.md +++ b/verification/local/output/default/README.md @@ -2,7 +2,7 @@ This directory contains generated deployment configs for the `default` scenario. Files: `mise.toml`, `dekit.yaml`, `process-compose.yaml`, `compose.yaml`, `k3s.yaml`, `mise.firmware-pico2.toml`, -`mise.firmware-esp32c6.toml`. +`mise.firmware-esp32c6.toml`, `mise.firmware-pico-plus2.toml`. No workflow modules were selected in the scenario input. @@ -201,3 +201,30 @@ The board takes an address over DHCP and serves the page at port 8080 of it. npm 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. + +## Run As Pimoroni Pico Plus 2 W Firmware + +The board runs the hub, and beside it each wasi runner the scenario declares, 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/default/mise.firmware-esp32c6.toml b/verification/local/output/default/mise.firmware-esp32c6.toml index 265cd76f..2fa09c1b 100644 --- a/verification/local/output/default/mise.firmware-esp32c6.toml +++ b/verification/local/output/default/mise.firmware-esp32c6.toml @@ -11,6 +11,7 @@ mise -E rust run build-firmware-esp32c6 MODULES_ELSEWHERE = "onnxruntime-web, stats-gl" MODULES_ELSEWHERE_URL = "https://cdn.jsdelivr.net/npm" MODULES_ROOT = "@edge-toolkit/et-ws-server-static" +RUNNER_GUESTS = "" [tasks.firmware-flash] depends = ["firmware-build"] @@ -31,3 +32,4 @@ mise run cargo-test-pkg et-int-ws-server-firmware MODULES_ELSEWHERE = "onnxruntime-web, stats-gl" MODULES_ELSEWHERE_URL = "https://cdn.jsdelivr.net/npm" MODULES_ROOT = "@edge-toolkit/et-ws-server-static" +RUNNER_GUESTS = "" diff --git a/verification/local/output/default/mise.firmware-pico-plus2.toml b/verification/local/output/default/mise.firmware-pico-plus2.toml new file mode 100644 index 00000000..1cb4fdde --- /dev/null +++ b/verification/local/output/default/mise.firmware-pico-plus2.toml @@ -0,0 +1,35 @@ +[tasks.firmware-build] +description = "Build the Pimoroni Pico Plus 2 W firmware serving default" +dir = "../../../.." +run = """ +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 = "" + +[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 default's modules from this host as the board would, and fetch each" +dir = "../../../.." +run = """ +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 = "" diff --git a/verification/local/output/default/mise.firmware-pico2.toml b/verification/local/output/default/mise.firmware-pico2.toml index 1cce7973..e449fd18 100644 --- a/verification/local/output/default/mise.firmware-pico2.toml +++ b/verification/local/output/default/mise.firmware-pico2.toml @@ -11,6 +11,7 @@ mise -E rust run build-firmware-pico2w MODULES_ELSEWHERE = "onnxruntime-web, stats-gl" MODULES_ELSEWHERE_URL = "https://cdn.jsdelivr.net/npm" MODULES_ROOT = "@edge-toolkit/et-ws-server-static" +RUNNER_GUESTS = "" [tasks.firmware-flash] depends = ["firmware-build"] @@ -31,3 +32,4 @@ mise run cargo-test-pkg et-int-ws-server-firmware MODULES_ELSEWHERE = "onnxruntime-web, stats-gl" MODULES_ELSEWHERE_URL = "https://cdn.jsdelivr.net/npm" MODULES_ROOT = "@edge-toolkit/et-ws-server-static" +RUNNER_GUESTS = "" diff --git a/verification/local/output/facility-security-scenario/README.md b/verification/local/output/facility-security-scenario/README.md index fe07f144..7c068a65 100644 --- a/verification/local/output/facility-security-scenario/README.md +++ b/verification/local/output/facility-security-scenario/README.md @@ -2,7 +2,7 @@ This directory contains generated deployment configs for the `facility-security-scenario` scenario. Files: `mise.toml`, `dekit.yaml`, `process-compose.yaml`, `compose.yaml`, `k3s.yaml`, `mise.firmware-pico2.toml`, -`mise.firmware-esp32c6.toml`. +`mise.firmware-esp32c6.toml`, `mise.firmware-pico-plus2.toml`. The scenario exposes these workflow modules: face-detection, har1, pyface1. @@ -201,3 +201,30 @@ The board takes an address over DHCP and serves the page at port 8080 of it. npm 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. + +## Run As Pimoroni Pico Plus 2 W Firmware + +The board runs the hub, and beside it each wasi runner the scenario declares, 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/facility-security-scenario/mise.firmware-esp32c6.toml b/verification/local/output/facility-security-scenario/mise.firmware-esp32c6.toml index b5697969..6ad0cf2e 100644 --- a/verification/local/output/facility-security-scenario/mise.firmware-esp32c6.toml +++ b/verification/local/output/facility-security-scenario/mise.firmware-esp32c6.toml @@ -15,6 +15,7 @@ mise -E rust run build-firmware-esp32c6 MODULES_ELSEWHERE = "onnxruntime-web, pyodide, stats-gl" MODULES_ELSEWHERE_URL = "https://cdn.jsdelivr.net/npm" MODULES_ROOT = "@edge-toolkit/et-ws-server-static" +RUNNER_GUESTS = "" [tasks.firmware-flash] depends = ["firmware-build"] @@ -39,3 +40,4 @@ mise run cargo-test-pkg et-int-ws-server-firmware MODULES_ELSEWHERE = "onnxruntime-web, pyodide, stats-gl" MODULES_ELSEWHERE_URL = "https://cdn.jsdelivr.net/npm" MODULES_ROOT = "@edge-toolkit/et-ws-server-static" +RUNNER_GUESTS = "" diff --git a/verification/local/output/facility-security-scenario/mise.firmware-pico-plus2.toml b/verification/local/output/facility-security-scenario/mise.firmware-pico-plus2.toml new file mode 100644 index 00000000..84aa06a3 --- /dev/null +++ b/verification/local/output/facility-security-scenario/mise.firmware-pico-plus2.toml @@ -0,0 +1,43 @@ +[tasks.firmware-build] +description = "Build the Pimoroni Pico Plus 2 W firmware serving facility-security-scenario" +dir = "../../../.." +run = """ +MODULES_PATHS="$PWD/data/model-modules/model-face1, $PWD/data/model-modules/model-har-motion1, $PWD/generated/python-ws" +MODULES_PATHS="$MODULES_PATHS, $PWD/libs/python-edge-toolkit, $PWD/libs/python-org" +MODULES_PATHS="$MODULES_PATHS, $PWD/services/ws-modules/face-detection, $PWD/services/ws-modules/har1" +MODULES_PATHS="$MODULES_PATHS, $PWD/services/ws-modules/pyface1, $PWD/services/ws-server/static" +MODULES_PATHS="$MODULES_PATHS, $PWD/services/ws-wasm-agent" +export MODULES_PATHS +mise -E rust run build-firmware-pico-plus2w +""" + +[tasks.firmware-build.env] +MODULES_ELSEWHERE = "onnxruntime-web, pyodide, stats-gl" +MODULES_ELSEWHERE_URL = "https://cdn.jsdelivr.net/npm" +MODULES_ROOT = "@edge-toolkit/et-ws-server-static" +RUNNER_GUESTS = "" + +[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 facility-security-scenario's modules from this host as the board would, and fetch each" +dir = "../../../.." +run = """ +MODULES_PATHS="$PWD/data/model-modules/model-face1, $PWD/data/model-modules/model-har-motion1, $PWD/generated/python-ws" +MODULES_PATHS="$MODULES_PATHS, $PWD/libs/python-edge-toolkit, $PWD/libs/python-org" +MODULES_PATHS="$MODULES_PATHS, $PWD/services/ws-modules/face-detection, $PWD/services/ws-modules/har1" +MODULES_PATHS="$MODULES_PATHS, $PWD/services/ws-modules/pyface1, $PWD/services/ws-server/static" +MODULES_PATHS="$MODULES_PATHS, $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, pyodide, stats-gl" +MODULES_ELSEWHERE_URL = "https://cdn.jsdelivr.net/npm" +MODULES_ROOT = "@edge-toolkit/et-ws-server-static" +RUNNER_GUESTS = "" diff --git a/verification/local/output/facility-security-scenario/mise.firmware-pico2.toml b/verification/local/output/facility-security-scenario/mise.firmware-pico2.toml index e775faed..cdbc6110 100644 --- a/verification/local/output/facility-security-scenario/mise.firmware-pico2.toml +++ b/verification/local/output/facility-security-scenario/mise.firmware-pico2.toml @@ -15,6 +15,7 @@ mise -E rust run build-firmware-pico2w MODULES_ELSEWHERE = "onnxruntime-web, pyodide, stats-gl" MODULES_ELSEWHERE_URL = "https://cdn.jsdelivr.net/npm" MODULES_ROOT = "@edge-toolkit/et-ws-server-static" +RUNNER_GUESTS = "" [tasks.firmware-flash] depends = ["firmware-build"] @@ -39,3 +40,4 @@ mise run cargo-test-pkg et-int-ws-server-firmware MODULES_ELSEWHERE = "onnxruntime-web, pyodide, stats-gl" MODULES_ELSEWHERE_URL = "https://cdn.jsdelivr.net/npm" MODULES_ROOT = "@edge-toolkit/et-ws-server-static" +RUNNER_GUESTS = "" diff --git a/verification/local/output/wasi-math1/README.md b/verification/local/output/wasi-math1/README.md index 13865916..fe8552b2 100644 --- a/verification/local/output/wasi-math1/README.md +++ b/verification/local/output/wasi-math1/README.md @@ -1,7 +1,7 @@ # wasi-math1 This directory contains generated deployment configs for the `wasi-math1` scenario. -Files: `mise.toml`, `dekit.yaml`, `process-compose.yaml`, `compose.yaml`, `k3s.yaml`. +Files: `mise.toml`, `dekit.yaml`, `process-compose.yaml`, `compose.yaml`, `k3s.yaml`, `mise.firmware-pico-plus2.toml`. The scenario exposes these workflow modules: wasi-math1, wasi-math1-sender. @@ -154,3 +154,30 @@ 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 declares, 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/wasi-math1/mise.firmware-pico-plus2.toml b/verification/local/output/wasi-math1/mise.firmware-pico-plus2.toml new file mode 100644 index 00000000..13384376 --- /dev/null +++ b/verification/local/output/wasi-math1/mise.firmware-pico-plus2.toml @@ -0,0 +1,37 @@ +[tasks.firmware-build] +description = "Build the Pimoroni Pico Plus 2 W firmware serving wasi-math1" +dir = "../../../.." +run = """ +MODULES_PATHS="$PWD/services/ws-modules/wasi-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, wasi-math1-twin=@edge-toolkit/et-ws-wasi-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 wasi-math1's modules from this host as the board would, and fetch each" +dir = "../../../.." +run = """ +MODULES_PATHS="$PWD/services/ws-modules/wasi-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, wasi-math1-twin=@edge-toolkit/et-ws-wasi-math1"