From 30177d2f7ccaa117a2a087934863d48967763c63 Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Thu, 29 Oct 2026 09:55:54 +0800 Subject: [PATCH] Allow building without std --- .github/workflows/no-std.yaml | 74 ++ .mise/config.rust.toml | 69 ++ .mise/config.toml | 2 +- .mise/config.windows.toml | 2 +- .mise/mise.lock | 2 +- CHANGELOG.md | 20 + CHECKS.md | 21 +- Cargo.lock | 1086 ++++++++++++++++- Cargo.toml | 40 +- README.md | 2 +- .../rules/no-env-macro-in-attribute.yaml | 5 +- config/ast-grep/rules/no-env-macro.yaml | 6 +- config/ast-grep/rules/no-env-var-literal.yaml | 14 + .../tests/no-env-macro-in-attribute.yaml | 13 + config/ast-grep/tests/no-env-macro.yaml | 17 + config/ast-grep/tests/no-env-var-literal.yaml | 14 + config/conftest/policy/jscpd/jscpd.rego | 2 +- config/deny.toml | 14 + config/jscpd-baseline.json | 46 +- config/osv-scanner.toml | 2 + config/upstream-cache/data.toml | 21 + generated/dart-rest/lib/clients/admin.dart | 4 + .../et_rest_client/api/admin/block_ip.py | 16 +- generated/rust-rest/Cargo.toml | 4 +- generated/rust-rest/src/lib.rs | 4 + generated/specs/rest.yaml | 4 + generated/zig-rest/src/et_rest_client.zig | 6 +- libs/actix-web-config/Cargo.toml | 2 +- libs/edge-toolkit/Cargo.toml | 100 +- libs/edge-toolkit/src/auth.rs | 9 +- libs/edge-toolkit/src/lib.rs | 5 + libs/edge-toolkit/src/ws.rs | 4 +- libs/edge-toolkit/src/ws_server.rs | 88 +- libs/edge-toolkit/tests/registry.rs | 96 +- libs/edge-toolkit/tests/registry_file.rs | 84 ++ libs/et-otlp/Cargo.toml | 8 +- libs/org/src/lib.rs | 1 + libs/test-otlp/Cargo.toml | 4 +- libs/test-recovery/Cargo.toml | 8 +- libs/ws-runner-common/Cargo.toml | 10 +- services/hub-backplane/Cargo.toml | 12 +- services/modules/Cargo.toml | 6 +- services/recovery-supervisor/Cargo.toml | 10 +- services/storage/Cargo.toml | 10 +- services/websockify/Cargo.toml | 4 +- services/ws-modules/audio1/Cargo.toml | 4 +- services/ws-modules/bluetooth/Cargo.toml | 4 +- services/ws-modules/comm1/Cargo.toml | 6 +- services/ws-modules/data1/Cargo.toml | 6 +- services/ws-modules/except1/Cargo.toml | 2 +- services/ws-modules/face-detection/Cargo.toml | 4 +- services/ws-modules/geolocation/Cargo.toml | 4 +- services/ws-modules/graphics-info/Cargo.toml | 4 +- services/ws-modules/har1/Cargo.toml | 4 +- services/ws-modules/llm1/Cargo.toml | 4 +- services/ws-modules/math1-sender/Cargo.toml | 2 +- services/ws-modules/math1/Cargo.toml | 6 +- services/ws-modules/nfc/Cargo.toml | 4 +- services/ws-modules/pic-viewer/Cargo.toml | 6 +- services/ws-modules/sensor1/Cargo.toml | 2 +- .../ws-modules/speech-recognition/Cargo.toml | 4 +- services/ws-modules/video1/Cargo.toml | 4 +- services/ws-modules/wasi-comm1/Cargo.toml | 2 +- services/ws-modules/wasi-math1/Cargo.toml | 4 +- services/ws-pyo3-runner/Cargo.toml | 8 +- services/ws-server-pico2w/Cargo.toml | 46 + services/ws-server-pico2w/README.md | 34 + services/ws-server-pico2w/build.rs | 79 ++ services/ws-server-pico2w/memory.x | 43 + services/ws-server-pico2w/src/firmware.rs | 187 +++ services/ws-server-pico2w/src/main.rs | 14 + services/ws-server/Cargo.toml | 90 +- services/ws-server/src/lib.rs | 16 +- services/ws-server/src/picoserve_server.rs | 50 + services/ws-server/src/routes.rs | 21 +- services/ws-server/tests/picoserve_server.rs | 110 ++ services/ws-test-server/Cargo.toml | 8 +- services/ws-wasi-runner/Cargo.toml | 12 +- services/ws-wasm-agent/Cargo.toml | 8 +- services/ws-web-runner/Cargo.toml | 8 +- services/ws/Cargo.toml | 93 +- services/ws/src/admin.rs | 86 +- services/ws/src/backplane.rs | 169 +++ services/ws/src/connection.rs | 692 ++++------- services/ws/src/hub.rs | 585 +++++++++ services/ws/src/lib.rs | 472 +------ services/ws/src/picoserve_hub.rs | 356 ++++++ services/ws/src/server.rs | 219 ++++ services/ws/src/telemetry.rs | 88 ++ services/ws/tests/admin.rs | 11 +- services/ws/tests/config.rs | 7 +- services/ws/tests/hub.rs | 384 ++++++ services/ws/tests/picoserve_hub.rs | 247 ++++ utilities/cli/Cargo.toml | 8 +- utilities/int-gen/Cargo.toml | 10 +- utilities/repo-check/Cargo.toml | 2 +- 96 files changed, 4908 insertions(+), 1302 deletions(-) create mode 100644 .github/workflows/no-std.yaml create mode 100644 config/ast-grep/rules/no-env-var-literal.yaml create mode 100644 config/ast-grep/tests/no-env-macro-in-attribute.yaml create mode 100644 config/ast-grep/tests/no-env-macro.yaml create mode 100644 config/ast-grep/tests/no-env-var-literal.yaml create mode 100644 libs/edge-toolkit/tests/registry_file.rs create mode 100644 services/ws-server-pico2w/Cargo.toml create mode 100644 services/ws-server-pico2w/README.md create mode 100644 services/ws-server-pico2w/build.rs create mode 100644 services/ws-server-pico2w/memory.x create mode 100644 services/ws-server-pico2w/src/firmware.rs create mode 100644 services/ws-server-pico2w/src/main.rs create mode 100644 services/ws-server/src/picoserve_server.rs create mode 100644 services/ws-server/tests/picoserve_server.rs create mode 100644 services/ws/src/backplane.rs create mode 100644 services/ws/src/hub.rs create mode 100644 services/ws/src/picoserve_hub.rs create mode 100644 services/ws/src/server.rs create mode 100644 services/ws/src/telemetry.rs create mode 100644 services/ws/tests/hub.rs create mode 100644 services/ws/tests/picoserve_hub.rs diff --git a/.github/workflows/no-std.yaml b/.github/workflows/no-std.yaml new file mode 100644 index 00000000..a9225e8a --- /dev/null +++ b/.github/workflows/no-std.yaml @@ -0,0 +1,74 @@ +--- +name: no-std + +# The crates that build without std, built where there is none, and et-ws-server as Raspberry Pi Pico 2 W firmware. +# One Linux job: a bare-metal target compiles the same on every host, so another platform would only repeat it. The +# board is not here, so the firmware is built and linted, not run; nothing is uploaded or published. +# +# Not path-gated: what breaks a no_std build is a dependency or a feature gaining std somewhere in the tree, which a +# path list would have to name every crate to catch. +"on": + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }} + +defaults: + run: + shell: bash --noprofile --norc -euo pipefail {0} + +env: + MISE_ENV: dart,dotnet,java,js,kotlin,python,r,rust,zig + +jobs: + no-std: + runs-on: ubuntu-latest + # Read-only pull of the maintainer-published ghcr.io mise-tools store (install-mise-tools restore step). + # Never write: the stores are published only from a maintainer's machine via `mise run push-mise-tools`. + permissions: + contents: read + packages: read + timeout-minutes: 120 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Show MISE_ENV + run: echo "MISE_ENV=$MISE_ENV" + + - name: Install mise + tools + uses: $/.github/actions/install-mise-tools + timeout-minutes: 60 + with: + github-token: ${{ github.token }} + + - name: Build the no_std crates for a bare-metal target + timeout-minutes: 20 + run: mise run build-no-std + + - name: Test the no_std crates built without std + timeout-minutes: 20 + run: mise run test-no-std + + - name: Fetch the Pico 2 W radio's firmware blobs + timeout-minutes: 5 + run: mise run fetch-cyw43-firmware-rclone + + # The firmware bakes in the network it joins, so CI builds it for one that does not exist. + - name: Build and lint the Pico 2 W firmware + timeout-minutes: 30 + env: + CYW43_FIRMWARE_DIR: ${{ github.workspace }}/target/cyw43-firmware + WIFI_NETWORK: edge-toolkit-ci + WIFI_PASSWORD: not-a-real-passphrase + run: | + mise run build-firmware-pico2w + mise run cargo-clippy-check-pico2w diff --git a/.mise/config.rust.toml b/.mise/config.rust.toml index 7619f7c4..8c07ca2a 100644 --- a/.mise/config.rust.toml +++ b/.mise/config.rust.toml @@ -8,6 +8,65 @@ # The rest of the Rust toolchain + tasks (the `rust` tool itself, cargo-*, the universal repo-wide checks, # ws-server, the e2e tests) still live in the default config; moving those out is a later pass. +# et-ws-server as Raspberry Pi Pico 2 W firmware: an ELF that picotool or probe-rs loads onto the board. +# Its build reads the radio's blobs from CYW43_FIRMWARE_DIR, and the network to join from WIFI_NETWORK and +# WIFI_PASSWORD; its name stays outside `build-modules`'s `build-ws-*` glob, since that has none of them. +[tasks.build-firmware-pico2w] +description = "Build et-ws-server as Raspberry Pi Pico 2 W firmware" +run = "cargo build --release -p et-int-ws-server-pico2w --target thumbv8m.main-none-eabihf" + +# 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. +[tasks.cargo-clippy-check-pico2w] +description = "Lint the Raspberry Pi Pico 2 W firmware for its own target" +run = "cargo clippy --release -p et-int-ws-server-pico2w --target thumbv8m.main-none-eabihf" + +# The Pico 2 W radio's firmware, CLM and NVRAM, from embassy's cyw43-firmware directory at the pinned commit. +# They land in the gitignored target/cyw43-firmware, for build-firmware-pico2w to read as CYW43_FIRMWARE_DIR, and +# each is checked against the SHA256 config/upstream-cache/data.toml records for it. +[tasks.fetch-cyw43-firmware-rclone] +description = "Fetch the Pico 2 W radio's firmware blobs into target/cyw43-firmware" +run = """ +set -- 43439A0.bin '{{ vars.cyw43_firmware_asset }}' 43439A0_clm.bin '{{ vars.cyw43_clm_asset }}' +set -- "$@" nvram_rp2040.bin '{{ vars.cyw43_nvram_asset }}' +while [ "$#" -gt 0 ]; do + file=$1 + export asset=$2 + shift 2 + dst="{{ vars.cyw43_dir }}/$file" + # shellcheck disable=SC2086 + rclone copyto "{{ vars.cyw43_repo }}/$file" "$dst" {{ vars.gh_http }} + sha=$(mise exec -- yq -p toml -oy '.asset[strenv(asset)].sha256' config/upstream-cache/data.toml) + actual=$(mise exec -- coreutils sha256sum -b "$dst" | mise exec -- coreutils cut -c1-64) + [ "$actual" = "$sha" ] || { + echo "cyw43 checksum mismatch for $file: expected=$sha actual=$actual" >&2 + exit 1 + } +done +""" +shell = "{{ vars.task_shell }}" + +# The crates that build without std, built for the Pico 2 W's bare-metal target, which has no std to fall back on. +# A host build of the same features can still link std through a dependency without anyone noticing; this cannot. +[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 +set -- -p et-org -p edge-toolkit -p et-ws-service -p et-ws-server +cargo build --lib --target thumbv8m.main-none-eabihf --no-default-features --features "$features" "$@" +""" +shell = "{{ vars.task_shell }}" + +# The tests of the no_std crates as they build without std, which runs only the test files that need none. +[tasks.test-no-std] +description = "Test the no_std crates built without std" +run = """ +features=et-ws-service/picoserve-hub,et-ws-server/picoserve-server +set -- -p edge-toolkit -p et-ws-service -p et-ws-server +cargo nextest run --config-file config/nextest.toml --no-default-features --features "$features" "$@" +""" +shell = "{{ vars.task_shell }}" + # Browser wasm-pack builds are coverage-instrumented when ET_TEST_COVERAGE is set. # The web_cov_wrapper RUSTC_WORKSPACE_WRAPPER (built by build-wasm-cov-wrapper) instruments only workspace # crates -- never dependency cdylibs such as wasm-streams that don't link minicov's @@ -155,3 +214,13 @@ coreutils cp "target/wasm32-wasip2/release/$wasm" "services/ws-modules/wasi-comm {{ vars.et_cli }} module-package-json --module-dir services/ws-modules/wasi-comm1 """ shell = "{{ vars.task_shell }}" + +[vars] +# The Pico 2 W radio's blobs, as fetch-cyw43-firmware-rclone fetches them. +# Each asset name carries embassy's short commit; config/upstream-cache/data.toml records the matching SHA256s, and +# checksums.rego cross-checks both sides. +cyw43_clm_asset = "cyw43-75f40b3-43439A0_clm.bin" +cyw43_dir = "target/cyw43-firmware" +cyw43_firmware_asset = "cyw43-75f40b3-43439A0.bin" +cyw43_nvram_asset = "cyw43-75f40b3-nvram_rp2040.bin" +cyw43_repo = ":http:embassy-rs/embassy/raw/75f40b383a14/cyw43-firmware" diff --git a/.mise/config.toml b/.mise/config.toml index 7b8430da..2201c0ee 100644 --- a/.mise/config.toml +++ b/.mise/config.toml @@ -227,7 +227,7 @@ zizmor = "latest" components = "clippy,rust-analyzer" os = ["linux", "macos"] profile = "minimal" -targets = "wasm32-unknown-unknown,wasm32-wasip2" +targets = "thumbv8m.main-none-eabihf,wasm32-unknown-unknown,wasm32-wasip2" # Use `stable`, not `latest`, so every lane resolves through the same rustup channel. # mise's `latest` picks the max of rust-lang/rust's GitHub Releases, which are published by separate # automation that lags the `stable` rustup channel the Windows [[tools.rust]] entries resolve through. That diff --git a/.mise/config.windows.toml b/.mise/config.windows.toml index 52868642..5cd89ddb 100644 --- a/.mise/config.windows.toml +++ b/.mise/config.windows.toml @@ -106,7 +106,7 @@ url = "https://github.com/edge-toolkit/core/releases/download/gnupg-w32-v1/2.5.2 [[tools.rust]] components = "clippy,rust-analyzer" profile = "minimal" -targets = "wasm32-unknown-unknown,wasm32-wasip2" +targets = "thumbv8m.main-none-eabihf,wasm32-unknown-unknown,wasm32-wasip2" version = "stable-{{ vars.win_rust_host }}" [[tools.rust]] diff --git a/.mise/mise.lock b/.mise/mise.lock index 1322a520..1b4521ec 100644 --- a/.mise/mise.lock +++ b/.mise/mise.lock @@ -1965,7 +1965,7 @@ specifiers = ["stable"] [tools.rust.options] components = "clippy,rust-analyzer" profile = "minimal" -targets = "wasm32-unknown-unknown,wasm32-wasip2" +targets = "thumbv8m.main-none-eabihf,wasm32-unknown-unknown,wasm32-wasip2" [[tools.taplo]] version = "0.10.0" diff --git a/CHANGELOG.md b/CHANGELOG.md index c65ed0bb..4c59e61f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ Changelog 1.1.0. ### Added +- `edge-toolkit` builds as `no_std` + `alloc` with `default-features = false`, keeping the WebSocket protocol types + in `ws`, `ports`, the `BasicAuth` struct and the in-memory agent registry of `ws_server`. The new default `std` + feature restores everything else, the registry file included. `et-org` is `no_std` unconditionally. +- `et-ws-service` builds as a `no_std` + `alloc` hub with `default-features = false`: `hub::Link` routes each agent's + frames through the registry and hands back what to send where, leaving the transport, ids and clock to the + embedder. Its features `std` (the actix-web hub), `nats` (the backplane) and `otel` (spans and metrics) are + separate. Without `nats`, `WsConfig` has no `backplane`. +- `et-ws-service`'s `picoserve-hub` feature, on by default, adds `picoserve_hub::Hub`: a `no_std` hub serving + `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"]`. +- `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. +- The `et-ws-server-pico2w` firmware runs et-ws-server on a Raspberry Pi Pico 2 W, over the board's Wi-Fi. + `MISE_ENV=rust mise run build-firmware-pico2w` builds it from `WIFI_NETWORK`, `WIFI_PASSWORD` and a + `CYW43_FIRMWARE_DIR` holding the radio's firmware blobs. - 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, @@ -62,6 +78,10 @@ Changelog 1.1.0. ### Changed +- **Breaking:** `et-ws-server`'s actix server, its binary and its environment config are behind the new default `std` + feature. A build with `default-features = false` and none of the features that imply `std` -- `modules`, `otlp`, + `qr`, `storage`, `tls`, `websockify` -- now gets the `no_std` library and no binary; add `std` to keep the server. + - The workspace requires actix-web 4.17. - **Breaking:** the minimum supported Rust version is 1.99.0, up from 1.91.1, for every published crate. - **Breaking:** an agent's IP address is always known. `AgentSummary::last_known_ip` and diff --git a/CHECKS.md b/CHECKS.md index ec3db909..5e08efbc 100644 --- a/CHECKS.md +++ b/CHECKS.md @@ -1,11 +1,11 @@ # Checks -- **Generated:** 2026-10-28 -- **Checks:** 78 +- **Generated:** 2026-10-30 +- **Checks:** 79 - **Tools:** 47 - **Shell-script checks:** 10 - **Checks with a repair pass:** 21 -- **Rules:** 359 custom local rules, or non-default strict linter settings +- **Rules:** 360 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 @@ -155,12 +155,17 @@ Rules, from `config/ast-grep/rules`: `et-ws-runner-common::config`) so every variable is declared in one typed place. Only these cargo-provided constants, which have no serde-env equivalent, are exempt: - `CARGO_BIN_EXE_` -- the built binary's path, injected by cargo for an integration test. - `CARGO_PKG_VERSION` / `CARGO_PKG_NAME` -- crate identity baked into the binary. - - `ET_WIT_DIR` -- the WIT directory injected at build time for wit-bindgen. `CARGO_MANIFEST_DIR` is deliberately NOT - exempt -- use `edge_toolkit::config::get_project_root()` at runtime (see the `no-cargo-manifest-dir` rule). + `ET_WIT_DIR` -- the WIT directory injected at build time for wit-bindgen. - `OUT_DIR` -- the build script's output + directory, for `include!`-ing what it generated. Configuration a bare-metal binary bakes in is read there, by its + `build.rs`, and generated as typed constants. `CARGO_MANIFEST_DIR` is deliberately NOT exempt -- use + `edge_toolkit::config::get_project_root()` at runtime (see the `no-cargo-manifest-dir` rule). - `no-env-macro-in-attribute` -- `env!` / `option_env!` inside an attribute are banned exactly as they are anywhere else, with the same allowlist of cargo-provided constants (`CARGO_BIN_EXE_`, `CARGO_PKG_VERSION`, - `CARGO_PKG_NAME`, `ET_WIT_DIR`). Read configuration through a `serde-env` struct instead, and locate repo files from - the project root rather than from `CARGO_MANIFEST_DIR`. + `CARGO_PKG_NAME`, `ET_WIT_DIR`, `OUT_DIR`). Read configuration through a `serde-env` struct instead, and locate repo + files from the project root rather than from `CARGO_MANIFEST_DIR`. +- `no-env-var-literal` -- `env::var` takes a named constant, never a string literal: a mistyped literal is a lookup that + silently fails, while a mistyped constant does not compile. Declare `const NAME: &str = "NAME";` beside the read. It + is the local twin of DeepSource RS-W1015. - `no-eq-bool-literal` -- `==`/`!=` with a `true` or `false` literal as either operand is a boolean written the long way. Use the boolean itself -- `x`, `!x` -- and when the value is not a `bool` (a parsed `serde_yaml::Value` field, say), convert it and keep the operator: `value == true` becomes `value.as_bool() == Some(true)`, and `value != false` @@ -327,6 +332,8 @@ Rules, from `config/ast-grep/rules`: - `cargo-clippy-check` (`default` env; run by `check:rust`). Repaired by `cargo-clippy-fix`. 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 packages (narrow inner-loop check). Config: `Cargo.toml`, `config/clippy.toml`. diff --git a/Cargo.lock b/Cargo.lock index 45e8167e..91697824 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -368,6 +368,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -455,7 +464,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -466,7 +475,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -520,12 +529,30 @@ dependencies = [ "serde", ] +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "ascii" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" +dependencies = [ + "term", +] + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -860,6 +887,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "az" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" + [[package]] name = "backon" version = "1.6.0" @@ -870,6 +903,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "bare-metal" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5deb64efa5bd81e31fcd1938615a6d98c82eafcbcd787162b6f63b91d6bac5b3" +dependencies = [ + "rustc_version 0.2.3", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -1018,6 +1060,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bitfield" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46afbd2983a5d5a7bd740ccb198caf5b82f45c40c09c0eed36052d91cb92e719" + [[package]] name = "bitflags" version = "1.3.2" @@ -1161,6 +1209,30 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bt-hci" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "211713d2e9fb4793ce4360a712c0764264aff6be48932ccf02ca2a331c0436a9" +dependencies = [ + "btuuid", + "defmt 1.2.0", + "embassy-sync", + "embedded-io 0.7.1", + "embedded-io-async 0.7.0", + "futures-intrusive", + "heapless 0.9.3", +] + +[[package]] +name = "btuuid" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f48f1e9b0aad0a4f05d17bdeae0fa20ff798e272a03a6940ca27ad9c5a6ae7" +dependencies = [ + "defmt 0.3.101", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -1281,7 +1353,7 @@ dependencies = [ "maybe-owned", "rustix 1.1.5", "rustix-linux-procfs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "winx", ] @@ -1391,9 +1463,11 @@ version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ + "defmt 1.2.0", "iana-time-zone", "js-sys", "num-traits", + "pure-rust-locales", "serde", "wasm-bindgen", "windows-link 0.2.1", @@ -1521,6 +1595,16 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width 0.1.14", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -1642,6 +1726,12 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "const-sha1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8a42181e0652c2997ae4d217f25b63c5337a52fd2279736e97b832fa0a3cff" + [[package]] name = "convert_case" version = "0.10.0" @@ -1677,6 +1767,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "cordyceps" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b9ab7e0ca1d179628fa0172b2b97203c7fa0cd81be2448bd446fb9559ca9261" +dependencies = [ + "loom", + "tracing", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1718,6 +1818,52 @@ dependencies = [ "libm", ] +[[package]] +name = "cortex-m" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "408ca8c6d703a196b73219fdb68fd8914d4fda90c34e2bd6859d4842f761bd5f" +dependencies = [ + "bare-metal", + "bitfield", + "cortex-m-macros", + "critical-section", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "volatile-register", +] + +[[package]] +name = "cortex-m-macros" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1922be58519ad40368fc4ca595a2cefa51a7abf947be3b0c90586dc7dbd0e2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cortex-m-rt" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f0f27b7ecbb9fad6702c8764d11d0b7245437de1575e34e39b2af95382f096" +dependencies = [ + "cortex-m-rt-macros", +] + +[[package]] +name = "cortex-m-rt-macros" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05cf9e0f899304705b85fda7b178fc383f2529ec2479693248b600e530d2327a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "countme" version = "3.0.1" @@ -2043,6 +2189,15 @@ version = "0.136.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b533db1679b4b728568bd490da6533cf50974c48a4d7534e78f1e0e02bc39769" +[[package]] +name = "crc-any" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46db9f663dfb869b80fcf59e32d7a80fc6c464a4f6328f3f06a00f5e36d05f8c" +dependencies = [ + "debug-helper", +] + [[package]] name = "crc-fast" version = "1.10.0" @@ -2244,7 +2399,7 @@ dependencies = [ "curve25519-dalek-derive", "digest 0.10.7", "fiat-crypto", - "rustc_version", + "rustc_version 0.4.1", "subtle", "zeroize", ] @@ -2260,6 +2415,40 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "cyw43" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "033e856bf6f38c96fee3e3f398cceff0ca2c2fae5db255bcd23495216c7047da" +dependencies = [ + "aligned", + "bt-hci", + "cortex-m", + "cortex-m-rt", + "defmt 1.2.0", + "embassy-futures", + "embassy-net-driver-channel", + "embassy-sync", + "embassy-time", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-io-async 0.7.0", + "futures", + "heapless 0.9.3", +] + +[[package]] +name = "cyw43-pio" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "768b74f36be804f7b1020938e711d91c0ab5a847cf620d3756245ae7e641e1e2" +dependencies = [ + "cyw43", + "defmt 1.2.0", + "embassy-rp", + "fixed", +] + [[package]] name = "darling" version = "0.20.11" @@ -2354,6 +2543,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" +[[package]] +name = "debug-helper" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80a4af69c60438a1a82af89d362f4729fd38db7b73f305a237636fad31ceb2bf" + [[package]] name = "debugid" version = "0.8.0" @@ -2404,6 +2599,16 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "defmt-rtt" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "214e79c85b13a733762150567c988c580cbf1ecdc07805a0478fe8ca2c8380c7" +dependencies = [ + "critical-section", + "defmt 1.2.0", +] + [[package]] name = "delegate-attr" version = "0.3.1" @@ -3950,7 +4155,7 @@ dependencies = [ "convert_case 0.10.0", "proc-macro2", "quote", - "rustc_version", + "rustc_version 0.4.1", "syn 2.0.119", "unicode-xid", ] @@ -4295,6 +4500,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_yaml", + "spin 0.10.1", "strum 0.28.0", "tempfile", "thiserror 2.0.21", @@ -4350,6 +4556,267 @@ dependencies = [ "zeroize", ] +[[package]] +name = "embassy-embedded-hal" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0641612053b2f34fc250bb63f6630ae75de46e02ade7f457268447081d709ce" +dependencies = [ + "embassy-futures", + "embassy-hal-internal 0.4.0", + "embassy-sync", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-storage", + "embedded-storage-async", + "nb 1.1.0", +] + +[[package]] +name = "embassy-executor" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0d3b15c9d7dc4fec1d8cb77112472fb008b3b28c51ad23838d83587a6d2f1e" +dependencies = [ + "cordyceps", + "cortex-m", + "critical-section", + "defmt 1.2.0", + "document-features", + "embassy-executor-macros", + "embassy-executor-timer-queue", +] + +[[package]] +name = "embassy-executor-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d11a246f53de5f97a387f40ac24726817cd0b6f833e7603baac784f29d6ff276" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "embassy-executor-timer-queue" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc328bf943af66b80b98755db9106bf7e7471b0cf47dc8559cd9a6be504cc9c" + +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-hal-internal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f10ce10a4dfdf6402d8e9bd63128986b96a736b1a0a6680547ed2ac55d55dba" +dependencies = [ + "num-traits", +] + +[[package]] +name = "embassy-hal-internal" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "568659fc53866d3d85c60fa33723fb751aa69e71507634fc2c19e7649432fb75" +dependencies = [ + "cortex-m", + "critical-section", + "defmt 1.2.0", + "num-traits", +] + +[[package]] +name = "embassy-net" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "347bc855bdbdf50ed9c5a1d80e8204badb0ba149b8732dde38e1e9708ed9d313" +dependencies = [ + "defmt 1.2.0", + "document-features", + "embassy-net-driver", + "embassy-sync", + "embassy-time", + "embedded-io-async 0.7.0", + "embedded-nal-async", + "heapless 0.9.3", + "managed", + "smoltcp", +] + +[[package]] +name = "embassy-net-driver" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524eb3c489760508f71360112bca70f6e53173e6fe48fc5f0efd0f5ab217751d" +dependencies = [ + "defmt 0.3.101", +] + +[[package]] +name = "embassy-net-driver-channel" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07d2eb9f05a6fc876500949856ea1be40773d866d8cb99384f72d0ae4568c16" +dependencies = [ + "embassy-futures", + "embassy-net-driver", + "embassy-sync", +] + +[[package]] +name = "embassy-rp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d98f472894c1ecffac5596c657af5305c57282d29e8746d7fec033951931bc8" +dependencies = [ + "cfg-if", + "chrono", + "cortex-m", + "cortex-m-rt", + "critical-section", + "defmt 1.2.0", + "document-features", + "embassy-embedded-hal", + "embassy-futures", + "embassy-hal-internal 0.5.0", + "embassy-sync", + "embassy-time", + "embassy-time-driver", + "embassy-time-queue-utils", + "embassy-usb-driver", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-hal-nb", + "embedded-io 0.7.1", + "embedded-io-async 0.7.0", + "embedded-storage", + "embedded-storage-async", + "fixed", + "nb 1.1.0", + "pio", + "rand_core 0.6.4", + "rand_core 0.9.5", + "rp-pac", + "rp2040-boot2", + "sha2-const-stable", + "smart-leds", +] + +[[package]] +name = "embassy-sync" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bbd85cf5a5ae56bdf26f618364af642d1d0a4e245cdd75cd9aabda382f65a81" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async 0.7.0", + "futures-core", + "futures-sink", + "heapless 0.9.3", +] + +[[package]] +name = "embassy-time" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "592b0c143ec626e821d4d90da51a2bd91d559d6c442b7c74a47d368c9e23d97a" +dependencies = [ + "cfg-if", + "critical-section", + "defmt 1.2.0", + "document-features", + "embassy-time-driver", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "futures-core", +] + +[[package]] +name = "embassy-time-driver" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ee71af1b3a0deaa53eaf2d39252f83504c853646e472400b763060389b9fcc9" +dependencies = [ + "document-features", +] + +[[package]] +name = "embassy-time-queue-utils" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "168297bf80aaf114b3c9ad589bf38b01b3009b9af7f97cd18086c5bbf96f5693" +dependencies = [ + "embassy-executor-timer-queue", + "heapless 0.9.3", +] + +[[package]] +name = "embassy-usb-driver" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa675c5f4349b6aa0fcffc4bf9b241f18cd11b97c1f8323273fb9a5449937fbd" +dependencies = [ + "defmt 1.2.0", + "embedded-io-async 0.6.1", + "embedded-io-async 0.7.0", +] + +[[package]] +name = "embedded-alloc" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2db634e07f552215f7a3572dc0a4d29760c637fcea5a8c18d2cc291f782c216c" +dependencies = [ + "critical-section", + "linked_list_allocator", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "embedded-hal-nb" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba4268c14288c828995299e59b12babdbe170f6c6d73731af1b4648142e8605" +dependencies = [ + "embedded-hal 1.0.0", + "nb 1.1.0", +] + [[package]] name = "embedded-io" version = "0.4.0" @@ -4362,6 +4829,77 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "embedded-io" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" +dependencies = [ + "defmt 1.2.0", +] + +[[package]] +name = "embedded-io-async" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff09972d4073aa8c299395be75161d582e7629cd663171d62af73c8d50dba3f" +dependencies = [ + "embedded-io 0.6.1", +] + +[[package]] +name = "embedded-io-async" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0" +dependencies = [ + "defmt 1.2.0", + "embedded-io 0.7.1", +] + +[[package]] +name = "embedded-nal" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56a28be191a992f28f178ec338a0bf02f63d7803244add736d026a471e6ed77" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "embedded-nal-async" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb5a1bd585135d302f8f6d7de329310938093da6271b37a6c94b8798795c0c6d" +dependencies = [ + "embedded-io-async 0.7.0", + "embedded-nal", +] + +[[package]] +name = "embedded-storage" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c815b3ed4213d85d6cfd274b871f430c0681084e28dfd4a537877f47f844ec83" + +[[package]] +name = "embedded-storage-async" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69aa7787b8ba0cf4e626c95839f811d853b75e24a0f7312a1eef324fedc13b21" +dependencies = [ + "embedded-storage", +] + +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -4402,7 +4940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4521,7 +5059,7 @@ dependencies = [ "regex", "reqwest", "schemars 1.2.3", - "semver", + "semver 1.0.28", "serde", "serde_json", "serde_yaml", @@ -4547,6 +5085,30 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "et-int-ws-server-pico2w" +version = "0.1.0" +dependencies = [ + "chrono", + "cortex-m-rt", + "cyw43", + "cyw43-pio", + "defmt 1.2.0", + "defmt-rtt", + "edge-toolkit", + "embassy-executor", + "embassy-net", + "embassy-rp", + "embassy-time", + "embedded-alloc", + "et-ws-server", + "et-ws-service", + "fs-err", + "panic-probe", + "picoserve", + "static_cell", +] + [[package]] name = "et-modules-service" version = "0.3.0" @@ -5077,7 +5639,9 @@ dependencies = [ "et-websockify-service", "et-ws-service", "fs-err", + "futures-util", "local-ip-address", + "picoserve", "qr2term", "rcgen", "rustls", @@ -5086,8 +5650,11 @@ dependencies = [ "serde-env", "serde-inline-default", "serde_default", + "serde_json", "tempfile", "thiserror 2.0.21", + "tokio", + "tokio-tungstenite", "tracing", "tracing-actix-web", "tracing-subscriber", @@ -5112,6 +5679,7 @@ dependencies = [ "fs-err", "futures-util", "opentelemetry", + "picoserve", "schemars 1.2.3", "secrecy", "serde", @@ -5119,10 +5687,12 @@ dependencies = [ "serde-inline-default", "serde_default", "serde_json", + "spin 0.10.1", "subtle", "tempfile", "thiserror 2.0.21", "tokio", + "tokio-tungstenite", "tracing", "utoipa", "uuid", @@ -5368,7 +5938,7 @@ checksum = "04839bdf9d8c10f66806fad16b852fc72aab80873aebc3cb69d85b4fa41543ed" dependencies = [ "autocfg", "defmt 0.3.101", - "heapless", + "heapless 0.8.0", "serde", ] @@ -5484,6 +6054,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "fixed" +version = "1.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9af2cbf772fa6d1c11358f92ef554cb6b386201210bcf0e91fb7fba8a907fb40" +dependencies = [ + "az", + "bytemuck", + "half", + "typenum", +] + [[package]] name = "fixedbitset" version = "0.4.2" @@ -5670,6 +6252,16 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", +] + [[package]] name = "futures-io" version = "0.3.34" @@ -5736,6 +6328,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "generator" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1be81b31c9ce4caf7f3178eeb90662bb1864d3ad363ec6bdada3201adff19c37" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link 0.2.1", + "windows-result 0.4.1", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -6099,6 +6706,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" dependencies = [ "hash32", + "serde", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "defmt 1.2.0", + "hash32", + "serde_core", "stable_deref_trait", ] @@ -6914,7 +7534,7 @@ checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ "proc-macro2", "quote", - "rustc_version", + "rustc_version 0.4.1", "simd_cesu8", "syn 2.0.119", ] @@ -7114,6 +7734,38 @@ dependencies = [ "libc", ] +[[package]] +name = "lalrpop" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" +dependencies = [ + "ascii-canvas", + "bit-set 0.8.0", + "ena", + "itertools 0.14.0", + "lalrpop-util", + "petgraph 0.7.1", + "pico-args", + "regex", + "regex-syntax", + "sha3", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" +dependencies = [ + "regex-automata", + "rustversion", +] + [[package]] name = "language-tags" version = "0.3.2" @@ -7187,6 +7839,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lhash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744a4c881f502e98c2241d2e5f50040ac73b30194d64452bb6260393b53f0dc9" + [[package]] name = "libbz2-rs-sys" version = "0.2.5" @@ -7297,15 +7955,21 @@ checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" [[package]] name = "libz-sys" -version = "1.1.30" +version = "1.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f710a23e6dbf193214fd46ca56a9d6864e550abe86202184532ae7275e46de19" +checksum = "ff20308ecabd635a2dc1dd5834a86c462cca20efba1d5d3ddab1f8aea3455cb2" dependencies = [ "cc", "pkg-config", "vcpkg", ] +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -7373,6 +8037,19 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "lru-slab" version = "0.1.3" @@ -7412,6 +8089,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" +[[package]] +name = "managed" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" + [[package]] name = "matchers" version = "0.2.0" @@ -7667,7 +8350,7 @@ dependencies = [ "bitflags 2.13.2", "cfg-if", "cfg_aliases", - "codespan-reporting", + "codespan-reporting 0.13.1", "half", "hashbrown 0.16.1", "hexf-parse", @@ -7695,6 +8378,21 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -7879,7 +8577,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7956,6 +8654,27 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "nutype" version = "0.8.0" @@ -7975,7 +8694,7 @@ dependencies = [ "kinded", "proc-macro2", "quote", - "rustc_version", + "rustc_version 0.4.1", "syn 2.0.119", "urlencoding", ] @@ -8416,6 +9135,16 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "panic-probe" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd402d00b0fb94c5aee000029204a46884b1262e0c443f166d86d2c0747e1a1a" +dependencies = [ + "cortex-m", + "defmt 1.2.0", +] + [[package]] name = "par-core" version = "2.0.0" @@ -8532,6 +9261,16 @@ dependencies = [ "indexmap", ] +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset 0.5.7", + "indexmap", +] + [[package]] name = "petgraph" version = "0.8.3" @@ -8638,6 +9377,47 @@ dependencies = [ "siphasher 1.0.4", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "picoserve" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fb416fcd483bf1db5aa8d90506aa6967b9d0128e2646a929538d24d117433e" +dependencies = [ + "const-sha1", + "data-encoding", + "embassy-net", + "embassy-time", + "embedded-io-async 0.7.0", + "futures-util", + "heapless 0.8.0", + "heapless 0.9.3", + "lhash", + "picoserve_derive", + "pin-project", + "ryu", + "serde", + "serde-json-core", + "thiserror 2.0.21", + "tokio", +] + +[[package]] +name = "picoserve_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "981b69aa2731710212ac4582c3adc5832e8559d34ec810c318fe73273b05ccfe" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -8664,6 +9444,54 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pio" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0ba4153cee9585abc451271aa437d9e8defdea8b468d48ba6b8f098cbe03d7f" +dependencies = [ + "pio-core", + "pio-proc", +] + +[[package]] +name = "pio-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61d90fddc3d67f21bbf93683bc461b05d6a29c708caf3ffb79947d7ff7095406" +dependencies = [ + "arrayvec", + "num_enum", + "paste", +] + +[[package]] +name = "pio-parser" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825266c1eaddf54f636d06eefa4bf3c99d774c14ec46a4a6c6e5128a0f10d205" +dependencies = [ + "lalrpop", + "lalrpop-util", + "pio-core", +] + +[[package]] +name = "pio-proc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a76571f5fe51af43cc80ac870fe0c79cc0cdd686b9002a6c4c84bfdd0176b" +dependencies = [ + "codespan-reporting 0.11.1", + "lalrpop-util", + "pio-core", + "pio-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -8808,6 +9636,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "presser" version = "0.3.1" @@ -8873,6 +9707,28 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -9066,6 +9922,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "pure-rust-locales" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869675ad2d7541aea90c6d88c81f46a7f4ea9af8cd0395d38f11a95126998a0d" +dependencies = [ + "defmt 1.2.0", +] + [[package]] name = "pxfm" version = "0.1.30" @@ -9186,7 +10051,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.3", "rustls", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.21", "tokio", "tracing", @@ -9226,9 +10091,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9651,6 +10516,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "ring" version = "0.17.14" @@ -9713,6 +10584,25 @@ dependencies = [ "text-size", ] +[[package]] +name = "rp-pac" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8af65855c40b2c35079514c5489abffc0429347fef25d8467ff98ad84b4322d3" +dependencies = [ + "cortex-m", + "cortex-m-rt", +] + +[[package]] +name = "rp2040-boot2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c92f344f63f950ee36cf4080050e4dce850839b9175da38f9d2ffb69b4dbb21" +dependencies = [ + "crc-any", +] + [[package]] name = "rsa" version = "0.9.10" @@ -9767,7 +10657,7 @@ dependencies = [ "quote", "regex", "relative-path", - "rustc_version", + "rustc_version 0.4.1", "syn 2.0.119", "unicode-ident", ] @@ -9805,13 +10695,22 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +dependencies = [ + "semver 0.9.0", +] + [[package]] name = "rustc_version" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "semver", + "semver 1.0.28", ] [[package]] @@ -9846,7 +10745,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9936,7 +10835,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs 1.0.10", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -10203,6 +11102,15 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser", +] + [[package]] name = "semver" version = "1.0.28" @@ -10213,6 +11121,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + [[package]] name = "seq-macro" version = "0.3.6" @@ -10249,6 +11163,17 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "serde-json-core" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b81787e655bd59cecadc91f7b6b8651330b2be6c33246039a65e5cd6f4e0828" +dependencies = [ + "heapless 0.8.0", + "ryu", + "serde", +] + [[package]] name = "serde_bytes" version = "0.11.19" @@ -10505,6 +11430,12 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha2-const-stable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f179d4e11094a893b82fff208f74d448a7512f99f5a0acbd5c679b705f83ed9" + [[package]] name = "sha3" version = "0.10.9" @@ -10605,7 +11536,7 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ - "rustc_version", + "rustc_version 0.4.1", "simdutf8", ] @@ -10666,6 +11597,24 @@ version = "2.0.0-alpha.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d44cfb396c3caf6fbfd0ab422af02631b69ddd96d2eff0b0f0724f9024051b" +[[package]] +name = "smart-leds" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66df34e571fa9993fa6f99131a374d58ca3d694b75f9baac93458fe0d6057bf0" +dependencies = [ + "smart-leds-trait", +] + +[[package]] +name = "smart-leds-trait" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f4441a131924d58da6b83a7ad765c460e64630cce504376c3a87a2558c487f" +dependencies = [ + "rgb", +] + [[package]] name = "smartstring" version = "1.0.1" @@ -10677,6 +11626,20 @@ dependencies = [ "version_check", ] +[[package]] +name = "smoltcp" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f73d40463bba65efc9adc6370b56df76d563cc46e2482bba58351b4afb7535e" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "cfg-if", + "defmt 0.3.101", + "heapless 0.9.3", + "managed", +] + [[package]] name = "snafu" version = "0.8.9" @@ -10715,7 +11678,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10817,7 +11780,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10826,6 +11789,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "static_cell" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0530892bb4fa575ee0da4b86f86c667132a94b74bb72160f58ee5a4afec74c23" +dependencies = [ + "portable-atomic", +] + [[package]] name = "strck" version = "1.0.0" @@ -10841,6 +11813,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", +] + [[package]] name = "string_enum" version = "1.0.2" @@ -11468,10 +12452,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -11508,6 +12492,15 @@ dependencies = [ "writeable", ] +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -12193,7 +13186,7 @@ dependencies = [ "quote", "regress", "schemars 0.8.22", - "semver", + "semver 1.0.28", "serde", "serde_json", "syn 3.0.9", @@ -12210,7 +13203,7 @@ dependencies = [ "proc-macro2", "quote", "schemars 0.8.22", - "semver", + "semver 1.0.28", "serde", "serde_json", "serde_tokenstream", @@ -12463,6 +13456,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcell" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77439c1b53d2303b20d9459b1ade71a83c716e3f9c34f3228c00e6f185d6c002" + [[package]] name = "vcpkg" version = "0.2.15" @@ -12475,6 +13474,21 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "volatile-register" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de437e2a6208b014ab52972a27e59b33fa2920d3e00fe05026167a1c509d19cc" +dependencies = [ + "vcell", +] + [[package]] name = "vsimd" version = "0.8.0" @@ -12757,7 +13771,7 @@ dependencies = [ "bitflags 2.13.2", "hashbrown 0.17.1", "indexmap", - "semver", + "semver 1.0.28", "serde", ] @@ -12770,7 +13784,7 @@ dependencies = [ "bitflags 2.13.2", "hashbrown 0.17.1", "indexmap", - "semver", + "semver 1.0.28", ] [[package]] @@ -12811,7 +13825,7 @@ dependencies = [ "pulley-interpreter", "rayon", "rustix 1.1.5", - "semver", + "semver 1.0.28", "serde", "serde_derive", "serde_json", @@ -12855,7 +13869,7 @@ dependencies = [ "object 0.40.0", "postcard", "rustc-demangle", - "semver", + "semver 1.0.28", "serde", "serde_derive", "sha2 0.10.9", @@ -13435,7 +14449,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -13967,7 +14981,7 @@ checksum = "784865de876ea3806ea6ffb02cb3754a6524d9a3e0ea587b0b8335de60aa9904" dependencies = [ "id-arena", "pretty_assertions", - "semver", + "semver 1.0.28", "serde", "wit-parser 0.258.3", ] @@ -13983,7 +14997,7 @@ dependencies = [ "id-arena", "indexmap", "log", - "semver", + "semver 1.0.28", "serde", "serde_derive", "serde_json", @@ -14002,7 +15016,7 @@ dependencies = [ "id-arena", "indexmap", "log", - "semver", + "semver 1.0.28", "serde", "serde_derive", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 9cdb47fb..b5caedd2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ members = [ "services/ws", "services/ws-pyo3-runner", "services/ws-server", + "services/ws-server-pico2w", "services/ws-wasm-agent", "services/ws-wasi-runner", "services/ws-web-runner", @@ -76,17 +77,22 @@ anyhow = "1" async-nats = { version = "0.50", default-features = false, features = ["aws-lc-rs", "kv"] } asyncapi-rust = "0.5" backon = { version = "1.6", default-features = false } -base64 = "0.23" -bytes = "1" +base64 = { version = "0.23", default-features = false } +bytes = { version = "1", default-features = false } bytesize = { version = "2", features = ["serde"] } cc = "1" -chrono = { version = "0.4", features = ["serde"] } +chrono = { version = "0.4", default-features = false, features = ["serde"] } clap = { version = "4.4", features = ["derive"] } clap-markdown = "0.1" cloudevents-sdk = "0.9" command-error = "0.8" const-hex = "1.19" +cortex-m-rt = "0.7" croner = "4" +cyw43 = "0.7" +cyw43-pio = "0.10" +defmt = "1" +defmt-rtt = "1" deno_core = "0.412" deno_error = "=0.7.1" deno_resolver = "0.90" @@ -96,7 +102,12 @@ deno_resolver = "0.90" # baking our own startup snapshot we'd otherwise hit the panic on first run. deno_runtime = { version = "0.267", features = ["transpile", "hmr"] } document-features = "0.2" -edge-toolkit = { path = "libs/edge-toolkit", version = "0.4.0" } +edge-toolkit = { path = "libs/edge-toolkit", version = "0.4.0", default-features = false } +embassy-executor = "0.10" +embassy-net = "0.9" +embassy-rp = "0.10" +embassy-time = "0.5" +embedded-alloc = { version = "0.7", default-features = false, features = ["llff"] } et-actix-web-config = { path = "libs/actix-web-config", version = "0.1.0" } et-cli = { path = "utilities/cli", version = "0.2.0" } et-hub-backplane = { path = "services/hub-backplane", version = "0.2.0" } @@ -116,8 +127,8 @@ et-wasi-guest = { path = "libs/wasi-guest", version = "0.2.0" } et-web = { path = "libs/web", version = "0.3.0" } et-websockify-service = { path = "services/websockify", version = "0.2.0" } et-ws-runner-common = { path = "libs/ws-runner-common", version = "0.3.0" } -et-ws-server = { path = "services/ws-server", version = "0.3.0" } -et-ws-service = { path = "services/ws", version = "0.2.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-wasm-agent = { path = "services/ws-wasm-agent", version = "0.3.0" } fake = "5" @@ -196,6 +207,8 @@ opentelemetry-proto = { version = "0.32", default-features = false, features = [ ] } opentelemetry_sdk = "0.32" ort = { version = "=2.0.0-rc.10", default-features = false, features = ["copy-dylibs"] } +panic-probe = "1" +picoserve = { version = "0.20", default-features = false, features = ["ws"] } port_check = "0.3" pretty_yaml = "0.6" prettyplease = "0.3" @@ -228,15 +241,17 @@ rustls = "0.23" schemars = { version = "1.2", features = ["derive"] } secrecy = { version = "0.10", features = ["serde"] } semver = "1" -serde = { version = "1.0", features = ["derive"] } +serde = { version = "1.0", default-features = false, features = ["derive"] } serde-env = "0.3" serde-inline-default = "1.0" serde_default = "0.2" -serde_json = "1" +serde_json = { version = "1", default-features = false } serde_path_to_error = "0.1" serde_with = { version = "3.24", default-features = false, features = ["macros"] } serde_yaml = "0.9" sha2 = { version = "0.11", default-features = false } +spin = { version = "0.10", default-features = false, features = ["spin_mutex"] } +static_cell = "2" strum = { version = "0.28", features = ["derive"] } subtle = "2.6" syn = "3" @@ -244,7 +259,7 @@ sys_traits = { version = "0.1", features = ["libc", "real"] } temp-env = "0.3" tempfile = "3" textwrap = { version = "0.16", default-features = false } -thiserror = "2" +thiserror = { version = "2", default-features = false } tokio = "1" tokio-tungstenite = { version = "0.30", default-features = false } toml = "1.1" @@ -252,7 +267,7 @@ toml = "1.1" # actix-codec enables it, so every workspace build already unifies it on, but a crate outside the actix tree built # by itself (cargo-hack's per-package clippy, wasm-pack) otherwise expands them smaller, and its # `#[expect(clippy::cognitive_complexity)]` goes unfulfilled there while it is required in the workspace build. -tracing = { version = "0.1", features = ["log"] } +tracing = { version = "0.1", default-features = false, features = ["log"] } tracing-actix-web = { version = "0.7", default-features = false, features = ["emit_event_on_error", "uuid_v7"] } tracing-log = "0.2" tracing-opentelemetry = "0.33" @@ -469,6 +484,11 @@ ignore = [ "deno_whoami", # Unmaintained upstream at xdg-rs/dirs. "dirs-sys-next", + # Deleted upstream from embassy-rs/embassy on 2026-08-28, so the "package in repo" heuristic reports "not in". + # That commit, "net: port to new xarxa", has no release yet: 0.2.0 is still what every released embassy-net + # (0.9.1, used by the Pico 2 W firmware and picoserve) and embassy-net-driver-channel (via cyw43) require. + # Delete this entry once a released embassy-net built on xarxa lets the lock drop it. + "embassy-net-driver", # Unmaintained upstream at faradayio/exec-rs. # Pulled by Deno's `node_shim` (process re-exec on Node compatibility shim). "exec", diff --git a/README.md b/README.md index 74c98da0..110bb735 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [coverage]: https://codecov.io/gh/edge-toolkit/core [crate-badge]: https://img.shields.io/crates/v/et-ws-server [crate]: https://crates.io/crates/et-ws-server -[checks-badge]: https://img.shields.io/badge/checks-%3E%3D340-blue +[checks-badge]: https://img.shields.io/badge/checks-%3E%3D360-blue [checks]: CHECKS.md [license-badge]: https://img.shields.io/badge/license-MIT%2BApache--2.0-blue [license]: #licence diff --git a/config/ast-grep/rules/no-env-macro-in-attribute.yaml b/config/ast-grep/rules/no-env-macro-in-attribute.yaml index 2d69313d..94b7f56d 100644 --- a/config/ast-grep/rules/no-env-macro-in-attribute.yaml +++ b/config/ast-grep/rules/no-env-macro-in-attribute.yaml @@ -3,7 +3,8 @@ language: Rust severity: error message: | `env!` / `option_env!` inside an attribute are banned exactly as they are anywhere else, with the same allowlist - of cargo-provided constants (`CARGO_BIN_EXE_`, `CARGO_PKG_VERSION`, `CARGO_PKG_NAME`, `ET_WIT_DIR`). + of cargo-provided constants (`CARGO_BIN_EXE_`, `CARGO_PKG_VERSION`, `CARGO_PKG_NAME`, `ET_WIT_DIR`, + `OUT_DIR`). Read configuration through a `serde-env` struct instead, and locate repo files from the project root rather than from `CARGO_MANIFEST_DIR`. # An attribute's arguments are a raw token tree, so a macro nested in one -- e.g. @@ -16,4 +17,4 @@ rule: - kind: inner_attribute_item - regex: '\b(option_)?env!\s*\(' - not: - regex: '"(CARGO_BIN_EXE_[A-Za-z0-9_.-]+|CARGO_PKG_VERSION|CARGO_PKG_NAME|ET_WIT_DIR)"' + regex: '"(CARGO_BIN_EXE_[A-Za-z0-9_.-]+|CARGO_PKG_VERSION|CARGO_PKG_NAME|ET_WIT_DIR|OUT_DIR)"' diff --git a/config/ast-grep/rules/no-env-macro.yaml b/config/ast-grep/rules/no-env-macro.yaml index 8813c750..ba39044e 100644 --- a/config/ast-grep/rules/no-env-macro.yaml +++ b/config/ast-grep/rules/no-env-macro.yaml @@ -9,6 +9,8 @@ message: | - `CARGO_BIN_EXE_` -- the built binary's path, injected by cargo for an integration test. - `CARGO_PKG_VERSION` / `CARGO_PKG_NAME` -- crate identity baked into the binary. - `ET_WIT_DIR` -- the WIT directory injected at build time for wit-bindgen. + - `OUT_DIR` -- the build script's output directory, for `include!`-ing what it generated. Configuration a + bare-metal binary bakes in is read there, by its `build.rs`, and generated as typed constants. `CARGO_MANIFEST_DIR` is deliberately NOT exempt -- use `edge_toolkit::config::get_project_root()` at runtime (see the `no-cargo-manifest-dir` rule). rule: @@ -19,6 +21,6 @@ rule: regex: "^(env|option_env)$" # Exempt a call only when its argument is one of the allowlisted names above. # `not` matches against this `macro_invocation`'s own text (the whole `env!("...")`); everything else - # (CARGO_MANIFEST_DIR, OUT_DIR, ad-hoc vars) fires. + # (CARGO_MANIFEST_DIR, ad-hoc vars) fires. - not: - regex: '"(CARGO_BIN_EXE_[A-Za-z0-9_.-]+|CARGO_PKG_VERSION|CARGO_PKG_NAME|ET_WIT_DIR)"' + regex: '"(CARGO_BIN_EXE_[A-Za-z0-9_.-]+|CARGO_PKG_VERSION|CARGO_PKG_NAME|ET_WIT_DIR|OUT_DIR)"' diff --git a/config/ast-grep/rules/no-env-var-literal.yaml b/config/ast-grep/rules/no-env-var-literal.yaml new file mode 100644 index 00000000..3429acd7 --- /dev/null +++ b/config/ast-grep/rules/no-env-var-literal.yaml @@ -0,0 +1,14 @@ +id: no-env-var-literal +language: Rust +severity: error +message: | + `env::var` takes a named constant, never a string literal: a mistyped literal is a lookup that silently fails, + while a mistyped constant does not compile. Declare `const NAME: &str = "NAME";` beside the read. + It is the local twin of DeepSource RS-W1015. +rule: + any: + - pattern: std::env::var($ARG) + - pattern: env::var($ARG) +constraints: + ARG: + kind: string_literal diff --git a/config/ast-grep/tests/no-env-macro-in-attribute.yaml b/config/ast-grep/tests/no-env-macro-in-attribute.yaml new file mode 100644 index 00000000..600fd8cb --- /dev/null +++ b/config/ast-grep/tests/no-env-macro-in-attribute.yaml @@ -0,0 +1,13 @@ +id: no-env-macro-in-attribute +valid: + - | + #![cfg_attr(doc, doc = include_str!(concat!(env!("OUT_DIR"), "/README.md")))] + - | + #[doc = concat!("version ", env!("CARGO_PKG_VERSION"))] + fn version() {} +invalid: + - | + #![cfg_attr(doc, doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md")))] + - | + #[doc = env!("WIFI_NETWORK")] + fn network() {} diff --git a/config/ast-grep/tests/no-env-macro.yaml b/config/ast-grep/tests/no-env-macro.yaml new file mode 100644 index 00000000..efdd3d5f --- /dev/null +++ b/config/ast-grep/tests/no-env-macro.yaml @@ -0,0 +1,17 @@ +id: no-env-macro +valid: + - | + const NAME: &str = env!("CARGO_PKG_NAME"); + - | + include!(concat!(env!("OUT_DIR"), "/board.rs")); + - | + fn wit() -> &'static str { + env!("ET_WIT_DIR") + } +invalid: + - | + const WIFI_NETWORK: &str = env!("WIFI_NETWORK"); + - | + const ROOT: Option<&str> = option_env!("CARGO_MANIFEST_DIR"); + - | + const OUTPUT: &str = env!("OUT_DIR_SUFFIX"); diff --git a/config/ast-grep/tests/no-env-var-literal.yaml b/config/ast-grep/tests/no-env-var-literal.yaml new file mode 100644 index 00000000..02b544c4 --- /dev/null +++ b/config/ast-grep/tests/no-env-var-literal.yaml @@ -0,0 +1,14 @@ +id: no-env-var-literal +valid: + - | + const OUT_DIR: &str = "OUT_DIR"; + let out_dir = env::var(OUT_DIR); + - | + let path = std::env::var_os("PATH"); + - | + let value = env::var(name); +invalid: + - | + let out_dir = env::var("OUT_DIR"); + - | + let target_os = std::env::var("CARGO_CFG_TARGET_OS"); diff --git a/config/conftest/policy/jscpd/jscpd.rego b/config/conftest/policy/jscpd/jscpd.rego index 8b882bb8..3ae386e8 100644 --- a/config/conftest/policy/jscpd/jscpd.rego +++ b/config/conftest/policy/jscpd/jscpd.rego @@ -28,7 +28,7 @@ package jscpd # Ceiling on the clone fingerprints recorded in config/jscpd-baseline.json. # May only ever be lowered. See the header for what raising it implies. -declared_clones := 347 +declared_clones := 360 # How far under the ceiling the baseline may sit before the ceiling has to be banked downward. max_slack := 20 diff --git a/config/deny.toml b/config/deny.toml index 796915fb..56a95f08 100644 --- a/config/deny.toml +++ b/config/deny.toml @@ -201,6 +201,17 @@ ignore = [ # again. The callbacks are the hashes' fixed-size block functions, which do not panic, and nothing here catches # a panic mid-hash and keeps the hasher. Drop this when digest 0.10 leaves the tree. { id = "GHSA-qwgh-2vcv-g2f7", reason = "expires 2026-10-31" }, + # bare-metal 0.2.5 (deprecated and archived, so no version is patched). + # Pulled only by the Pico 2 W firmware, through cortex-m 0.7, which cyw43 0.7 and embassy-rp 0.10 require through + # their newest releases. Deprecated != vulnerable: cortex-m uses it for its single-core `Mutex` and + # `CriticalSection` types, and the firmware takes its critical sections from embassy-rp's `critical-section` + # implementation. Drop this when cortex-m 0.8 reaches them. + { id = "RUSTSEC-2026-0110", reason = "expires 2026-10-31" }, + # proc-macro-error2 2.0.1 (unmaintained, so no version is patched). + # Pulled only by the Pico 2 W firmware, through the pio-proc assembler macro embassy-rp 0.10 requires through its + # newest release. It runs at compile time inside that macro and reports its errors; none of it is in the firmware. + # Drop this when pio-proc leaves it. + { id = "RUSTSEC-2026-0173", reason = "expires 2026-10-31" }, ] [licenses] @@ -208,6 +219,9 @@ version = 2 # Compatible with our own `Apache-2.0 OR MIT` workspace license. # Add a new entry only when a transitive dep introduces a new SPDX expression we've reviewed. allow = [ + # smoltcp + managed (transitive via embassy-net, the Pico 2 W firmware's TCP/IP stack). + # BSD Zero Clause -- MIT without even the attribution clause; permissive, OSI-approved. + "0BSD", "Apache-2.0", # cranelift / wasmtime stack -- LLVM-derived code carries the Apache 2.0 text plus an extra patent grant for LLVM. # Still permissive. diff --git a/config/jscpd-baseline.json b/config/jscpd-baseline.json index b72d3076..fe828c3d 100644 --- a/config/jscpd-baseline.json +++ b/config/jscpd-baseline.json @@ -17,6 +17,7 @@ "084d33784cf81a99": 1, "0875c25e5ebb12cc": 1, "0c04b2e97c016262": 1, + "0c2432c487251d55": 1, "0c8f78b8c0d0f6b4": 1, "0ea84f1f5db88fe7": 1, "0f033dd72cb08a76": 1, @@ -26,6 +27,7 @@ "106569d682a6a53f": 1, "109b672c3d6939d9": 1, "10b898032781bd2b": 1, + "1235e9f0b50beb4c": 1, "12bdba43f82ce591": 1, "136c57b4180f7238": 1, "13d42a6bd52e4560": 1, @@ -35,15 +37,18 @@ "162b7eccbfa52a74": 1, "16acb47f836cc1f8": 1, "1808b7fc36bbb822": 2, + "1887cc62f89c1e8c": 1, "1a07ddf368451c0a": 1, "1a1867cca4e1d4e8": 1, "1a1e1611e4db4656": 1, + "1a46175d923bf850": 3, "1a97e5c72a30152e": 1, "1bd6f5989d1961e5": 1, "1c64414236b8476c": 1, "1c8cf13e10b67513": 1, "1ce17b70419170cb": 1, "1e540a5d293c0201": 1, + "1eb6ac9ba657bc49": 3, "1f8c3b477a9abb84": 1, "20149cb81e6e030d": 1, "20b0690adee22fd3": 1, @@ -60,6 +65,7 @@ "2d5e921d26ac1b89": 1, "2e16d6fbda66df34": 1, "2e738d4de357d244": 1, + "2ed92f1bb371f90f": 1, "2f97adcddfac1644": 1, "3192274800f8c0d1": 1, "31a1985df946271b": 1, @@ -71,7 +77,6 @@ "34cd0de3c97e4264": 1, "35d7f304546de8a8": 1, "378e3311bed8161a": 1, - "380c183a78daef06": 1, "389aea3e8662fb9b": 1, "39df8b0c7c0dccc1": 1, "3a3c6b4e89834dc8": 1, @@ -80,20 +85,17 @@ "3b30dfa6b363682d": 2, "3c0efbffa8bb6f9b": 1, "3c49721a9a85b54b": 1, + "3c5c4bdf7be7387e": 1, "3de59029c7b786b0": 1, "3e5aadc3e77078b6": 1, "3e6e51f257b8453d": 1, - "3ef0ad9e8ac464b8": 1, "4268c1cf96611f46": 1, - "436da4a36eb14164": 1, - "43e742aef05399f9": 1, "43f0e3fca21daafa": 1, "448a09ade148d8c5": 1, "4534795197b3c34c": 1, "45486be737012557": 1, "461eb8f3a4cf80d3": 1, "4a5b2cd87f30b9d3": 1, - "4ce7111960572d59": 1, "4e1f48e8e76d2edd": 1, "4e27ef1bb20894c2": 1, "4eff6767e2aba30c": 1, @@ -104,17 +106,23 @@ "51d9869c79c352cd": 1, "521c8a862b4eacb3": 1, "533bdecb47a87756": 1, + "544d0bff2e6dd595": 1, "54b576854edda79b": 1, "54f0cec8830d66e4": 1, + "54ff4824efd1fd62": 1, "55951b2a3cedabb3": 1, "564cd61a6a950a0d": 1, "569e50cf9502975e": 1, + "56bf385e38ce3b73": 1, "57be9802f47b2062": 1, + "57fdb3b550645dfa": 1, "583f38ee93d4fa73": 1, "58b70a86860c6b0d": 1, "58d184730f880246": 1, + "5990eb8206926248": 1, "5a38489b585cef78": 1, "5a5b6a5ae3b87eea": 1, + "5b29341cf992fa9f": 1, "5cfb0f0873663a20": 1, "5da193ae29a89044": 1, "5e1507230a488a93": 1, @@ -140,6 +148,7 @@ "6b7ef9701aa8a94b": 1, "6bad88d8a503b406": 1, "6d80cc58c007a238": 1, + "6dfd3f0a2d24cbc1": 1, "6e7055e0693626a4": 1, "6f84b76a00168521": 1, "70e0d868d8dd652b": 1, @@ -161,10 +170,11 @@ "7f343488c1aacd37": 1, "7f700d1f7681c95e": 1, "7fa442e9e2160278": 1, + "7fba18906d3d51f1": 1, "7fc2541d22f60998": 1, - "8029fe381bcd4fcf": 1, "81b15523291b195e": 1, "81c0a5c7a32d0775": 1, + "8250a6be25852811": 1, "82db4dce7d23aee8": 1, "8390cc689d64efcb": 1, "84b3a552aa5c21c2": 1, @@ -175,14 +185,15 @@ "879bfe322b3d676b": 1, "87f1eade46af4d27": 1, "88bca8b73d89f892": 1, + "893d7c317a045d82": 1, "8996a093b7bb3b54": 1, "89bb6ca7b46aa5cd": 1, - "8abf3cb1650a0f79": 1, "8acea4476f04e4a0": 2, "8b5b2be949793716": 1, "8c912ef640675113": 1, "8d739440b2cad0b0": 1, "8f29fa9325c6a7fa": 1, + "8fcac3476268b171": 1, "928c39b341599636": 1, "92cbe2a88c46dd90": 6, "92ed119fbc8ed830": 1, @@ -203,12 +214,11 @@ "9cb0f337a422df56": 1, "9e7af9bc8ddddb59": 1, "9e835c94361d6917": 1, - "9e85dea9729102f6": 1, "9ec9ce10070abf0a": 1, + "9ed30a2d34c205ae": 1, "a03ab32ade9684de": 1, "a0438c241cc4a042": 1, "a0a741f05c43798a": 1, - "a15d4cc233170300": 1, "a1ef5137775b605c": 1, "a29faaf6abee280d": 4, "a3111223b027f569": 1, @@ -218,7 +228,6 @@ "a52b3254cc78b001": 1, "a58c4944142e3bc2": 1, "a58e3e798522b26c": 1, - "a5af3ef1eaa4a09a": 1, "a5f04a02187cc3c8": 1, "a5f0c7895f14c806": 2, "a68200ccaced4620": 1, @@ -227,18 +236,19 @@ "a743e181c2f6424b": 1, "a896f65e46666bd0": 1, "a92c94299aa8f156": 1, + "a9b99a8f3b63f49d": 1, "a9f793157ec6fced": 1, "aa68ceead4a59e5f": 1, "aad9806c62fff62f": 1, "aaeb9cdf748826bd": 1, - "ab4dbe992894e239": 4, - "abd1ba3d952afd42": 3, + "ab4dbe992894e239": 5, "ac4638cd75018e36": 2, "acd9fa393bd6abf8": 1, "acdebc833a1835f4": 1, "ad6c8d0f5db047f5": 1, "ae590b605758bec9": 1, - "afc243bb6427e6d9": 1, + "b01781537f9ea400": 1, + "b04b47360d15964e": 1, "b07cc7ec51bc1602": 1, "b29077918336343b": 1, "b32ec6344a29f094": 1, @@ -255,8 +265,10 @@ "bab02c8fb19dca60": 1, "bbd85a05647fdf5e": 1, "bbda58a7774990a3": 1, + "bd0a27d6a9ee4b87": 1, "bd684492ef8fdd33": 1, "c01708e08c252d4a": 1, + "c1022d238799885e": 3, "c10acaf9d504846e": 1, "c10d9673e6b919c8": 1, "c14eb5379f6e8eea": 1, @@ -271,20 +283,18 @@ "c6ad80475453274a": 1, "c6e1e53b3f01fafb": 1, "c713bd192586f25f": 1, + "c7bf05bb2529b46b": 1, "c889a14a7d34fe32": 1, - "c88cfe85339022ed": 1, "c8aa41bf2df4989a": 1, "c8f96481b232f2ca": 1, "c9d01b7aee7b49ad": 1, "ca1ed8d0528c4816": 1, "ca30990aafdaceb3": 1, "cb5488b69ff4c99f": 1, - "cbba8dd8354abafe": 3, "cbc385e98ca0d525": 1, "cc04ffb35a35eff3": 1, "cc9e2428ec6b5759": 1, "cd216052533fd84d": 1, - "cd57c0d12e203463": 3, "ce17c249a87973d7": 1, "ce8437d3ac124830": 1, "ce990318bfc4aa30": 1, @@ -302,9 +312,12 @@ "da7b27ae8c89cb73": 1, "da812f4e54fefbf1": 1, "db4fb9ed218e4d66": 1, + "dbf01492cda8f9be": 1, + "dd101dfd2d94984f": 1, "dd162080de996936": 1, "dd41701c3663b9f3": 1, "ddca47a4caded0fd": 1, + "dfab26504ac7abe4": 1, "e152738c60f43b0e": 1, "e25456ad9cc7f9f9": 1, "e4b9e7313fc22777": 1, @@ -328,7 +341,6 @@ "f2f40a2a067eb8ed": 1, "f32c2b14cbf25a04": 1, "f3eba887cab8ffc1": 1, - "f45581aa7a8806a0": 1, "f5104a6be7ba929d": 1, "f773b3122d6cb21a": 1, "f8dacf11c2b785d4": 1, diff --git a/config/osv-scanner.toml b/config/osv-scanner.toml index 48d55f8e..0115178c 100644 --- a/config/osv-scanner.toml +++ b/config/osv-scanner.toml @@ -10,8 +10,10 @@ IgnoredVulns = [ { id = "RUSTSEC-2025-0134", ignoreUntil = 2026-10-31 }, { id = "RUSTSEC-2025-0141", ignoreUntil = 2026-10-31 }, { id = "RUSTSEC-2026-0097", ignoreUntil = 2026-10-31 }, + { id = "RUSTSEC-2026-0110", ignoreUntil = 2026-10-31 }, { id = "RUSTSEC-2026-0118", ignoreUntil = 2026-10-31 }, { id = "RUSTSEC-2026-0119", ignoreUntil = 2026-10-31 }, + { id = "RUSTSEC-2026-0173", ignoreUntil = 2026-10-31 }, { id = "RUSTSEC-2026-0249", ignoreUntil = 2026-10-31 }, { id = "RUSTSEC-2026-0258", ignoreUntil = 2026-10-31 }, { id = "RUSTSEC-2026-0285", ignoreUntil = 2026-10-31 }, diff --git a/config/upstream-cache/data.toml b/config/upstream-cache/data.toml index c0ead809..1533df7c 100644 --- a/config/upstream-cache/data.toml +++ b/config/upstream-cache/data.toml @@ -73,6 +73,27 @@ sha256 = "7ed49c2c170aefe81e7e91b472a761d922ef3c064b6051068c2dc0ccc98bd53b" upstream = "https://huggingface.co/onnx-community/silero-vad" url = "https://huggingface.co/onnx-community/silero-vad/resolve/84f0d86/onnx/model_fp16.onnx" +# The Pico 2 W's CYW43439 radio: its firmware, country locale matrix and the NVRAM for the board's module. +# Fetched straight from embassy's cyw43-firmware directory at a pinned commit, for CI to build the firmware with; +# nothing republishes them. Infineon's Permissive Binary License 1.0 has no SPDX id, hence the LicenseRef. +[asset."cyw43-75f40b3-43439A0.bin"] +license = "LicenseRef-Permissive-Binary-License-1.0" +sha256 = "5555e0261da2610a500d68c18d895cace0152bbefbf76f4aa683ebce77e3d7eb" +upstream = "https://github.com/embassy-rs/embassy/tree/main/cyw43-firmware" +url = "https://github.com/embassy-rs/embassy/raw/75f40b383a14/cyw43-firmware/43439A0.bin" + +[asset."cyw43-75f40b3-43439A0_clm.bin"] +license = "LicenseRef-Permissive-Binary-License-1.0" +sha256 = "e712b3d218e8b1e2747b092e03b8b0afcb8c8c8e355d2a4a0d47b493800f3f89" +upstream = "https://github.com/embassy-rs/embassy/tree/main/cyw43-firmware" +url = "https://github.com/embassy-rs/embassy/raw/75f40b383a14/cyw43-firmware/43439A0_clm.bin" + +[asset."cyw43-75f40b3-nvram_rp2040.bin"] +license = "LicenseRef-Permissive-Binary-License-1.0" +sha256 = "4904bdbb0c937bd0ac2eb2a1d62f2da4dd90e32082384e02874e8d671b0f330d" +upstream = "https://github.com/embassy-rs/embassy/tree/main/cyw43-firmware" +url = "https://github.com/embassy-rs/embassy/raw/75f40b383a14/cyw43-firmware/nvram_rp2040.bin" + [asset."rustpython-wasm-08a1d6f.tar.gz"] license = "MIT" sha256 = "7b81d53f5b3a63c60fc2bc1717e87466026d796ec522b925f009532c7b0291f3" diff --git a/generated/dart-rest/lib/clients/admin.dart b/generated/dart-rest/lib/clients/admin.dart index 916a45d7..ac443403 100644 --- a/generated/dart-rest/lib/clients/admin.dart +++ b/generated/dart-rest/lib/clients/admin.dart @@ -25,6 +25,10 @@ abstract class Admin { /// Block an address: refuse it on every hub of the backplane. /// + /// Every open socket from that address is closed within a heartbeat, whichever agent it carries, and every later. + /// connection from it is refused. The block outlives a hub restart: a local hub saves it with its registry, and a. + /// NATS-backed hub keeps it in the shared blocklist. + /// /// Blocking an address already blocked succeeds again, so the request can be repeated safely. A path segment that. /// is not an IPv4 or IPv6 address matches no route and is answered 404. /// diff --git a/generated/python-rest/et_rest_client/api/admin/block_ip.py b/generated/python-rest/et_rest_client/api/admin/block_ip.py index 05901d33..254a29f4 100644 --- a/generated/python-rest/et_rest_client/api/admin/block_ip.py +++ b/generated/python-rest/et_rest_client/api/admin/block_ip.py @@ -59,7 +59,13 @@ def sync_detailed( ) -> Response[Any]: """Block an address: refuse it on every hub of the backplane. - Blocking an address already blocked succeeds again, so the request can be repeated safely. A path + Every open socket from that address is closed within a heartbeat, whichever agent it carries, and + every later + connection from it is refused. The block outlives a hub restart: a local hub saves it with its + registry, and a + NATS-backed hub keeps it in the shared blocklist. + + Blocking an address already blocked succeeds again, so the request can be repeated safely. A path segment that is not an IPv4 or IPv6 address matches no route and is answered 404. @@ -92,7 +98,13 @@ async def asyncio_detailed( ) -> Response[Any]: """Block an address: refuse it on every hub of the backplane. - Blocking an address already blocked succeeds again, so the request can be repeated safely. A path + Every open socket from that address is closed within a heartbeat, whichever agent it carries, and + every later + connection from it is refused. The block outlives a hub restart: a local hub saves it with its + registry, and a + NATS-backed hub keeps it in the shared blocklist. + + Blocking an address already blocked succeeds again, so the request can be repeated safely. A path segment that is not an IPv4 or IPv6 address matches no route and is answered 404. diff --git a/generated/rust-rest/Cargo.toml b/generated/rust-rest/Cargo.toml index 3128caf5..a08e8b23 100644 --- a/generated/rust-rest/Cargo.toml +++ b/generated/rust-rest/Cargo.toml @@ -23,8 +23,8 @@ tracing = ["dep:opentelemetry", "dep:opentelemetry-http", "dep:tracing-opentelem opentelemetry = { workspace = true, optional = true } opentelemetry-http = { workspace = true, optional = true } progenitor-client.workspace = true -serde.workspace = true -tracing.workspace = true +serde = { workspace = true, features = ["std"] } +tracing = { workspace = true, features = ["std"] } tracing-opentelemetry = { workspace = true, optional = true } # `rustls` and `stream` are native-only -- WASM reqwest dispatches via the diff --git a/generated/rust-rest/src/lib.rs b/generated/rust-rest/src/lib.rs index d109c15e..c59c3606 100644 --- a/generated/rust-rest/src/lib.rs +++ b/generated/rust-rest/src/lib.rs @@ -219,6 +219,10 @@ impl Client { } /**Block an address: refuse it on every hub of the backplane + Every open socket from that address is closed within a heartbeat, whichever agent it carries, and every later + connection from it is refused. The block outlives a hub restart: a local hub saves it with its registry, and a + NATS-backed hub keeps it in the shared blocklist. + Blocking an address already blocked succeeds again, so the request can be repeated safely. A path segment that is not an IPv4 or IPv6 address matches no route and is answered 404. diff --git a/generated/specs/rest.yaml b/generated/specs/rest.yaml index dec7f900..c4d71837 100644 --- a/generated/specs/rest.yaml +++ b/generated/specs/rest.yaml @@ -53,6 +53,10 @@ paths: - admin summary: "Block an address: refuse it on every hub of the backplane." description: |- + Every open socket from that address is closed within a heartbeat, whichever agent it carries, and every later + connection from it is refused. The block outlives a hub restart: a local hub saves it with its registry, and a + NATS-backed hub keeps it in the shared blocklist. + Blocking an address already blocked succeeds again, so the request can be repeated safely. A path segment that is not an IPv4 or IPv6 address matches no route and is answered 404. operationId: block_ip diff --git a/generated/zig-rest/src/et_rest_client.zig b/generated/zig-rest/src/et_rest_client.zig index d08aa5c8..2f8787aa 100644 --- a/generated/zig-rest/src/et_rest_client.zig +++ b/generated/zig-rest/src/et_rest_client.zig @@ -1,7 +1,7 @@ // // This code was generated by openapi2zig 0.6.0 (13a4354) // Changes to this file may cause incorrect behavior and will be lost if the code is regenerated -// Checksum: ed51863c7f64ca9 +// Checksum: a2c02b870410c97b // const std = @import("std"); @@ -546,6 +546,10 @@ pub fn evict_agentResult(client: *Client, agent_id: []const u8) !ApiResult(Evict // Block an address: refuse it on every hub of the backplane. // // Description: +// Every open socket from that address is closed within a heartbeat, whichever agent it carries, and every later +// connection from it is refused. The block outlives a hub restart: a local hub saves it with its registry, and a +// NATS-backed hub keeps it in the shared blocklist. +// // Blocking an address already blocked succeeds again, so the request can be repeated safely. A path segment that // is not an IPv4 or IPv6 address matches no route and is answered 404. // diff --git a/libs/actix-web-config/Cargo.toml b/libs/actix-web-config/Cargo.toml index 3e485048..6225adb1 100644 --- a/libs/actix-web-config/Cargo.toml +++ b/libs/actix-web-config/Cargo.toml @@ -30,7 +30,7 @@ humantime.workspace = true humantime-serde.workspace = true nutype.workspace = true schemars = { workspace = true, optional = true } -serde.workspace = true +serde = { workspace = true, features = ["std"] } serde_default.workspace = true serde_with.workspace = true diff --git a/libs/edge-toolkit/Cargo.toml b/libs/edge-toolkit/Cargo.toml index 14a30d7b..678d2204 100644 --- a/libs/edge-toolkit/Cargo.toml +++ b/libs/edge-toolkit/Cargo.toml @@ -13,37 +13,107 @@ features = ["docs"] [lib] doctest = false +# The tests of what only `std` provides. +# Every other test file covers the `no_std` part of the crate, and runs with `--no-default-features` as well. +[[test]] +name = "args" +required-features = ["std"] + +[[test]] +name = "config" +required-features = ["std"] + +[[test]] +name = "et_org_python_twin" +required-features = ["std"] + +[[test]] +name = "http_pyodide" +required-features = ["std"] + +[[test]] +name = "mise_env" +required-features = ["std"] + +[[test]] +name = "module_source" +required-features = ["std"] + +[[test]] +name = "no_mise" +required-features = ["std"] + +[[test]] +name = "npm_mod" +required-features = ["std"] + +[[test]] +name = "pipx_site_packages" +required-features = ["std"] + +[[test]] +name = "registry_file" +required-features = ["std"] + +[[test]] +name = "staged_module_dirs" +required-features = ["std"] + [features] +default = ["std"] +## Everything that needs an operating system. +## Configuration loading, the registry file, process arguments and the mise lookups. Without it the crate is +## `no_std` + `alloc`: the WebSocket protocol types, the port allocation, the `BasicAuth` config, and the hub's agent +## registry held in memory only. +std = [ + "base64/std", + "chrono/std", + "dep:base64", + "dep:command-error", + "dep:et-path", + "dep:fs-err", + "dep:humantime-serde", + "dep:serde-inline-default", + "dep:serde_default", + "dep:serde_yaml", + "dep:strum", + "dep:tracing", + "serde/std", + "serde_json/std", + "thiserror/std", +] ## Derives JSON Schema and AsyncAPI descriptions for the WebSocket protocol types. ## The published specs are generated from them. Off by default, so wasm and runtime crates do not pull in schemars ## and asyncapi-rust. -schema-export = ["dep:asyncapi-rust", "dep:schemars", "schemars/chrono04"] +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"] +env-schema = ["dep:schemars", "std"] ## Internal: renders this feature list into the crate documentation, as docs.rs builds it. docs = ["dep:document-features"] [dependencies] asyncapi-rust = { workspace = true, optional = true } -base64.workspace = true -chrono.workspace = true -command-error.workspace = true +base64 = { workspace = true, optional = true } +chrono = { workspace = true, features = ["alloc"] } +command-error = { workspace = true, optional = true } document-features = { workspace = true, optional = true } -et-path.workspace = true -fs-err.workspace = true -humantime-serde.workspace = true +et-path = { workspace = true, optional = true } +fs-err = { workspace = true, optional = true } +humantime-serde = { workspace = true, optional = true } schemars = { workspace = true, optional = true } secrecy.workspace = true -serde.workspace = true -serde-inline-default.workspace = true -serde_default.workspace = true -serde_json.workspace = true +serde = { workspace = true, features = ["alloc"] } +serde-inline-default = { workspace = true, optional = true } +serde_default = { workspace = true, optional = true } +serde_json = { workspace = true, features = ["alloc"] } serde_path_to_error.workspace = true -serde_yaml.workspace = true -strum.workspace = true +serde_yaml = { workspace = true, optional = true } +# The registry's lock when there is no `std::sync::Mutex`. +spin.workspace = true +strum = { workspace = true, optional = true } thiserror.workspace = true -tracing.workspace = true +tracing = { workspace = true, features = ["std"], optional = true } [dev-dependencies] cloudevents-sdk.workspace = true diff --git a/libs/edge-toolkit/src/auth.rs b/libs/edge-toolkit/src/auth.rs index 1b7ba0cc..1c76d2f4 100644 --- a/libs/edge-toolkit/src/auth.rs +++ b/libs/edge-toolkit/src/auth.rs @@ -1,5 +1,11 @@ +#[cfg(not(feature = "std"))] +use alloc::string::String; + +#[cfg(feature = "std")] use base64::{Engine as _, engine::general_purpose::STANDARD as b64standard}; -use secrecy::{ExposeSecret as _, SecretString}; +#[cfg(feature = "std")] +use secrecy::ExposeSecret as _; +use secrecy::SecretString; use serde::Deserialize; #[derive(Clone, Debug, Deserialize)] @@ -22,6 +28,7 @@ impl BasicAuth { } /// Add authorisation header to `HashMap`. + #[cfg(feature = "std")] pub fn add_basic_auth_header(&self, headers: &mut std::collections::HashMap) { let mut buf = String::default(); b64standard.encode_string( diff --git a/libs/edge-toolkit/src/lib.rs b/libs/edge-toolkit/src/lib.rs index 07fe09ec..53543b07 100644 --- a/libs/edge-toolkit/src/lib.rs +++ b/libs/edge-toolkit/src/lib.rs @@ -1,9 +1,14 @@ #![cfg_attr(doc, doc = include_str!("../README.md"))] #![cfg_attr(feature = "docs", doc = "## Feature flags")] #![cfg_attr(feature = "docs", doc = document_features::document_features!())] +#![cfg_attr(not(feature = "std"), no_std)] +extern crate alloc; + +#[cfg(feature = "std")] pub mod args; pub mod auth; +#[cfg(feature = "std")] pub mod config; pub mod ports; pub mod ws; diff --git a/libs/edge-toolkit/src/ws.rs b/libs/edge-toolkit/src/ws.rs index 1d501185..cc8805ad 100644 --- a/libs/edge-toolkit/src/ws.rs +++ b/libs/edge-toolkit/src/ws.rs @@ -1,4 +1,6 @@ -use std::net::IpAddr; +#[cfg(not(feature = "std"))] +use alloc::{borrow::ToOwned as _, format, string::String, vec::Vec}; +use core::net::IpAddr; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; diff --git a/libs/edge-toolkit/src/ws_server.rs b/libs/edge-toolkit/src/ws_server.rs index 85db824d..a2280db3 100644 --- a/libs/edge-toolkit/src/ws_server.rs +++ b/libs/edge-toolkit/src/ws_server.rs @@ -1,10 +1,21 @@ -use std::collections::{BTreeMap, BTreeSet}; -use std::net::IpAddr; -use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; - +use alloc::collections::{BTreeMap, BTreeSet}; +use alloc::sync::Arc; +#[cfg(not(feature = "std"))] +use alloc::{ + string::{String, ToString as _}, + vec::Vec, +}; +use core::net::IpAddr; +#[cfg(feature = "std")] +use std::sync::{Mutex as LockImpl, MutexGuard as Guard, PoisonError}; + +#[cfg(feature = "std")] use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +#[cfg(not(feature = "std"))] +use spin::{Mutex as LockImpl, MutexGuard as Guard}; use thiserror::Error; +#[cfg(feature = "std")] use tracing::{info, warn}; use crate::ws::{AgentConnectionState, AgentSummary, ConnectStatus}; @@ -12,12 +23,20 @@ use crate::ws::{AgentConnectionState, AgentSummary, ConnectStatus}; /// Longest agent id an agent may choose for itself; a generated UUID is 36 characters. pub const MAX_AGENT_ID_LEN: usize = 64; +/// The lock the registry's shared state sits behind. +/// +/// `std::sync::Mutex` wherever there is one, so a `std` caller's `lock()` reports poisoning as it always has; a spin +/// lock without `std`, where nothing can poison it. +pub type Lock = LockImpl; + #[derive(Debug, Error)] #[non_exhaustive] pub enum RegistryError { + #[cfg(feature = "std")] #[error(transparent)] Io(#[from] std::io::Error), + #[cfg(feature = "std")] #[error("failed to (de)serialize registry YAML")] Yaml(#[from] serde_yaml::Error), @@ -28,6 +47,7 @@ pub enum RegistryError { UnknownAgent(String), } +#[cfg(feature = "std")] impl From> for RegistryError { fn from(_source: PoisonError) -> Self { Self::LockPoisoned @@ -51,6 +71,7 @@ pub enum AcknowledgeError { LockPoisoned, } +#[cfg(feature = "std")] impl From> for AcknowledgeError { fn from(_source: PoisonError) -> Self { Self::LockPoisoned @@ -61,10 +82,12 @@ impl From> for AcknowledgeError { /// /// We never observe poisoned state in the wild -- every panic-prone path holds the lock briefly around infallible map /// ops. Recovering keeps the registry usable if a future change introduces a panic under the lock. -fn lock_agents( - agents: &Mutex>>, -) -> MutexGuard<'_, BTreeMap>> { - agents.lock().unwrap_or_else(PoisonError::into_inner) +fn lock_recovering(lock: &Lock) -> Guard<'_, T> { + #[cfg(feature = "std")] + let guard = lock.lock().unwrap_or_else(PoisonError::into_inner); + #[cfg(not(feature = "std"))] + let guard = lock.lock(); + guard } /// Whether `id` is acceptable as an agent id the agent chose itself rather than one the hub generated. @@ -180,38 +203,33 @@ impl AgentRecord { /// branch-coverage gate scores once rather than once per session type. #[derive(Clone, Debug, Default)] #[non_exhaustive] -pub struct IpBlocklist(Arc>>); +pub struct IpBlocklist(Arc>>); impl IpBlocklist { /// A blocklist refusing exactly `ips`. #[must_use] pub fn from_ips(ips: BTreeSet) -> Self { - Self(Arc::new(Mutex::new( + Self(Arc::new(Lock::new( ips.into_iter().map(|ip| ip.to_canonical()).collect(), ))) } /// Refuse `ip` from now on, returning whether it was not refused already. + #[must_use] pub fn block(&self, ip: IpAddr) -> bool { - self.0 - .lock() - .unwrap_or_else(PoisonError::into_inner) - .insert(ip.to_canonical()) + lock_recovering(&self.0).insert(ip.to_canonical()) } /// Whether `ip` is refused. #[must_use] pub fn is_blocked(&self, ip: IpAddr) -> bool { - self.0 - .lock() - .unwrap_or_else(PoisonError::into_inner) - .contains(&ip.to_canonical()) + lock_recovering(&self.0).contains(&ip.to_canonical()) } /// Every refused address, in order. #[must_use] pub fn ips(&self) -> BTreeSet { - self.0.lock().unwrap_or_else(PoisonError::into_inner).clone() + lock_recovering(&self.0).clone() } } @@ -230,6 +248,7 @@ pub struct RegistryFile { /// Either registry layout: the current one, or the bare agent map written before the hub kept a blocklist. /// /// `deny_unknown_fields` on [`RegistryFile`] is what tells them apart, since a bare map is keyed by agent ids. +#[cfg(feature = "std")] #[derive(Deserialize)] #[serde(untagged)] enum StoredRegistry { @@ -238,6 +257,7 @@ enum StoredRegistry { } /// Read the registry file at `path`, whose agents are records of type `R`; a missing file is an empty registry. +#[cfg(feature = "std")] pub fn read_registry_file(path: &std::path::Path) -> Result>, RegistryError> where R: DeserializeOwned, @@ -263,6 +283,7 @@ where } /// Parse a registry file's YAML in either layout, giving a bare agent map an empty blocklist. +#[cfg(feature = "std")] #[expect( clippy::single_call_fn, reason = "the parsing step of read_registry_file, kept apart from its file handling and logging" @@ -283,7 +304,7 @@ where #[derive(Clone)] #[non_exhaustive] pub struct AgentRegistry { - pub agents: Arc>>>, + pub agents: Arc>>>, pub blocklist: IpBlocklist, } @@ -292,7 +313,7 @@ impl AgentRegistry { #[must_use] pub fn from_agents(agents: BTreeMap>) -> Self { Self { - agents: Arc::new(Mutex::new(agents)), + agents: Arc::new(Lock::new(agents)), blocklist: IpBlocklist::default(), } } @@ -305,6 +326,7 @@ impl AgentRegistry { } } +#[cfg(feature = "std")] impl AgentRegistry { pub fn load(path: &std::path::Path) -> Result { let file = read_registry_file::>(path)?; @@ -313,6 +335,7 @@ impl AgentRegistry { } impl AgentRegistry { + #[cfg(feature = "std")] pub fn save(&self, path: &std::path::Path) -> Result<(), RegistryError> { let agents = self.agents.lock()?; let yaml = serde_yaml::to_string(&RegistryFile { @@ -327,7 +350,7 @@ impl AgentRegistry { /// The address `agent_id` last connected from. pub fn agent_ip(&self, agent_id: &str) -> Result { - lock_agents(&self.agents) + lock_recovering(&self.agents) .get(agent_id) .map(|record| record.last_known_ip) .ok_or_else(|| RegistryError::UnknownAgent(agent_id.to_string())) @@ -340,7 +363,7 @@ impl AgentRegistry { client_ip: IpAddr, session: S, ) -> (String, ConnectStatus) { - let mut agents = lock_agents(&self.agents); + let mut agents = lock_recovering(&self.agents); if let Some(requested) = requested_id.as_deref() && let Some(record) = agents.get_mut(requested) @@ -388,7 +411,7 @@ impl AgentRegistry { /// /// Returns whether the record was marked, so a caller mirroring the registry elsewhere skips the same writes. pub fn mark_disconnected_if(&self, agent_id: &str, is_current: &dyn Fn(&S) -> bool) -> bool { - let mut agents = lock_agents(&self.agents); + let mut agents = lock_recovering(&self.agents); let marked = agents .get_mut(agent_id) .filter(|record| record.session.as_ref().is_none_or(is_current)) @@ -403,7 +426,7 @@ impl AgentRegistry { #[must_use] pub fn list_agents(&self) -> Vec { - let agents = lock_agents(&self.agents); + let agents = lock_recovering(&self.agents); let mut summaries = agents .iter() .map(|(agent_id, record)| AgentSummary { @@ -433,7 +456,7 @@ impl AgentRegistry { message: serde_json::Value, retention: MessageRetention, ) -> Option<(PendingDirectMessage, Option)> { - let mut agents = lock_agents(&self.agents); + let mut agents = lock_recovering(&self.agents); let recipient = agents.get_mut(to_agent_id)?; let pending = PendingDirectMessage::new(message_id, from_agent_id.to_string(), server_received_at, message); @@ -449,7 +472,7 @@ impl AgentRegistry { /// Drop every queued message `from_agent_id` sent, from every recipient's queue, returning how many were dropped. #[must_use] pub fn forget_messages_from(&self, from_agent_id: &str) -> usize { - let mut agents = lock_agents(&self.agents); + let mut agents = lock_recovering(&self.agents); let dropped = agents .values_mut() .map(|record| { @@ -466,7 +489,7 @@ impl AgentRegistry { #[must_use] pub fn pending_messages_for(&self, agent_id: &str) -> Vec { - let agents = lock_agents(&self.agents); + let agents = lock_recovering(&self.agents); agents .get(agent_id) .map(|record| { @@ -483,7 +506,10 @@ impl AgentRegistry { recipient_agent_id: &str, message_id: &str, ) -> Result<(String, Option, String), AcknowledgeError> { + #[cfg(feature = "std")] let mut agents = self.agents.lock()?; + #[cfg(not(feature = "std"))] + let mut agents = self.agents.lock(); let recipient = agents .get_mut(recipient_agent_id) .ok_or_else(|| AcknowledgeError::UnknownAgent(recipient_agent_id.to_string()))?; @@ -509,7 +535,7 @@ impl AgentRegistry { #[must_use] pub fn connected_sessions(&self, excluding_agent_id: &str) -> Vec<(String, S)> { - let agents = lock_agents(&self.agents); + let agents = lock_recovering(&self.agents); agents .iter() .filter_map(|(agent_id, record)| { @@ -523,7 +549,7 @@ impl AgentRegistry { #[must_use] pub fn agent_session(&self, agent_id: &str) -> Option { - let agents = lock_agents(&self.agents); + let agents = lock_recovering(&self.agents); let session = agents.get(agent_id).and_then(|record| record.session.clone()); drop(agents); session @@ -533,7 +559,7 @@ impl AgentRegistry { impl Default for AgentRegistry { fn default() -> Self { Self { - agents: Arc::new(Mutex::new(BTreeMap::new())), + agents: Arc::new(Lock::new(BTreeMap::new())), blocklist: IpBlocklist::default(), } } diff --git a/libs/edge-toolkit/tests/registry.rs b/libs/edge-toolkit/tests/registry.rs index fdb3ccb9..fc0ee406 100644 --- a/libs/edge-toolkit/tests/registry.rs +++ b/libs/edge-toolkit/tests/registry.rs @@ -1,7 +1,7 @@ -//! Covers the `AgentRegistry` persistence + session-lookup paths the ws-server integration tests skip: -//! save/load round-trip (including the missing-file and populated branches), reconnect, `agent_session`, and -//! the `with_pending_direct_messages` builder. `S = String` stands in for the runtime session handle (which is -//! `#[serde(skip)]`, so it is never persisted). +//! Covers the in-memory `AgentRegistry` paths the ws-server integration tests skip: reconnect, disconnect, +//! `agent_session`, the blocklist, message queueing and acknowledgement under each retention, and the +//! `with_pending_direct_messages` builder. None of it needs `std`. `S = String` stands in for the runtime session +//! handle. #![cfg(test)] use std::collections::{BTreeMap, BTreeSet}; @@ -10,46 +10,12 @@ use std::net::{IpAddr, Ipv4Addr}; use edge_toolkit::ws::{AgentConnectionState, ConnectStatus}; use edge_toolkit::ws_server::{ AcknowledgeError, AgentRecord, AgentRegistry, IpBlocklist, MAX_AGENT_ID_LEN, MessageRetention, RegistryError, - is_valid_agent_id, read_registry_file, + is_valid_agent_id, }; -use tempfile::tempdir; const FIRST_IP: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST); const SECOND_IP: IpAddr = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)); -#[test] -fn save_load_roundtrip_and_session_lookup() { - let registry = AgentRegistry::::default(); - - // Fresh connection (Assigned), then a reconnect for the same id which swaps in a new session. - let (agent_id, _assigned) = registry.connect_agent(None, "agent-1".to_string(), FIRST_IP, "sess-a".to_string()); - let (_same_id, _reconnected) = registry.connect_agent( - Some(agent_id.clone()), - "agent-x".to_string(), - SECOND_IP, - "sess-b".to_string(), - ); - - assert_eq!( - registry.agent_session(&agent_id).as_deref(), - Some("sess-b"), - "reconnect keeps the newest session" - ); - assert_eq!(registry.agent_session("nobody"), None, "unknown agent has no session"); - - // Persist and reload. Sessions are #[serde(skip)], so they return as None, but the agent survives. - let dir = tempdir().unwrap(); - let path = dir.path().join("registry.yaml"); - registry.save(&path).unwrap(); - let reloaded = AgentRegistry::::load(&path).unwrap(); - assert_eq!(reloaded.agent_session(&agent_id), None, "sessions are not persisted"); - assert_eq!(reloaded.list_agents()[0].last_known_ip, SECOND_IP); - - // load() on a missing file yields an empty registry rather than erroring. - let empty = AgentRegistry::::load(&dir.path().join("absent.yaml")).unwrap(); - assert_eq!(empty.agent_session(&agent_id), None); -} - #[test] fn an_unsafe_id_is_replaced_and_an_unknown_disconnect_is_a_no_op() { let registry = AgentRegistry::::default(); @@ -144,16 +110,12 @@ fn a_displaced_session_cannot_disconnect_the_one_that_replaced_it() { #[test] fn a_record_with_no_session_is_disconnected_whatever_the_test_says() { - // Loaded from disk, a record carries no session, so no closing connection can prove it owns one. - let dir = tempdir().unwrap(); - let path = dir.path().join("registry.yaml"); - let registry = AgentRegistry::::default(); - let (agent_id, _assigned) = registry.connect_agent(None, "agent-1".to_string(), FIRST_IP, "sess-a".to_string()); - registry.save(&path).unwrap(); - let reloaded = AgentRegistry::::load(&path).unwrap(); + // A record loaded from disk carries no session, so no closing connection can prove it owns one. + let record = AgentRecord::::new(AgentConnectionState::Connected, FIRST_IP, None); + let registry = AgentRegistry::from_agents(BTreeMap::from([("agent-1".to_string(), record)])); - assert!(reloaded.mark_disconnected_if(&agent_id, &|_session| false)); - assert_eq!(reloaded.list_agents()[0].state, AgentConnectionState::Disconnected); + assert!(registry.mark_disconnected_if("agent-1", &|_session| false)); + assert_eq!(registry.list_agents()[0].state, AgentConnectionState::Disconnected); } #[test] @@ -213,44 +175,6 @@ fn an_agents_address_is_its_last_known_ip() { )); } -#[test] -fn the_blocklist_is_saved_and_loaded_with_the_agents() { - let dir = tempdir().unwrap(); - let path = dir.path().join("registry.yaml"); - let registry = AgentRegistry::::default(); - let (agent_id, _assigned) = registry.connect_agent(None, "agent-1".to_string(), FIRST_IP, "sess-a".to_string()); - assert!(registry.blocklist.block(SECOND_IP)); - registry.save(&path).unwrap(); - - let reloaded = AgentRegistry::::load(&path).unwrap(); - assert_eq!(reloaded.blocklist.ips(), BTreeSet::from([SECOND_IP])); - assert_eq!(reloaded.agent_ip(&agent_id).unwrap(), FIRST_IP); -} - -#[test] -fn a_registry_saved_before_the_blocklist_still_loads() { - let dir = tempdir().unwrap(); - let path = dir.path().join("registry.yaml"); - fs_err::write(&path, "agent-1:\n state: disconnected\n last_known_ip: 127.0.0.1\n").unwrap(); - - let file = read_registry_file::>(&path).unwrap(); - assert_eq!(file.agents["agent-1"].last_known_ip, FIRST_IP); - assert!(file.blocked_ips.is_empty()); - assert_eq!(AgentRegistry::::load(&path).unwrap().list_agents().len(), 1); -} - -#[test] -fn a_registry_that_is_neither_layout_is_an_error() { - let dir = tempdir().unwrap(); - let path = dir.path().join("registry.yaml"); - fs_err::write(&path, "- a list, not a registry\n").unwrap(); - - assert!(matches!( - AgentRegistry::::load(&path), - Err(RegistryError::Yaml(_)) - )); -} - #[test] fn agent_record_with_pending_builder_replaces_the_map() { let record = AgentRecord::::new(AgentConnectionState::Disconnected, FIRST_IP, None) diff --git a/libs/edge-toolkit/tests/registry_file.rs b/libs/edge-toolkit/tests/registry_file.rs new file mode 100644 index 00000000..0ffcafb4 --- /dev/null +++ b/libs/edge-toolkit/tests/registry_file.rs @@ -0,0 +1,84 @@ +//! Covers the `AgentRegistry` persistence the `std` feature adds: the save/load round-trip (including the +//! missing-file and populated branches), the blocklist saved beside the agents, and both file layouts. `S = String` +//! stands in for the runtime session handle (which is `#[serde(skip)]`, so it is never persisted). +#![cfg(test)] + +use std::collections::BTreeSet; +use std::net::{IpAddr, Ipv4Addr}; + +use edge_toolkit::ws_server::{AgentRecord, AgentRegistry, RegistryError, read_registry_file}; +use tempfile::tempdir; + +const FIRST_IP: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST); +const SECOND_IP: IpAddr = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)); + +#[test] +fn save_load_roundtrip_and_session_lookup() { + let registry = AgentRegistry::::default(); + + // Fresh connection (Assigned), then a reconnect for the same id which swaps in a new session. + let (agent_id, _assigned) = registry.connect_agent(None, "agent-1".to_string(), FIRST_IP, "sess-a".to_string()); + let (_same_id, _reconnected) = registry.connect_agent( + Some(agent_id.clone()), + "agent-x".to_string(), + SECOND_IP, + "sess-b".to_string(), + ); + + assert_eq!( + registry.agent_session(&agent_id).as_deref(), + Some("sess-b"), + "reconnect keeps the newest session" + ); + assert_eq!(registry.agent_session("nobody"), None, "unknown agent has no session"); + + // Persist and reload. Sessions are #[serde(skip)], so they return as None, but the agent survives. + let dir = tempdir().unwrap(); + let path = dir.path().join("registry.yaml"); + registry.save(&path).unwrap(); + let reloaded = AgentRegistry::::load(&path).unwrap(); + assert_eq!(reloaded.agent_session(&agent_id), None, "sessions are not persisted"); + assert_eq!(reloaded.list_agents()[0].last_known_ip, SECOND_IP); + + // load() on a missing file yields an empty registry rather than erroring. + let empty = AgentRegistry::::load(&dir.path().join("absent.yaml")).unwrap(); + assert_eq!(empty.agent_session(&agent_id), None); +} + +#[test] +fn the_blocklist_is_saved_and_loaded_with_the_agents() { + let dir = tempdir().unwrap(); + let path = dir.path().join("registry.yaml"); + let registry = AgentRegistry::::default(); + let (agent_id, _assigned) = registry.connect_agent(None, "agent-1".to_string(), FIRST_IP, "sess-a".to_string()); + assert!(registry.blocklist.block(SECOND_IP)); + registry.save(&path).unwrap(); + + let reloaded = AgentRegistry::::load(&path).unwrap(); + assert_eq!(reloaded.blocklist.ips(), BTreeSet::from([SECOND_IP])); + assert_eq!(reloaded.agent_ip(&agent_id).unwrap(), FIRST_IP); +} + +#[test] +fn a_registry_saved_before_the_blocklist_still_loads() { + let dir = tempdir().unwrap(); + let path = dir.path().join("registry.yaml"); + fs_err::write(&path, "agent-1:\n state: disconnected\n last_known_ip: 127.0.0.1\n").unwrap(); + + let file = read_registry_file::>(&path).unwrap(); + assert_eq!(file.agents["agent-1"].last_known_ip, FIRST_IP); + assert!(file.blocked_ips.is_empty()); + assert_eq!(AgentRegistry::::load(&path).unwrap().list_agents().len(), 1); +} + +#[test] +fn a_registry_that_is_neither_layout_is_an_error() { + let dir = tempdir().unwrap(); + let path = dir.path().join("registry.yaml"); + fs_err::write(&path, "- a list, not a registry\n").unwrap(); + + assert!(matches!( + AgentRegistry::::load(&path), + Err(RegistryError::Yaml(_)) + )); +} diff --git a/libs/et-otlp/Cargo.toml b/libs/et-otlp/Cargo.toml index 623365d8..0a0b8b40 100644 --- a/libs/et-otlp/Cargo.toml +++ b/libs/et-otlp/Cargo.toml @@ -11,22 +11,22 @@ repository.workspace = true doctest = false [dependencies] -edge-toolkit.workspace = true +edge-toolkit = { workspace = true, features = ["std"] } hostname.workspace = true opentelemetry.workspace = true opentelemetry-appender-tracing.workspace = true opentelemetry-otlp.workspace = true opentelemetry_sdk.workspace = true -serde.workspace = true +serde = { workspace = true, features = ["std"] } serde-env.workspace = true -tracing.workspace = true +tracing = { workspace = true, features = ["std"] } tracing-log.workspace = true tracing-opentelemetry.workspace = true tracing-subscriber.workspace = true [dev-dependencies] fs-err.workspace = true -serde_json.workspace = true +serde_json = { workspace = true, features = ["std"] } [lints] workspace = true diff --git a/libs/org/src/lib.rs b/libs/org/src/lib.rs index 9700bec1..802d6e35 100644 --- a/libs/org/src/lib.rs +++ b/libs/org/src/lib.rs @@ -9,6 +9,7 @@ //! //! The organisation name itself is written exactly once, in [`ORG`]'s macro, and every longer string is assembled from //! it. That is what makes the rule enforceable rather than aspirational: there is a single literal to protect. +#![no_std] /// The organisation name as a literal, for the constants below to build on. /// diff --git a/libs/test-otlp/Cargo.toml b/libs/test-otlp/Cargo.toml index a73e7fcc..723e8577 100644 --- a/libs/test-otlp/Cargo.toml +++ b/libs/test-otlp/Cargo.toml @@ -17,13 +17,13 @@ opentelemetry.workspace = true opentelemetry-otlp.workspace = true opentelemetry-proto.workspace = true opentelemetry_sdk.workspace = true -tracing.workspace = true +tracing = { workspace = true, features = ["std"] } tracing-subscriber.workspace = true [dev-dependencies] prost.workspace = true reqwest.workspace = true -serde_json.workspace = true +serde_json = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } [lints] diff --git a/libs/test-recovery/Cargo.toml b/libs/test-recovery/Cargo.toml index c8f14ad6..b7966360 100644 --- a/libs/test-recovery/Cargo.toml +++ b/libs/test-recovery/Cargo.toml @@ -18,17 +18,17 @@ path = "src/bin/command-probe.rs" test = false [dependencies] -chrono.workspace = true +chrono = { workspace = true, features = ["clock"] } clap.workspace = true -edge-toolkit.workspace = true +edge-toolkit = { workspace = true, features = ["std"] } et-recovery-supervisor.workspace = true serde-env.workspace = true -serde_json.workspace = true +serde_json = { workspace = true, features = ["std"] } [dev-dependencies] futures-util.workspace = true tokio = { workspace = true, features = ["io-util", "macros", "process", "rt", "time"] } -tracing.workspace = true +tracing = { workspace = true, features = ["std"] } tracing-subscriber.workspace = true [lints] diff --git a/libs/ws-runner-common/Cargo.toml b/libs/ws-runner-common/Cargo.toml index 5410355f..17929706 100644 --- a/libs/ws-runner-common/Cargo.toml +++ b/libs/ws-runner-common/Cargo.toml @@ -25,7 +25,7 @@ docs = ["dep:document-features"] [dependencies] document-features = { workspace = true, optional = true } -edge-toolkit.workspace = true +edge-toolkit = { workspace = true, features = ["std"] } et-otlp.workspace = true et-rest-client.workspace = true futures-util.workspace = true @@ -33,15 +33,15 @@ humantime-serde.workspace = true reqwest.workspace = true retry-policies.workspace = true schemars = { workspace = true, optional = true } -serde.workspace = true +serde = { workspace = true, features = ["std"] } serde-inline-default.workspace = true serde_default.workspace = true -serde_json.workspace = true +serde_json = { workspace = true, features = ["std"] } serde_path_to_error.workspace = true -thiserror.workspace = true +thiserror = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["time"] } tokio-tungstenite = { workspace = true, features = ["connect"] } -tracing.workspace = true +tracing = { workspace = true, features = ["attributes", "std"] } [dev-dependencies] et-rest-client.workspace = true diff --git a/services/hub-backplane/Cargo.toml b/services/hub-backplane/Cargo.toml index e8a7df5f..2e43fb3e 100644 --- a/services/hub-backplane/Cargo.toml +++ b/services/hub-backplane/Cargo.toml @@ -12,15 +12,15 @@ doctest = false [dependencies] async-nats.workspace = true -bytes.workspace = true +bytes = { workspace = true, features = ["std"] } const-hex.workspace = true -edge-toolkit.workspace = true +edge-toolkit = { workspace = true, features = ["std"] } futures-util.workspace = true -serde.workspace = true -serde_json.workspace = true -thiserror.workspace = true +serde = { workspace = true, features = ["std"] } +serde_json = { workspace = true, features = ["std"] } +thiserror = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["time"] } -tracing.workspace = true +tracing = { workspace = true, features = ["std"] } uuid.workspace = true [dev-dependencies] diff --git a/services/modules/Cargo.toml b/services/modules/Cargo.toml index e2faf85c..1c353b05 100644 --- a/services/modules/Cargo.toml +++ b/services/modules/Cargo.toml @@ -26,13 +26,13 @@ docs = ["dep:document-features"] actix-files.workspace = true actix-web.workspace = true document-features = { workspace = true, optional = true } -edge-toolkit.workspace = true +edge-toolkit = { workspace = true, features = ["std"] } fs-err.workspace = true schemars = { workspace = true, optional = true } -serde.workspace = true +serde = { workspace = true, features = ["std"] } serde-inline-default.workspace = true serde_default.workspace = true -serde_json.workspace = true +serde_json = { workspace = true, features = ["std"] } utoipa = { workspace = true, optional = true } [dev-dependencies] diff --git a/services/recovery-supervisor/Cargo.toml b/services/recovery-supervisor/Cargo.toml index 82dc242d..30ee3f1f 100644 --- a/services/recovery-supervisor/Cargo.toml +++ b/services/recovery-supervisor/Cargo.toml @@ -30,19 +30,19 @@ docs = ["dep:document-features"] [dependencies] document-features = { workspace = true, optional = true } -edge-toolkit.workspace = true +edge-toolkit = { workspace = true, features = ["std"] } et-ws-runner-common.workspace = true fs-err.workspace = true futures-util.workspace = true schemars = { workspace = true, optional = true } -serde.workspace = true +serde = { workspace = true, features = ["std"] } serde-env.workspace = true -serde_json.workspace = true +serde_json = { workspace = true, features = ["std"] } tempfile.workspace = true -thiserror.workspace = true +thiserror = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["io-util", "macros", "process", "rt-multi-thread", "signal", "time"] } tokio-tungstenite = { workspace = true, features = ["rustls-tls-native-roots"] } -tracing.workspace = true +tracing = { workspace = true, features = ["attributes", "std"] } tracing-subscriber.workspace = true url.workspace = true uuid.workspace = true diff --git a/services/storage/Cargo.toml b/services/storage/Cargo.toml index a5cf8ced..1e24d6bd 100644 --- a/services/storage/Cargo.toml +++ b/services/storage/Cargo.toml @@ -39,7 +39,7 @@ docs = ["dep:document-features"] actix-web.workspace = true actix-web-thiserror.workspace = true document-features = { workspace = true, optional = true } -edge-toolkit.workspace = true +edge-toolkit = { workspace = true, features = ["std"] } et-hub-backplane.workspace = true fs-err.workspace = true futures-util.workspace = true @@ -47,11 +47,11 @@ image = { workspace = true, optional = true } log.workspace = true object_store.workspace = true schemars = { workspace = true, optional = true } -serde.workspace = true +serde = { workspace = true, features = ["std"] } serde_default.workspace = true -serde_json.workspace = true -thiserror.workspace = true -tracing.workspace = true +serde_json = { workspace = true, features = ["std"] } +thiserror = { workspace = true, features = ["std"] } +tracing = { workspace = true, features = ["std"] } url.workspace = true utoipa = { workspace = true, optional = true } diff --git a/services/websockify/Cargo.toml b/services/websockify/Cargo.toml index c1bc01cc..c125da97 100644 --- a/services/websockify/Cargo.toml +++ b/services/websockify/Cargo.toml @@ -13,10 +13,10 @@ doctest = false [dependencies] actix-web.workspace = true actix-ws.workspace = true -bytes.workspace = true +bytes = { workspace = true, features = ["std"] } futures-util.workspace = true tokio = { workspace = true, features = ["io-util", "macros", "net", "rt"] } -tracing.workspace = true +tracing = { workspace = true, features = ["std"] } [dev-dependencies] actix-web.workspace = true diff --git a/services/ws-modules/audio1/Cargo.toml b/services/ws-modules/audio1/Cargo.toml index 30774f05..e5963a97 100644 --- a/services/ws-modules/audio1/Cargo.toml +++ b/services/ws-modules/audio1/Cargo.toml @@ -19,8 +19,8 @@ test = false et-org.workspace = true et-web.workspace = true et-ws-wasm-agent.workspace = true -serde_json.workspace = true -tracing.workspace = true +serde_json = { workspace = true, features = ["std"] } +tracing = { workspace = true, features = ["std"] } tracing-wasm.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true diff --git a/services/ws-modules/bluetooth/Cargo.toml b/services/ws-modules/bluetooth/Cargo.toml index c8de0d1a..f8a7d03c 100644 --- a/services/ws-modules/bluetooth/Cargo.toml +++ b/services/ws-modules/bluetooth/Cargo.toml @@ -20,8 +20,8 @@ et-org.workspace = true et-web.workspace = true et-ws-wasm-agent.workspace = true js-sys.workspace = true -serde_json.workspace = true -tracing.workspace = true +serde_json = { workspace = true, features = ["std"] } +tracing = { workspace = true, features = ["std"] } tracing-wasm.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true diff --git a/services/ws-modules/comm1/Cargo.toml b/services/ws-modules/comm1/Cargo.toml index 7e75df85..996620eb 100644 --- a/services/ws-modules/comm1/Cargo.toml +++ b/services/ws-modules/comm1/Cargo.toml @@ -17,11 +17,11 @@ doctest = false test = false [dependencies] -edge-toolkit.workspace = true +edge-toolkit = { workspace = true, features = ["std"] } et-web.workspace = true et-ws-wasm-agent.workspace = true -serde_json.workspace = true -tracing.workspace = true +serde_json = { workspace = true, features = ["std"] } +tracing = { workspace = true, features = ["std"] } tracing-wasm.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true diff --git a/services/ws-modules/data1/Cargo.toml b/services/ws-modules/data1/Cargo.toml index 33c00419..b6c649fb 100644 --- a/services/ws-modules/data1/Cargo.toml +++ b/services/ws-modules/data1/Cargo.toml @@ -17,7 +17,7 @@ doctest = false test = false [dependencies] -edge-toolkit.workspace = true +edge-toolkit = { workspace = true, features = ["std"] } et-web.workspace = true et-ws-wasm-agent.workspace = true # Disable the `tracing` default feature for this browser WASM target. @@ -25,8 +25,8 @@ et-ws-wasm-agent.workspace = true et-rest-client.workspace = true futures-util.workspace = true js-sys.workspace = true -serde_json.workspace = true -tracing.workspace = true +serde_json = { workspace = true, features = ["std"] } +tracing = { workspace = true, features = ["std"] } tracing-wasm.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true diff --git a/services/ws-modules/except1/Cargo.toml b/services/ws-modules/except1/Cargo.toml index a5806b50..ab5c95f3 100644 --- a/services/ws-modules/except1/Cargo.toml +++ b/services/ws-modules/except1/Cargo.toml @@ -22,7 +22,7 @@ test = false [dependencies] et-web.workspace = true et-ws-wasm-agent.workspace = true -tracing.workspace = true +tracing = { workspace = true, features = ["std"] } tracing-wasm.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true diff --git a/services/ws-modules/face-detection/Cargo.toml b/services/ws-modules/face-detection/Cargo.toml index 514830c0..dcd66fae 100644 --- a/services/ws-modules/face-detection/Cargo.toml +++ b/services/ws-modules/face-detection/Cargo.toml @@ -23,8 +23,8 @@ et-org.workspace = true et-web.workspace = true et-ws-wasm-agent.workspace = true js-sys.workspace = true -serde_json.workspace = true -tracing.workspace = true +serde_json = { workspace = true, features = ["std"] } +tracing = { workspace = true, features = ["std"] } tracing-wasm.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true diff --git a/services/ws-modules/geolocation/Cargo.toml b/services/ws-modules/geolocation/Cargo.toml index 821732fa..53814f0f 100644 --- a/services/ws-modules/geolocation/Cargo.toml +++ b/services/ws-modules/geolocation/Cargo.toml @@ -20,8 +20,8 @@ et-org.workspace = true et-web.workspace = true et-ws-wasm-agent.workspace = true js-sys.workspace = true -serde_json.workspace = true -tracing.workspace = true +serde_json = { workspace = true, features = ["std"] } +tracing = { workspace = true, features = ["std"] } tracing-wasm.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true diff --git a/services/ws-modules/graphics-info/Cargo.toml b/services/ws-modules/graphics-info/Cargo.toml index 0ba186a7..11d4cef5 100644 --- a/services/ws-modules/graphics-info/Cargo.toml +++ b/services/ws-modules/graphics-info/Cargo.toml @@ -20,8 +20,8 @@ et-org.workspace = true et-web.workspace = true et-ws-wasm-agent.workspace = true js-sys.workspace = true -serde_json.workspace = true -tracing.workspace = true +serde_json = { workspace = true, features = ["std"] } +tracing = { workspace = true, features = ["std"] } tracing-wasm.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true diff --git a/services/ws-modules/har1/Cargo.toml b/services/ws-modules/har1/Cargo.toml index efc5f63e..1ff0a975 100644 --- a/services/ws-modules/har1/Cargo.toml +++ b/services/ws-modules/har1/Cargo.toml @@ -23,8 +23,8 @@ et-org.workspace = true et-web.workspace = true et-ws-wasm-agent.workspace = true js-sys.workspace = true -serde_json.workspace = true -tracing.workspace = true +serde_json = { workspace = true, features = ["std"] } +tracing = { workspace = true, features = ["std"] } tracing-wasm.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true diff --git a/services/ws-modules/llm1/Cargo.toml b/services/ws-modules/llm1/Cargo.toml index bc923086..13089ca2 100644 --- a/services/ws-modules/llm1/Cargo.toml +++ b/services/ws-modules/llm1/Cargo.toml @@ -24,8 +24,8 @@ et-org.workspace = true et-web.workspace = true et-ws-wasm-agent.workspace = true js-sys.workspace = true -serde_json.workspace = true -tracing.workspace = true +serde_json = { workspace = true, features = ["std"] } +tracing = { workspace = true, features = ["std"] } tracing-wasm.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true diff --git a/services/ws-modules/math1-sender/Cargo.toml b/services/ws-modules/math1-sender/Cargo.toml index ff2fca18..725ea46a 100644 --- a/services/ws-modules/math1-sender/Cargo.toml +++ b/services/ws-modules/math1-sender/Cargo.toml @@ -20,7 +20,7 @@ test = false et-rest-client.workspace = true et-web.workspace = true et-ws-wasm-agent.workspace = true -tracing.workspace = true +tracing = { workspace = true, features = ["std"] } tracing-wasm.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true diff --git a/services/ws-modules/math1/Cargo.toml b/services/ws-modules/math1/Cargo.toml index 6499266f..3125c90b 100644 --- a/services/ws-modules/math1/Cargo.toml +++ b/services/ws-modules/math1/Cargo.toml @@ -21,9 +21,9 @@ et-rest-client.workspace = true et-web.workspace = true et-ws-wasm-agent.workspace = true futures-util.workspace = true -serde.workspace = true -serde_json.workspace = true -tracing.workspace = true +serde = { workspace = true, features = ["std"] } +serde_json = { workspace = true, features = ["std"] } +tracing = { workspace = true, features = ["std"] } tracing-wasm.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true diff --git a/services/ws-modules/nfc/Cargo.toml b/services/ws-modules/nfc/Cargo.toml index 0ea5abb9..e4aafaef 100644 --- a/services/ws-modules/nfc/Cargo.toml +++ b/services/ws-modules/nfc/Cargo.toml @@ -20,8 +20,8 @@ et-org.workspace = true et-web.workspace = true et-ws-wasm-agent.workspace = true js-sys.workspace = true -serde_json.workspace = true -tracing.workspace = true +serde_json = { workspace = true, features = ["std"] } +tracing = { workspace = true, features = ["std"] } tracing-wasm.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true diff --git a/services/ws-modules/pic-viewer/Cargo.toml b/services/ws-modules/pic-viewer/Cargo.toml index 48d6c6ee..b1ae8833 100644 --- a/services/ws-modules/pic-viewer/Cargo.toml +++ b/services/ws-modules/pic-viewer/Cargo.toml @@ -16,12 +16,12 @@ doctest = false test = false [dependencies] -edge-toolkit.workspace = true +edge-toolkit = { workspace = true, features = ["std"] } et-org.workspace = true et-web.workspace = true et-ws-wasm-agent.workspace = true -serde_json.workspace = true -tracing.workspace = true +serde_json = { workspace = true, features = ["std"] } +tracing = { workspace = true, features = ["std"] } tracing-wasm.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true diff --git a/services/ws-modules/sensor1/Cargo.toml b/services/ws-modules/sensor1/Cargo.toml index fa07f8a7..31e84b8f 100644 --- a/services/ws-modules/sensor1/Cargo.toml +++ b/services/ws-modules/sensor1/Cargo.toml @@ -23,7 +23,7 @@ test = false et-web.workspace = true et-ws-wasm-agent.workspace = true js-sys.workspace = true -tracing.workspace = true +tracing = { workspace = true, features = ["std"] } tracing-wasm.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true diff --git a/services/ws-modules/speech-recognition/Cargo.toml b/services/ws-modules/speech-recognition/Cargo.toml index 3718d489..413b9632 100644 --- a/services/ws-modules/speech-recognition/Cargo.toml +++ b/services/ws-modules/speech-recognition/Cargo.toml @@ -20,8 +20,8 @@ et-org.workspace = true et-web.workspace = true et-ws-wasm-agent.workspace = true js-sys.workspace = true -serde_json.workspace = true -tracing.workspace = true +serde_json = { workspace = true, features = ["std"] } +tracing = { workspace = true, features = ["std"] } tracing-wasm.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true diff --git a/services/ws-modules/video1/Cargo.toml b/services/ws-modules/video1/Cargo.toml index d5f78e51..997b90e4 100644 --- a/services/ws-modules/video1/Cargo.toml +++ b/services/ws-modules/video1/Cargo.toml @@ -20,8 +20,8 @@ et-org.workspace = true et-web.workspace = true et-ws-wasm-agent.workspace = true js-sys.workspace = true -serde_json.workspace = true -tracing.workspace = true +serde_json = { workspace = true, features = ["std"] } +tracing = { workspace = true, features = ["std"] } tracing-wasm.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true diff --git a/services/ws-modules/wasi-comm1/Cargo.toml b/services/ws-modules/wasi-comm1/Cargo.toml index 166d86f9..e6368ebf 100644 --- a/services/ws-modules/wasi-comm1/Cargo.toml +++ b/services/ws-modules/wasi-comm1/Cargo.toml @@ -27,7 +27,7 @@ docs = ["dep:document-features"] [target.'cfg(target_os = "wasi")'.dependencies] document-features = { workspace = true, optional = true } et-wasi-guest.workspace = true -serde_json.workspace = true +serde_json = { workspace = true, features = ["std"] } [lints] workspace = true diff --git a/services/ws-modules/wasi-math1/Cargo.toml b/services/ws-modules/wasi-math1/Cargo.toml index 97bacf23..cc2ad703 100644 --- a/services/ws-modules/wasi-math1/Cargo.toml +++ b/services/ws-modules/wasi-math1/Cargo.toml @@ -28,8 +28,8 @@ docs = ["dep:document-features"] [target.'cfg(target_os = "wasi")'.dependencies] document-features = { workspace = true, optional = true } et-wasi-guest.workspace = true -serde = { workspace = true, features = ["derive"] } -serde_json.workspace = true +serde = { workspace = true, features = ["derive", "std"] } +serde_json = { workspace = true, features = ["std"] } [lints] workspace = true diff --git a/services/ws-pyo3-runner/Cargo.toml b/services/ws-pyo3-runner/Cargo.toml index 7abc9086..37945613 100644 --- a/services/ws-pyo3-runner/Cargo.toml +++ b/services/ws-pyo3-runner/Cargo.toml @@ -16,7 +16,7 @@ name = "et-ws-pyo3-runner" path = "src/main.rs" [dependencies] -edge-toolkit.workspace = true +edge-toolkit = { workspace = true, features = ["std"] } et-otlp.workspace = true # Typed ws-server REST client (storage GET/PUT). # `tracing` injects the W3C traceparent on each request, matching the other native runners. @@ -26,10 +26,10 @@ et-rest-client = { workspace = true, features = ["tracing"] } et-ws-runner-common.workspace = true futures-util.workspace = true pyo3.workspace = true -thiserror.workspace = true +thiserror = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] } tokio-tungstenite = { workspace = true, features = ["connect"] } -tracing.workspace = true +tracing = { workspace = true, features = ["std"] } [dev-dependencies] backon = { workspace = true, features = ["tokio-sleep"] } @@ -37,7 +37,7 @@ command-error.workspace = true et-org.workspace = true et-ws-test-server.workspace = true rstest.workspace = true -serde_json.workspace = true +serde_json = { workspace = true, features = ["std"] } [build-dependencies] pyo3-build-config.workspace = true diff --git a/services/ws-server-pico2w/Cargo.toml b/services/ws-server-pico2w/Cargo.toml new file mode 100644 index 00000000..85d40a9d --- /dev/null +++ b/services/ws-server-pico2w/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "et-int-ws-server-pico2w" +publish = false +description = "et-ws-server as firmware for the Raspberry Pi Pico 2 W" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +# `#[embassy_executor::main]` expands to `#[cortex_m_rt::entry]`, which cargo-shear's scan cannot see. +[package.metadata.cargo-shear] +ignored = ["cortex-m-rt"] + +[[bin]] +bench = false +doctest = false +name = "et-ws-server-pico2w" +path = "src/main.rs" +test = false + +# The firmware's dependencies are scoped to bare-metal Arm, and its body is gated the same way. +# On any other target -- the workspace's host builds, lints and tests -- the binary compiles to an empty `main`. +[target.'cfg(all(target_arch = "arm", target_os = "none"))'.dependencies] +chrono = { workspace = true, features = ["alloc"] } +cortex-m-rt.workspace = true +cyw43 = { workspace = true, features = ["defmt"] } +cyw43-pio = { workspace = true, features = ["defmt"] } +defmt.workspace = true +defmt-rtt.workspace = true +edge-toolkit.workspace = true +embassy-executor = { workspace = true, features = ["defmt", "executor-thread", "platform-cortex-m"] } +embassy-net = { workspace = true, features = ["defmt", "dhcpv4", "medium-ethernet", "proto-ipv4", "tcp"] } +embassy-rp = { workspace = true, features = ["critical-section-impl", "defmt", "rp235xa", "time-driver"] } +embassy-time = { workspace = true, features = ["defmt"] } +embedded-alloc.workspace = true +et-ws-server = { workspace = true, features = ["picoserve-server"] } +et-ws-service = { workspace = true, features = ["picoserve-hub"] } +panic-probe = { workspace = true, features = ["defmt"] } +picoserve = { workspace = true, features = ["embassy"] } +static_cell.workspace = true + +[build-dependencies] +fs-err.workspace = true + +[lints] +workspace = true diff --git a/services/ws-server-pico2w/README.md b/services/ws-server-pico2w/README.md new file mode 100644 index 00000000..df5dc442 --- /dev/null +++ b/services/ws-server-pico2w/README.md @@ -0,0 +1,34 @@ +# et-int-ws-server-pico2w + +et-ws-server as firmware for the Raspberry Pi Pico 2 W, built as the `et-ws-server-pico2w` binary: the `no_std` +hub's `/ws`, with `/health` and `/favicon.ico` beside it, served over the 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. + +It is a local hub, with no backplane, admin API, idle timeout or registry file. The board has no clock, so the times +it stamps on messages are its uptime counted from the Unix epoch. + +## Building + +The build bakes in the network to join and the CYW43439 radio's firmware, so it needs three variables: + +- `WIFI_NETWORK` and `WIFI_PASSWORD`: the network. +- `CYW43_FIRMWARE_DIR`: a directory holding the radio's `43439A0.bin`, `43439A0_clm.bin` and `nvram_rp2040.bin`, as + the `cyw43-firmware` directory of the embassy repository does. Infineon licenses those blobs separately from this + project, so they are not part of it. + +```sh +MISE_ENV=rust mise run build-firmware-pico2w +``` + +The firmware is `target/thumbv8m.main-none-eabihf/release/et-ws-server-pico2w`, an ELF. Load it with picotool while +the board is in BOOTSEL mode, or with probe-rs over a debug probe, which also shows its defmt log: + +```sh +picotool load -x -t elf target/thumbv8m.main-none-eabihf/release/et-ws-server-pico2w +``` + +```sh +probe-rs run --chip RP235x target/thumbv8m.main-none-eabihf/release/et-ws-server-pico2w +``` + +The log names the address it serves on, as `ws://
:8080/ws`. diff --git a/services/ws-server-pico2w/build.rs b/services/ws-server-pico2w/build.rs new file mode 100644 index 00000000..2d3620b6 --- /dev/null +++ b/services/ws-server-pico2w/build.rs @@ -0,0 +1,79 @@ +//! Puts the Pico 2 W's memory layout where the linker looks, links the firmware with cortex-m-rt's and defmt's linker +//! scripts, and generates `board.rs`: what the firmware bakes in, read here from the build's environment. On any +//! other target the binary is empty and needs none of it. + +use std::env; +use std::error::Error; +use std::path::PathBuf; + +/// The variables `board.rs` is generated from. +const BOARD_VARIABLES: [&str; 3] = ["CYW43_FIRMWARE_DIR", "WIFI_NETWORK", "WIFI_PASSWORD"]; +/// The operating system cargo is building for, `none` for the board. +const CARGO_CFG_TARGET_OS: &str = "CARGO_CFG_TARGET_OS"; +/// The directory cargo gives this build script for what it generates. +const OUT_DIR: &str = "OUT_DIR"; + +fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed=memory.x"); + for variable in BOARD_VARIABLES { + println!("cargo:rerun-if-env-changed={variable}"); + } + if env::var(CARGO_CFG_TARGET_OS)? != "none" { + return Ok(()); + } + let out_dir = PathBuf::from(env::var(OUT_DIR)?); + fs_err::write(out_dir.join("memory.x"), include_bytes!("memory.x"))?; + fs_err::write(out_dir.join("board.rs"), board()?)?; + println!("cargo:rustc-link-search={}", out_dir.display()); + println!("cargo:rustc-link-arg-bins=--nmagic"); + println!("cargo:rustc-link-arg-bins=-Tlink.x"); + println!("cargo:rustc-link-arg-bins=-Tdefmt.x"); + Ok(()) +} + +/// The variable `name`, which the firmware cannot be built without. +fn required(name: &str) -> Result> { + let Ok(value) = env::var(name) else { + return Err(format!("the Pico 2 W firmware needs {name} set when it is built").into()); + }; + Ok(value) +} + +/// The source of `board.rs`: the network to join, and the radio's blobs from `CYW43_FIRMWARE_DIR`. +#[expect( + clippy::single_call_fn, + reason = "the generation step of main, kept apart from its linker directives" +)] +fn board() -> Result> { + let firmware_dir = PathBuf::from(required("CYW43_FIRMWARE_DIR")?); + // A build script runs in its own crate's directory, so a relative path would quietly resolve against that. + if !firmware_dir.is_absolute() { + return Err("CYW43_FIRMWARE_DIR must be an absolute path".into()); + } + let blob = |file: &str| -> Result> { + let path = firmware_dir.join(file); + if !path.is_file() { + return Err(format!("CYW43_FIRMWARE_DIR holds no {file}").into()); + } + let path = path.to_str().ok_or("CYW43_FIRMWARE_DIR is not valid UTF-8")?; + Ok(format!("include_bytes!({path:?})")) + }; + let network = required("WIFI_NETWORK")?; + let password = required("WIFI_PASSWORD")?; + let firmware = blob("43439A0.bin")?; + let clm = blob("43439A0_clm.bin")?; + let nvram = blob("nvram_rp2040.bin")?; + Ok(format!( + "/// The network the board joins. +const WIFI_NETWORK: &str = {network:?}; +/// The network's passphrase. +const WIFI_PASSWORD: &str = {password:?}; +/// The CYW43439's firmware. +static RADIO_FIRMWARE: &cyw43::Aligned = &cyw43::Aligned(*{firmware}); +/// The CYW43439's country locale matrix. +static RADIO_CLM: &[u8] = {clm}; +/// The CYW43439's configuration for the Pico 2 W's module. +static RADIO_NVRAM: &cyw43::Aligned = &cyw43::Aligned(*{nvram}); +" + )) +} diff --git a/services/ws-server-pico2w/memory.x b/services/ws-server-pico2w/memory.x new file mode 100644 index 00000000..3428cb96 --- /dev/null +++ b/services/ws-server-pico2w/memory.x @@ -0,0 +1,43 @@ +/* The RP2350's memory as the Pico 2 W wires it: 4 MiB of flash and 512 KiB of striped SRAM. */ +MEMORY { + FLASH : ORIGIN = 0x10000000, LENGTH = 4096K + RAM : ORIGIN = 0x20000000, LENGTH = 512K + SRAM4 : ORIGIN = 0x20080000, LENGTH = 4K + SRAM5 : ORIGIN = 0x20081000, LENGTH = 4K +} + +/* The boot ROM looks for the image definition block within the first 4 KiB of flash, after the vector table. */ +SECTIONS { + .start_block : ALIGN(4) + { + __start_block_addr = .; + KEEP(*(.start_block)); + KEEP(*(.boot_info)); + } > FLASH +} INSERT AFTER .vector_table; + +/* Code follows the block, on the 8-byte boundary cortex-m-rt aligns .text to. */ +_stext = ALIGN(ADDR(.start_block) + SIZEOF(.start_block), 8); + +/* Binary info entries, which picotool reads to describe the image. */ +SECTIONS { + .bi_entries : ALIGN(4) + { + __bi_entries_start = .; + KEEP(*(.bi_entries)); + . = ALIGN(4); + __bi_entries_end = .; + } > FLASH +} INSERT AFTER .text; + +/* The block that closes the image's block loop. */ +SECTIONS { + .end_block : ALIGN(4) + { + __end_block_addr = .; + KEEP(*(.end_block)); + } > FLASH +} INSERT AFTER .uninit; + +PROVIDE(start_to_end = __end_block_addr - __start_block_addr); +PROVIDE(end_to_start = __start_block_addr - __end_block_addr); diff --git a/services/ws-server-pico2w/src/firmware.rs b/services/ws-server-pico2w/src/firmware.rs new file mode 100644 index 00000000..d840f453 --- /dev/null +++ b/services/ws-server-pico2w/src/firmware.rs @@ -0,0 +1,187 @@ +//! The firmware: bring up the CYW43439's Wi-Fi, join the network named at build time, take an address over DHCP, and +//! serve the hub on as many sockets at once as there are connection tasks. + +#![expect( + clippy::future_not_send, + clippy::single_call_fn, + clippy::unwrap_used, + reason = "embassy's thread-mode executor runs every task on the one core; each is spawned once, or bring-up panics" +)] + +use alloc::format; +use alloc::string::String; +use core::net::{IpAddr, Ipv4Addr}; + +use chrono::DateTime; +use cyw43::JoinOptions; +use cyw43_pio::{DEFAULT_CLOCK_DIVIDER, PioSpi}; +use defmt::{Debug2Format, info, warn}; +use edge_toolkit::ports::Services; +use edge_toolkit::ws_server::MessageRetention; +use embassy_executor::Spawner; +use embassy_net::tcp::TcpSocket; +use embassy_net::{Config as NetConfig, DhcpConfig, Stack, StackResources}; +use embassy_rp::gpio::{Level, Output}; +use embassy_rp::peripherals::{DMA_CH0, PIO0, TRNG}; +use embassy_rp::pio::{self, Pio}; +use embassy_rp::trng::{self, Trng}; +use embassy_rp::{bind_interrupts, dma}; +use embassy_time::Instant; +use embedded_alloc::LlffHeap as Heap; +use et_ws_server::picoserve_server::serve; +use et_ws_service::hub::Stamps; +use et_ws_service::picoserve_hub::{self, Hub}; +use picoserve::time::EmbassyTimer; +use static_cell::StaticCell; +use {defmt_rtt as _, panic_probe as _}; + +/// The port the hub serves on, as the `std` server's plain-HTTP listener does. +const PORT: u16 = Services::InsecureWebSocketServer.port(); +/// How many connections the hub serves at once: one task, and one socket's buffers, each. +const CONNECTIONS: usize = 4; +/// The largest WebSocket frame the hub reads; each connection holds a buffer this size on the heap. +const MAX_FRAME_SIZE: usize = 8 * 1024; +/// How many messages for one connection the hub holds while it writes earlier ones, each up to `MAX_FRAME_SIZE`. +const MAX_QUEUED: usize = 2; +/// Each connection's TCP receive and transmit buffer. +const TCP_BUFFER: usize = 4 * 1024; +/// Each connection's buffer for the HTTP request head. +const HTTP_BUFFER: usize = 2048; +/// The heap the hub's registry, routing and frame buffers allocate from. +const HEAP_SIZE: usize = 160 * 1024; + +include!(concat!(env!("OUT_DIR"), "/board.rs")); + +#[global_allocator] +static HEAP: Heap = Heap::empty(); + +bind_interrupts!(struct Irqs { + DMA_IRQ_0 => dma::InterruptHandler; + PIO0_IRQ_0 => pio::InterruptHandler; + TRNG_IRQ => trng::InterruptHandler; +}); + +/// Ids unique across boots -- a word the TRNG draws at boot, then a count -- and the time since boot. +/// +/// The board has no clock, so a time is its uptime counted from the Unix epoch. +struct BoardStamps { + boot: u32, + issued: u32, +} + +impl Stamps for BoardStamps { + fn new_id(&mut self) -> String { + self.issued = self.issued.wrapping_add(1); + format!("{:08x}-{}", self.boot, self.issued) + } + + fn now(&mut self) -> String { + let millis = i64::try_from(Instant::now().as_millis()).unwrap_or(i64::MAX); + DateTime::from_timestamp_millis(millis).unwrap_or_default().to_rfc3339() + } +} + +type Radio = cyw43::Runner<'static, cyw43::SpiBus, PioSpi<'static, PIO0, 0>>>; + +#[embassy_executor::task] +async fn radio_task(runner: Radio) -> ! { + runner.run().await +} + +#[embassy_executor::task] +async fn net_task(mut runner: embassy_net::Runner<'static, cyw43::NetDriver<'static>>) -> ! { + runner.run().await +} + +/// Accept connections on [`PORT`] one at a time, serving each until it closes. +#[embassy_executor::task(pool_size = CONNECTIONS)] +async fn connection_task(stack: Stack<'static>, hub: &'static Hub) -> ! { + let mut rx_buffer = [0; TCP_BUFFER]; + let mut tx_buffer = [0; TCP_BUFFER]; + let mut http_buffer = [0; HTTP_BUFFER]; + let config = picoserve_hub::config(); + loop { + let mut socket = TcpSocket::new(stack, &mut rx_buffer, &mut tx_buffer); + if let Err(error) = socket.accept(PORT).await { + warn!("Failed to accept a connection: {}", error); + continue; + } + let client_ip = socket + .remote_endpoint() + .map_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED), |remote| remote.addr.into()); + if let Err(error) = serve(hub, socket, client_ip, EmbassyTimer, &config, &mut http_buffer).await { + warn!( + "Connection from {} ended: {}", + Debug2Format(&client_ip), + Debug2Format(&error) + ); + } + } +} + +#[embassy_executor::main] +async fn main(spawner: Spawner) { + #[expect( + unsafe_code, + 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. + unsafe { + embedded_alloc::init!(HEAP, HEAP_SIZE); + } + let peripherals = embassy_rp::init(embassy_rp::config::Config::default()); + let mut trng = Trng::new(peripherals.TRNG, Irqs, trng::Config::default()); + + let power = Output::new(peripherals.PIN_23, Level::Low); + let chip_select = Output::new(peripherals.PIN_25, Level::High); + let mut pio = Pio::new(peripherals.PIO0, Irqs); + let spi = PioSpi::new( + &mut pio.common, + pio.sm0, + DEFAULT_CLOCK_DIVIDER, + pio.irq0, + chip_select, + peripherals.PIN_24, + peripherals.PIN_29, + dma::Channel::new(peripherals.DMA_CH0, Irqs), + ); + static RADIO: StaticCell = StaticCell::new(); + let radio_state = RADIO.init(cyw43::State::new()); + let (device, mut control, radio) = cyw43::new(radio_state, power, spi, RADIO_FIRMWARE, RADIO_NVRAM).await; + spawner.spawn(radio_task(radio).unwrap()); + control.init(RADIO_CLM).await; + control + .set_power_management(cyw43::PowerManagementMode::PowerSave) + .await; + + static NETWORK: StaticCell> = StaticCell::new(); + let (stack, network) = embassy_net::new( + device, + NetConfig::dhcpv4(DhcpConfig::default()), + NETWORK.init(StackResources::new()), + trng.blocking_next_u64(), + ); + spawner.spawn(net_task(network).unwrap()); + + while let Err(error) = control + .join(WIFI_NETWORK, JoinOptions::new(WIFI_PASSWORD.as_bytes())) + .await + { + warn!("Failed to join {}: {}; retrying", WIFI_NETWORK, Debug2Format(&error)); + } + stack.wait_config_up().await; + if let Some(config) = stack.config_v4() { + info!("Serving ws://{}:{}/ws", Debug2Format(&config.address.address()), PORT); + } + + let stamps = BoardStamps { + boot: trng.blocking_next_u32(), + issued: 0, + }; + static HUB: StaticCell> = StaticCell::new(); + let hub = Hub::new(stamps, MessageRetention::LatestPerSender, MAX_FRAME_SIZE, MAX_QUEUED); + let hub: &'static Hub = HUB.init(hub); + for _ in 0..CONNECTIONS { + spawner.spawn(connection_task(stack, hub).unwrap()); + } +} diff --git a/services/ws-server-pico2w/src/main.rs b/services/ws-server-pico2w/src/main.rs new file mode 100644 index 00000000..12d763d6 --- /dev/null +++ b/services/ws-server-pico2w/src/main.rs @@ -0,0 +1,14 @@ +//! et-ws-server as firmware for the Raspberry Pi Pico 2 W: the hub's `/ws`, `/health` and `/favicon.ico` on port +//! 8080, over the board's own Wi-Fi. +#![cfg_attr(all(target_arch = "arm", target_os = "none"), no_std, no_main)] +#![recursion_limit = "256"] + +#[cfg(all(target_arch = "arm", target_os = "none"))] +extern crate alloc; + +#[cfg(all(target_arch = "arm", target_os = "none"))] +mod firmware; + +/// Anywhere but on the board there is nothing to run; the binary exists there so the workspace's host builds compile. +#[cfg(not(all(target_arch = "arm", target_os = "none")))] +fn main() {} diff --git a/services/ws-server/Cargo.toml b/services/ws-server/Cargo.toml index 0e71b4f1..72cd8f91 100644 --- a/services/ws-server/Cargo.toml +++ b/services/ws-server/Cargo.toml @@ -18,32 +18,80 @@ doctest = false doctest = false name = "et-ws-server" path = "src/main.rs" +required-features = ["std"] [[test]] name = "configure_app" required-features = ["modules", "storage"] +[[test]] +name = "health" +required-features = ["std"] + +[[test]] +name = "http_config" +required-features = ["std"] + +[[test]] +name = "net" +required-features = ["std"] + +[[test]] +name = "picoserve_server" +required-features = ["picoserve-server"] + [[test]] name = "tls" required-features = ["tls-self-signed"] [features] #! Every service the hub can mount beyond `/ws` and `/health` is a feature, all on by default. -#! A deployment that needs only the hub sets `default-features = false` and opts back into the services it serves. +#! A deployment that needs only the hub sets `default-features = false` and opts back into `std` and the services it +#! serves. -default = ["modules", "otlp", "qr", "storage", "tls", "tls-self-signed", "tty-image", "websockify"] +default = [ + "modules", + "otlp", + "picoserve-server", + "qr", + "std", + "storage", + "tls", + "tls-self-signed", + "tty-image", + "websockify", +] +## The actix-web server and its binary, configured from the environment, with the NATS backplane. +## Without it the crate is `no_std` + `alloc`, and serves through picoserve under `picoserve-server`. +std = [ + "dep:actix-web", + "dep:et-actix-web-config", + "dep:local-ip-address", + "dep:serde-env", + "dep:serde_default", + "dep:thiserror", + "dep:tracing-actix-web", + "dep:tracing-subscriber", + "edge-toolkit/std", + "et-ws-service/nats", + "et-ws-service/std", + "serde/std", + "tracing/std", +] +## A `no_std` server for `/ws`, `/health` and `/favicon.ico` through picoserve, on any socket the embedder accepts. +picoserve-server = ["dep:picoserve", "et-ws-service/picoserve-hub"] ## Serves ws-module packages from disk under `/modules`, and the root module as the browser UI at `/`. -modules = ["dep:et-modules-service"] +modules = ["dep:et-modules-service", "std"] ## Exports traces and logs over OTLP when `OTLP_*` is configured. ## Each request span is parented on the caller's W3C `traceparent`. -otlp = ["dep:et-otlp", "tracing-actix-web/opentelemetry_0_32"] +otlp = ["dep:et-otlp", "et-ws-service/otel", "std", "tracing-actix-web/opentelemetry_0_32"] ## Prints the advertised URL as a terminal QR code at startup, for phones joining the hub. -qr = ["dep:qr2term"] +qr = ["dep:qr2term", "std"] ## Per-agent file storage under `/storage`, backed by any `object_store` URL. -storage = ["dep:et-storage-service"] +storage = ["dep:et-storage-service", "std"] ## Adds the HTTPS/WSS listener, serving the PEM pair named by `TLS_CERT_FILE` and `TLS_KEY_FILE`. ## Without it the hub serves plain HTTP only, which browsers refuse for camera, sensors and other secure-context APIs. -tls = ["actix-web/rustls-0_23", "dep:rustls", "dep:serde-inline-default", "et-actix-web-config/rustls-0_23"] +tls = ["actix-web/rustls-0_23", "dep:rustls", "dep:serde-inline-default", "et-actix-web-config/rustls-0_23", "std"] ## Generates a self-signed `localhost` pair into `TLS_CERT_FILE` and `TLS_KEY_FILE` when they do not exist yet. ## Without it a missing pair is a startup error, for deployments that provision their own certificates. tls-self-signed = ["dep:fs-err", "dep:rcgen", "tls"] @@ -52,7 +100,7 @@ tls-self-signed = ["dep:fs-err", "dep:rcgen", "tls"] tty-image = ["et-storage-service/tty-image", "storage"] ## Relays `/websockify` WebSocket traffic to the hub's own loopback HTTP port. ## It serves browser runtimes such as webR that cannot open a TCP socket. -websockify = ["dep:et-websockify-service"] +websockify = ["dep:et-websockify-service", "std"] ## Adds OpenAPI metadata to the REST handlers for `et-int-gen` to harvest into the published spec. ## Production builds do not need it. openapi-spec = [ @@ -60,6 +108,7 @@ openapi-spec = [ "et-modules-service?/openapi-spec", "et-storage-service?/openapi-spec", "et-ws-service/openapi-spec", + "std", ] ## Internal: derives JSON Schema for the hub's environment configuration. ## et-int-gen enables it to render the hub's environment variables into its HELP.md. @@ -70,39 +119,46 @@ env-schema = [ "et-modules-service?/env-schema", "et-storage-service?/env-schema", "et-ws-service/env-schema", + "std", ] ## Internal: renders this feature list into the crate documentation, as docs.rs builds it. docs = ["dep:document-features"] [dependencies] -actix-web.workspace = true +actix-web = { workspace = true, optional = true } document-features = { workspace = true, optional = true } edge-toolkit.workspace = true -et-actix-web-config.workspace = true +et-actix-web-config = { workspace = true, optional = true } et-modules-service = { workspace = true, optional = true } et-otlp = { workspace = true, optional = true } et-storage-service = { workspace = true, optional = true } et-websockify-service = { workspace = true, optional = true } et-ws-service.workspace = true fs-err = { workspace = true, optional = true } -local-ip-address.workspace = true +local-ip-address = { workspace = true, optional = true } +picoserve = { workspace = true, features = ["json"], optional = true } qr2term = { workspace = true, optional = true } rcgen = { workspace = true, optional = true } rustls = { workspace = true, optional = true } schemars = { workspace = true, optional = true } -serde.workspace = true -serde-env.workspace = true +serde = { workspace = true, features = ["alloc"] } +serde-env = { workspace = true, optional = true } serde-inline-default = { workspace = true, optional = true } -serde_default.workspace = true -thiserror.workspace = true +serde_default = { workspace = true, optional = true } +thiserror = { workspace = true, features = ["std"], optional = true } tracing.workspace = true -tracing-actix-web.workspace = true -tracing-subscriber.workspace = true +tracing-actix-web = { workspace = true, optional = true } +tracing-subscriber = { workspace = true, optional = true } utoipa = { workspace = true, optional = true } [dev-dependencies] actix-rt.workspace = true +futures-util.workspace = true +picoserve = { workspace = true, features = ["tokio"] } +serde_json = { workspace = true, features = ["std"] } tempfile.workspace = true +tokio = { workspace = true, features = ["io-util", "macros", "net", "rt", "time"] } +tokio-tungstenite = { workspace = true, features = ["connect"] } [lints] workspace = true diff --git a/services/ws-server/src/lib.rs b/services/ws-server/src/lib.rs index 07d6083d..43ddc8b1 100644 --- a/services/ws-server/src/lib.rs +++ b/services/ws-server/src/lib.rs @@ -2,26 +2,40 @@ #![cfg_attr(feature = "docs", doc = "## Feature flags")] #![cfg_attr(feature = "docs", doc = document_features::document_features!())] #![cfg_attr(docsrs, feature(doc_cfg))] +#![cfg_attr(not(feature = "std"), no_std)] + +#[cfg(not(feature = "std"))] +extern crate alloc; #[cfg(feature = "websockify")] use std::net::{Ipv4Addr, SocketAddr}; +#[cfg(feature = "std")] use actix_web::{HttpResponse, web}; +#[cfg(feature = "std")] pub use et_ws_service::{AgentSession, WsAgentRegistry}; +#[cfg(feature = "std")] pub mod config; +#[cfg(feature = "std")] pub mod net; +#[cfg(feature = "picoserve-server")] +pub mod picoserve_server; pub mod routes; #[cfg(feature = "tls")] pub mod tls; +#[cfg(feature = "std")] +use self::config::Config; +#[cfg(feature = "std")] pub use self::routes::health; -use crate::config::Config; +#[cfg(feature = "std")] pub async fn no_content() -> HttpResponse { HttpResponse::NoContent().finish() } +#[cfg(feature = "std")] pub fn configure_app(cfg: &mut web::ServiceConfig, agent_registry: web::Data, config: &Config) { let _configured = cfg .app_data(agent_registry) diff --git a/services/ws-server/src/picoserve_server.rs b/services/ws-server/src/picoserve_server.rs new file mode 100644 index 00000000..84cf0659 --- /dev/null +++ b/services/ws-server/src/picoserve_server.rs @@ -0,0 +1,50 @@ +//! The `no_std` ws-server: the hub's `/ws`, with `/health` and `/favicon.ico` beside it, served through picoserve. +//! +//! As with [`Hub::serve`], the embedder owns the network stack and the executor, accepts each connection, and runs +//! [`serve`] for it with the one [`Hub`] every connection shares. The services the `std` server mounts besides the hub +//! -- storage, modules, websockify -- need a filesystem or an OS, and are not served here. + +use core::net::IpAddr; + +use et_ws_service::hub::Stamps; +use et_ws_service::picoserve_hub::Hub; +use picoserve::io::Socket; +use picoserve::response::{Json, StatusCode}; +use picoserve::routing::get; +use picoserve::{Config, Router, Server, Timer}; + +use crate::routes::HealthResponse; + +/// Serve one accepted connection from `client_ip` until it closes. +/// +/// `GET /health` answers what the `std` server's does, `GET /favicon.ico` answers 204, and `GET /ws` is `hub`'s, as +/// [`Hub::routes`] describes; every other request is answered 404. `config` is usually +/// [`et_ws_service::picoserve_hub::config`], and `http_buffer` holds the request head. +/// +/// As for [`Hub::serve`], the crate awaiting this one needs `#![recursion_limit = "256"]`. +#[expect( + clippy::future_not_send, + reason = "picoserve's futures hold Cells; its executors run each connection on one thread" +)] +pub async fn serve( + hub: &Hub, + socket: S, + client_ip: IpAddr, + timer: M, + config: &Config, + http_buffer: &mut [u8], +) -> Result<(), picoserve::Error> +where + T: Stamps, + M: Timer, + S: Socket, +{ + let app = hub.routes( + Router::new() + .route("/favicon.ico", get(async || StatusCode::NO_CONTENT)) + .route("/health", get(async || Json(HealthResponse::healthy()))), + client_ip, + ); + let _disconnection = Server::custom(&app, timer, config, http_buffer).serve(socket).await?; + Ok(()) +} diff --git a/services/ws-server/src/routes.rs b/services/ws-server/src/routes.rs index 0de801c0..837455e3 100644 --- a/services/ws-server/src/routes.rs +++ b/services/ws-server/src/routes.rs @@ -15,6 +15,10 @@ ) )] +#[cfg(not(feature = "std"))] +use alloc::string::{String, ToString as _}; + +#[cfg(feature = "std")] use actix_web::HttpResponse; use serde::{Deserialize, Serialize}; @@ -29,6 +33,17 @@ pub struct HealthResponse { pub service: String, } +impl HealthResponse { + /// What a server that is up answers, whichever server it is. + #[must_use] + pub fn healthy() -> Self { + Self { + status: "healthy".to_string(), + service: "ws-server".to_string(), + } + } +} + /// Liveness probe. /// /// Returns a small JSON document identifying the service so external @@ -42,9 +57,7 @@ pub struct HealthResponse { responses((status = 200, description = "Server is up", body = HealthResponse)) ) )] +#[cfg(feature = "std")] pub async fn health() -> HttpResponse { - HttpResponse::Ok().json(HealthResponse { - status: "healthy".to_string(), - service: "ws-server".to_string(), - }) + HttpResponse::Ok().json(HealthResponse::healthy()) } diff --git a/services/ws-server/tests/picoserve_server.rs b/services/ws-server/tests/picoserve_server.rs new file mode 100644 index 00000000..384e9f5e --- /dev/null +++ b/services/ws-server/tests/picoserve_server.rs @@ -0,0 +1,110 @@ +//! The `no_std` ws-server over real TCP sockets, with tokio standing in for an embedded executor and network stack: +//! `/health` and `/favicon.ico` answer as the `std` server's do, and `/ws` is the hub. +#![cfg(test)] +#![recursion_limit = "256"] + +use std::net::{Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; + +use edge_toolkit::ws::{ClientMessage, ConnectStatus, ServerMessage}; +use edge_toolkit::ws_server::MessageRetention; +use et_ws_server::picoserve_server::serve; +use et_ws_server::routes::HealthResponse; +use et_ws_service::hub::Stamps; +use et_ws_service::picoserve_hub::{Hub, config}; +use futures_util::{SinkExt as _, StreamExt as _}; +use picoserve::time::TokioTimer; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::task::LocalSet; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Message; + +/// How long a test waits for an answer before failing rather than hanging. +const PATIENCE: Duration = Duration::from_secs(5); + +/// Ids `id-1`, `id-2`, ... in turn, and a fixed time. +#[derive(Default)] +struct Counter(u32); + +impl Stamps for Counter { + fn new_id(&mut self) -> String { + self.0 = self.0.saturating_add(1); + format!("id-{}", self.0) + } + + fn now(&mut self) -> String { + "2026-10-30T00:00:00Z".to_owned() + } +} + +/// The server on a fresh loopback port, one task per connection as an embedder would, and its address. +#[expect( + clippy::single_call_fn, + reason = "the embedder's accept loop, kept apart from what the test asserts" +)] +async fn served() -> SocketAddr { + let hub = Arc::new(Hub::new(Counter::default(), MessageRetention::All, 1024, 16)); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap(); + let address = listener.local_addr().unwrap(); + let _accepting = tokio::task::spawn_local(async move { + loop { + let (socket, peer) = listener.accept().await.unwrap(); + let hub = Arc::clone(&hub); + let _serving = tokio::task::spawn_local(async move { + let mut http_buffer = [0; 2048]; + let _served = serve(&hub, socket, peer.ip(), TokioTimer, &config(), &mut http_buffer).await; + }); + } + }); + address +} + +/// The whole response to a bodiless `GET path`, headers and body, as text. +async fn get(address: SocketAddr, path: &str) -> String { + let mut socket = TcpStream::connect(address).await.unwrap(); + let request = format!("GET {path} HTTP/1.1\r\nHost: hub\r\nConnection: close\r\n\r\n"); + socket.write_all(request.as_bytes()).await.unwrap(); + let mut response = String::default(); + let _read = tokio::time::timeout(PATIENCE, socket.read_to_string(&mut response)) + .await + .unwrap() + .unwrap(); + response +} + +#[tokio::test] +async fn it_serves_health_favicon_and_the_hub() { + // Boxed because its size depends on how the build unifies features: over 16 KiB with the whole workspace, under + // it alone, so an expectation of `clippy::large_futures` could hold in only one of the two. + LocalSet::new() + .run_until(Box::pin(async { + let address = served().await; + + let health = get(address, "/health").await; + assert!(health.starts_with("HTTP/1.1 200")); + assert!(health.contains("Content-Type: application/json")); + let (_head, body) = health.split_once("\r\n\r\n").unwrap(); + let body: HealthResponse = serde_json::from_str(body).unwrap(); + assert_eq!((body.status.as_str(), body.service.as_str()), ("healthy", "ws-server")); + + assert!(get(address, "/favicon.ico").await.starts_with("HTTP/1.1 204")); + assert!(get(address, "/elsewhere").await.starts_with("HTTP/1.1 404")); + + let (mut agent, _response) = connect_async(format!("ws://{address}/ws")).await.unwrap(); + let connect = ClientMessage::Connect { agent_id: None }; + let frame = Message::text(serde_json::to_string(&connect).unwrap()); + agent.send(frame).await.unwrap(); + let ack = tokio::time::timeout(PATIENCE, agent.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!( + ServerMessage::from_text_frame(ack.to_text().unwrap()).unwrap(), + ServerMessage::ConnectAck { status: ConnectStatus::Assigned, agent_id } if agent_id == "id-1" + )); + })) + .await; +} diff --git a/services/ws-test-server/Cargo.toml b/services/ws-test-server/Cargo.toml index 16423c2f..efbe3df2 100644 --- a/services/ws-test-server/Cargo.toml +++ b/services/ws-test-server/Cargo.toml @@ -13,16 +13,16 @@ doctest = false [dependencies] actix-rt.workspace = true actix-web.workspace = true -edge-toolkit.workspace = true +edge-toolkit = { workspace = true, features = ["std"] } et-modules-service.workspace = true et-storage-service.workspace = true et-test-helpers.workspace = true -et-ws-service.workspace = true +et-ws-service = { workspace = true, features = ["nats", "otel"] } fs-err.workspace = true futures-util.workspace = true -serde_json.workspace = true +serde_json = { workspace = true, features = ["std"] } tempfile.workspace = true -thiserror.workspace = true +thiserror = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["macros", "net", "rt", "time"] } tokio-tungstenite = { workspace = true, features = ["connect"] } # Same TracingLogger setup as the real ws-server. diff --git a/services/ws-wasi-runner/Cargo.toml b/services/ws-wasi-runner/Cargo.toml index 270628d9..7c344752 100644 --- a/services/ws-wasi-runner/Cargo.toml +++ b/services/ws-wasi-runner/Cargo.toml @@ -49,9 +49,9 @@ coverage = ["et-ws-runner-common/coverage"] docs = ["dep:document-features"] [dependencies] -chrono.workspace = true +chrono = { workspace = true, features = ["std"] } document-features = { workspace = true, optional = true } -edge-toolkit.workspace = true +edge-toolkit = { workspace = true, features = ["std"] } et-otlp.workspace = true et-rest-client = { workspace = true, features = ["tracing"] } et-ws-runner-common.workspace = true @@ -62,12 +62,12 @@ futures-util.workspace = true # ORT teardown -- _exit skips them. libc.workspace = true reqwest = { workspace = true, features = ["json", "rustls", "stream"] } -serde_json.workspace = true +serde_json = { workspace = true, features = ["std"] } serde_path_to_error.workspace = true -thiserror.workspace = true +thiserror = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "sync", "time"] } tokio-tungstenite = { workspace = true, features = ["connect"] } -tracing.workspace = true +tracing = { workspace = true, features = ["std"] } wasmtime.workspace = true wasmtime-wasi.workspace = true # wasi-nn standardised ML inference. @@ -95,7 +95,7 @@ reqwest = { workspace = true, features = ["blocking"] } retry.workspace = true rstest.workspace = true secrecy.workspace = true -serde_json.workspace = true +serde_json = { workspace = true, features = ["std"] } tempfile.workspace = true [lints] diff --git a/services/ws-wasm-agent/Cargo.toml b/services/ws-wasm-agent/Cargo.toml index f1797708..5c193cbe 100644 --- a/services/ws-wasm-agent/Cargo.toml +++ b/services/ws-wasm-agent/Cargo.toml @@ -22,13 +22,13 @@ coverage = ["et-web/coverage"] docs = ["dep:document-features"] [dependencies] -chrono.workspace = true +chrono = { workspace = true, features = ["clock", "wasmbind"] } document-features = { workspace = true, optional = true } -edge-toolkit.workspace = true +edge-toolkit = { workspace = true, features = ["std"] } et-web.workspace = true js-sys.workspace = true -serde_json.workspace = true -tracing.workspace = true +serde_json = { workspace = true, features = ["std"] } +tracing = { workspace = true, features = ["std"] } tracing-wasm.workspace = true wasm-bindgen.workspace = true web-sys = { workspace = true, features = [ diff --git a/services/ws-web-runner/Cargo.toml b/services/ws-web-runner/Cargo.toml index 63ea997a..c93a8e5b 100644 --- a/services/ws-web-runner/Cargo.toml +++ b/services/ws-web-runner/Cargo.toml @@ -47,9 +47,9 @@ et-ws-runner-common.workspace = true # lib.rs `build_rest_client`). Features match et-rest-client's native build. reqwest = { workspace = true, features = ["json", "query", "rustls", "stream"] } sys_traits.workspace = true -thiserror.workspace = true +thiserror = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } -tracing.workspace = true +tracing = { workspace = true, features = ["std"] } # Depend on winapi with `std` here so cargo feature unification turns it on for the shared winapi. # deno_io (pulled via deno_runtime) declares `winapi` without its `std` feature, @@ -60,12 +60,12 @@ tracing.workspace = true winapi.workspace = true [dev-dependencies] -edge-toolkit.workspace = true +edge-toolkit = { workspace = true, features = ["std"] } et-org.workspace = true et-ws-test-server.workspace = true fs-err.workspace = true rstest.workspace = true -serde_json.workspace = true +serde_json = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["macros", "rt", "time"] } [build-dependencies] diff --git a/services/ws/Cargo.toml b/services/ws/Cargo.toml index eae43d99..3cc822df 100644 --- a/services/ws/Cargo.toml +++ b/services/ws/Cargo.toml @@ -13,45 +13,102 @@ features = ["docs"] [lib] doctest = false +# The tests of the actix-web hub. `hub`, the test of the routing core, runs with `--no-default-features` as well. +[[test]] +name = "admin" +required-features = ["std"] + +[[test]] +name = "config" +required-features = ["std"] + +[[test]] +name = "load_registry" +required-features = ["std"] + +[[test]] +name = "ws_handler" +required-features = ["std"] + +[[test]] +name = "picoserve_hub" +required-features = ["picoserve-hub"] + [features] +default = ["nats", "otel", "picoserve-hub"] +## The actix-web hub: the `/ws` endpoint, the admin API, idle timeouts and the registry file. +## Without it the crate is `no_std` + `alloc` and holds only `hub`, the routing an embedder drives over its own +## transport. +std = [ + "bytes/std", + "dep:actix-web", + "dep:actix-ws", + "dep:bytesize", + "dep:chrono", + "dep:futures-util", + "dep:secrecy", + "dep:serde", + "dep:serde-inline-default", + "dep:serde_default", + "dep:subtle", + "dep:thiserror", + "dep:tokio", + "dep:uuid", + "edge-toolkit/std", + "serde_json/std", + "tracing/std", +] +## A `no_std` hub serving `/ws` over HTTP and WebSocket through picoserve, on any socket the embedder accepts. +picoserve-hub = ["dep:picoserve", "dep:spin"] +## Shares agents between hubs through the NATS JetStream backplane `WS_BACKPLANE` names. +## Without it every hub is local, keeping its agents in its own registry. +nats = ["dep:et-hub-backplane", "std"] +## Records the hub's spans and metrics through the global OpenTelemetry tracer and meter. +otel = ["dep:opentelemetry", "std"] ## Adds OpenAPI metadata to the admin handlers for `et-int-gen` to harvest into the published spec. -openapi-spec = ["dep:utoipa"] +openapi-spec = ["dep:utoipa", "std"] ## Internal: derives JSON Schema for `WsConfig`, which et-int-gen renders into the hub's HELP.md. -env-schema = ["dep:schemars"] +env-schema = ["dep:schemars", "std"] ## Internal: renders this feature list into the crate documentation, as docs.rs builds it. docs = ["dep:document-features"] [dependencies] -actix-web.workspace = true -actix-ws.workspace = true +actix-web = { workspace = true, optional = true } +actix-ws = { workspace = true, optional = true } bytes.workspace = true -bytesize.workspace = true -chrono.workspace = true +bytesize = { workspace = true, optional = true } +chrono = { workspace = true, features = ["clock"], optional = true } document-features = { workspace = true, optional = true } edge-toolkit.workspace = true -et-hub-backplane.workspace = true -futures-util.workspace = true -opentelemetry.workspace = true +et-hub-backplane = { workspace = true, optional = true } +futures-util = { workspace = true, optional = true } +opentelemetry = { workspace = true, optional = true } +picoserve = { workspace = true, optional = true } schemars = { workspace = true, optional = true } -secrecy.workspace = true -serde.workspace = true -serde-inline-default.workspace = true -serde_default.workspace = true -serde_json.workspace = true -subtle.workspace = true -thiserror.workspace = true -tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } +secrecy = { workspace = true, optional = true } +serde = { workspace = true, features = ["std"], optional = true } +serde-inline-default = { workspace = true, optional = true } +serde_default = { workspace = true, optional = true } +serde_json = { workspace = true, features = ["alloc"] } +spin = { workspace = true, optional = true } +subtle = { workspace = true, optional = true } +thiserror = { workspace = true, features = ["std"], optional = true } +tokio = { workspace = true, features = ["macros", "rt", "sync", "time"], optional = true } tracing.workspace = true utoipa = { workspace = true, optional = true } -uuid.workspace = true +uuid = { workspace = true, optional = true } [dev-dependencies] actix-http.workspace = true actix-rt.workspace = true et-test-helpers.workspace = true fs-err.workspace = true +futures-util.workspace = true +picoserve = { workspace = true, features = ["tokio"] } serde-env.workspace = true tempfile.workspace = true +tokio = { workspace = true, features = ["macros", "net", "rt", "time"] } +tokio-tungstenite = { workspace = true, features = ["connect"] } [lints] workspace = true diff --git a/services/ws/src/admin.rs b/services/ws/src/admin.rs index d37b3b47..8671fc97 100644 --- a/services/ws/src/admin.rs +++ b/services/ws/src/admin.rs @@ -19,6 +19,7 @@ use actix_web::dev::Payload; use actix_web::http::{StatusCode, header}; use actix_web::{FromRequest, HttpRequest, HttpResponse, ResponseError, web}; use edge_toolkit::ws_server::RegistryError; +#[cfg(feature = "nats")] use et_hub_backplane::{Backplane, BackplaneError}; use secrecy::{ExposeSecret as _, SecretString}; use serde::{Deserialize, Serialize}; @@ -26,7 +27,9 @@ use subtle::ConstantTimeEq as _; use thiserror::Error; use tracing::{info, warn}; -use crate::{WsAgentRegistry, WsConfig, backplane_of}; +#[cfg(feature = "nats")] +use crate::backplane::backplane_of; +use crate::server::{WsAgentRegistry, WsConfig}; /// Who may use the admin API, read from the `WS_ADMIN_TOKEN` string. /// @@ -85,6 +88,7 @@ pub enum AdminError { #[error("unknown agent {0}")] UnknownAgent(String), + #[cfg(feature = "nats")] #[error("hub backplane unavailable")] Backplane(#[source] BackplaneError), @@ -102,6 +106,7 @@ impl From for AdminError { } } +#[cfg(feature = "nats")] impl From for AdminError { fn from(error: BackplaneError) -> Self { if let BackplaneError::UnknownAgent(agent_id) = error { @@ -118,6 +123,7 @@ impl ResponseError for AdminError { Self::Disabled => StatusCode::FORBIDDEN, Self::Unauthorized => StatusCode::UNAUTHORIZED, Self::UnknownAgent(_) => StatusCode::NOT_FOUND, + #[cfg(feature = "nats")] Self::Backplane(_) => StatusCode::SERVICE_UNAVAILABLE, Self::Registry(_) => StatusCode::INTERNAL_SERVER_ERROR, } @@ -137,10 +143,23 @@ impl ResponseError for AdminError { /// Taking one as a handler argument is what puts a route behind the admin token: actix refuses the request with the /// [`AdminError`] before the handler runs. #[derive(Debug)] +#[cfg_attr(not(feature = "nats"), non_exhaustive)] pub struct AdminRequest { + #[cfg(feature = "nats")] backplane: Backplane, } +#[cfg(feature = "nats")] +impl AdminRequest { + /// Refuse `ip` on every other hub of the backplane, through the shared blocklist; a no-op for a local hub. + async fn block_on_other_hubs(&self, ip: IpAddr) -> Result<(), AdminError> { + if let Backplane::Nats(nats) = &self.backplane { + nats.block_ip(ip).await?; + } + Ok(()) + } +} + impl FromRequest for AdminRequest { type Error = AdminError; type Future = Ready>; @@ -151,6 +170,7 @@ impl FromRequest for AdminRequest { .map(|config| config.admin_token.clone()) .unwrap_or_default(); ready(authorize(req, &token).map(|()| Self { + #[cfg(feature = "nats")] backplane: backplane_of(req), })) } @@ -182,20 +202,11 @@ fn authorize(req: &HttpRequest, token: &AdminToken) -> Result<(), AdminError> { } } -/// Refuse `ip` on this hub and, through the shared blocklist, on every other hub of `backplane`. +/// Block an address: refuse it on every hub of the backplane. /// /// Every open socket from that address is closed within a heartbeat, whichever agent it carries, and every later /// connection from it is refused. The block outlives a hub restart: a local hub saves it with its registry, and a /// NATS-backed hub keeps it in the shared blocklist. -async fn block_everywhere(backplane: &Backplane, registry: &WsAgentRegistry, ip: IpAddr) -> Result<(), AdminError> { - if let Backplane::Nats(nats) = backplane { - nats.block_ip(ip).await?; - } - let _newly = registry.blocklist.block(ip); - Ok(()) -} - -/// Block an address: refuse it on every hub of the backplane. /// /// Blocking an address already blocked succeeds again, so the request can be repeated safely. A path segment that /// is not an IPv4 or IPv6 address matches no route and is answered 404. @@ -216,13 +227,22 @@ async fn block_everywhere(backplane: &Backplane, registry: &WsAgentRegistry, ip: ) ) )] +#[cfg_attr( + not(feature = "nats"), + expect( + unused_variables, + reason = "the admin token is all a request carries without a backplane" + ) +)] pub async fn block_ip( admin: AdminRequest, ip: web::Path, registry: web::Data, ) -> Result { let ip = ip.into_inner(); - block_everywhere(&admin.backplane, ®istry, ip).await?; + #[cfg(feature = "nats")] + admin.block_on_other_hubs(ip).await?; + let _newly = registry.blocklist.block(ip); info!("Blocked IP {ip}"); Ok(HttpResponse::NoContent().finish()) } @@ -249,22 +269,42 @@ pub async fn block_ip( ) ) )] +#[cfg_attr( + not(feature = "nats"), + expect( + unused_variables, + reason = "the admin token is all a request carries without a backplane" + ) +)] pub async fn evict_agent( admin: AdminRequest, agent_id: web::Path, registry: web::Data, ) -> Result { let agent_id = agent_id.into_inner(); - let backplane = admin.backplane; - let blocked_ip = match &backplane { - Backplane::Local => registry.agent_ip(&agent_id)?, - Backplane::Nats(nats) => nats.agent_ip(&agent_id).await?, - }; - block_everywhere(&backplane, ®istry, blocked_ip).await?; - let dropped = match &backplane { - Backplane::Local => registry.forget_messages_from(&agent_id), - Backplane::Nats(nats) => nats.forget_messages_from(&agent_id).await?, - }; + #[cfg(feature = "nats")] + if let Backplane::Nats(nats) = &admin.backplane { + let blocked_ip = nats.agent_ip(&agent_id).await?; + admin.block_on_other_hubs(blocked_ip).await?; + let _newly = registry.blocklist.block(blocked_ip); + let dropped = nats.forget_messages_from(&agent_id).await?; + return Ok(evicted(agent_id, blocked_ip, dropped)); + } + let blocked_ip = registry.agent_ip(&agent_id)?; + let _newly = registry.blocklist.block(blocked_ip); + let dropped = registry.forget_messages_from(&agent_id); + Ok(evicted(agent_id, blocked_ip, dropped)) +} + +/// The answer to an eviction of `agent_id` that blocked `blocked_ip` and dropped `dropped` of its queued messages. +#[cfg_attr( + not(feature = "nats"), + expect( + clippy::single_call_fn, + reason = "shared by the local and backplane evictions, which are one without nats" + ) +)] +fn evicted(agent_id: String, blocked_ip: IpAddr, dropped: usize) -> HttpResponse { info!("Evicted agent {agent_id}; blocked IP {blocked_ip}; dropped {dropped} queued messages it sent"); - Ok(HttpResponse::Ok().json(EvictedAgent { agent_id, blocked_ip })) + HttpResponse::Ok().json(EvictedAgent { agent_id, blocked_ip }) } diff --git a/services/ws/src/backplane.rs b/services/ws/src/backplane.rs new file mode 100644 index 00000000..fc2e21ec --- /dev/null +++ b/services/ws/src/backplane.rs @@ -0,0 +1,169 @@ +//! The NATS backplane several hubs share their agents through, from this hub's side. + +use std::pin::Pin; + +use actix_web::{HttpRequest, web}; +pub use et_hub_backplane::{Backplane, BackplaneConfig, BackplaneError}; +use et_hub_backplane::{Frame, Inbound}; +use futures_util::{Stream, StreamExt as _}; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; +use tracing::{info, warn}; + +use crate::hub::SessionMessage; +use crate::server::WsAgentRegistry; + +impl From for SessionMessage { + fn from(value: Frame) -> Self { + match value { + Frame::Text(text) => Self::Text(text), + Frame::Binary(bytes) => Self::Binary(bytes), + } + } +} + +/// The backplane the app serving `req` joined. +/// +/// An app that never joined one is a local hub. +pub(crate) fn backplane_of(req: &HttpRequest) -> Backplane { + req.app_data::>() + .map_or(Backplane::Local, |backplane| backplane.get_ref().clone()) +} + +/// Start delivering what other hubs send this hub's agents; a no-op for a `Local` backplane. +/// +/// Returns once the subscription is live and the shared blocklist is in `registry`, so nothing another hub sends or +/// blocks afterwards can be missed. Delivery then runs on a task of the current actix runtime until the +/// [`BackplaneDelivery`] returned is stopped. +/// +/// `max_frame_size` is the largest frame this hub accepts. A broker whose `max_payload` is smaller cannot carry such a +/// frame to another hub, so that mismatch is warned about here, once, rather than left to surface as a failed relay. +pub async fn start_backplane( + backplane: &Backplane, + registry: &WsAgentRegistry, + max_frame_size: usize, +) -> Result { + let Backplane::Nats(nats) = backplane else { + return Ok(BackplaneDelivery(DeliveryState::Local)); + }; + warn_if_broker_payload_too_small(nats.max_payload(), max_frame_size); + let inbox = Box::pin(nats.subscribe().await?); + for ip in nats.blocked_ips().await? { + let _newly = registry.blocklist.block(ip); + } + let (stop, stopped) = oneshot::channel::<()>(); + let task = actix_web::rt::spawn(deliver_until_stopped(inbox, stopped, registry.clone())); + info!("Hub {} joined the NATS backplane", nats.hub_id()); + Ok(BackplaneDelivery(DeliveryState::Nats { stop, task })) +} + +/// Say, at startup, when the broker cannot carry a frame as large as this hub accepts. +#[expect( + clippy::single_call_fn, + reason = "the startup check of start_backplane, kept out of its body" +)] +fn warn_if_broker_payload_too_small(max_payload: usize, max_frame_size: usize) { + if max_payload < max_frame_size { + let effect = "a larger frame reaches only the agents of the hub it arrived at"; + warn!("NATS max_payload {max_payload} < WS_MAX_FRAME_SIZE {max_frame_size}; raise the broker's: {effect}"); + } +} + +/// Hand each frame `inbox` yields to the local agents it is for, until `stopped` fires or the inbox ends. +#[expect( + clippy::integer_division_remainder_used, + clippy::single_call_fn, + reason = "the delivery task start_backplane spawns; tokio::select! uses % internally" +)] +async fn deliver_until_stopped( + mut inbox: Pin>>, + mut stopped: oneshot::Receiver<()>, + registry: WsAgentRegistry, +) { + loop { + tokio::select! { + _stop = &mut stopped => break, + inbound = inbox.next() => match inbound { + Some(inbound) => deliver_inbound(®istry, inbound), + None => break, + }, + } + } +} + +/// Hand one frame from another hub to the local agents it is for. +#[expect( + clippy::single_call_fn, + reason = "the per-frame step of the delivery loop, kept out of the select!" +)] +fn deliver_inbound(registry: &WsAgentRegistry, inbound: Inbound) { + match inbound { + Inbound::Direct { to_agent_id, frame } => { + if let Some(session) = registry.agent_session(&to_agent_id) { + let _sent = session.send(frame.into()); + } + } + Inbound::Broadcast { from_agent_id, frame } => { + for (_, session) in registry.connected_sessions(&from_agent_id) { + let _sent = session.send(frame.clone().into()); + } + } + Inbound::Blocked { ip } => { + if registry.blocklist.block(ip) { + info!("Blocked IP {ip} on the backplane's word"); + } + } + } +} + +/// This hub's delivery of what other hubs send its agents, running until [`BackplaneDelivery::stop`]. +#[must_use = "a delivery left to be dropped with the runtime aborts the process on Windows; call stop"] +#[non_exhaustive] +pub struct BackplaneDelivery(DeliveryState); + +/// Whether there is a delivery task to stop. +enum DeliveryState { + Local, + Nats { + stop: oneshot::Sender<()>, + task: JoinHandle<()>, + }, +} + +impl BackplaneDelivery { + /// Stop delivering, and wait until the backplane subscriptions are closed. + /// + /// Call it while the runtime is still running. async-nats unsubscribes by spawning a task when a subscription is + /// dropped, so a subscription left to be dropped with the runtime panics with + /// + /// there is no reactor running, must be called from the context of a Tokio 1.x runtime + /// + /// from `async-nats-0.50.0/src/lib.rs:1424`, and on Windows that panic in a destructor aborts the process with + /// `0xc0000409`. Observed on commit + /// at + /// . + pub async fn stop(self) { + if let DeliveryState::Nats { stop, task } = self.0 { + // Either the task already ended or it is told to now; both leave only the join to wait for. + let _sent = stop.send(()); + if let Err(error) = task.await { + warn!("The backplane delivery task ended abnormally: {error}"); + } + } + } +} + +/// Mark every agent connected to this hub as gone from the shared directory, ahead of the hub shutting down. +/// +/// A stopping server drops its connection tasks rather than letting each one run its own disconnect, so without this +/// the directory would go on placing those agents on a hub that is no longer there. A no-op for `Local`. +pub async fn withdraw_local_agents(backplane: &Backplane, registry: &WsAgentRegistry) { + let Backplane::Nats(nats) = backplane else { + return; + }; + for (agent_id, _session) in registry.connected_sessions("") { + if let Err(error) = nats.withdraw(&agent_id).await { + warn!("Failed to withdraw agent {agent_id} from the backplane: {error}"); + } + } +} diff --git a/services/ws/src/connection.rs b/services/ws/src/connection.rs index fab20a5b..8a610f12 100644 --- a/services/ws/src/connection.rs +++ b/services/ws/src/connection.rs @@ -1,128 +1,177 @@ -//! One agent's WebSocket connection: its registry identity, its inbound dispatch, and the read/write/heartbeat loop. +//! One agent's WebSocket connection: the read/write/heartbeat loop around the routing its [`Link`] decides. -use std::net::IpAddr; use std::time::{Duration, Instant}; use actix_ws::{AggregatedMessage, AggregatedMessageStream, CloseCode, CloseReason, Session}; use bytes::Bytes; -use chrono::Utc; -use edge_toolkit::ws::{ClientMessage, ConnectStatus, MessageDeliveryStatus, MessageScope, ServerMessage}; -use edge_toolkit::ws_server::{MessageRetention, PendingDirectMessage}; +#[cfg(feature = "nats")] +use edge_toolkit::ws::MessageDeliveryStatus; +use edge_toolkit::ws::{ClientMessage, ServerMessage}; +#[cfg(feature = "nats")] +use edge_toolkit::ws_server::PendingDirectMessage; +#[cfg(feature = "nats")] use et_hub_backplane::{Backplane, BackplaneError, Frame, NatsBackplane, Presence}; use futures_util::StreamExt as _; -use opentelemetry::global; -use opentelemetry::trace::{Span, Tracer as _}; use tokio::sync::mpsc::UnboundedReceiver; use tracing::{error, info, warn}; -use uuid::Uuid; -use crate::{ - ACTIVE_CONNECTIONS, AgentSession, HEARTBEAT_INTERVAL, MESSAGES_RECEIVED, SessionMessage, WsAgentRegistry, - log_client_event, -}; +#[cfg(feature = "nats")] +use crate::hub::{self, Shared, Stamps as _}; +use crate::hub::{Link, Routing, SessionMessage}; +use crate::server::{AgentSession, HEARTBEAT_INTERVAL, SystemStamps, WsAgentRegistry}; +use crate::telemetry::{self, HubSpan}; pub(crate) struct Connection { - agent_id: Option, + link: Link, last_activity: Instant, - client_ip: IpAddr, registry: WsAgentRegistry, /// Where agents on other hubs are found; in NATS mode it, not `registry`, decides where a message goes. + #[cfg(feature = "nats")] backplane: Backplane, session: Session, - outbox: AgentSession, /// Idle timeout for this connection, or `None` to never time out. idle_timeout: Option, - /// Whether a direct message this agent sends replaces its earlier unacknowledged one to the same recipient. - retention: MessageRetention, } impl Connection { #[expect(clippy::single_call_fn, reason = "inherent constructor; used once by ws_handler")] pub(crate) fn new( registry: WsAgentRegistry, - backplane: Backplane, - client_ip: IpAddr, + link: Link, session: Session, - outbox: AgentSession, idle_timeout: Option, - retention: MessageRetention, ) -> Self { - info!("New WebSocket connection for client IP {}", client_ip); + info!("New WebSocket connection for client IP {}", link.client_ip()); Self { - agent_id: None, + link, last_activity: Instant::now(), - client_ip, registry, - backplane, + #[cfg(feature = "nats")] + backplane: Backplane::Local, session, - outbox, idle_timeout, - retention, } } - fn current_agent_id(&self) -> &str { - self.agent_id.as_deref().unwrap_or("unassigned") - } - - fn assigned_agent_id(&self) -> Option<&str> { - self.agent_id.as_deref() + /// Share this connection's agents through `backplane` rather than keeping them to this hub. + #[cfg(feature = "nats")] + pub(crate) fn with_backplane(mut self, backplane: Backplane) -> Self { + self.backplane = backplane; + self } fn mark_activity(&mut self) { self.last_activity = Instant::now(); } - fn assign_or_reconnect_agent(&mut self, requested_id: Option) -> (String, ConnectStatus) { - let new_id = Uuid::now_v7().to_string(); - let (assigned_id, status) = - self.registry - .connect_agent(requested_id, new_id, self.client_ip, self.outbox.clone()); - self.agent_id = Some(assigned_id.clone()); - (assigned_id, status) + /// Carry out `routing`: other agents' messages, then the steps other hubs repeat, then the replies. + async fn apply(&mut self, routing: Routing) { + for (session, message) in routing.deliveries { + let _sent = session.send(message); + } + #[cfg(feature = "nats")] + for shared in routing.shared { + self.share(shared).await; + } + for reply in &routing.replies { + self.send_json(reply).await; + } } - /// This connection's agent id, auto-registering one on first use. - /// - /// A client that never sends `et-connect` -- e.g. a frontend that speaks only its own protocol -- still joins the - /// hub relay: its first unrecognised frame implicitly registers the session, so the frame is broadcast to the other - /// agents and this client then receives the relayed replies. et-protocol agents send `et-connect` first, so they - /// are already assigned by the time they relay and this is a no-op. - async fn ensure_assigned_agent(&mut self) -> String { - if let Some(id) = self.assigned_agent_id() { - return id.to_string(); + /// Repeat `shared` on every other hub of the backplane; a no-op for a local hub. + #[cfg(feature = "nats")] + #[expect( + clippy::cognitive_complexity, + reason = "the score is error!/warn! expansion; the body branches three times" + )] + async fn share(&self, shared: Shared) { + let Backplane::Nats(nats) = &self.backplane else { + return; + }; + match shared { + Shared::Joined { agent_id } => self.announce(nats, &agent_id).await, + Shared::Broadcast { from_agent_id, frame } => match backplane_frame(frame) { + Ok(frame) => { + if let Err(error) = nats.broadcast(&from_agent_id, frame).await { + warn!("Failed to broadcast from {from_agent_id} to the other hubs: {error}"); + } + } + Err(error) => error!("Failed to serialize broadcast from {from_agent_id}: {error}"), + }, } - let (id, _status) = self.assign_or_reconnect_agent(None); - self.announce(&id).await; - info!("Auto-registered relay client {} as agent {id}", self.client_ip); - id } /// Place `agent_id` on this hub in the shared directory, taking it over from any hub that held it before. /// /// A failure is logged rather than refused: the agent is still served by this hub, only unreachable from others. - async fn announce(&self, agent_id: &str) { - if let Backplane::Nats(nats) = &self.backplane - && let Err(error) = nats.announce(agent_id, self.client_ip).await - { + #[cfg(feature = "nats")] + async fn announce(&self, nats: &NatsBackplane, agent_id: &str) { + if let Err(error) = nats.announce(agent_id, self.link.client_ip()).await { error!("Failed to announce agent {agent_id} on the backplane: {error}"); } } - /// Everything waiting for `agent_id`: in this process's registry, or in the shared mailbox in NATS mode. - async fn pending_messages(&self, agent_id: &str) -> Vec { - match &self.backplane { - Backplane::Local => self.registry.pending_messages_for(agent_id), - Backplane::Nats(nats) => nats.pending_for(agent_id).await.unwrap_or_else(|error| { - error!("Failed to read the backplane mailbox of {agent_id}: {error}"); - Vec::new() - }), + /// Route `message` on a hub that shares its agents through `nats`. + /// + /// A connect reads the shared mailbox, a listing reads the shared directory, and direct messages and their + /// acknowledgements go through the shared mailbox; everything else routes as on a local hub. + #[cfg(feature = "nats")] + #[expect( + clippy::cognitive_complexity, + reason = "one arm per frame the backplane routes; the score is error! expansion" + )] + async fn route_via_backplane(&mut self, nats: &NatsBackplane, message: ClientMessage) { + match message { + ClientMessage::Connect { agent_id } => { + let (assigned_id, status) = self.link.connect(&self.registry, agent_id, &mut SystemStamps); + // Before the ack, so an agent that has been acked is already reachable from every hub. + self.announce(nats, &assigned_id).await; + let ack = self.link.connect_ack(assigned_id.clone(), status); + self.send_json(&ack).await; + let pending = nats.pending_for(&assigned_id).await.unwrap_or_else(|error| { + error!("Failed to read the backplane mailbox of {assigned_id}: {error}"); + Vec::new() + }); + for reply in self.link.pending_replies(pending) { + self.send_json(&reply).await; + } + } + ClientMessage::ListAgents => match nats.list_agents().await { + Ok(agents) => { + let listed = self.link.listed(agents); + self.send_json(&listed).await; + } + Err(error) => { + error!("Backplane directory listing failed: {error}"); + self.send_backplane_unavailable(&error).await; + } + }, + ClientMessage::SendAgentMessage { to_agent_id, message } => match self.link.direct_sender(&to_agent_id) { + Ok(from_agent_id) => { + self.send_direct_via_backplane(nats, from_agent_id, to_agent_id, message) + .await; + } + Err(refusal) => self.send_json(&refusal.reply()).await, + }, + ClientMessage::MessageAck { message_id } => match self.link.acknowledger() { + Ok(recipient_agent_id) => { + self.acknowledge_via_backplane(nats, recipient_agent_id, message_id) + .await; + } + Err(refusal) => self.send_json(&refusal.reply()).await, + }, + other @ (ClientMessage::Alive { .. } + | ClientMessage::BroadcastMessage { .. } + | ClientMessage::ClientEvent { .. } + | ClientMessage::RelayText { .. } + | ClientMessage::RelayBinary { .. }) => self.route_locally(other).await, } } /// Hand `message` to `to_agent_id`, whose socket the directory places on hub `hub_id`. /// /// The local session is written directly when that hub is this one; anything else crosses the backplane. + #[cfg(feature = "nats")] async fn deliver_via_backplane( &self, nats: &NatsBackplane, @@ -140,19 +189,11 @@ impl Connection { nats.send_to_hub(hub_id, to_agent_id, Frame::Text(text)).await } - /// Send `frame` from `from_agent_id` to the agents of every other hub; a no-op for a local hub. - async fn broadcast_to_other_hubs(&self, from_agent_id: &str, frame: Frame) { - if let Backplane::Nats(nats) = &self.backplane - && let Err(error) = nats.broadcast(from_agent_id, frame).await - { - warn!("Failed to broadcast from {from_agent_id} to the other hubs: {error}"); - } - } - /// The direct-message path when a NATS backplane decides where the recipient is. /// /// Every message lands in the shared mailbox first, so it survives the recipient reconnecting to another hub before /// it acknowledges, whichever hub it is delivered through. + #[cfg(feature = "nats")] #[expect( clippy::cognitive_complexity, reason = "the same three status replies as the local path, plus the backplane failure reply" @@ -167,7 +208,7 @@ impl Connection { let destination = match nats.presence(&to_agent_id).await { Ok(Presence::Unknown) => { warn!("direct message target {to_agent_id} is not a known agent"); - self.send_invalid(None, format!("unknown target agent {to_agent_id}")) + self.send_json(&hub::invalid(None, format!("unknown target agent {to_agent_id}"))) .await; return; } @@ -178,13 +219,8 @@ impl Connection { return; } }; - let pending = PendingDirectMessage::new( - Uuid::now_v7().to_string(), - from_agent_id, - Utc::now().to_rfc3339(), - message, - ); - if let Err(error) = nats.queue(&to_agent_id, pending.clone(), self.retention).await { + let pending = PendingDirectMessage::new(SystemStamps.new_id(), from_agent_id, SystemStamps.now(), message); + if let Err(error) = nats.queue(&to_agent_id, pending.clone(), self.link.retention()).await { error!("Backplane mailbox write for {to_agent_id} failed: {error}"); self.send_backplane_unavailable(&error).await; return; @@ -211,21 +247,23 @@ impl Connection { "Direct message {} queued for disconnected agent {to_agent_id}", pending.message_id ); - self.send_status( + self.send_json(&hub::status( Some(pending.message_id), MessageDeliveryStatus::Queued, format!("message queued for agent {to_agent_id}"), - ) + )) .await; } /// Tell the client its request failed because the backplane could not be reached. + #[cfg(feature = "nats")] async fn send_backplane_unavailable(&mut self, error: &BackplaneError) { - self.send_invalid(None, format!("hub backplane unavailable: {error}")) + self.send_json(&hub::invalid(None, format!("hub backplane unavailable: {error}"))) .await; } /// Deliver `pending`, already in the mailbox, to `to_agent_id` on hub `hub_id`, and report it delivered. + #[cfg(feature = "nats")] #[expect( clippy::cognitive_complexity, reason = "the score is info!/warn! expansion; the body branches once" @@ -238,15 +276,8 @@ impl Connection { pending: PendingDirectMessage, ) { let message_id = pending.message_id.clone(); - let agent_message = ServerMessage::AgentMessage { - message_id: message_id.clone(), - from_agent_id: pending.from_agent_id, - scope: MessageScope::Direct, - server_received_at: pending.server_received_at, - message: pending.message, - }; let (status, detail) = match self - .deliver_via_backplane(nats, hub_id, to_agent_id, agent_message) + .deliver_via_backplane(nats, hub_id, to_agent_id, hub::direct_message(pending)) .await { Ok(()) => { @@ -259,15 +290,16 @@ impl Connection { (MessageDeliveryStatus::Queued, "queued for") } }; - self.send_status( + self.send_json(&hub::status( Some(message_id), status, format!("message {detail} agent {to_agent_id}"), - ) + )) .await; } /// The ack path when the message sits in the shared mailbox, telling its sender wherever that sender is now. + #[cfg(feature = "nats")] #[expect( clippy::cognitive_complexity, reason = "the score is info!/warn! expansion; the body branches once" @@ -282,22 +314,23 @@ impl Connection { Ok(sender_agent_id) => sender_agent_id, Err(error) => { warn!("Invalid ack from {recipient_agent_id} for {message_id}: {error}"); - self.send_invalid(Some(message_id), error.to_string()).await; + self.send_json(&hub::invalid(Some(message_id), error.to_string())).await; return; } }; info!("Agent {recipient_agent_id} acknowledged direct message {message_id} from {sender_agent_id}"); - self.send_status( + self.send_json(&hub::status( Some(message_id.clone()), MessageDeliveryStatus::Acknowledged, "message acknowledged", - ) + )) .await; self.send_receipt_via_backplane(nats, &recipient_agent_id, &sender_agent_id, message_id) .await; } /// Tell `sender_agent_id`, on whichever hub holds it, that `recipient_agent_id` acknowledged `message_id`. + #[cfg(feature = "nats")] async fn send_receipt_via_backplane( &self, nats: &NatsBackplane, @@ -305,11 +338,7 @@ impl Connection { sender_agent_id: &str, message_id: String, ) { - let receipt = ServerMessage::MessageStatus { - message_id: Some(message_id), - status: MessageDeliveryStatus::Acknowledged, - detail: format!("agent {recipient_agent_id} acknowledged receipt"), - }; + let receipt = hub::receipt(recipient_agent_id, message_id); let delivered = match nats.presence(sender_agent_id).await { Ok(Presence::Connected { hub_id, .. }) => { self.deliver_via_backplane(nats, &hub_id, sender_agent_id, receipt) @@ -331,11 +360,9 @@ impl Connection { match serde_json::to_string(response) { Ok(json) => { if let Err(err) = self.session.text(json).await { - warn!("Failed to send message to {}: {:?}", self.current_agent_id(), err); + warn!("Failed to send message to {}: {:?}", self.link.current_agent_id(), err); } else { - let tracer = global::tracer("ws-server"); - let mut sent_span = tracer.start("ws.message.sent"); - sent_span.end(); + let _sent = HubSpan::start("ws.message.sent"); } } Err(error) => { @@ -346,374 +373,82 @@ impl Connection { async fn send_text(&mut self, text: String) { if let Err(err) = self.session.text(text).await { - warn!("Failed to forward text to {}: {:?}", self.current_agent_id(), err); + warn!("Failed to forward text to {}: {:?}", self.link.current_agent_id(), err); } } async fn send_binary(&mut self, bytes: Bytes) { if let Err(err) = self.session.binary(bytes).await { - warn!("Failed to forward binary to {}: {:?}", self.current_agent_id(), err); - } - } - - async fn send_status( - &mut self, - message_id: Option, - status: MessageDeliveryStatus, - detail: impl Into, - ) { - self.send_json(&ServerMessage::MessageStatus { - message_id, - status, - detail: detail.into(), - }) - .await; - } - - async fn send_invalid(&mut self, message_id: Option, detail: impl Into) { - self.send_json(&ServerMessage::Invalid { - message_id, - detail: detail.into(), - }) - .await; - } - - async fn deliver_pending_messages(&mut self) { - let Some(agent_id) = self.assigned_agent_id().map(str::to_string) else { - return; - }; - for pending in self.pending_messages(&agent_id).await { - info!( - "Delivering pending message {} to agent {} from {}", - pending.message_id, agent_id, pending.from_agent_id + warn!( + "Failed to forward binary to {}: {:?}", + self.link.current_agent_id(), + err ); - self.send_json(&ServerMessage::AgentMessage { - message_id: pending.message_id, - from_agent_id: pending.from_agent_id, - scope: MessageScope::Direct, - server_received_at: pending.server_received_at, - message: pending.message, - }) - .await; } } + /// Decode and route one text frame. #[expect( clippy::cognitive_complexity, - reason = "linear send/queue/unknown-recipient dispatch; splitting scatters the three status replies" + reason = "the score is info!/warn! expansion; the body branches twice" )] - async fn handle_send_direct( - &mut self, - span: &mut impl Span, - from_agent_id: String, - to_agent_id: String, - message: serde_json::Value, - ) { + async fn handle_text(&mut self, text: &str) { + let _span = HubSpan::start("ws.message.received"); + info!( + "Received message from client {}: {:?}", + self.link.current_agent_id(), + text + ); + let message = match ClientMessage::from_text_frame(text) { + Ok(message) => message, + Err(err) => { + warn!( + "Decode error for et-* frame from {}: {}", + self.link.current_agent_id(), + err + ); + return; + } + }; + #[cfg(feature = "nats")] if let Backplane::Nats(nats) = &self.backplane { let nats = nats.clone(); - self.send_direct_via_backplane(&nats, from_agent_id, to_agent_id, message) - .await; - span.end(); - return; - } - let server_received_at = Utc::now().to_rfc3339(); - let Some((pending, recipient_session)) = self.registry.queue_direct( - Uuid::now_v7().to_string(), - &from_agent_id, - &to_agent_id, - server_received_at, - message, - self.retention, - ) else { - warn!("direct message target {to_agent_id} is not a connected agent"); - self.send_invalid(None, format!("unknown target agent {to_agent_id}")) - .await; - span.end(); + self.route_via_backplane(&nats, message).await; return; - }; - let message_id = pending.message_id.clone(); - - if let Some(recipient) = recipient_session { - info!( - "Direct message {} delivered from {} to {}", - message_id, from_agent_id, to_agent_id - ); - let _sent = recipient.send(SessionMessage::Json(ServerMessage::AgentMessage { - message_id: message_id.clone(), - from_agent_id, - scope: MessageScope::Direct, - server_received_at: pending.server_received_at, - message: pending.message, - })); - self.send_status( - Some(message_id), - MessageDeliveryStatus::Delivered, - format!("message delivered to agent {to_agent_id}"), - ) - .await; - } else { - info!( - "Direct message {} queued from {} to disconnected agent {}", - message_id, from_agent_id, to_agent_id - ); - self.send_status( - Some(message_id), - MessageDeliveryStatus::Queued, - format!("message queued for agent {to_agent_id}"), - ) - .await; } - span.end(); + self.route_locally(message).await; } - /// Hub-style fallback: forward raw text to every connected agent except the sender. - /// - /// Used by the `RelayText` / `RelayBinary` arms of the inbound dispatcher when a frame doesn't carry our `et-*` - /// tag. - fn broadcast_raw_text(&self, from_agent_id: &str, text: &str) { - let recipients = self.registry.connected_sessions(from_agent_id); - info!( - "Broadcasting unrecognised text message from {} to {} agent(s)", - from_agent_id, - recipients.len() - ); - for (_, recipient) in recipients { - let _sent = recipient.send(SessionMessage::Text(text.to_string())); - } - } - - /// Hub-style fallback for binary frames -- same shape as the text path. - fn broadcast_raw_binary(&self, from_agent_id: &str, bytes: &Bytes) { - let recipients = self.registry.connected_sessions(from_agent_id); - info!( - "Broadcasting unrecognised binary message ({} bytes) from {} to {} agent(s)", - bytes.len(), - from_agent_id, - recipients.len() - ); - for (_, recipient) in recipients { - let _sent = recipient.send(SessionMessage::Binary(bytes.clone())); - } + /// Route `message` through this hub's own registry, and carry out what that asks. + async fn route_locally(&mut self, message: ClientMessage) { + let routing = self.link.route(&self.registry, message, &mut SystemStamps); + self.apply(routing).await; } /// Returns `false` when the connection should terminate. - #[expect( - clippy::cognitive_complexity, - clippy::too_many_lines, - reason = "single dispatcher for inbound ClientMessage variants; splitting it scatters handlers into trivial fns" - )] - // skipcq: RS-R1000 -- dispatcher cyclomatic complexity is inherent to the ClientMessage match; not splittable async fn handle_inbound(&mut self, msg: AggregatedMessage) -> bool { - MESSAGES_RECEIVED.add(1, &[]); + telemetry::message_received(); + self.mark_activity(); match msg { AggregatedMessage::Ping(ping) => { - self.mark_activity(); let _pong: Result<(), actix_ws::Closed> = self.session.pong(&ping).await; } - AggregatedMessage::Pong(_) => { - self.mark_activity(); - } + AggregatedMessage::Pong(_) => {} AggregatedMessage::Binary(bytes) => { - self.mark_activity(); - let from_agent_id = self.ensure_assigned_agent().await; - self.broadcast_raw_binary(&from_agent_id, &bytes); - self.broadcast_to_other_hubs(&from_agent_id, Frame::Binary(bytes)).await; + let routing = self.link.route_binary(&self.registry, bytes, &mut SystemStamps); + self.apply(routing).await; } AggregatedMessage::Close(reason) => { - self.mark_activity(); info!( "WebSocket close request from client: {} reason: {:?}", - self.current_agent_id(), + self.link.current_agent_id(), reason ); - let tracer = global::tracer("ws-server"); - let mut span = tracer.start("ws.disconnect"); - span.end(); + let _span = HubSpan::start("ws.disconnect"); let _closed: Result<(), actix_ws::Closed> = self.session.clone().close(reason).await; return false; } - AggregatedMessage::Text(text) => { - self.mark_activity(); - let tracer = global::tracer("ws-server"); - let mut span = tracer.start("ws.message.received"); - info!("Received message from client {}: {:?}", self.current_agent_id(), text); - - match ClientMessage::from_text_frame(&text) { - Err(err) => { - warn!("Decode error for et-* frame from {}: {}", self.current_agent_id(), err); - } - Ok(msg) => match msg { - ClientMessage::Connect { agent_id } => { - let requested_id = agent_id.clone(); - info!( - "Connect message: requested_agent_id={:?} client_ip={}", - requested_id, self.client_ip - ); - let (assigned_id, status) = self.assign_or_reconnect_agent(agent_id); - // Before the ack, so an agent that has been acked is already reachable from every hub. - self.announce(&assigned_id).await; - info!( - "Agent {} status {:?}connected from IP {}", - assigned_id, status, self.client_ip - ); - self.send_json(&ServerMessage::ConnectAck { - agent_id: assigned_id, - status: status.clone(), - }) - .await; - info!( - "WebSocket connection ready for client {} with status {:?}", - self.current_agent_id(), - status - ); - self.deliver_pending_messages().await; - } - ClientMessage::Alive { timestamp } => { - info!("Alive message from client {} at {}", self.current_agent_id(), timestamp); - self.send_json(&ServerMessage::Response { - message: format!("Alive message received at {}", Utc::now().to_rfc3339()), - }) - .await; - } - ClientMessage::ListAgents => { - let agents = match &self.backplane { - Backplane::Local => self.registry.list_agents(), - Backplane::Nats(nats) => match nats.list_agents().await { - Ok(agents) => agents, - Err(error) => { - error!("Backplane directory listing failed: {error}"); - self.send_backplane_unavailable(&error).await; - span.end(); - return true; - } - }, - }; - info!( - "Agent {} requested list_agents; returning {} agents", - self.current_agent_id(), - agents.len() - ); - self.send_json(&ServerMessage::ListAgentsResponse { agents }).await; - } - ClientMessage::SendAgentMessage { to_agent_id, message } => { - let Some(from_agent_id) = self.assigned_agent_id().map(str::to_string) else { - self.send_invalid(None, "agent must connect before sending messages") - .await; - span.end(); - return true; - }; - - if from_agent_id == to_agent_id { - self.send_invalid(None, "agent cannot send a direct message to itself") - .await; - span.end(); - return true; - } - - // Unknown / departed recipients are handled by handle_send_direct's queue miss below -- a - // single place that answers Invalid -- so there is no pre-check here. - self.handle_send_direct(&mut span, from_agent_id, to_agent_id, message) - .await; - return true; - } - ClientMessage::BroadcastMessage { message } => { - let Some(from_agent_id) = self.assigned_agent_id().map(str::to_string) else { - self.send_invalid(None, "agent must connect before broadcasting messages") - .await; - span.end(); - return true; - }; - - let recipients = self.registry.connected_sessions(&from_agent_id); - let message_id = Uuid::now_v7().to_string(); - let server_received_at = Utc::now().to_rfc3339(); - info!( - "Broadcast message {} from {} to {} agent(s)", - message_id, - from_agent_id, - recipients.len() - ); - let broadcast = ServerMessage::AgentMessage { - message_id: message_id.clone(), - from_agent_id: from_agent_id.clone(), - scope: MessageScope::Broadcast, - server_received_at, - message, - }; - for (_, recipient) in &recipients { - let _sent = recipient.send(SessionMessage::Json(broadcast.clone())); - } - match serde_json::to_string(&broadcast) { - Ok(text) => self.broadcast_to_other_hubs(&from_agent_id, Frame::Text(text)).await, - Err(error) => error!("Failed to serialize broadcast {message_id}: {error}"), - } - self.send_status( - Some(message_id), - MessageDeliveryStatus::Broadcast, - format!("broadcast sent to {} connected agents", recipients.len()), - ) - .await; - } - ClientMessage::MessageAck { message_id } => { - let Some(recipient_agent_id) = self.assigned_agent_id().map(str::to_string) else { - self.send_invalid(None, "agent must connect before acknowledging messages") - .await; - span.end(); - return true; - }; - - if let Backplane::Nats(nats) = &self.backplane { - let nats = nats.clone(); - self.acknowledge_via_backplane(&nats, recipient_agent_id, message_id) - .await; - span.end(); - return true; - } - match self.registry.acknowledge_message(&recipient_agent_id, &message_id) { - Ok((message_id, sender_session, sender_agent_id)) => { - info!( - "Agent {} acknowledged direct message {} from {}", - recipient_agent_id, message_id, sender_agent_id - ); - self.send_status( - Some(message_id.clone()), - MessageDeliveryStatus::Acknowledged, - "message acknowledged", - ) - .await; - if let Some(sender) = sender_session { - let _sent = sender.send(SessionMessage::Json(ServerMessage::MessageStatus { - message_id: Some(message_id), - status: MessageDeliveryStatus::Acknowledged, - detail: format!("agent {recipient_agent_id} acknowledged receipt"), - })); - } - } - Err(error) => { - warn!("Invalid ack from {} for {}: {}", recipient_agent_id, message_id, error); - self.send_invalid(Some(message_id), error.to_string()).await; - } - } - } - ClientMessage::ClientEvent { event } => log_client_event(self.current_agent_id(), &event), - ClientMessage::RelayText { content } => { - let from_agent_id = self.ensure_assigned_agent().await; - self.broadcast_raw_text(&from_agent_id, &content); - self.broadcast_to_other_hubs(&from_agent_id, Frame::Text(content)).await; - } - ClientMessage::RelayBinary { content } => { - // A binary tungstenite frame is dispatched by the outer `AggregatedMessage::Binary` arm. If - // a client explicitly sends `{"type":"et-relay-binary",...}` as a text frame, honour it by - // relaying the payload as a binary frame. - let from_agent_id = self.ensure_assigned_agent().await; - let bytes = Bytes::from(content); - self.broadcast_raw_binary(&from_agent_id, &bytes); - self.broadcast_to_other_hubs(&from_agent_id, Frame::Binary(bytes)).await; - } - }, - } - span.end(); - } + AggregatedMessage::Text(text) => self.handle_text(&text).await, } true } @@ -729,7 +464,7 @@ impl Connection { } warn!( "WebSocket connection timed out for client {} after {:?} of inactivity", - self.current_agent_id(), + self.link.current_agent_id(), idle_for ); let reason = CloseReason { @@ -742,13 +477,13 @@ impl Connection { /// Close the session when its client address has been blocked since it connected, reporting whether it did. async fn close_if_blocked(&self) -> bool { - if !self.registry.blocklist.is_blocked(self.client_ip) { + if !self.registry.blocklist.is_blocked(self.link.client_ip()) { return false; } warn!( "Closing WebSocket connection for client {} from blocked IP {}", - self.current_agent_id(), - self.client_ip + self.link.current_agent_id(), + self.link.client_ip() ); let reason = CloseReason { code: CloseCode::Policy, @@ -769,15 +504,15 @@ impl Connection { mut stream: AggregatedMessageStream, mut outbound: UnboundedReceiver, ) { - let tracer = global::tracer("ws-server"); - let mut connect_span = tracer.start("ws.connect"); - info!( - "WebSocket connection established for client IP {} with agent {}", - self.client_ip, - self.current_agent_id() - ); - connect_span.end(); - ACTIVE_CONNECTIONS.add(1, &[]); + { + let _connect_span = HubSpan::start("ws.connect"); + info!( + "WebSocket connection established for client IP {} with agent {}", + self.link.client_ip(), + self.link.current_agent_id() + ); + } + telemetry::connections_changed(1); let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL); heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); @@ -794,9 +529,8 @@ impl Connection { } } Some(Err(e)) => { - error!("WebSocket error for client {}: {:?}", self.current_agent_id(), e); - let mut err_span = tracer.start("ws.error"); - err_span.end(); + error!("WebSocket error for client {}: {:?}", self.link.current_agent_id(), e); + let _err_span = HubSpan::start("ws.error"); break; } None => break, @@ -817,33 +551,63 @@ impl Connection { } } - ACTIVE_CONNECTIONS.add(-1, &[]); + telemetry::connections_changed(-1); self.release_identity().await; } /// Mark this connection's agent disconnected, here and in the shared directory, unless it has been taken over. - #[expect( - clippy::cognitive_complexity, - reason = "the score is info!/warn! expansion; the body branches three times" + #[cfg_attr( + feature = "nats", + expect( + clippy::cognitive_complexity, + reason = "the score is info!/warn! expansion; the body branches three times" + ) + )] + #[cfg_attr( + not(feature = "nats"), + expect(clippy::unused_async, reason = "withdrawing from the backplane is its only await") )] async fn release_identity(&self) { - let Some(agent_id) = self.agent_id.as_deref() else { + let Some(agent_id) = self.link.agent_id() else { info!( "WebSocket connection closed before agent assignment for client IP {}", - self.client_ip + self.link.client_ip() ); return; }; // Only this connection's own session: another may have taken the id over since, and is still live. + #[cfg_attr( + not(feature = "nats"), + expect(unused_variables, reason = "only the backplane needs to know it was current") + )] let was_current = self - .registry - .mark_disconnected_if(agent_id, &|session| session.same_channel(&self.outbox)); + .link + .release(&self.registry, &|session| session.same_channel(self.link.session())); + #[cfg(feature = "nats")] if was_current && let Backplane::Nats(nats) = &self.backplane && let Err(error) = nats.withdraw(agent_id).await { warn!("Failed to withdraw agent {agent_id} from the backplane: {error}"); } - info!("Agent {} disconnected; last known IP {}", agent_id, self.client_ip); + info!( + "Agent {} disconnected; last known IP {}", + agent_id, + self.link.client_ip() + ); } } + +/// `frame` as it crosses the backplane to the other hubs, where a protocol message travels as its JSON text. +#[cfg(feature = "nats")] +#[expect( + clippy::single_call_fn, + reason = "the encoding step of share, kept out of its logging" +)] +fn backplane_frame(frame: SessionMessage) -> Result { + Ok(match frame { + SessionMessage::Json(message) => Frame::Text(serde_json::to_string(&message)?), + SessionMessage::Text(text) => Frame::Text(text), + SessionMessage::Binary(bytes) => Frame::Binary(bytes), + }) +} diff --git a/services/ws/src/hub.rs b/services/ws/src/hub.rs new file mode 100644 index 00000000..7f6f0051 --- /dev/null +++ b/services/ws/src/hub.rs @@ -0,0 +1,585 @@ +//! The hub's routing of agents' frames, apart from any transport, clock or id source. +//! +//! An embedder owns the sockets. For each connection it keeps a [`Link`], hands it every frame the agent sends, and +//! carries out the [`Routing`] that comes back: [`Routing::deliveries`] go to the sessions they name, the +//! [`Routing::shared`] steps go to any other hubs the embedder shares its agents with, and [`Routing::replies`] go back +//! down the connection itself. Fresh ids and timestamps come from the embedder's [`Stamps`]. +//! +//! Everything here is `no_std` + `alloc`; the actix-web server under the `std` feature is one such embedder. + +#[cfg(not(feature = "std"))] +use alloc::{ + format, + string::{String, ToString as _}, + vec::Vec, +}; +use core::net::IpAddr; + +use bytes::Bytes; +use edge_toolkit::ws::{ + AgentSummary, ClientMessage, CloudEvent, ConnectStatus, MessageDeliveryStatus, MessageScope, ServerMessage, + client_event_type, +}; +use edge_toolkit::ws_server::{AgentRegistry, MessageRetention, PendingDirectMessage}; +use tracing::{info, warn}; + +/// Outbound envelope written to an agent's websocket session. +/// +/// `Json` is the normal path for protocol messages. `Text` and `Binary` carry payloads the server forwards verbatim -- +/// used by the hub-style fallback that broadcasts unrecognised frames to every other connected agent. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub enum SessionMessage { + Json(ServerMessage), + Text(String), + Binary(Bytes), +} + +impl From for SessionMessage { + fn from(value: ServerMessage) -> Self { + Self::Json(value) + } +} + +/// Where the hub's fresh ids and receipt times come from. +pub trait Stamps { + /// An id no other agent or message on the hub has: for an agent that names none, and for every message. + fn new_id(&mut self) -> String; + + /// The current time, as an RFC 3339 timestamp. + fn now(&mut self) -> String; +} + +/// A step of a frame's routing that every other hub sharing this one's agents has to hear of. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub enum Shared { + /// `agent_id` is now reached through this hub. It precedes the replies, so an acknowledged agent is reachable. + Joined { agent_id: String }, + /// `frame` from `from_agent_id` is for every agent, including those connected to other hubs. + Broadcast { + from_agent_id: String, + frame: SessionMessage, + }, +} + +/// What one frame asks the embedder to do. +#[derive(Debug)] +#[non_exhaustive] +pub struct Routing { + /// Messages for other agents, each with the session it goes to. + pub deliveries: Vec<(S, SessionMessage)>, + /// Steps other hubs must repeat, in order; a hub that shares its agents with none ignores them. + pub shared: Vec, + /// Messages for the agent that sent the frame, in order. + pub replies: Vec, +} + +impl Routing { + /// A routing whose only effect is `reply` to the sender. + fn reply(reply: ServerMessage) -> Self { + let mut routing = Self::default(); + routing.replies.push(reply); + routing + } +} + +impl Default for Routing { + fn default() -> Self { + Self { + deliveries: Vec::new(), + shared: Vec::new(), + replies: Vec::new(), + } + } +} + +/// A delivery status for the sender of a message. +#[must_use] +pub fn status(message_id: Option, status: MessageDeliveryStatus, detail: D) -> ServerMessage +where + D: Into, +{ + ServerMessage::MessageStatus { + message_id, + status, + detail: detail.into(), + } +} + +/// The refusal of a frame the hub cannot act on. +#[must_use] +pub fn invalid(message_id: Option, detail: D) -> ServerMessage +where + D: Into, +{ + ServerMessage::Invalid { + message_id, + detail: detail.into(), + } +} + +/// Why the hub will not act on a frame, as the agent that sent it is told. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Refusal(&'static str); + +impl Refusal { + /// The `et-invalid` reply that tells the agent. + #[must_use] + pub fn reply(self) -> ServerMessage { + invalid(None, self.0) + } +} + +/// The direct message `pending` holds, as its recipient receives it. +#[must_use] +pub fn direct_message(pending: PendingDirectMessage) -> ServerMessage { + ServerMessage::AgentMessage { + message_id: pending.message_id, + from_agent_id: pending.from_agent_id, + scope: MessageScope::Direct, + server_received_at: pending.server_received_at, + message: pending.message, + } +} + +/// The receipt telling a sender that `recipient_agent_id` acknowledged its message `message_id`. +#[must_use] +pub fn receipt(recipient_agent_id: &str, message_id: String) -> ServerMessage { + status( + Some(message_id), + MessageDeliveryStatus::Acknowledged, + format!("agent {recipient_agent_id} acknowledged receipt"), + ) +} + +/// Log an agent's `et-client-event`, the observability frame the hub records and does not answer. +/// +/// An `et.video_cv.inference` event additionally gets its detected class, confidence and processing time logged as +/// fields of their own, so the camera modules' results read at a glance. +#[expect( + clippy::cognitive_complexity, + clippy::single_call_fn, + reason = "the stateless client-event arm of the inbound dispatcher; its score is info! expansion" +)] +fn log_client_event(agent_id: &str, event: &CloudEvent) { + let details = &event.data; + if event.event_type == client_event_type("video_cv", "inference") { + let detected_class = details + .get("detected_class") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + let confidence = details + .get("confidence") + .and_then(serde_json::Value::as_f64) + .unwrap_or_default(); + let processed_at = details + .get("processed_at") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + info!( + "Video inference received from {}: class={} confidence={:.4} processed_at={}", + agent_id, detected_class, confidence, processed_at + ); + } + info!( + "Client event from {}: type={} source={} id={} time={} data={}", + agent_id, + event.event_type, + event.source, + event.id, + event.time.to_rfc3339(), + details + ); +} + +/// One agent's connection to the hub: its identity in the registry, and the routing of what it sends. +/// +/// `S` is the handle the embedder writes to the agent through; the registry keeps a clone of it for as long as the +/// agent holds its id. +#[derive(Debug)] +#[non_exhaustive] +pub struct Link { + agent_id: Option, + client_ip: IpAddr, + session: S, + /// Whether a direct message this agent sends replaces its earlier unacknowledged one to the same recipient. + retention: MessageRetention, +} + +impl Link { + /// A connection from `client_ip` that has not taken an agent id yet. + #[must_use] + pub const fn new(client_ip: IpAddr, session: S, retention: MessageRetention) -> Self { + Self { + agent_id: None, + client_ip, + session, + retention, + } + } + + /// The agent id this connection holds, once it has one. + #[must_use] + pub fn agent_id(&self) -> Option<&str> { + self.agent_id.as_deref() + } + + /// The agent id, or `unassigned`, for log lines. + #[must_use] + pub fn current_agent_id(&self) -> &str { + self.agent_id.as_deref().unwrap_or("unassigned") + } + + /// The address the connection came from. + #[must_use] + pub const fn client_ip(&self) -> IpAddr { + self.client_ip + } + + /// The handle the registry holds for this connection. + #[must_use] + pub const fn session(&self) -> &S { + &self.session + } + + /// The retention of the direct messages this agent sends. + #[must_use] + pub const fn retention(&self) -> MessageRetention { + self.retention + } + + /// Take `requested_id`, or a fresh id, in `registry`. + pub fn connect( + &mut self, + registry: &AgentRegistry, + requested_id: Option, + stamps: &mut T, + ) -> (String, ConnectStatus) + where + T: Stamps, + { + info!( + "Connect message: requested_agent_id={:?} client_ip={}", + requested_id, self.client_ip + ); + let (assigned_id, status) = self.assign(registry, requested_id, stamps); + info!( + "Agent {} status {:?}connected from IP {}", + assigned_id, status, self.client_ip + ); + (assigned_id, status) + } + + fn assign( + &mut self, + registry: &AgentRegistry, + requested_id: Option, + stamps: &mut T, + ) -> (String, ConnectStatus) + where + T: Stamps, + { + let (assigned_id, status) = + registry.connect_agent(requested_id, stamps.new_id(), self.client_ip, self.session.clone()); + self.agent_id = Some(assigned_id.clone()); + (assigned_id, status) + } + + /// The acknowledgement of a connect that took `agent_id` with `status`. + #[must_use] + pub fn connect_ack(&self, agent_id: String, status: ConnectStatus) -> ServerMessage { + info!( + "WebSocket connection ready for client {} with status {:?}", + self.current_agent_id(), + status + ); + ServerMessage::ConnectAck { agent_id, status } + } + + /// The direct messages waiting for this agent, as it receives them. + #[must_use] + pub fn pending_replies(&self, pending: Vec) -> Vec { + pending + .into_iter() + .map(|pending| { + info!( + "Delivering pending message {} to agent {} from {}", + pending.message_id, + self.current_agent_id(), + pending.from_agent_id + ); + direct_message(pending) + }) + .collect() + } + + /// The answer to `et-list-agents`, listing `agents`. + #[must_use] + pub fn listed(&self, agents: Vec) -> ServerMessage { + info!( + "Agent {} requested list_agents; returning {} agents", + self.current_agent_id(), + agents.len() + ); + ServerMessage::ListAgentsResponse { agents } + } + + /// The agent sending a direct message to `to_agent_id`, or the refusal when it may not. + pub fn direct_sender(&self, to_agent_id: &str) -> Result { + let Some(from_agent_id) = self.agent_id.clone() else { + return Err(Refusal("agent must connect before sending messages")); + }; + if from_agent_id == to_agent_id { + return Err(Refusal("agent cannot send a direct message to itself")); + } + Ok(from_agent_id) + } + + /// The agent acknowledging a message, or the refusal when it has not connected. + pub fn acknowledger(&self) -> Result { + self.agent_id + .clone() + .ok_or(Refusal("agent must connect before acknowledging messages")) + } + + /// This connection's agent id, taking one on first use, with the routing that taking it needs. + /// + /// A client that never sends `et-connect` -- e.g. a frontend that speaks only its own protocol -- still joins the + /// hub relay: its first unrecognised frame implicitly registers the session, so the frame is broadcast to the other + /// agents and this client then receives the relayed replies. et-protocol agents send `et-connect` first, so they + /// are already assigned by the time they relay and this is a no-op. + fn ensure_assigned(&mut self, registry: &AgentRegistry, stamps: &mut T) -> (String, Routing) + where + T: Stamps, + { + let mut routing = Routing::default(); + if let Some(id) = self.agent_id() { + return (id.to_string(), routing); + } + let (id, _status) = self.assign(registry, None, stamps); + routing.shared.push(Shared::Joined { agent_id: id.clone() }); + info!("Auto-registered relay client {} as agent {id}", self.client_ip); + (id, routing) + } + + /// Route one decoded frame through `registry`, the agents of this hub alone. + pub fn route(&mut self, registry: &AgentRegistry, message: ClientMessage, stamps: &mut T) -> Routing + where + T: Stamps, + { + match message { + ClientMessage::Connect { agent_id } => { + let (assigned_id, status) = self.connect(registry, agent_id, stamps); + let mut routing = Routing::reply(self.connect_ack(assigned_id.clone(), status)); + routing + .replies + .extend(self.pending_replies(registry.pending_messages_for(&assigned_id))); + routing.shared.push(Shared::Joined { agent_id: assigned_id }); + routing + } + ClientMessage::Alive { timestamp } => { + info!("Alive message from client {} at {}", self.current_agent_id(), timestamp); + Routing::reply(ServerMessage::Response { + message: format!("Alive message received at {}", stamps.now()), + }) + } + ClientMessage::ListAgents => Routing::reply(self.listed(registry.list_agents())), + ClientMessage::SendAgentMessage { to_agent_id, message } => match self.direct_sender(&to_agent_id) { + Ok(from_agent_id) => self.send_direct(registry, &from_agent_id, &to_agent_id, message, stamps), + Err(refusal) => Routing::reply(refusal.reply()), + }, + ClientMessage::BroadcastMessage { message } => self.broadcast(registry, message, stamps), + ClientMessage::MessageAck { message_id } => match self.acknowledger() { + Ok(recipient_agent_id) => Self::acknowledge(registry, &recipient_agent_id, message_id), + Err(refusal) => Routing::reply(refusal.reply()), + }, + ClientMessage::ClientEvent { event } => { + log_client_event(self.current_agent_id(), &event); + Routing::default() + } + ClientMessage::RelayText { content } => self.relay(registry, SessionMessage::Text(content), stamps), + // A binary frame normally reaches the hub as one; a client that sends `et-relay-binary` as a text frame + // has its payload relayed as a binary frame all the same. + ClientMessage::RelayBinary { content } => { + self.relay(registry, SessionMessage::Binary(Bytes::from(content)), stamps) + } + } + } + + /// Route a binary frame, which is always a relay; the typed catalogue is JSON-only. + pub fn route_binary(&mut self, registry: &AgentRegistry, bytes: Bytes, stamps: &mut T) -> Routing + where + T: Stamps, + { + self.relay(registry, SessionMessage::Binary(bytes), stamps) + } + + #[expect( + clippy::cognitive_complexity, + reason = "linear send/queue/unknown-recipient dispatch; splitting scatters the three status replies" + )] + fn send_direct( + &self, + registry: &AgentRegistry, + from_agent_id: &str, + to_agent_id: &str, + message: serde_json::Value, + stamps: &mut T, + ) -> Routing + where + T: Stamps, + { + let server_received_at = stamps.now(); + let Some((pending, recipient_session)) = registry.queue_direct( + stamps.new_id(), + from_agent_id, + to_agent_id, + server_received_at, + message, + self.retention, + ) else { + warn!("direct message target {to_agent_id} is not a connected agent"); + return Routing::reply(invalid(None, format!("unknown target agent {to_agent_id}"))); + }; + let message_id = pending.message_id.clone(); + + let mut routing = Routing::default(); + if let Some(recipient) = recipient_session { + info!( + "Direct message {} delivered from {} to {}", + message_id, from_agent_id, to_agent_id + ); + routing + .deliveries + .push((recipient, SessionMessage::Json(direct_message(pending)))); + routing.replies.push(status( + Some(message_id), + MessageDeliveryStatus::Delivered, + format!("message delivered to agent {to_agent_id}"), + )); + } else { + info!( + "Direct message {} queued from {} to disconnected agent {}", + message_id, from_agent_id, to_agent_id + ); + routing.replies.push(status( + Some(message_id), + MessageDeliveryStatus::Queued, + format!("message queued for agent {to_agent_id}"), + )); + } + routing + } + + fn broadcast(&self, registry: &AgentRegistry, message: serde_json::Value, stamps: &mut T) -> Routing + where + T: Stamps, + { + let Some(from_agent_id) = self.agent_id.clone() else { + return Routing::reply(invalid(None, "agent must connect before broadcasting messages")); + }; + let recipients = registry.connected_sessions(&from_agent_id); + let message_id = stamps.new_id(); + let server_received_at = stamps.now(); + info!( + "Broadcast message {} from {} to {} agent(s)", + message_id, + from_agent_id, + recipients.len() + ); + let broadcast = ServerMessage::AgentMessage { + message_id: message_id.clone(), + from_agent_id: from_agent_id.clone(), + scope: MessageScope::Broadcast, + server_received_at, + message, + }; + let mut routing = Routing::reply(status( + Some(message_id), + MessageDeliveryStatus::Broadcast, + format!("broadcast sent to {} connected agents", recipients.len()), + )); + routing.deliveries = recipients + .into_iter() + .map(|(_, recipient)| (recipient, SessionMessage::Json(broadcast.clone()))) + .collect(); + routing.shared.push(Shared::Broadcast { + from_agent_id, + frame: SessionMessage::Json(broadcast), + }); + routing + } + + #[expect( + clippy::cognitive_complexity, + clippy::single_call_fn, + reason = "the acknowledgement arm of route; its score is info!/warn! expansion" + )] + fn acknowledge(registry: &AgentRegistry, recipient_agent_id: &str, message_id: String) -> Routing { + match registry.acknowledge_message(recipient_agent_id, &message_id) { + Ok((message_id, sender_session, sender_agent_id)) => { + info!( + "Agent {} acknowledged direct message {} from {}", + recipient_agent_id, message_id, sender_agent_id + ); + let mut routing = Routing::reply(status( + Some(message_id.clone()), + MessageDeliveryStatus::Acknowledged, + "message acknowledged", + )); + if let Some(sender) = sender_session { + let receipt = receipt(recipient_agent_id, message_id); + routing.deliveries.push((sender, SessionMessage::Json(receipt))); + } + routing + } + Err(error) => { + warn!("Invalid ack from {} for {}: {}", recipient_agent_id, message_id, error); + Routing::reply(invalid(Some(message_id), error.to_string())) + } + } + } + + /// Hub-style fallback: forward a raw frame to every connected agent except the sender. + #[expect( + clippy::cognitive_complexity, + reason = "the score is info! expansion; the body branches once on the frame kind" + )] + fn relay(&mut self, registry: &AgentRegistry, frame: SessionMessage, stamps: &mut T) -> Routing + where + T: Stamps, + { + let (from_agent_id, mut routing) = self.ensure_assigned(registry, stamps); + let recipients = registry.connected_sessions(&from_agent_id); + match &frame { + SessionMessage::Binary(bytes) => info!( + "Broadcasting unrecognised binary message ({} bytes) from {} to {} agent(s)", + bytes.len(), + from_agent_id, + recipients.len() + ), + SessionMessage::Json(_) | SessionMessage::Text(_) => info!( + "Broadcasting unrecognised text message from {} to {} agent(s)", + from_agent_id, + recipients.len() + ), + } + routing.deliveries = recipients + .into_iter() + .map(|(_, recipient)| (recipient, frame.clone())) + .collect(); + routing.shared.push(Shared::Broadcast { from_agent_id, frame }); + routing + } + + /// Mark this connection's agent disconnected unless `is_current` says its session has been taken over. + /// + /// Returns whether it was marked; see [`AgentRegistry::mark_disconnected_if`]. + #[must_use] + pub fn release(&self, registry: &AgentRegistry, is_current: &dyn Fn(&S) -> bool) -> bool { + self.agent_id + .as_deref() + .is_some_and(|agent_id| registry.mark_disconnected_if(agent_id, is_current)) + } +} diff --git a/services/ws/src/lib.rs b/services/ws/src/lib.rs index 648c4202..8974e8a2 100644 --- a/services/ws/src/lib.rs +++ b/services/ws/src/lib.rs @@ -1,454 +1,32 @@ #![cfg_attr(doc, doc = include_str!("../README.md"))] #![cfg_attr(feature = "docs", doc = "## Feature flags")] #![cfg_attr(feature = "docs", doc = document_features::document_features!())] +#![cfg_attr(not(feature = "std"), no_std)] -use std::collections::BTreeMap; -use std::path::PathBuf; -use std::pin::Pin; -use std::sync::LazyLock; -use std::time::Duration; - -use actix_web::{Error, HttpRequest, HttpResponse, web}; -use bytes::Bytes; -use edge_toolkit::ws::{CloudEvent, ServerMessage, client_event_type}; -pub use edge_toolkit::ws_server::MessageRetention; -use edge_toolkit::ws_server::{ - AgentRecord, AgentRegistry, IpBlocklist, PendingDirectMessage, RegistryError, read_registry_file, -}; -pub use et_hub_backplane::{Backplane, BackplaneConfig, BackplaneError}; -use et_hub_backplane::{Frame, Inbound}; -use futures_util::{Stream, StreamExt as _}; -use opentelemetry::{ - global, - metrics::{Counter, UpDownCounter}, - trace::{Span as _, Tracer as _}, -}; -use serde::Deserialize; -use serde_default::DefaultFromSerde; -use serde_inline_default::serde_inline_default; -use tokio::sync::mpsc::{self, UnboundedSender}; -use tokio::sync::oneshot; -use tokio::task::JoinHandle; -use tracing::{info, warn}; +#[cfg(any(not(feature = "std"), feature = "picoserve-hub"))] +extern crate alloc; +#[cfg(feature = "std")] pub mod admin; +#[cfg(feature = "nats")] +mod backplane; +#[cfg(feature = "std")] mod connection; - -use self::admin::AdminToken; -use self::connection::Connection; - -/// Default idle timeout before the hub closes a quiet connection. -pub const DEFAULT_CONNECTION_TIMEOUT: Duration = Duration::from_secs(15); -pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(1); - -/// Default max WebSocket frame size (64 MiB). -/// -/// Large binary payloads fanned out via default broadcast (e.g. tensors) easily blow past actix-ws's 64 KiB default. -/// Override via the `WS_MAX_FRAME_SIZE` env var, as a human byte size (`serde-env` translates `[ws] max_frame_size` -/// to `WS_MAX_FRAME_SIZE`). -pub const DEFAULT_MAX_FRAME_SIZE: usize = 64 * 1024 * 1024; - -// Hub metrics, recorded through the global meter `et_otlp::init` installs (mirrors the `global::tracer` use above). -// Built lazily on first use -- by then the meter provider is set -- and cached for the process. -static MESSAGES_RECEIVED: LazyLock> = LazyLock::new(|| { - global::meter("ws-server") - .u64_counter("et_ws.messages.received") - .with_description("Inbound WebSocket frames the hub has handled") - .build() -}); -static ACTIVE_CONNECTIONS: LazyLock> = LazyLock::new(|| { - global::meter("ws-server") - .i64_up_down_counter("et_ws.connections.active") - .with_description("Currently-open WebSocket connections") - .build() -}); - -/// Runtime knobs for the WebSocket hub. -/// -/// Populated by `serde-env` in `et-ws-server::main`, then handed to `configure`. -#[serde_inline_default] -#[derive(Clone, Debug, DefaultFromSerde, Deserialize)] -#[cfg_attr(feature = "env-schema", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct WsConfig { - /// Largest single WebSocket frame the hub will accept. - /// - /// Frames above this are dropped by actix-ws before they reach the handler, so callers shipping big tensors / blobs - /// need to raise it above their payload size. `WS_MAX_FRAME_SIZE` takes a human byte size (e.g. `64MiB`, `64MB`, - /// `512KiB`) or a plain byte count; unset defaults to 64 MiB. - #[serde(default = "default_max_frame_size", deserialize_with = "deserialize_byte_size")] - #[cfg_attr(feature = "env-schema", schemars(with = "String", extend("default" = "64MiB")))] - pub max_frame_size: usize, - - /// Idle period before the hub closes a connection, as a humantime duration (e.g. `15s`, `1m30s`). - /// - /// Unset defaults to 15s; `none`/`off`/`disabled` turns the idle timeout off (the hub never closes a connection for - /// inactivity), which suits a frontend that sits idle. - #[serde( - default = "default_connection_timeout", - deserialize_with = "edge_toolkit::config::deserialize_optional_humantime" - )] - #[cfg_attr(feature = "env-schema", schemars(with = "Option", extend("default" = "15s")))] - pub connection_timeout: Option, - - /// What the hub shares its agents through: `local` (the default) or a `nats://` / `tls://` URL. - /// - /// `local` keeps every agent in this process, persisted to the `WS_AGENT_REGISTRY` file. A NATS URL names a - /// server with `JetStream`, and every hub pointed at it shares one agent directory, one mailbox of unacknowledged - /// direct messages, and delivery between them, so hubs scale out behind a load balancer: an agent on one hub - /// lists, messages, broadcasts to and acknowledges agents on any other, and may reconnect to a different hub - /// without losing what was queued for it. The directory and mailbox live in `JetStream`, so the registry file is - /// neither read nor written in this mode. - #[serde(default)] - #[cfg_attr(feature = "env-schema", schemars(with = "String", extend("default" = "local")))] - pub backplane: BackplaneConfig, - - /// Which unacknowledged direct messages a recipient holds: `latest-per-sender` (the default) or `all`. - /// - /// `latest-per-sender` keeps only the newest message from each sender, replacing the earlier one, so a recipient - /// holds at most one message per sender and the hub's memory for them is bounded. `all` keeps every message until - /// it is acknowledged, with no bound on how many pile up for a recipient that stays away. - #[serde(default)] - #[cfg_attr( - feature = "env-schema", - schemars(with = "String", extend("default" = "latest-per-sender")) - )] - pub message_retention: MessageRetention, - - /// The YAML file a local hub loads its agent registry from at startup and saves it to on shutdown. - #[serde_inline_default(PathBuf::from("registry.yaml"))] - #[cfg_attr(feature = "env-schema", schemars(extend("default" = "registry.yaml")))] - pub agent_registry: PathBuf, - - /// Bearer token authorizing the admin API: `PUT /blocked-ips/{ip}` and `DELETE /agents/{agent_id}`. - /// - /// Unset disables the admin API, which then refuses every request with 403; an empty value is rejected. - #[serde(default)] - #[cfg_attr(feature = "env-schema", schemars(with = "Option"))] - pub admin_token: AdminToken, -} - -const fn default_max_frame_size() -> usize { - DEFAULT_MAX_FRAME_SIZE -} - -/// Parse `WS_MAX_FRAME_SIZE` as a human byte size (e.g. `64MiB`, `64MB`, `512KiB`) or a plain byte count. -/// -/// Delegates the parsing to `bytesize`. -fn deserialize_byte_size<'de, D>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - // `bytesize`'s own `Deserialize` parses the human size ("64MiB", "512KiB", a bare byte count); its `D::Error` - // cascades through `?`, no `.map_err`. `usize::try_from` only narrows on 32-bit hosts, where clamping a frame cap - // to `usize::MAX` is harmless. - let size = ::deserialize(deserializer)?; - Ok(usize::try_from(size.as_u64()).unwrap_or(usize::MAX)) -} - -#[expect( - clippy::unnecessary_wraps, - reason = "serde default fn must return the field type Option; the default is always Some" -)] -const fn default_connection_timeout() -> Option { - Some(DEFAULT_CONNECTION_TIMEOUT) -} - -/// Outbound envelope written to an agent's websocket session. -/// -/// `Json` is the normal path for protocol messages. `Text` and `Binary` carry payloads the server forwards verbatim -- -/// used by the hub-style fallback that broadcasts unrecognised frames to every other connected agent. -#[derive(Clone, Debug)] -#[non_exhaustive] -pub enum SessionMessage { - Json(ServerMessage), - Text(String), - Binary(Bytes), -} - -impl From for SessionMessage { - fn from(value: ServerMessage) -> Self { - Self::Json(value) - } -} - -impl From for SessionMessage { - fn from(value: Frame) -> Self { - match value { - Frame::Text(text) => Self::Text(text), - Frame::Binary(bytes) => Self::Binary(bytes), - } - } -} - -pub type AgentSession = UnboundedSender; -pub type WsAgentRegistry = AgentRegistry; - -// Deserialize using a session-less record type, then convert. -#[derive(serde::Deserialize)] -struct BareRecord { - state: edge_toolkit::ws::AgentConnectionState, - last_known_ip: std::net::IpAddr, - #[serde(default)] - pending_direct_messages: BTreeMap, -} - -/// Load a registry from disk. Sessions are not persisted, so they are initialised to `None`. -pub fn load_registry(path: &std::path::Path) -> Result { - let file = read_registry_file::(path)?; - let agents = file - .agents - .into_iter() - .map(|(id, record)| { - ( - id, - AgentRecord::new(record.state, record.last_known_ip, None) - .with_pending_direct_messages(record.pending_direct_messages), - ) - }) - .collect(); - Ok(WsAgentRegistry::from_agents(agents).with_blocklist(IpBlocklist::from_ips(file.blocked_ips))) -} - -/// Log an agent's `et-client-event`, the observability frame the hub records and does not answer. -/// -/// An `et.video_cv.inference` event additionally gets its detected class, confidence and processing time logged as -/// fields of their own, so the camera modules' results read at a glance. -#[expect( - clippy::cognitive_complexity, - clippy::single_call_fn, - reason = "the stateless client-event arm of the inbound dispatcher; its score is info! expansion" -)] -fn log_client_event(agent_id: &str, event: &CloudEvent) { - let details = &event.data; - if event.event_type == client_event_type("video_cv", "inference") { - let detected_class = details - .get("detected_class") - .and_then(|value| value.as_str()) - .unwrap_or("unknown"); - let confidence = details - .get("confidence") - .and_then(serde_json::Value::as_f64) - .unwrap_or_default(); - let processed_at = details - .get("processed_at") - .and_then(|value| value.as_str()) - .unwrap_or("unknown"); - info!( - "Video inference received from {}: class={} confidence={:.4} processed_at={}", - agent_id, detected_class, confidence, processed_at - ); - } - info!( - "Client event from {}: type={} source={} id={} time={} data={}", - agent_id, - event.event_type, - event.source, - event.id, - event.time.to_rfc3339(), - details - ); -} - -/// The backplane the app serving `req` joined. -/// -/// An app that never joined one is a local hub. -fn backplane_of(req: &HttpRequest) -> Backplane { - req.app_data::>() - .map_or(Backplane::Local, |backplane| backplane.get_ref().clone()) -} - -#[expect( - clippy::future_not_send, - reason = "actix-web HttpRequest and Payload are Rc-backed and !Send; handler runs on actix's single thread" -)] -pub async fn ws_handler( - req: HttpRequest, - body: web::Payload, - registry: web::Data, - config: web::Data, -) -> Result { - let tracer = global::tracer("ws-server"); - let mut span = tracer.start("ws.connect"); - - // The socket peer only, never `Forwarded` / `X-Forwarded-For`: those are client-supplied text that would reach - // the logs unvalidated. Every listener is TCP, so a request without a peer means the server was bound wrongly. - let client_ip = req - .peer_addr() - .ok_or_else(|| actix_web::error::ErrorInternalServerError("websocket request has no peer address"))? - .ip(); - if registry.blocklist.is_blocked(client_ip) { - warn!("Refused a WebSocket connection from blocked IP {client_ip}"); - span.end(); - return Ok(HttpResponse::Forbidden().finish()); - } - - let (response, session, msg_stream) = actix_ws::handle(&req, body)?; - let stream = msg_stream - .max_frame_size(config.max_frame_size) - .aggregate_continuations(); - - let (tx, rx) = mpsc::unbounded_channel::(); - let conn = Connection::new( - registry.get_ref().clone(), - backplane_of(&req), - client_ip, - session, - tx, - config.connection_timeout, - config.message_retention, - ); - - let _join = actix_web::rt::spawn(async move { - conn.run(stream, rx).await; - }); - - span.end(); - Ok(response) -} - -/// Start delivering what other hubs send this hub's agents; a no-op for a `Local` backplane. -/// -/// Returns once the subscription is live and the shared blocklist is in `registry`, so nothing another hub sends or -/// blocks afterwards can be missed. Delivery then runs on a task of the current actix runtime until the -/// [`BackplaneDelivery`] returned is stopped. -/// -/// `max_frame_size` is the largest frame this hub accepts. A broker whose `max_payload` is smaller cannot carry such a -/// frame to another hub, so that mismatch is warned about here, once, rather than left to surface as a failed relay. -pub async fn start_backplane( - backplane: &Backplane, - registry: &WsAgentRegistry, - max_frame_size: usize, -) -> Result { - let Backplane::Nats(nats) = backplane else { - return Ok(BackplaneDelivery(DeliveryState::Local)); - }; - warn_if_broker_payload_too_small(nats.max_payload(), max_frame_size); - let inbox = Box::pin(nats.subscribe().await?); - for ip in nats.blocked_ips().await? { - let _newly = registry.blocklist.block(ip); - } - let (stop, stopped) = oneshot::channel::<()>(); - let task = actix_web::rt::spawn(deliver_until_stopped(inbox, stopped, registry.clone())); - info!("Hub {} joined the NATS backplane", nats.hub_id()); - Ok(BackplaneDelivery(DeliveryState::Nats { stop, task })) -} - -/// Say, at startup, when the broker cannot carry a frame as large as this hub accepts. -#[expect( - clippy::single_call_fn, - reason = "the startup check of start_backplane, kept out of its body" -)] -fn warn_if_broker_payload_too_small(max_payload: usize, max_frame_size: usize) { - if max_payload < max_frame_size { - let effect = "a larger frame reaches only the agents of the hub it arrived at"; - warn!("NATS max_payload {max_payload} < WS_MAX_FRAME_SIZE {max_frame_size}; raise the broker's: {effect}"); - } -} - -/// Hand each frame `inbox` yields to the local agents it is for, until `stopped` fires or the inbox ends. -#[expect( - clippy::integer_division_remainder_used, - clippy::single_call_fn, - reason = "the delivery task start_backplane spawns; tokio::select! uses % internally" -)] -async fn deliver_until_stopped( - mut inbox: Pin>>, - mut stopped: oneshot::Receiver<()>, - registry: WsAgentRegistry, -) { - loop { - tokio::select! { - _stop = &mut stopped => break, - inbound = inbox.next() => match inbound { - Some(inbound) => deliver_inbound(®istry, inbound), - None => break, - }, - } - } -} - -/// Hand one frame from another hub to the local agents it is for. -#[expect( - clippy::single_call_fn, - reason = "the per-frame step of the delivery loop, kept out of the select!" -)] -fn deliver_inbound(registry: &WsAgentRegistry, inbound: Inbound) { - match inbound { - Inbound::Direct { to_agent_id, frame } => { - if let Some(session) = registry.agent_session(&to_agent_id) { - let _sent = session.send(frame.into()); - } - } - Inbound::Broadcast { from_agent_id, frame } => { - for (_, session) in registry.connected_sessions(&from_agent_id) { - let _sent = session.send(frame.clone().into()); - } - } - Inbound::Blocked { ip } => { - if registry.blocklist.block(ip) { - info!("Blocked IP {ip} on the backplane's word"); - } - } - } -} - -/// This hub's delivery of what other hubs send its agents, running until [`BackplaneDelivery::stop`]. -#[must_use = "a delivery left to be dropped with the runtime aborts the process on Windows; call stop"] -#[non_exhaustive] -pub struct BackplaneDelivery(DeliveryState); - -/// Whether there is a delivery task to stop. -enum DeliveryState { - Local, - Nats { - stop: oneshot::Sender<()>, - task: JoinHandle<()>, - }, -} - -impl BackplaneDelivery { - /// Stop delivering, and wait until the backplane subscriptions are closed. - /// - /// Call it while the runtime is still running. async-nats unsubscribes by spawning a task when a subscription is - /// dropped, so a subscription left to be dropped with the runtime panics with - /// - /// there is no reactor running, must be called from the context of a Tokio 1.x runtime - /// - /// from `async-nats-0.50.0/src/lib.rs:1424`, and on Windows that panic in a destructor aborts the process with - /// `0xc0000409`. Observed on commit - /// at - /// . - pub async fn stop(self) { - if let DeliveryState::Nats { stop, task } = self.0 { - // Either the task already ended or it is told to now; both leave only the join to wait for. - let _sent = stop.send(()); - if let Err(error) = task.await { - warn!("The backplane delivery task ended abnormally: {error}"); - } - } - } -} - -/// Mark every agent connected to this hub as gone from the shared directory, ahead of the hub shutting down. -/// -/// A stopping server drops its connection tasks rather than letting each one run its own disconnect, so without this -/// the directory would go on placing those agents on a hub that is no longer there. A no-op for `Local`. -pub async fn withdraw_local_agents(backplane: &Backplane, registry: &WsAgentRegistry) { - let Backplane::Nats(nats) = backplane else { - return; - }; - for (agent_id, _session) in registry.connected_sessions("") { - if let Err(error) = nats.withdraw(&agent_id).await { - warn!("Failed to withdraw agent {agent_id} from the backplane: {error}"); - } - } -} - -pub fn configure(cfg: &mut web::ServiceConfig, config: &WsConfig) { - let _routed = cfg - .app_data(web::Data::new(config.clone())) - .route("/ws", web::get().to(ws_handler)) - .route("/agents/{agent_id}", web::delete().to(admin::evict_agent)) - .route("/blocked-ips/{ip}", web::put().to(admin::block_ip)); -} +pub mod hub; +#[cfg(feature = "picoserve-hub")] +pub mod picoserve_hub; +#[cfg(feature = "std")] +mod server; +#[cfg(feature = "std")] +mod telemetry; + +#[cfg(feature = "nats")] +pub use self::backplane::{ + Backplane, BackplaneConfig, BackplaneDelivery, BackplaneError, start_backplane, withdraw_local_agents, +}; +pub use self::hub::SessionMessage; +#[cfg(feature = "std")] +pub use self::server::{ + AgentSession, DEFAULT_CONNECTION_TIMEOUT, DEFAULT_MAX_FRAME_SIZE, HEARTBEAT_INTERVAL, MessageRetention, + WsAgentRegistry, WsConfig, configure, load_registry, ws_handler, +}; diff --git a/services/ws/src/picoserve_hub.rs b/services/ws/src/picoserve_hub.rs new file mode 100644 index 00000000..485dc6d1 --- /dev/null +++ b/services/ws/src/picoserve_hub.rs @@ -0,0 +1,356 @@ +//! 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. +//! +//! 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. + +use alloc::collections::VecDeque; +use alloc::sync::Arc; +#[cfg(not(feature = "std"))] +use alloc::vec; +use core::future::poll_fn; +use core::net::IpAddr; +use core::task::{Poll, Waker}; + +use bytes::Bytes; +use edge_toolkit::ws::ClientMessage; +use edge_toolkit::ws_server::{AgentRegistry, MessageRetention}; +use picoserve::futures::Either; +use picoserve::io::{Read, Socket, Write}; +use picoserve::response::StatusCode; +use picoserve::response::ws::{Message, ReadMessageError, SocketRx, SocketTx, WebSocketCallback, WebSocketUpgrade}; +use picoserve::routing::{PathRouter, get}; +use picoserve::time::Duration; +use picoserve::{Config, Router, Server, Timeouts, Timer}; +use tracing::{error, info, warn}; + +use crate::hub::{Link, Routing, SessionMessage, Stamps}; + +/// How long a connection may last: picoserve's `read_request` bound, which also covers the upgraded WebSocket. +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. +#[must_use] +pub const fn config() -> Config { + Config::new(Timeouts { + read_request: CONNECTION_LIFETIME, + ..Timeouts::const_default() + }) +} + +/// What a connection's agent has been sent by others and not yet written to its socket, and the task to wake for it. +#[derive(Debug)] +struct Mailbox { + queue: VecDeque, + max_queued: usize, + waker: Option, +} + +/// The handle the registry holds for one connection: other agents' messages to it are queued here. +#[derive(Clone, Debug)] +pub struct Outbox(Arc>); + +impl Outbox { + /// An empty outbox holding at most `max_queued` messages, though always the newest. + /// + /// The bound is what keeps a connection that stops reading from taking the heap with it: past it, the oldest + /// message is dropped to make room. + #[must_use] + pub fn new(max_queued: usize) -> Self { + Self(Arc::new(spin::Mutex::new(Mailbox { + max_queued, + queue: VecDeque::new(), + waker: None, + }))) + } + + /// Queue `message` for the connection, and wake it if it is waiting. + pub fn send(&self, message: SessionMessage) { + let waker = { + let mut mailbox = self.0.lock(); + if mailbox.queue.len() >= mailbox.max_queued && mailbox.queue.pop_front().is_some() { + warn!("Dropped the oldest queued message for a connection that is not reading"); + } + mailbox.queue.push_back(message); + mailbox.waker.take() + }; + if let Some(waker) = waker { + waker.wake(); + } + } + + /// Whether `self` and `other` are handles to the same connection. + #[must_use] + pub fn same_connection(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } + + /// The next queued message, once there is one. + /// + /// Cancel-safe, as picoserve requires of the signal it races a frame read against: a message leaves the queue only + /// in the poll that returns it, and the waker is stored under the same lock [`Outbox::send`] queues under. + pub async fn next(&self) -> SessionMessage { + poll_fn(|context| { + let mut mailbox = self.0.lock(); + mailbox.queue.pop_front().map_or_else( + || { + mailbox.waker = Some(context.waker().clone()); + Poll::Pending + }, + Poll::Ready, + ) + }) + .await + } +} + +/// The hub every connection shares: its registry, the source of its ids and times, and its limits. +pub struct Hub { + registry: AgentRegistry, + stamps: spin::Mutex, + retention: MessageRetention, + max_frame_size: usize, + max_queued: usize, +} + +impl Hub +where + T: Stamps, +{ + /// An empty hub taking ids and times from `stamps`, accepting frames of up to `max_frame_size` bytes. + /// + /// Each connection allocates a buffer of `max_frame_size` for the frames it reads, and queues at most `max_queued` + /// of the messages other agents send it while it is still writing earlier ones, dropping the oldest past that. + #[must_use] + pub fn new(stamps: T, retention: MessageRetention, max_frame_size: usize, max_queued: usize) -> Self { + Self { + max_frame_size, + max_queued, + retention, + stamps: spin::Mutex::new(stamps), + registry: AgentRegistry::default(), + } + } + + /// The agents the hub knows, and the addresses it refuses. + #[must_use] + pub const fn registry(&self) -> &AgentRegistry { + &self.registry + } + + /// 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. + #[expect( + clippy::future_not_send, + reason = "picoserve's futures hold Cells; its executors run each connection on one thread" + )] + pub async fn serve( + &self, + socket: S, + client_ip: IpAddr, + timer: M, + config: &Config, + http_buffer: &mut [u8], + ) -> Result<(), picoserve::Error> + where + M: Timer, + S: Socket, + { + let app = self.routes(Router::new(), client_ip); + let _disconnection = Server::custom(&app, timer, config, http_buffer).serve(socket).await?; + Ok(()) + } + + /// `router`, with the hub's `GET /ws` added for a connection from `client_ip`. + /// + /// For a server that serves more than the hub on one socket: it builds its own router through this, and serves it + /// as [`Hub::serve`] does. The upgrade is refused with 403 for an address the registry blocks. + pub fn routes

(&self, router: Router

, client_ip: IpAddr) -> Router + where + P: PathRouter, + { + router.route( + "/ws", + get(async move |upgrade: WebSocketUpgrade| { + if self.registry.blocklist.is_blocked(client_ip) { + warn!("Refused a WebSocket connection from blocked IP {client_ip}"); + return Err(StatusCode::FORBIDDEN); + } + Ok(upgrade.on_upgrade(Connection { hub: self, client_ip })) + }), + ) + } + + /// Route one frame from `link`'s agent. + fn route(&self, link: &mut Link, frame: Frame) -> Routing { + let mut stamps = self.stamps.lock(); + match frame { + Frame::Message(message) => link.route(&self.registry, message, &mut *stamps), + Frame::Binary(bytes) => link.route_binary(&self.registry, bytes, &mut *stamps), + } + } + + /// Exchange frames with `link`'s agent until it closes: route what it sends, and write what others send it. + async fn exchange( + &self, + link: &mut Link, + rx: &mut SocketRx, + tx: &mut SocketTx, + ) -> Result<(), W::Error> + where + R: Read, + W: Write, + { + let outbox = link.session().clone(); + let mut buffer = vec![0; self.max_frame_size]; + loop { + match rx.next_message(&mut buffer, outbox.next()).await? { + Either::First(message) => match received(link, message) { + Received::Frame(frame) => deliver(self.route(link, frame), tx).await?, + Received::Ping(data) => tx.send_pong(data).await?, + Received::Ignored => {} + Received::Ended => return Ok(()), + }, + Either::Second(queued) => write(tx, queued).await?, + } + } + } +} + +/// A frame from an agent the hub routes. +enum Frame { + Message(ClientMessage), + Binary(Bytes), +} + +/// What one message read from an agent's socket asks of the connection. +enum Received<'buffer> { + /// A frame to route. + Frame(Frame), + /// A ping to answer with these bytes. + Ping(&'buffer [u8]), + /// Nothing: a pong, or an `et-*` frame that did not decode. + Ignored, + /// The end of the connection: the agent closed it, or sent what could not be read. + Ended, +} + +/// What `message`, read from `link`'s agent, asks of the connection. +#[expect( + clippy::cognitive_complexity, + clippy::single_call_fn, + reason = "the read step of exchange, kept out of its loop; the score is info!/warn! expansion" +)] +fn received<'buffer>(link: &Link, message: Result, ReadMessageError>) -> Received<'buffer> { + match message { + Ok(Message::Text(text)) => match ClientMessage::from_text_frame(text) { + Ok(message) => Received::Frame(Frame::Message(message)), + Err(err) => { + warn!("Decode error for et-* frame from {}: {}", link.current_agent_id(), err); + Received::Ignored + } + }, + Ok(Message::Binary(bytes)) => Received::Frame(Frame::Binary(Bytes::copy_from_slice(bytes))), + Ok(Message::Ping(data)) => Received::Ping(data), + Ok(Message::Pong(_)) => Received::Ignored, + Ok(Message::Close(reason)) => { + info!( + "WebSocket close request from client: {} reason: {:?}", + link.current_agent_id(), + reason + ); + Received::Ended + } + Err(err) => { + warn!( + "Unreadable WebSocket message from {}: {:?}", + link.current_agent_id(), + err + ); + Received::Ended + } + } +} + +/// Carry out `routing`: other agents' messages go to their outboxes, and the replies down `tx`. +/// +/// `routing.shared` is for other hubs, and this hub has none. +#[expect(clippy::single_call_fn, reason = "the write step of exchange, kept out of its loop")] +async fn deliver(routing: Routing, tx: &mut SocketTx) -> Result<(), W::Error> +where + W: Write, +{ + for (session, message) in routing.deliveries { + session.send(message); + } + for reply in routing.replies { + write(tx, SessionMessage::Json(reply)).await?; + } + Ok(()) +} + +/// Write `message` to the agent at the far end of `tx`. +async fn write(tx: &mut SocketTx, message: SessionMessage) -> Result<(), W::Error> +where + W: Write, +{ + match message { + SessionMessage::Json(message) => match serde_json::to_string(&message) { + Ok(text) => tx.send_text(&text).await, + Err(err) => { + error!("Failed to serialize websocket response: {}", err); + Ok(()) + } + }, + SessionMessage::Text(text) => tx.send_text(&text).await, + SessionMessage::Binary(bytes) => tx.send_binary(&bytes).await, + } +} + +/// One upgraded WebSocket connection, from `client_ip`. +struct Connection<'hub, T> { + hub: &'hub Hub, + client_ip: IpAddr, +} + +impl WebSocketCallback for Connection<'_, T> +where + T: Stamps, +{ + #[expect( + clippy::cognitive_complexity, + reason = "the score is info! expansion; the body branches once" + )] + async fn run(self, mut rx: SocketRx, mut tx: SocketTx) -> Result<(), W::Error> + where + R: Read, + W: Write, + { + info!("New WebSocket connection for client IP {}", self.client_ip); + let mut link = Link::new(self.client_ip, Outbox::new(self.hub.max_queued), self.hub.retention); + let exchanged = self.hub.exchange(&mut link, &mut rx, &mut tx).await; + // Only this connection's own session: another may have taken the id over since, and is still live. + let _released = link.release(&self.hub.registry, &|session| session.same_connection(link.session())); + info!( + "Agent {} disconnected; last known IP {}", + link.current_agent_id(), + self.client_ip + ); + exchanged?; + tx.close(None).await + } +} diff --git a/services/ws/src/server.rs b/services/ws/src/server.rs new file mode 100644 index 00000000..d5203a9b --- /dev/null +++ b/services/ws/src/server.rs @@ -0,0 +1,219 @@ +//! The actix-web hub: its configuration, its registry file, and the `/ws` endpoint each agent connects to. + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::time::Duration; + +use actix_web::{Error, HttpRequest, HttpResponse, web}; +use chrono::Utc; +pub use edge_toolkit::ws_server::MessageRetention; +use edge_toolkit::ws_server::{ + AgentRecord, AgentRegistry, IpBlocklist, PendingDirectMessage, RegistryError, read_registry_file, +}; +use serde::Deserialize; +use serde_default::DefaultFromSerde; +use serde_inline_default::serde_inline_default; +use tokio::sync::mpsc::{self, UnboundedSender}; +use tracing::warn; +use uuid::Uuid; + +use crate::admin::{self, AdminToken}; +#[cfg(feature = "nats")] +use crate::backplane::{BackplaneConfig, backplane_of}; +use crate::connection::Connection; +use crate::hub::{Link, SessionMessage, Stamps}; +use crate::telemetry::HubSpan; + +/// Default idle timeout before the hub closes a quiet connection. +pub const DEFAULT_CONNECTION_TIMEOUT: Duration = Duration::from_secs(15); +pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(1); + +/// Default max WebSocket frame size (64 MiB). +/// +/// Large binary payloads fanned out via default broadcast (e.g. tensors) easily blow past actix-ws's 64 KiB default. +/// Override via the `WS_MAX_FRAME_SIZE` env var, as a human byte size (`serde-env` translates `[ws] max_frame_size` +/// to `WS_MAX_FRAME_SIZE`). +pub const DEFAULT_MAX_FRAME_SIZE: usize = 64 * 1024 * 1024; + +/// Runtime knobs for the WebSocket hub. +/// +/// Populated by `serde-env` in `et-ws-server::main`, then handed to `configure`. +#[serde_inline_default] +#[derive(Clone, Debug, DefaultFromSerde, Deserialize)] +#[cfg_attr(feature = "env-schema", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct WsConfig { + /// Largest single WebSocket frame the hub will accept. + /// + /// Frames above this are dropped by actix-ws before they reach the handler, so callers shipping big tensors / blobs + /// need to raise it above their payload size. `WS_MAX_FRAME_SIZE` takes a human byte size (e.g. `64MiB`, `64MB`, + /// `512KiB`) or a plain byte count; unset defaults to 64 MiB. + #[serde(default = "default_max_frame_size", deserialize_with = "deserialize_byte_size")] + #[cfg_attr(feature = "env-schema", schemars(with = "String", extend("default" = "64MiB")))] + pub max_frame_size: usize, + + /// Idle period before the hub closes a connection, as a humantime duration (e.g. `15s`, `1m30s`). + /// + /// Unset defaults to 15s; `none`/`off`/`disabled` turns the idle timeout off (the hub never closes a connection for + /// inactivity), which suits a frontend that sits idle. + #[serde( + default = "default_connection_timeout", + deserialize_with = "edge_toolkit::config::deserialize_optional_humantime" + )] + #[cfg_attr(feature = "env-schema", schemars(with = "Option", extend("default" = "15s")))] + pub connection_timeout: Option, + + /// What the hub shares its agents through: `local` (the default) or a `nats://` / `tls://` URL. + /// + /// `local` keeps every agent in this process, persisted to the `WS_AGENT_REGISTRY` file. A NATS URL names a + /// server with `JetStream`, and every hub pointed at it shares one agent directory, one mailbox of unacknowledged + /// direct messages, and delivery between them, so hubs scale out behind a load balancer: an agent on one hub + /// lists, messages, broadcasts to and acknowledges agents on any other, and may reconnect to a different hub + /// without losing what was queued for it. The directory and mailbox live in `JetStream`, so the registry file is + /// neither read nor written in this mode. + #[cfg(feature = "nats")] + #[serde(default)] + #[cfg_attr(feature = "env-schema", schemars(with = "String", extend("default" = "local")))] + pub backplane: BackplaneConfig, + + /// Which unacknowledged direct messages a recipient holds: `latest-per-sender` (the default) or `all`. + /// + /// `latest-per-sender` keeps only the newest message from each sender, replacing the earlier one, so a recipient + /// holds at most one message per sender and the hub's memory for them is bounded. `all` keeps every message until + /// it is acknowledged, with no bound on how many pile up for a recipient that stays away. + #[serde(default)] + #[cfg_attr( + feature = "env-schema", + schemars(with = "String", extend("default" = "latest-per-sender")) + )] + pub message_retention: MessageRetention, + + /// The YAML file a local hub loads its agent registry from at startup and saves it to on shutdown. + #[serde_inline_default(PathBuf::from("registry.yaml"))] + #[cfg_attr(feature = "env-schema", schemars(extend("default" = "registry.yaml")))] + pub agent_registry: PathBuf, + + /// Bearer token authorizing the admin API: `PUT /blocked-ips/{ip}` and `DELETE /agents/{agent_id}`. + /// + /// Unset disables the admin API, which then refuses every request with 403; an empty value is rejected. + #[serde(default)] + #[cfg_attr(feature = "env-schema", schemars(with = "Option"))] + pub admin_token: AdminToken, +} + +const fn default_max_frame_size() -> usize { + DEFAULT_MAX_FRAME_SIZE +} + +/// Parse `WS_MAX_FRAME_SIZE` as a human byte size (e.g. `64MiB`, `64MB`, `512KiB`) or a plain byte count. +/// +/// Delegates the parsing to `bytesize`. +fn deserialize_byte_size<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + // `bytesize`'s own `Deserialize` parses the human size ("64MiB", "512KiB", a bare byte count); its `D::Error` + // cascades through `?`, no `.map_err`. `usize::try_from` only narrows on 32-bit hosts, where clamping a frame cap + // to `usize::MAX` is harmless. + let size = ::deserialize(deserializer)?; + Ok(usize::try_from(size.as_u64()).unwrap_or(usize::MAX)) +} + +#[expect( + clippy::unnecessary_wraps, + reason = "serde default fn must return the field type Option; the default is always Some" +)] +const fn default_connection_timeout() -> Option { + Some(DEFAULT_CONNECTION_TIMEOUT) +} + +pub type AgentSession = UnboundedSender; +pub type WsAgentRegistry = AgentRegistry; + +/// The hub's ids and times: version 7 UUIDs, and the wall clock. +pub(crate) struct SystemStamps; + +impl Stamps for SystemStamps { + fn new_id(&mut self) -> String { + Uuid::now_v7().to_string() + } + + fn now(&mut self) -> String { + Utc::now().to_rfc3339() + } +} + +// Deserialize using a session-less record type, then convert. +#[derive(serde::Deserialize)] +struct BareRecord { + state: edge_toolkit::ws::AgentConnectionState, + last_known_ip: std::net::IpAddr, + #[serde(default)] + pending_direct_messages: BTreeMap, +} + +/// Load a registry from disk. Sessions are not persisted, so they are initialised to `None`. +pub fn load_registry(path: &std::path::Path) -> Result { + let file = read_registry_file::(path)?; + let agents = file + .agents + .into_iter() + .map(|(id, record)| { + ( + id, + AgentRecord::new(record.state, record.last_known_ip, None) + .with_pending_direct_messages(record.pending_direct_messages), + ) + }) + .collect(); + Ok(WsAgentRegistry::from_agents(agents).with_blocklist(IpBlocklist::from_ips(file.blocked_ips))) +} + +#[expect( + clippy::future_not_send, + reason = "actix-web HttpRequest and Payload are Rc-backed and !Send; handler runs on actix's single thread" +)] +pub async fn ws_handler( + req: HttpRequest, + body: web::Payload, + registry: web::Data, + config: web::Data, +) -> Result { + let _span = HubSpan::start("ws.connect"); + + // The socket peer only, never `Forwarded` / `X-Forwarded-For`: those are client-supplied text that would reach + // the logs unvalidated. Every listener is TCP, so a request without a peer means the server was bound wrongly. + let client_ip = req + .peer_addr() + .ok_or_else(|| actix_web::error::ErrorInternalServerError("websocket request has no peer address"))? + .ip(); + if registry.blocklist.is_blocked(client_ip) { + warn!("Refused a WebSocket connection from blocked IP {client_ip}"); + return Ok(HttpResponse::Forbidden().finish()); + } + + let (response, session, msg_stream) = actix_ws::handle(&req, body)?; + let stream = msg_stream + .max_frame_size(config.max_frame_size) + .aggregate_continuations(); + + let (tx, rx) = mpsc::unbounded_channel::(); + let link = Link::new(client_ip, tx, config.message_retention); + let conn = Connection::new(registry.get_ref().clone(), link, session, config.connection_timeout); + #[cfg(feature = "nats")] + let conn = conn.with_backplane(backplane_of(&req)); + + let _join = actix_web::rt::spawn(async move { + conn.run(stream, rx).await; + }); + + Ok(response) +} + +pub fn configure(cfg: &mut web::ServiceConfig, config: &WsConfig) { + let _routed = cfg + .app_data(web::Data::new(config.clone())) + .route("/ws", web::get().to(ws_handler)) + .route("/agents/{agent_id}", web::delete().to(admin::evict_agent)) + .route("/blocked-ips/{ip}", web::put().to(admin::block_ip)); +} diff --git a/services/ws/src/telemetry.rs b/services/ws/src/telemetry.rs new file mode 100644 index 00000000..8335fd92 --- /dev/null +++ b/services/ws/src/telemetry.rs @@ -0,0 +1,88 @@ +//! The hub's spans and metrics, recorded through the global tracer and meter `et_otlp::init` installs. +//! +//! Every one of them is a no-op without the `otel` feature, so the hub's code records them unconditionally. + +#[cfg(feature = "otel")] +use std::sync::LazyLock; + +#[cfg(feature = "otel")] +use opentelemetry::global::{self, BoxedSpan}; +#[cfg(feature = "otel")] +use opentelemetry::metrics::{Counter, UpDownCounter}; +#[cfg(feature = "otel")] +use opentelemetry::trace::{Span as _, Tracer as _}; + +// Built lazily on first use -- by then the meter provider is set -- and cached for the process. +#[cfg(feature = "otel")] +static MESSAGES_RECEIVED: LazyLock> = LazyLock::new(|| { + global::meter("ws-server") + .u64_counter("et_ws.messages.received") + .with_description("Inbound WebSocket frames the hub has handled") + .build() +}); +#[cfg(feature = "otel")] +static ACTIVE_CONNECTIONS: LazyLock> = LazyLock::new(|| { + global::meter("ws-server") + .i64_up_down_counter("et_ws.connections.active") + .with_description("Currently-open WebSocket connections") + .build() +}); + +/// Count one inbound WebSocket frame. +#[expect( + clippy::single_call_fn, + reason = "keeps the meter, and its absence without `otel`, out of the frame loop" +)] +#[cfg_attr( + not(feature = "otel"), + expect(clippy::missing_const_for_fn, reason = "it is empty only without `otel`") +)] +pub(crate) fn message_received() { + #[cfg(feature = "otel")] + MESSAGES_RECEIVED.add(1, &[]); +} + +/// Count `delta` WebSocket connections opening, or closing when negative. +#[cfg_attr( + not(feature = "otel"), + expect(unused_variables, reason = "there is no meter to count on without `otel`") +)] +#[cfg_attr( + not(feature = "otel"), + expect(clippy::missing_const_for_fn, reason = "it is empty only without `otel`") +)] +pub(crate) fn connections_changed(delta: i64) { + #[cfg(feature = "otel")] + ACTIVE_CONNECTIONS.add(delta, &[]); +} + +/// A span of the hub's, ended when it is dropped. +pub(crate) struct HubSpan { + #[cfg(feature = "otel")] + span: BoxedSpan, +} + +impl HubSpan { + /// Start the span `name`. + #[cfg_attr( + not(feature = "otel"), + expect(unused_variables, reason = "there is no tracer to start a span on without `otel`") + )] + #[cfg_attr( + not(feature = "otel"), + expect(clippy::missing_const_for_fn, reason = "it holds no span to start without `otel`") + )] + pub(crate) fn start(name: &'static str) -> Self { + Self { + #[cfg(feature = "otel")] + span: global::tracer("ws-server").start(name), + } + } +} + +#[cfg(feature = "otel")] +impl Drop for HubSpan { + fn drop(&mut self) { + self.span.end(); + } +} diff --git a/services/ws/tests/admin.rs b/services/ws/tests/admin.rs index f135169a..ffce6026 100644 --- a/services/ws/tests/admin.rs +++ b/services/ws/tests/admin.rs @@ -9,8 +9,10 @@ use actix_web::dev::{Service, ServiceResponse}; use actix_web::http::{StatusCode, header}; use actix_web::{App, ResponseError as _, test, web}; use edge_toolkit::ws_server::{MessageRetention, RegistryError}; +#[cfg(feature = "nats")] +use et_ws_service::BackplaneError; use et_ws_service::admin::{AdminError, AdminToken, EvictedAgent}; -use et_ws_service::{BackplaneError, WsAgentRegistry, WsConfig, configure}; +use et_ws_service::{WsAgentRegistry, WsConfig, configure}; const TOKEN: &str = "s3cret"; const AGENT_IP: IpAddr = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 7)); @@ -199,9 +201,14 @@ async fn an_eviction_drops_the_messages_the_agent_queued() { } #[actix_rt::test] -async fn each_failure_maps_to_its_status() { +async fn a_registry_failure_maps_to_its_status() { let from_registry = AdminError::from(RegistryError::LockPoisoned); assert_eq!(from_registry.status_code(), StatusCode::INTERNAL_SERVER_ERROR); +} + +#[cfg(feature = "nats")] +#[actix_rt::test] +async fn each_backplane_failure_maps_to_its_status() { let from_backplane = AdminError::from(BackplaneError::NoPendingMessage("m".to_owned())); assert_eq!(from_backplane.status_code(), StatusCode::SERVICE_UNAVAILABLE); let unknown = AdminError::from(BackplaneError::UnknownAgent("a".to_owned())); diff --git a/services/ws/tests/config.rs b/services/ws/tests/config.rs index a1f3f3e1..9e260e13 100644 --- a/services/ws/tests/config.rs +++ b/services/ws/tests/config.rs @@ -8,7 +8,9 @@ )] use et_test_helpers::temp_env; -use et_ws_service::{BackplaneConfig, MessageRetention, WsConfig}; +#[cfg(feature = "nats")] +use et_ws_service::BackplaneConfig; +use et_ws_service::{MessageRetention, WsConfig}; use serde::Deserialize; // Mirror the ws-server's nesting so the env key is `WS_MAX_FRAME_SIZE`. @@ -64,6 +66,7 @@ fn connection_timeout_none_sentinel_disables() { }); } +#[cfg(feature = "nats")] #[test] fn backplane_absent_defaults_to_local() { temp_env::with_var_unset("WS_BACKPLANE", || { @@ -85,6 +88,7 @@ fn message_retention_parses_all() { }); } +#[cfg(feature = "nats")] #[test] fn backplane_parses_a_nats_url() { temp_env::with_var("WS_BACKPLANE", Some("nats://broker:4222"), || { @@ -92,6 +96,7 @@ fn backplane_parses_a_nats_url() { }); } +#[cfg(feature = "nats")] #[test] fn backplane_rejects_an_unknown_scheme() { temp_env::with_var("WS_BACKPLANE", Some("redis://broker"), || { diff --git a/services/ws/tests/hub.rs b/services/ws/tests/hub.rs new file mode 100644 index 00000000..b5c70b80 --- /dev/null +++ b/services/ws/tests/hub.rs @@ -0,0 +1,384 @@ +//! The `no_std` routing core, `hub::Link`, driven directly with no transport: each frame's replies, deliveries and +//! shared steps. `S = String` stands in for the session handle, named after the agent that holds it. +#![cfg(test)] + +use std::net::{IpAddr, Ipv4Addr}; + +use bytes::Bytes; +use edge_toolkit::ws::{ClientMessage, ConnectStatus, MessageDeliveryStatus, MessageScope, ServerMessage}; +use edge_toolkit::ws_server::{AgentRegistry, MessageRetention}; +use et_ws_service::SessionMessage; +use et_ws_service::hub::{Link, Routing, Shared, Stamps}; +use serde_json::json; + +const CLIENT_IP: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST); +const NOW: &str = "2026-10-29T00:00:00Z"; + +/// Ids `id-1`, `id-2`, ... in turn, and a fixed time. +#[derive(Default)] +struct Counter(u32); + +impl Stamps for Counter { + fn new_id(&mut self) -> String { + self.0 = self.0.saturating_add(1); + format!("id-{}", self.0) + } + + fn now(&mut self) -> String { + NOW.to_owned() + } +} + +/// A link that has connected as `agent_id`, its session named after it. +fn connected(registry: &AgentRegistry, agent_id: &str, stamps: &mut Counter) -> Link { + let mut link = Link::new(CLIENT_IP, agent_id.to_owned(), MessageRetention::All); + let connect = ClientMessage::Connect { + agent_id: Some(agent_id.to_owned()), + }; + let _routing = link.route(registry, connect, stamps); + link +} + +/// The one reply `routing` holds. +fn only_reply(routing: Routing) -> ServerMessage { + let [reply] = <[ServerMessage; 1]>::try_from(routing.replies).unwrap(); + reply +} + +/// The status and detail of a `MessageStatus` reply. +fn status_of(reply: &ServerMessage) -> (&MessageDeliveryStatus, &str) { + let ServerMessage::MessageStatus { status, detail, .. } = reply else { + panic!("not a status: {reply:?}"); + }; + (status, detail) +} + +/// The detail of an `Invalid` reply. +fn refusal_of(reply: &ServerMessage) -> &str { + let ServerMessage::Invalid { detail, .. } = reply else { + panic!("not a refusal: {reply:?}"); + }; + detail +} + +#[test] +fn a_connect_takes_an_id_acks_it_and_joins() { + let registry = AgentRegistry::::default(); + let mut stamps = Counter::default(); + let mut link = Link::new(CLIENT_IP, "s".to_owned(), MessageRetention::LatestPerSender); + assert_eq!(link.current_agent_id(), "unassigned"); + assert_eq!((link.client_ip(), link.session().as_str()), (CLIENT_IP, "s")); + assert_eq!(link.retention(), MessageRetention::LatestPerSender); + + let routing = link.route(®istry, ClientMessage::Connect { agent_id: None }, &mut stamps); + assert_eq!(link.agent_id(), Some("id-1")); + assert!(routing.deliveries.is_empty()); + assert!(matches!(routing.shared.as_slice(), [Shared::Joined { agent_id }] if agent_id == "id-1")); + assert!(matches!( + only_reply(routing), + ServerMessage::ConnectAck { agent_id, status: ConnectStatus::Assigned } if agent_id == "id-1" + )); +} + +#[test] +fn a_reconnect_receives_what_was_queued_while_it_was_away() { + let registry = AgentRegistry::::default(); + let mut stamps = Counter::default(); + let away = connected(®istry, "a", &mut stamps); + let mut sender = connected(®istry, "b", &mut stamps); + assert!(away.release(®istry, &|session| session == "a")); + + let send = ClientMessage::SendAgentMessage { + to_agent_id: "a".to_owned(), + message: json!({"n": 1_i32}), + }; + let queued = sender.route(®istry, send, &mut stamps); + assert!(queued.deliveries.is_empty()); + assert_eq!( + status_of(&only_reply(queued)), + (&MessageDeliveryStatus::Queued, "message queued for agent a") + ); + + let mut back = Link::new(CLIENT_IP, "a2".to_owned(), MessageRetention::All); + let connect = ClientMessage::Connect { + agent_id: Some("a".to_owned()), + }; + let routing = back.route(®istry, connect, &mut stamps); + let [ack, pending] = <[ServerMessage; 2]>::try_from(routing.replies).unwrap(); + assert!(matches!( + ack, + ServerMessage::ConnectAck { + status: ConnectStatus::Reconnected, + .. + } + )); + assert!(matches!( + pending, + ServerMessage::AgentMessage { from_agent_id, scope: MessageScope::Direct, server_received_at, .. } + if from_agent_id == "b" && server_received_at == NOW + )); +} + +#[test] +fn alive_and_list_are_answered() { + let registry = AgentRegistry::::default(); + let mut stamps = Counter::default(); + let mut link = connected(®istry, "a", &mut stamps); + + let alive = link.route( + ®istry, + ClientMessage::Alive { + timestamp: NOW.to_owned(), + }, + &mut stamps, + ); + assert!(matches!( + only_reply(alive), + ServerMessage::Response { message } if message == format!("Alive message received at {NOW}") + )); + + let listed = link.route(®istry, ClientMessage::ListAgents, &mut stamps); + assert!(matches!( + only_reply(listed), + ServerMessage::ListAgentsResponse { agents } if agents.len() == 1 && agents[0].agent_id == "a" + )); +} + +#[test] +fn a_direct_message_is_delivered_to_a_connected_recipient() { + let registry = AgentRegistry::::default(); + let mut stamps = Counter::default(); + let mut sender = connected(®istry, "a", &mut stamps); + let _recipient = connected(®istry, "b", &mut stamps); + + let send = ClientMessage::SendAgentMessage { + to_agent_id: "b".to_owned(), + message: json!("hi"), + }; + let routing = sender.route(®istry, send, &mut stamps); + assert!(routing.shared.is_empty()); + assert!(matches!( + routing.deliveries.as_slice(), + [(session, SessionMessage::Json(ServerMessage::AgentMessage { from_agent_id, .. }))] + if session == "b" && from_agent_id == "a" + )); + assert_eq!( + status_of(&only_reply(routing)), + (&MessageDeliveryStatus::Delivered, "message delivered to agent b") + ); +} + +#[test] +fn a_direct_message_is_refused_unconnected_to_itself_and_to_a_stranger() { + let registry = AgentRegistry::::default(); + let mut stamps = Counter::default(); + let send = |recipient: &str| ClientMessage::SendAgentMessage { + to_agent_id: recipient.to_owned(), + message: json!(null), + }; + + let mut unconnected = Link::new(CLIENT_IP, "u".to_owned(), MessageRetention::All); + let refused = unconnected.route(®istry, send("a"), &mut stamps); + assert_eq!( + refusal_of(&only_reply(refused)), + "agent must connect before sending messages" + ); + + let mut link = connected(®istry, "a", &mut stamps); + let to_itself = link.route(®istry, send("a"), &mut stamps); + assert_eq!( + refusal_of(&only_reply(to_itself)), + "agent cannot send a direct message to itself" + ); + let to_stranger = link.route(®istry, send("nobody"), &mut stamps); + assert_eq!(refusal_of(&only_reply(to_stranger)), "unknown target agent nobody"); +} + +#[test] +fn a_broadcast_reaches_every_other_agent_and_every_other_hub() { + let registry = AgentRegistry::::default(); + let mut stamps = Counter::default(); + let mut sender = connected(®istry, "a", &mut stamps); + let _other = connected(®istry, "b", &mut stamps); + + let broadcast = ClientMessage::BroadcastMessage { message: json!(1_i32) }; + let routing = sender.route(®istry, broadcast, &mut stamps); + assert!(matches!( + routing.deliveries.as_slice(), + [(session, SessionMessage::Json(ServerMessage::AgentMessage { scope: MessageScope::Broadcast, .. }))] + if session == "b" + )); + assert!(matches!( + routing.shared.as_slice(), + [Shared::Broadcast { from_agent_id, frame: SessionMessage::Json(_) }] if from_agent_id == "a" + )); + assert_eq!( + status_of(&only_reply(routing)), + ( + &MessageDeliveryStatus::Broadcast, + "broadcast sent to 1 connected agents" + ) + ); + + let mut unconnected = Link::new(CLIENT_IP, "u".to_owned(), MessageRetention::All); + let refused = unconnected.route( + ®istry, + ClientMessage::BroadcastMessage { message: json!(1_i32) }, + &mut stamps, + ); + assert_eq!( + refusal_of(&only_reply(refused)), + "agent must connect before broadcasting messages" + ); +} + +#[test] +fn an_acknowledgement_tells_both_ends() { + let registry = AgentRegistry::::default(); + let mut stamps = Counter::default(); + let mut sender = connected(®istry, "a", &mut stamps); + let mut recipient = connected(®istry, "b", &mut stamps); + let direct = ClientMessage::SendAgentMessage { + to_agent_id: "b".to_owned(), + message: json!(null), + }; + let delivered = sender.route(®istry, direct, &mut stamps); + let ServerMessage::MessageStatus { + message_id: Some(message_id), + .. + } = only_reply(delivered) + else { + panic!("the send was not given a message id"); + }; + + let ack = ClientMessage::MessageAck { + message_id: message_id.clone(), + }; + let routing = recipient.route(®istry, ack, &mut stamps); + assert!(matches!( + routing.deliveries.as_slice(), + [(session, SessionMessage::Json(ServerMessage::MessageStatus { detail, .. }))] + if session == "a" && detail == "agent b acknowledged receipt" + )); + assert_eq!( + status_of(&only_reply(routing)), + (&MessageDeliveryStatus::Acknowledged, "message acknowledged") + ); + + let again = recipient.route(®istry, ClientMessage::MessageAck { message_id }, &mut stamps); + assert_eq!(refusal_of(&only_reply(again)), "no pending message to acknowledge"); + + let mut unconnected = Link::new(CLIENT_IP, "u".to_owned(), MessageRetention::All); + let ack = ClientMessage::MessageAck { + message_id: "m".to_owned(), + }; + let refused = unconnected.route(®istry, ack, &mut stamps); + assert_eq!( + refusal_of(&only_reply(refused)), + "agent must connect before acknowledging messages" + ); +} + +#[test] +fn a_client_event_is_only_logged() { + let registry = AgentRegistry::::default(); + let mut stamps = Counter::default(); + let mut link = connected(®istry, "a", &mut stamps); + for event_type in ["et.video_cv.inference", "et.sensor.reading"] { + let frame = json!({ + "type": "et-client-event", + "event": { + "data": {"detected_class": "cat", "confidence": 0.5_f64, "processed_at": NOW}, + "type": event_type, + "id": "e1", + "source": "/modules/x", + "specversion": "1.0", + "time": NOW, + }, + }); + let event = ClientMessage::from_text_frame(&frame.to_string()).unwrap(); + let routing = link.route(®istry, event, &mut stamps); + assert!(routing.deliveries.is_empty() && routing.shared.is_empty() && routing.replies.is_empty()); + } +} + +#[test] +fn a_relay_registers_its_sender_and_reaches_every_other_agent() { + let registry = AgentRegistry::::default(); + let mut stamps = Counter::default(); + let _listener = connected(®istry, "a", &mut stamps); + let mut relay = Link::new(CLIENT_IP, "r".to_owned(), MessageRetention::All); + + let text = relay.route( + ®istry, + ClientMessage::RelayText { + content: "raw".to_owned(), + }, + &mut stamps, + ); + let relay_id = relay.agent_id().unwrap().to_owned(); + assert!(matches!( + text.deliveries.as_slice(), + [(session, SessionMessage::Text(content))] if session == "a" && content == "raw" + )); + assert!(matches!( + text.shared.as_slice(), + [Shared::Joined { agent_id }, Shared::Broadcast { from_agent_id, .. }] + if *agent_id == relay_id && *from_agent_id == relay_id + )); + assert!(text.replies.is_empty()); +} + +#[test] +fn a_binary_relay_reaches_every_other_agent_as_binary() { + let registry = AgentRegistry::::default(); + let mut stamps = Counter::default(); + let _listener = connected(®istry, "a", &mut stamps); + let mut relay = Link::new(CLIENT_IP, "r".to_owned(), MessageRetention::All); + + // `et-relay-binary` sent as a text frame is relayed as a binary one. + let binary = relay.route( + ®istry, + ClientMessage::RelayBinary { content: vec![1, 2] }, + &mut stamps, + ); + assert!(matches!( + binary.deliveries.as_slice(), + [(session, SessionMessage::Binary(bytes))] if session == "a" && bytes.as_ref() == [1, 2] + )); + + // Registered by that frame, so the raw binary frame after it joins nothing. + let raw = relay.route_binary(®istry, Bytes::from_static(&[3]), &mut stamps); + assert!(matches!( + raw.deliveries.as_slice(), + [(_, SessionMessage::Binary(bytes))] if bytes.as_ref() == [3] + )); + assert!(matches!(raw.shared.as_slice(), [Shared::Broadcast { .. }])); +} + +#[test] +fn a_displaced_link_cannot_release_its_replacement() { + let registry = AgentRegistry::::default(); + let mut stamps = Counter::default(); + let first = connected(®istry, "a", &mut stamps); + let mut second = Link::new(CLIENT_IP, "a-again".to_owned(), MessageRetention::All); + let connect = ClientMessage::Connect { + agent_id: Some("a".to_owned()), + }; + let _routing = second.route(®istry, connect, &mut stamps); + + assert!(!first.release(®istry, &|session| session == first.session())); + assert!(second.release(®istry, &|session| session == second.session())); + assert!(!Link::new(CLIENT_IP, "never".to_owned(), MessageRetention::All).release(®istry, &|_session| true)); +} + +#[test] +fn a_server_message_is_sent_as_json() { + let message = ServerMessage::Response { + message: "ok".to_owned(), + }; + assert!(matches!( + SessionMessage::from(message), + SessionMessage::Json(ServerMessage::Response { .. }) + )); +} diff --git a/services/ws/tests/picoserve_hub.rs b/services/ws/tests/picoserve_hub.rs new file mode 100644 index 00000000..da1647a2 --- /dev/null +++ b/services/ws/tests/picoserve_hub.rs @@ -0,0 +1,247 @@ +//! 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"] + +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; + +use edge_toolkit::ws::{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 futures_util::{SinkExt as _, StreamExt as _}; +use picoserve::time::TokioTimer; +use serde_json::json; +use tokio::net::{TcpListener, TcpStream}; +use tokio::task::LocalSet; +use tokio_tungstenite::tungstenite::{Error as WsError, Message}; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; + +/// The largest frame the hub under test reads. +const MAX_FRAME_SIZE: usize = 1024; +/// How many messages the hub under test holds for a connection. +const MAX_QUEUED: usize = 16; +/// How long a test waits for a frame before failing rather than hanging. +const PATIENCE: Duration = Duration::from_secs(5); + +type Client = WebSocketStream>; + +/// Ids `id-1`, `id-2`, ... in turn, and a fixed time. +#[derive(Default)] +struct Counter(u32); + +impl Stamps for Counter { + fn new_id(&mut self) -> String { + self.0 = self.0.saturating_add(1); + format!("id-{}", self.0) + } + + fn now(&mut self) -> String { + "2026-10-30T00:00:00Z".to_owned() + } +} + +/// Run the test `body` makes on one thread, as an embedded executor runs the hub's connections. +#[expect( + clippy::future_not_send, + reason = "a LocalSet is one thread by design, as picoserve's connections need" +)] +async fn on_one_thread(body: B) +where + B: FnOnce() -> F, + F: Future, +{ + LocalSet::new().run_until(Box::pin(body())).await; +} + +/// A hub served on a fresh loopback port, one task per connection as an embedder would, and its address. +async fn served_hub() -> (Arc>, SocketAddr) { + let hub = Arc::new(Hub::new( + Counter::default(), + MessageRetention::All, + MAX_FRAME_SIZE, + MAX_QUEUED, + )); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap(); + let address = listener.local_addr().unwrap(); + let serving = Arc::clone(&hub); + let _accepting = tokio::task::spawn_local(async move { + loop { + let (socket, peer) = listener.accept().await.unwrap(); + let hub = Arc::clone(&serving); + let _serving = tokio::task::spawn_local(async move { + let mut http_buffer = [0; 2048]; + let _served = hub + .serve(socket, peer.ip(), TokioTimer, &config(), &mut http_buffer) + .await; + }); + } + }); + (hub, address) +} + +/// A WebSocket open on `/ws` that has not connected as an agent. +async fn open(address: SocketAddr) -> Client { + connect_async(format!("ws://{address}/ws")).await.unwrap().0 +} + +/// A client connected as `agent_id`, with the status its connect was acknowledged with. +async fn connect(address: SocketAddr, agent_id: &str) -> (Client, ConnectStatus) { + let mut client = open(address).await; + let connect = ClientMessage::Connect { + agent_id: Some(agent_id.to_owned()), + }; + send(&mut client, &connect).await; + let ServerMessage::ConnectAck { status, .. } = receive(&mut client).await else { + panic!("the connect was not acknowledged"); + }; + (client, status) +} + +async fn send(client: &mut Client, message: &ClientMessage) { + client + .send(Message::text(serde_json::to_string(message).unwrap())) + .await + .unwrap(); +} + +/// The next frame `client` is sent. +async fn next_frame(client: &mut Client) -> Message { + tokio::time::timeout(PATIENCE, client.next()) + .await + .unwrap() + .unwrap() + .unwrap() +} + +/// The next frame `client` is sent, as a protocol message. +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 { + matches!(receive(client).await, ServerMessage::MessageStatus { status, .. } if status == *expected) +} + +#[tokio::test] +async fn agents_on_separate_sockets_reach_each_other() { + on_one_thread(async || { + let (_hub, address) = served_hub().await; + let (mut alice, alice_status) = connect(address, "alice").await; + 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!(matches!( + receive(&mut bob).await, + ServerMessage::AgentMessage { from_agent_id, scope: MessageScope::Direct, .. } if from_agent_id == "alice" + )); + + send(&mut bob, &ClientMessage::ListAgents).await; + assert!(matches!( + receive(&mut bob).await, + ServerMessage::ListAgentsResponse { agents } if agents.len() == 2 + )); + + // Frames outside the protocol are relayed to every other agent as they came. + bob.send(Message::text("raw text")).await.unwrap(); + assert_eq!(next_frame(&mut alice).await, Message::text("raw text")); + bob.send(Message::binary(vec![1, 2, 3])).await.unwrap(); + assert_eq!(next_frame(&mut alice).await, Message::binary(vec![1, 2, 3])); + }) + .await; +} + +#[tokio::test] +async fn a_message_waits_for_a_recipient_that_has_gone() { + on_one_thread(async || { + let (_hub, address) = served_hub().await; + let (mut alice, _alice_status) = connect(address, "alice").await; + let (mut bob, _bob_status) = connect(address, "bob").await; + bob.close(None).await.unwrap(); + // 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); + + let (mut bob, status) = connect(address, "bob").await; + assert_eq!(status, ConnectStatus::Reconnected); + assert!(matches!( + receive(&mut bob).await, + ServerMessage::AgentMessage { from_agent_id, .. } if from_agent_id == "alice" + )); + }) + .await; +} + +#[tokio::test] +async fn control_frames_and_undecodable_frames_leave_the_connection_open() { + on_one_thread(async || { + let (_hub, address) = served_hub().await; + let (mut alice, _alice_status) = connect(address, "alice").await; + + alice.send(Message::Ping(vec![7].into())).await.unwrap(); + assert_eq!(next_frame(&mut alice).await, Message::Pong(vec![7].into())); + alice.send(Message::Pong(vec![8].into())).await.unwrap(); + alice + .send(Message::text(r#"{"type":"et-connect","agent_id":5}"#)) + .await + .unwrap(); + + send(&mut alice, &ClientMessage::ListAgents).await; + assert!(matches!( + receive(&mut alice).await, + ServerMessage::ListAgentsResponse { agents } if agents.len() == 1 + )); + }) + .await; +} + +#[tokio::test] +async fn a_frame_larger_than_the_hub_reads_ends_its_connection() { + on_one_thread(async || { + let (_hub, address) = served_hub().await; + let mut client = open(address).await; + client.send(Message::binary(vec![0; MAX_FRAME_SIZE + 1])).await.unwrap(); + let ended = tokio::time::timeout(PATIENCE, client.next()).await.unwrap(); + assert!(!matches!(ended, Some(Ok(Message::Text(_) | Message::Binary(_))))); + }) + .await; +} + +#[tokio::test] +async fn a_blocked_address_is_refused_and_other_paths_are_not_found() { + on_one_thread(async || { + let (hub, address) = served_hub().await; + let missing = connect_async(format!("ws://{address}/elsewhere")).await.unwrap_err(); + assert!(matches!(missing, WsError::Http(response) if response.status() == 404)); + + assert!(hub.registry().blocklist.block(IpAddr::V4(Ipv4Addr::LOCALHOST))); + let refused = connect_async(format!("ws://{address}/ws")).await.unwrap_err(); + assert!(matches!(refused, WsError::Http(response) if response.status() == 403)); + }) + .await; +} + +#[tokio::test] +async fn a_full_outbox_drops_its_oldest_message() { + let outbox = Outbox::new(2); + for text in ["first", "second", "third"] { + outbox.send(SessionMessage::Text(text.to_owned())); + } + assert!(matches!(outbox.next().await, SessionMessage::Text(text) if text == "second")); + assert!(matches!(outbox.next().await, SessionMessage::Text(text) if text == "third")); +} diff --git a/utilities/cli/Cargo.toml b/utilities/cli/Cargo.toml index 9c2dad8d..c1d731d1 100644 --- a/utilities/cli/Cargo.toml +++ b/utilities/cli/Cargo.toml @@ -19,7 +19,7 @@ path = "src/main.rs" bytesize.workspace = true clap = { workspace = true, features = ["env"] } croner.workspace = true -edge-toolkit.workspace = true +edge-toolkit = { workspace = true, features = ["std"] } et-org.workspace = true et-path.workspace = true et-recovery-supervisor.workspace = true @@ -31,12 +31,12 @@ pretty_yaml.workspace = true rand.workspace = true rand_chacha.workspace = true reqwest = { workspace = true, features = ["json", "rustls"] } -serde.workspace = true -serde_json.workspace = true +serde = { workspace = true, features = ["std"] } +serde_json = { workspace = true, features = ["std"] } serde_yaml.workspace = true sha2.workspace = true textwrap.workspace = true -thiserror.workspace = true +thiserror = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["rt"] } toml.workspace = true diff --git a/utilities/int-gen/Cargo.toml b/utilities/int-gen/Cargo.toml index f06a55a7..d3314315 100644 --- a/utilities/int-gen/Cargo.toml +++ b/utilities/int-gen/Cargo.toml @@ -27,8 +27,8 @@ et-onnx.workspace = true et-recovery-supervisor = { workspace = true, features = ["env-schema"] } et-storage-service = { workspace = true, features = ["openapi-spec"] } et-ws-runner-common = { workspace = true, features = ["env-schema"] } -et-ws-server = { workspace = true, features = ["env-schema", "openapi-spec"] } -et-ws-service = { workspace = true, features = ["openapi-spec"] } +et-ws-server = { workspace = true, features = ["default", "env-schema", "openapi-spec"] } +et-ws-service = { workspace = true, features = ["nats", "openapi-spec", "otel"] } fs-err.workspace = true heck.workspace = true jiff.workspace = true @@ -43,12 +43,12 @@ regex = { workspace = true, features = ["std"] } reqwest = { workspace = true, features = ["blocking", "rustls"] } schemars.workspace = true semver.workspace = true -serde.workspace = true -serde_json.workspace = true +serde = { workspace = true, features = ["std"] } +serde_json = { workspace = true, features = ["std"] } serde_yaml.workspace = true syn.workspace = true tempfile.workspace = true -thiserror.workspace = true +thiserror = { workspace = true, features = ["std"] } toml.workspace = true tree-sitter.workspace = true tree-sitter-zig.workspace = true diff --git a/utilities/repo-check/Cargo.toml b/utilities/repo-check/Cargo.toml index 1731e4c3..0cc069de 100644 --- a/utilities/repo-check/Cargo.toml +++ b/utilities/repo-check/Cargo.toml @@ -17,7 +17,7 @@ test = false command-error.workspace = true fs-err.workspace = true regex = { workspace = true, features = ["perf", "std", "unicode-perl"] } -thiserror.workspace = true +thiserror = { workspace = true, features = ["std"] } [lints] workspace = true