diff --git a/.github/scripts/registry_package_order.py b/.github/scripts/registry_package_order.py new file mode 100644 index 00000000..d065ceb4 --- /dev/null +++ b/.github/scripts/registry_package_order.py @@ -0,0 +1,37 @@ +"""Order alternate-registry packages by their normal and build dependencies.""" + +import json +import sys + + +def package_order(metadata): + packages = { + package["name"]: package + for package in metadata["packages"] + if package.get("publish") == ["phoxal"] + } + dependencies = { + name: { + dependency["name"] + for dependency in package["dependencies"] + if dependency.get("kind") != "dev" and dependency["name"] in packages + } + for name, package in packages.items() + } + pending = set(packages) + result = [] + while pending: + ready = sorted(name for name in pending if not dependencies[name] & pending) + if not ready: + raise ValueError("alternate-registry package dependency cycle: " + ", ".join(sorted(pending))) + for name in ready: + result.append((name, bool(dependencies[name]))) + pending.remove(name) + if not result: + raise ValueError("no alternate-registry packages found") + return result + + +if __name__ == "__main__": + for name, dependent in package_order(json.load(sys.stdin)): + print(f"{name}\t{int(dependent)}") diff --git a/.github/scripts/test_registry_package_order.py b/.github/scripts/test_registry_package_order.py new file mode 100644 index 00000000..7219f5d6 --- /dev/null +++ b/.github/scripts/test_registry_package_order.py @@ -0,0 +1,34 @@ +import unittest + +from registry_package_order import package_order + + +def package(name, dependencies=(), publish=None): + return { + "name": name, + "publish": ["phoxal"] if publish is None else publish, + "dependencies": [{"name": dependency, "kind": kind} for dependency, kind in dependencies], + } + + +class PackageOrderTests(unittest.TestCase): + def test_transitive_build_dependencies_and_dev_cycles(self): + metadata = {"packages": [ + package("last", [("middle", "build")]), + package("middle", [("root", None)]), + package("root", [("last", "dev"), ("public", None)]), + package("public", publish=["crates-io"]), + ]} + self.assertEqual(package_order(metadata), [("root", False), ("middle", True), ("last", True)]) + + def test_normal_cycle_is_refused(self): + with self.assertRaisesRegex(ValueError, "cycle"): + package_order({"packages": [package("a", [("b", None)]), package("b", [("a", None)])]}) + + def test_empty_release_is_refused(self): + with self.assertRaisesRegex(ValueError, "no alternate-registry"): + package_order({"packages": []}) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99e84dc4..fc3c2198 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,14 +18,12 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true -# The workspace builds on a bare runner by design. Runnable crates own native -# build requirements in package.metadata.phoxal.build; phoxal's -# `authoring_workspace_build_requirements` test keeps this bootstrap input -# synchronized with their declared union. -# Nothing in the workspace links a native simulator any more: the Webots -# controller lives in phoxal/simulator-webots and carries that requirement with -# it, so this workflow installs no simulator and the checks run on a bare -# runner. +# Runnable crates own apt build requirements in package.metadata.phoxal.build; +# phoxal's `authoring_workspace_build_requirements` test keeps that bootstrap +# input synchronized with their declared union. +# The two Webots controller packages link libController even during +# `cargo check`, so the shared workflow installs the one supported native SDK +# before its clippy and test jobs. jobs: ci: if: >- @@ -33,8 +31,72 @@ jobs: (github.event_name == 'push' && !startsWith(github.event.head_commit.message, 'chore(release): release v')) uses: phoxal/.github/.github/workflows/rust-ci.yml@main + with: + webots-version: R2025a secrets: inherit + # The ordinary tests prove deterministic source generation. This launches the + # supported native runtime and refuses asset-loader warnings, so IndexedFaceSet + # output cannot regress to a representation R2025a only appears to parse. + webots-native-renderer: + name: Webots native renderer + if: >- + github.event_name == 'pull_request' || + (github.event_name == 'push' && + !startsWith(github.event.head_commit.message, 'chore(release): release v')) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Install native runtime prerequisites + run: | + sudo apt-get update + sudo apt-get install --yes \ + ffmpeg \ + libavcodec-extra \ + libegl1 \ + libglu1-mesa \ + libxkbcommon-x11-0 \ + libxcb-keysyms1 \ + libxcb-image0 \ + libxcb-icccm4 \ + libxcb-randr0 \ + libxcb-render-util0 \ + libxcb-cursor0 \ + libxcb-xinerama0 \ + libxcomposite1 \ + libxtst6 \ + libnss3 \ + xvfb + + - name: Cache Webots download + id: cache-webots-native-renderer + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/webots.tar.bz2 + key: webots-R2025a-${{ runner.os }}-${{ runner.arch }} + + - name: Download Webots + if: steps.cache-webots-native-renderer.outputs.cache-hit != 'true' + run: | + curl --fail --location --silent --show-error \ + "https://github.com/cyberbotics/webots/releases/download/R2025a/webots-R2025a-x86-64.tar.bz2" \ + --output "$RUNNER_TEMP/webots.tar.bz2" + + - name: Install Webots + run: | + tar -xjf "$RUNNER_TEMP/webots.tar.bz2" -C "$RUNNER_TEMP" + echo "WEBOTS_HOME=$RUNNER_TEMP/webots" >> "$GITHUB_ENV" + + - name: Prove decoded GLB geometry in R2025a + run: >- + xvfb-run --auto-servernum + cargo test -p phoxal-simulator-webots-host --bin phoxal-simulator-webots-host + installed_webots_loads_native_decoded_geometry_without_asset_warnings + -- --ignored --nocapture + # Every consumer profile, built explicitly. # # The shared `ci` job builds the workspace, and a workspace build unifies @@ -81,10 +143,9 @@ jobs: done cargo doc -p phoxal --all-features --no-deps - # The shared `ci` job's `cargo test --workspace` unifies only the features - # the workspace members enable, which never includes `session` or - # `simulator`. The tests that live in those profiles, and the integration - # tests that state `required-features`, run only here. + # The shared workspace test unifies the features its members enable. + # Test all profiles explicitly as well, including integration tests with + # `required-features`, independently of the current member composition. - name: Test every profile together run: cargo test -p phoxal --all-features @@ -128,8 +189,7 @@ jobs: shell: bash run: | set -euo pipefail - status=0 - report="$(cargo semver-checks --package phoxal --all-features 2>&1)" || status=$? + report="$(cargo semver-checks --package phoxal --all-features 2>&1)" || true echo "$report" { echo "## Rust API surface (all profiles)" @@ -160,6 +220,9 @@ jobs: - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Test registry dependency ordering + run: python3 -m unittest discover -s .github/scripts -p 'test_registry_package_order.py' + - name: Check the workspace policy shell: bash run: | diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index fc9003c2..96275eb3 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -62,6 +62,7 @@ jobs: with: fetch-depth: 0 - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Identify train publication id: train shell: bash @@ -290,6 +291,27 @@ jobs: - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Cache Webots download + id: cache-webots-publish + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/webots.tar.bz2 + key: webots-R2025a-${{ runner.os }}-${{ runner.arch }} + + - name: Download Webots + if: steps.cache-webots-publish.outputs.cache-hit != 'true' + run: | + set -euo pipefail + curl --fail --location --silent --show-error \ + "https://github.com/cyberbotics/webots/releases/download/R2025a/webots-R2025a-x86-64.tar.bz2" \ + --output "$RUNNER_TEMP/webots.tar.bz2" + + - name: Install Webots + run: | + set -euo pipefail + tar -xjf "$RUNNER_TEMP/webots.tar.bz2" -C "$RUNNER_TEMP" + echo "WEBOTS_HOME=$RUNNER_TEMP/webots" >> "$GITHUB_ENV" + # The checked-out source must actually BE this train. This is what stops # a resume publishing someone else's version: an old release commit # predating the registry model selects no packages at all, and any commit @@ -318,21 +340,6 @@ jobs: [[ "$wrong" == "0" ]] || exit 1 echo "all ${found} package(s) are ${TRAIN}" - - name: Package every executable - shell: bash - run: | - set -euo pipefail - mapfile -t packages < <( - cargo metadata --no-deps --format-version 1 \ - | jq -r '.packages[] | select(.publish == ["phoxal"]) | .name' \ - | sort - ) - [[ ${#packages[@]} -gt 0 ]] || { echo "no executable packages found" >&2; exit 1; } - echo "packaging ${#packages[@]} executables at ${TRAIN}" - for package in "${packages[@]}"; do - cargo package -p "$package" - done - - name: Generate GitHub App token id: app-token uses: actions/create-github-app-token@v3 @@ -352,48 +359,6 @@ jobs: # checkout needs real history. fetch-depth: 0 - # Immutability is the registry's whole contract, so drop anything already - # published rather than asking margo to overwrite it. A partly-published - # train then resumes cleanly instead of failing. - - name: Drop already-published versions - shell: bash - run: | - set -euo pipefail - skipped=0 - # margo stores an artifact as crates////.crate, - # so the published file is named for the VERSION, not the package. Glob - # the prefix directories rather than reimplementing Cargo's prefix rule. - while read -r package version; do - local_crate="target/package/${package}-${version}.crate" - [[ -f "$local_crate" ]] || { echo "::error::expected $local_crate"; exit 1; } - published="" - for candidate in registry/crates/*/*/"$package"/"$version".crate; do - [[ -f "$candidate" ]] && { published="$candidate"; break; } - done - [[ -n "$published" ]] || continue - - # Same version already in the registry. Resume only if it is the - # same artifact: identical content means an earlier run of THIS - # train got that far. Different content means the version would - # have to change to describe this build, and versions are - # immutable - so stop rather than publish a contradiction. - if [[ "$(sha256sum < "$local_crate")" == "$(sha256sum < "$published")" ]]; then - echo " already published, skipping: ${package} ${version}" - rm "$local_crate" - skipped=$((skipped + 1)) - else - echo "::error::${package} ${version} is already published with different content." - echo "::error::Published versions are immutable; cut a new patch train instead." - exit 1 - fi - done < <( - cargo metadata --no-deps --format-version 1 \ - | jq -r '.packages[] | select(.publish == ["phoxal"]) | "\(.name) \(.version)"' - ) - remaining=$(find target/package -maxdepth 1 -name '*.crate' | wc -l | tr -d ' ') - echo "publishing $remaining package(s), skipped $skipped already-published" - echo "REMAINING=$remaining" >> "$GITHUB_ENV" - # margo is called directly rather than through `integer32llc/margo-actions`. # The action looked like the obvious choice, but at its only pinnable ref # its input is `registry-dir`, not the `registry` its README documents - @@ -402,7 +367,6 @@ jobs: # internally, while this registry was created by 0.1.7. Two behaviours we # do not control, in exchange for a download and a loop. - name: Install margo - if: env.REMAINING != '0' shell: bash run: | set -euo pipefail @@ -413,46 +377,127 @@ jobs: "$RUNNER_TEMP/margo" --help >/dev/null 2>&1 || true echo "$RUNNER_TEMP" >> "$GITHUB_PATH" - - name: Add the packages to the registry - if: env.REMAINING != '0' + # The controllers depend on the host from this same immutable train. + # Stage independent artifacts in a local registry commit first so Cargo + # can normalize the controller manifests without publishing a partial + # train. The final push remains one atomic append. + - name: Package and stage every executable in dependency order shell: bash run: | set -euo pipefail + cargo metadata --locked --no-deps --format-version 1 \ + | python3 .github/scripts/registry_package_order.py \ + > "$RUNNER_TEMP/registry-package-order.tsv" + + base="$(git -C registry rev-parse HEAD)" + echo "BASE=$base" >> "$GITHUB_ENV" + git -C registry config user.name "phoxal-release-bot" + git -C registry config user.email "release-bot@phoxal.com" added=0 - for crate in target/package/*.crate; do - margo add --registry registry "$crate" + skipped=0 + + stage_package() { + local package="$1" + local local_crate="target/package/${package}-${TRAIN}.crate" + [[ -f "$local_crate" ]] || { echo "::error::expected $local_crate"; exit 1; } + local published="" + for candidate in registry/crates/*/*/"$package"/"$TRAIN".crate; do + [[ -f "$candidate" ]] && { published="$candidate"; break; } + done + if [[ -n "$published" ]]; then + if [[ "$(sha256sum < "$local_crate")" == "$(sha256sum < "$published")" ]]; then + echo " already published, skipping: ${package} ${TRAIN}" + skipped=$((skipped + 1)) + return + fi + echo "::error::${package} ${TRAIN} is already published with different content." + echo "::error::Published versions are immutable; cut a new patch train instead." + exit 1 + fi + margo add --registry registry "$local_crate" added=$((added + 1)) - done - echo "added $added package(s) to the registry" - [[ "$added" == "$REMAINING" ]] || { - echo "::error::added $added but expected $REMAINING" - exit 1 } - - name: Commit the registry - if: env.REMAINING != '0' - working-directory: registry + local_index="file://$(cd registry && pwd)" + while read -r package dependent; do + if [[ "$dependent" == "1" ]]; then + cargo package --locked -p "$package" --no-verify \ + --config 'source.phoxal.registry="sparse+https://phoxal.github.io/registry/"' \ + --config 'source.phoxal.replace-with="local-phoxal"' \ + --config "source.local-phoxal.registry=\"${local_index}\"" + else + cargo package --locked -p "$package" + fi + stage_package "$package" + # Cargo reads committed index state for the next dependency level. + git -C registry add -A + if ! git -C registry diff --cached --quiet; then + if [[ "$(git -C registry rev-parse HEAD)" == "$base" ]]; then + git -C registry commit -m "publish framework train v${TRAIN}" + else + git -C registry commit --amend --no-edit + fi + fi + done < "$RUNNER_TEMP/registry-package-order.tsv" + + if [[ "$(git -C registry rev-parse HEAD)" == "$base" ]]; then + echo "REGISTRY_CHANGED=false" >> "$GITHUB_ENV" + echo "all ${skipped} package(s) were already published identically" + else + echo "REGISTRY_CHANGED=true" >> "$GITHUB_ENV" + echo "staged $added package(s), skipped $skipped already-published" + fi + + # `--no-verify` is necessary while Cargo first normalizes each controller + # because its host dependency is not public yet. Verify the exact staged + # archives afterwards against a disposable local download endpoint. + - name: Verify every staged dependent package shell: bash run: | set -euo pipefail - git config user.name "phoxal-release-bot" - git config user.email "release-bot@phoxal.com" - echo "BASE=$(git rev-parse HEAD)" >> "$GITHUB_ENV" - git add -A - git commit -m "publish framework train v${TRAIN}" + resolver="$RUNNER_TEMP/phoxal-registry-resolver" + registry_path="$(cd registry && pwd)" + git clone "file://${registry_path}" "$resolver" + local_download="file://${resolver}/crates/{lowerprefix}/{crate}/{version}.crate" + jq --arg dl "$local_download" '.dl = $dl' "$resolver/config.json" \ + > "$resolver/config.json.next" + mv "$resolver/config.json.next" "$resolver/config.json" + git -C "$resolver" config user.name "phoxal-release-verifier" + git -C "$resolver" config user.email "release-verifier@phoxal.com" + git -C "$resolver" add config.json + git -C "$resolver" commit -m "use local artifacts for package verification" + resolver_index="file://${resolver}" + + while read -r package dependent; do + [[ "$dependent" == "1" ]] || continue + artifact="" + for candidate in registry/crates/*/*/"$package"/"$TRAIN".crate; do + [[ -f "$candidate" ]] && { artifact="$candidate"; break; } + done + [[ -n "$artifact" ]] || { echo "::error::missing staged $package $TRAIN"; exit 1; } + expected="$(sha256sum < "$artifact")" + cargo package --locked -p "$package" \ + --config 'source.phoxal.registry="sparse+https://phoxal.github.io/registry/"' \ + --config 'source.phoxal.replace-with="local-phoxal"' \ + --config "source.local-phoxal.registry=\"${resolver_index}\"" + observed="$(sha256sum < "target/package/${package}-${TRAIN}.crate")" + [[ "$observed" == "$expected" ]] || { + echo "::error::verified $package archive differs from the staged artifact" + exit 1 + } + done < "$RUNNER_TEMP/registry-package-order.tsv" # Everything below runs BEFORE the push. The registry's immutability # workflow only triggers afterwards, and a malformed append would already # be permanent by then - so the same validator runs here, on the commit # that is about to be pushed, while it can still be thrown away. - name: Verify the append is valid before pushing - if: env.REMAINING != '0' + if: env.REGISTRY_CHANGED == 'true' working-directory: registry shell: bash run: python3 .github/scripts/check_append_only.py "$BASE" HEAD - name: Verify every packaged version is in the index - if: env.REMAINING != '0' shell: bash run: | set -euo pipefail @@ -488,7 +533,7 @@ jobs: echo "every packaged version has a matching index record" - name: Deploy the registry - if: env.REMAINING != '0' + if: env.REGISTRY_CHANGED == 'true' working-directory: registry shell: bash run: git push origin main diff --git a/Cargo.lock b/Cargo.lock index 93cfc567..907d25ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -446,6 +446,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -509,9 +515,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -527,7 +533,7 @@ dependencies = [ "iana-time-zone", "num-traits", "serde", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -752,14 +758,38 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", ] [[package]] @@ -775,13 +805,24 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.119", ] @@ -826,6 +867,29 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive-new" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_setters" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7e6f6fa1f03c14ae082120b84b3c7fbd7b8588d924cf2d7c3daf9afd49df8b9" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -995,6 +1059,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1040,6 +1113,12 @@ dependencies = [ "spin 0.9.9", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -1469,7 +1548,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -1590,6 +1669,21 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1666,7 +1760,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.19", "walkdir", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1814,7 +1908,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1941,6 +2035,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "nalgebra" version = "0.34.2" @@ -2045,6 +2149,15 @@ dependencies = [ "serde", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2175,12 +2288,31 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + [[package]] name = "objc2-encode" version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.36.7" @@ -2267,7 +2399,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2420,6 +2552,7 @@ dependencies = [ "serde_json", "serde_yaml", "serial_test", + "sha2", "system_shutdown", "tempfile", "thiserror 2.0.19", @@ -2485,7 +2618,6 @@ name = "phoxal-fixture" version = "0.67.1" dependencies = [ "phoxal", - "serde_json", "tempfile", ] @@ -2594,6 +2726,69 @@ dependencies = [ "phoxal", ] +[[package]] +name = "phoxal-simulator-webots-host" +version = "0.67.1" +dependencies = [ + "anyhow", + "clap", + "image", + "libc", + "nalgebra", + "phoxal", + "phoxal-simulator-webots-shared", + "rmp-serde", + "serde", + "serde_json", + "sha2", + "sysinfo", + "tempfile", + "thiserror 2.0.19", + "tobj", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "webots-proto-ast", +] + +[[package]] +name = "phoxal-simulator-webots-robot-controller" +version = "0.67.1" +dependencies = [ + "anyhow", + "clap", + "phoxal", + "phoxal-simulator-webots-shared", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "webots-rs", +] + +[[package]] +name = "phoxal-simulator-webots-shared" +version = "0.67.1" +dependencies = [ + "phoxal", + "rmp-serde", + "serde", + "thiserror 2.0.19", +] + +[[package]] +name = "phoxal-simulator-webots-world-controller" +version = "0.67.1" +dependencies = [ + "anyhow", + "clap", + "phoxal-simulator-webots-shared", + "tracing", + "tracing-subscriber", + "webots-rs", +] + [[package]] name = "phoxal-supervisor" version = "0.67.1" @@ -2674,6 +2869,19 @@ dependencies = [ "winapi", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -2736,6 +2944,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quick-xml" version = "0.36.2" @@ -3442,7 +3656,7 @@ version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.119", @@ -3497,6 +3711,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha2-const-stable" version = "0.1.0" @@ -3746,13 +3971,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "sysinfo" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + [[package]] name = "system_shutdown" version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29396e5e1b637d102ec5037bf7fbb8da78264fa299101ced9ce827756b1bac02" dependencies = [ - "windows", + "windows 0.62.2", "zbus", ] @@ -3901,6 +4140,12 @@ dependencies = [ "tokio-rustls", ] +[[package]] +name = "tobj" +version = "4.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eb8e04167c1c0c76b5de63226fd733485ad63ef71e40de31e16272f47b099e2" + [[package]] name = "token-cell" version = "2.1.1" @@ -4482,6 +4727,30 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webots-proto-ast" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aefb42b60ee47b54aac9773039ae0b9094a8c5febf21a85293964b561f892cd" +dependencies = [ + "derive-new", + "derive_setters", + "serde", + "thiserror 2.0.19", +] + +[[package]] +name = "webots-rs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "510ceabe38e75ee597f2a61b289bef4c8df314cde89e6574d80c166e2a5e3018" +dependencies = [ + "derive-new", + "derive_setters", + "libc", + "thiserror 2.0.19", +] + [[package]] name = "webpki-root-certs" version = "1.0.9" @@ -4541,16 +4810,38 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", ] [[package]] @@ -4559,7 +4850,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core", + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -4570,9 +4874,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", ] [[package]] @@ -4581,9 +4896,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core", - "windows-link", - "windows-threading", + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -4608,20 +4923,45 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + [[package]] name = "windows-numerics" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core", - "windows-link", + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -4630,7 +4970,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -4639,7 +4988,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4657,7 +5006,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4676,13 +5025,22 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-threading" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -5499,6 +5857,21 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.14.0" diff --git a/Cargo.toml b/Cargo.toml index e5b329fe..3bf5ddcb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,10 @@ members = [ "services/perception", "services/safety", "services/video", + "simulators/webots/host", + "simulators/webots/shared", + "simulators/webots/world-controller", + "simulators/webots/robot-controller", "xtask", ] # Keep the workspace's command runner out of plain root cargo builds: `xtask` @@ -66,6 +70,10 @@ default-members = [ "services/perception", "services/safety", "services/video", + "simulators/webots/host", + "simulators/webots/shared", + "simulators/webots/world-controller", + "simulators/webots/robot-controller", ] [workspace.dependencies] @@ -82,6 +90,7 @@ gilrs = "0.11.2" getrandom = "0.3.4" heck = "0.5.0" json5 = "0.4.1" +image = { version = "0.25.8", default-features = false, features = ["jpeg", "png"] } libc = "0.2.180" nalgebra = { version = "0.34.2", features = ["serde-serialize"] } proc-macro2 = "1.0" @@ -105,6 +114,7 @@ syn = { version = "2.0", features = ["full", "extra-traits"] } # workspace's `rust-version`. sysinfo = { version = "0.36", default-features = false, features = ["disk", "system"] } tempfile = "3.27.0" +tobj = { version = "4.0.4", default-features = false, features = ["use_f64"] } thiserror = "2.0.18" tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread", "time", "signal", "sync", "fs", "process"] } tokio-util = { version = "0.7", default-features = false } @@ -113,6 +123,8 @@ tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } trybuild = "1" urdf-rs = "0.9.0" +webots-proto-ast = "0.2.0" +webots-rs = "0.2" # Default features minus transport_compression: Phoxal never enables zenoh # compression at runtime, so the feature only pulls lz4_flex into shipped # binaries. Re-enable defaults if a Phoxal transport ever compresses. diff --git a/README.md b/README.md index 534fc4fc..28f6dc61 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,12 @@ This repository and its source are the authority for current framework implement ## Repository -- `phoxal/` — the one framework library -- `crates/` — the proc-macro package and the test fixture stager -- `supervisor/` — framework-train execution observer -- `services/`, `components/` — official runtime packages -- `fixture/` — the authored test robot and components (the example robot project is [phoxal/robot-rover](https://github.com/phoxal/robot-rover)) +- `phoxal/` - the one framework library +- `crates/` - the proc-macro package and the test fixture stager +- `supervisor/` - framework-train execution observer +- `services/`, `components/` - official runtime packages +- `simulators/` - exact-train simulator adapter packages kept outside the universal framework library +- `fixture/` - the authored test robot, world, and components (the example robot project is [phoxal/robot-rover](https://github.com/phoxal/robot-rover)) See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and contribution requirements. diff --git a/components/ddsm115/meshes/ddsm115.mtl b/components/ddsm115/meshes/motorized_wheel.mtl similarity index 100% rename from components/ddsm115/meshes/ddsm115.mtl rename to components/ddsm115/meshes/motorized_wheel.mtl diff --git a/components/zed_f9p/component.yaml b/components/zed_f9p/component.yaml index a513e835..cf4fe086 100644 --- a/components/zed_f9p/component.yaml +++ b/components/zed_f9p/component.yaml @@ -3,6 +3,7 @@ capabilities: gnss: kind: gnss publish_rate_hz: 10.0 + coordinate_system: wgs84 target: kind: link id: sensor_link diff --git a/crates/fixture/Cargo.toml b/crates/fixture/Cargo.toml index 972a576c..11f9523f 100644 --- a/crates/fixture/Cargo.toml +++ b/crates/fixture/Cargo.toml @@ -20,7 +20,6 @@ publish = false # and land as `phoxal::model`'s canonical types, so it needs the framework's one # library with the authored-source layer enabled. phoxal = { workspace = true, features = ["authoring"] } -serde_json = { workspace = true } tempfile = { workspace = true } [lints] diff --git a/fixture/world.yaml b/fixture/world.yaml new file mode 100644 index 00000000..b686d9fe --- /dev/null +++ b/fixture/world.yaml @@ -0,0 +1,50 @@ +schema: phoxal/world/v0 + +assets: + floor: + geometry: + kind: box + size: [20.0, 20.0, 0.1] + implicit-mesh: + geometry: + kind: mesh + path: components/drive_motor/meshes/drive_motor.glb + detailed-visual: + geometry: + kind: mesh + path: components/drive_motor/meshes/drive_motor.glb + scale: [2.0, 2.0, 2.0] + collision: + kind: box + size: [0.2, 0.2, 0.2] + +world: + id: glb-acceptance + time_step_ms: 12 + gravity_mps2: [0.0, 0.0, -9.81] + spawn_points: + loading-bay: + xyz: [0.0, -2.0, 0.0] + rpy: [0.0, 0.0, 0.0] + inspection-bay: + xyz: [0.0, 2.0, 0.0] + rpy: [0.0, 0.0, 0.0] + entities: + floor: + asset: floor + instances: + - pose: + xyz: [0.0, 0.0, -0.05] + rpy: [0.0, 0.0, 0.0] + implicit-mesh: + asset: implicit-mesh + instances: + - pose: + xyz: [-0.5, 0.0, 0.2] + rpy: [0.0, 0.0, 0.0] + detailed-visual: + asset: detailed-visual + instances: + - pose: + xyz: [0.5, 0.0, 0.2] + rpy: [0.0, 0.0, 0.0] diff --git a/fixture/worlds/two-member/world.yaml b/fixture/worlds/two-member/world.yaml new file mode 100644 index 00000000..7c71e271 --- /dev/null +++ b/fixture/worlds/two-member/world.yaml @@ -0,0 +1,26 @@ +schema: phoxal/world/v0 + +assets: + floor: + geometry: + kind: box + size: [20.0, 20.0, 0.1] + +world: + id: two-member + time_step_ms: 12 + gravity_mps2: [0.0, 0.0, -9.81] + spawn_points: + west-bay: + xyz: [-2.0, 0.0, 0.0] + rpy: [0.0, 0.0, 0.0] + east-bay: + xyz: [2.0, 0.0, 0.0] + rpy: [0.0, 0.0, 3.141592653589793] + entities: + floor: + asset: floor + instances: + - pose: + xyz: [0.0, 0.0, -0.05] + rpy: [0.0, 0.0, 0.0] diff --git a/fixture/worlds/warehouse/world.yaml b/fixture/worlds/warehouse/world.yaml new file mode 100644 index 00000000..38a3aebb --- /dev/null +++ b/fixture/worlds/warehouse/world.yaml @@ -0,0 +1,23 @@ +schema: phoxal/world/v0 + +assets: + floor: + geometry: + kind: box + size: [20.0, 20.0, 0.1] + +world: + id: warehouse + time_step_ms: 12 + gravity_mps2: [0.0, 0.0, -9.81] + spawn_points: + loading-bay: + xyz: [0.0, 0.0, 0.0] + rpy: [0.0, 0.0, 0.0] + entities: + floor: + asset: floor + instances: + - pose: + xyz: [0.0, 0.0, -0.05] + rpy: [0.0, 0.0, 0.0] diff --git a/phoxal/Cargo.toml b/phoxal/Cargo.toml index af0df053..65b7a515 100644 --- a/phoxal/Cargo.toml +++ b/phoxal/Cargo.toml @@ -92,11 +92,13 @@ schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_bytes = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } serde_yaml = { workspace = true, optional = true } # The supervisor asks its own host to reboot or power off. system_shutdown = { workspace = true, optional = true } thiserror = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time", "signal", "sync", "fs"] } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time", "signal", "sync", "fs", "io-util", "net"] } # `rt`: the supervisor's one shutdown token, cancelled from the signal handler # and awaited by everything it owns. tokio-util = { workspace = true, features = ["rt"], optional = true } @@ -142,9 +144,6 @@ jsonschema = { workspace = true } # other place in either repository that parses this section. object = "0.36" serial_test = { workspace = true } -# `tests/participant_metadata.rs` builds a throwaway participant crate against -# this one and throws it away again. -tempfile = { workspace = true } trybuild = { workspace = true } # `test-util` (paused/mocked virtual time) is additive to the `[dependencies]` # `tokio` features above and only applies to test/bench/example builds, never diff --git a/phoxal/src/__compat/mod.rs b/phoxal/src/__compat/mod.rs index 40713a07..4637aa1f 100644 --- a/phoxal/src/__compat/mod.rs +++ b/phoxal/src/__compat/mod.rs @@ -8,7 +8,7 @@ //! //! `contract_surface()` is the crate aggregate. Each owner still states its own //! records beside its own definitions - `bus::__compat`, `bundle::__compat`, -//! `participant::metadata::__compat`, and the three api families, whose records +//! `participant::metadata::__compat`, and the five api families, whose records //! the `nodes!`/`endpoints!` declarations emit from the same structure that //! renders their concrete keys - and this module only collects them and renders //! the one canonical document. @@ -30,7 +30,7 @@ use crate::participant::launch::Launch; /// The canonical rendering of this crate's whole contract surface. /// /// Every process/wire fact the framework owns, in one deterministic document: -/// the three api families' endpoints, the bus envelopes and key constants, the +/// the five api families' endpoints, the bus envelopes and key constants, the /// bundle manifest document, the participant metadata document, and the launch /// argv contract. #[must_use] @@ -45,6 +45,8 @@ fn contract_records() -> Vec { crate::api::contract_records(&mut records); crate::runtime::api::contract_records(&mut records); crate::supervisor::api::contract_records(&mut records); + crate::simulation::api::contract_records(&mut records); + crate::world::api::__compat::contract_records(&mut records); crate::bus::__compat::contract_records(&mut records); crate::bundle::__compat::contract_records(&mut records); crate::participant::metadata::__compat::contract_records(&mut records); @@ -92,9 +94,7 @@ mod tests { fn the_launch_record_is_the_supervisor_owned_argv_contract() { let expected = ContractRecord::launch([ LaunchArgument::new("participant-id", true, false, LaunchValueShape::Text), - LaunchArgument::new("bundle-root", true, false, LaunchValueShape::Text), - LaunchArgument::new("connect", true, true, LaunchValueShape::Text), - LaunchArgument::new("simulation", false, false, LaunchValueShape::Flag), + LaunchArgument::new("connect", true, false, LaunchValueShape::Text), ]); assert_eq!(ContractRecord::launch(launch_arguments()), expected); @@ -125,16 +125,15 @@ mod tests { } } - /// `--simulation` is the launch contract's only bare switch: it is a - /// launcher decision with no value to carry. Everything else names a fact - /// and therefore consumes an argv token. + /// The launch ABI carries only identity and rendezvous text values. A time + /// domain is supervisor authority, not a launcher switch. #[test] - fn simulation_is_the_only_bare_switch() { + fn the_launch_abi_has_no_bare_switches() { let flags = launch_arguments() .into_iter() .filter(|argument| argument.value == LaunchValueShape::Flag) .map(|argument| argument.name) .collect::>(); - assert_eq!(flags, ["simulation"]); + assert!(flags.is_empty()); } } diff --git a/phoxal/src/__compat/wire.rs b/phoxal/src/__compat/wire.rs index 46574ee5..1aeecd05 100644 --- a/phoxal/src/__compat/wire.rs +++ b/phoxal/src/__compat/wire.rs @@ -54,6 +54,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::num::{NonZeroU32, NonZeroU64}; +use std::path::PathBuf; /// One serialized wire shape. /// @@ -579,6 +580,12 @@ primitive_wire_schema! { NonZeroU64 => WireSchema::U64, } +impl DescribeWire for PathBuf { + fn wire_schema() -> WireSchema { + WireSchema::opaque("PathBuf", WireSchema::String) + } +} + impl DescribeWire for &T { fn wire_schema() -> WireSchema { T::wire_schema() diff --git a/phoxal/src/authoring/mod.rs b/phoxal/src/authoring/mod.rs index 5d68cbe5..3bdf7a8e 100644 --- a/phoxal/src/authoring/mod.rs +++ b/phoxal/src/authoring/mod.rs @@ -54,6 +54,7 @@ use source::SourceError; pub mod build_requirements; pub mod schema; pub mod source; +pub mod world; mod normalized; diff --git a/phoxal/src/authoring/normalized.rs b/phoxal/src/authoring/normalized.rs index becc109c..b2c0fceb 100644 --- a/phoxal/src/authoring/normalized.rs +++ b/phoxal/src/authoring/normalized.rs @@ -24,7 +24,7 @@ use std::path::PathBuf; use crate::model::CapabilityRole; use crate::model::component::capability::CapabilityKind; use crate::model::identity::{CapabilityId, LinkId}; -use crate::model::robot::{KinematicConfig, MotionLimits}; +use crate::model::kinematics::{KinematicConfig, MotionLimits}; use crate::authoring::source::robot::driver::DriverConfig; @@ -86,6 +86,39 @@ pub(crate) struct Simulation { pub links: BTreeMap>, } +/// World facts after source defaults and spellings have been resolved. +#[derive(Debug, Clone)] +pub(crate) struct World { + pub id: String, + pub time_step_ms: u64, + pub gravity_mps2: [f64; 3], + pub assets: BTreeMap, + pub spawn_points: BTreeMap, + pub entities: BTreeMap, +} + +#[derive(Debug, Clone)] +pub(crate) struct WorldAsset { + pub geometry: WorldGeometry, + pub collision: WorldGeometry, +} + +#[derive(Debug, Clone)] +pub(crate) struct WorldEntity { + pub asset: String, + pub instances: Vec, +} + +/// Primitives are already canonical; only mesh source paths need compilation. +#[derive(Debug, Clone)] +pub(crate) enum WorldGeometry { + Primitive(crate::model::geometry::Geometry), + Mesh { + path: PathBuf, + scale: Option<[f64; 3]>, + }, +} + impl Robot { /// Every component type this robot mounts at least one instance of. pub(crate) fn used_component_types(&self) -> BTreeSet<&str> { diff --git a/phoxal/src/authoring/schema.rs b/phoxal/src/authoring/schema.rs index cfc21600..1fc7d4ff 100644 --- a/phoxal/src/authoring/schema.rs +++ b/phoxal/src/authoring/schema.rs @@ -19,6 +19,7 @@ impl DocumentKind { Self::Robot => "robot.schema.json", Self::Component => "component.schema.json", Self::Simulation => "simulation.schema.json", + Self::World => "world.schema.json", } } @@ -38,6 +39,8 @@ impl DocumentKind { Self::Simulation => SchemaGenerator::new(SchemaSettings::draft2020_12()) .into_root_schema_for::( ), + Self::World => SchemaGenerator::new(SchemaSettings::draft2020_12()) + .into_root_schema_for::(), }; let (title, description) = self.schema_metadata(); schema.insert("title".into(), title.into()); @@ -59,6 +62,10 @@ impl DocumentKind { "Phoxal simulation manifest (phoxal/simulation/v0)", "Editor schema for an authored Phoxal simulation.yaml document.", ), + Self::World => ( + "Phoxal world manifest (phoxal/world/v0)", + "Editor schema for an authored Phoxal world.yaml document.", + ), } } } @@ -89,6 +96,7 @@ mod tests { DocumentKind::Robot => root.join("robot/rgbd-imu-diff-drive/robot.yaml"), DocumentKind::Component => root.join("components/drive_motor/component.yaml"), DocumentKind::Simulation => root.join("components/drive_motor/simulation.yaml"), + DocumentKind::World => root.join("worlds/warehouse/world.yaml"), }; let document = std::fs::read_to_string(path).expect("fixture document should be readable"); yaml_value(&document) @@ -109,7 +117,7 @@ mod tests { fn schemas_are_self_validating_and_name_their_documents() { assert_eq!( DocumentKind::ALL.len(), - 3, + 4, "update the stable schema-generation inventory for every document kind" ); for kind in DocumentKind::ALL { @@ -126,6 +134,10 @@ mod tests { "Phoxal simulation manifest (phoxal/simulation/v0)", "simulation.schema.json", ), + DocumentKind::World => ( + "Phoxal world manifest (phoxal/world/v0)", + "world.schema.json", + ), }; let generated = kind.generate(); assert_eq!(kind.schema_file_name(), file_name); diff --git a/phoxal/src/authoring/source/document.rs b/phoxal/src/authoring/source/document.rs index e0482283..ba26d3a2 100644 --- a/phoxal/src/authoring/source/document.rs +++ b/phoxal/src/authoring/source/document.rs @@ -12,7 +12,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use super::strict_yaml::StrictYamlError; -use super::{component, robot, simulation}; +use super::{component, robot, simulation, world}; /// Which authored document a value or a failure belongs to. /// @@ -27,11 +27,13 @@ pub enum DocumentKind { Component, /// A component-local `simulation.yaml` document. Simulation, + /// An explicit `world.yaml` document. + World, } impl DocumentKind { /// Every authored document kind, in stable order. - pub const ALL: [Self; 3] = [Self::Robot, Self::Component, Self::Simulation]; + pub const ALL: [Self; 4] = [Self::Robot, Self::Component, Self::Simulation, Self::World]; /// The file name a document of this kind always has inside its directory. #[must_use] @@ -40,6 +42,7 @@ impl DocumentKind { Self::Robot => "robot.yaml", Self::Component => "component.yaml", Self::Simulation => "simulation.yaml", + Self::World => "world.yaml", } } } @@ -50,6 +53,7 @@ impl fmt::Display for DocumentKind { Self::Robot => "robot", Self::Component => "component", Self::Simulation => "simulation", + Self::World => "world", }) } } @@ -63,6 +67,7 @@ pub enum Violations { Robot(Vec), Component(Vec), Simulation(Vec), + World(Vec), } impl Violations { @@ -73,6 +78,7 @@ impl Violations { Self::Robot(_) => DocumentKind::Robot, Self::Component(_) => DocumentKind::Component, Self::Simulation(_) => DocumentKind::Simulation, + Self::World(_) => DocumentKind::World, } } @@ -104,6 +110,15 @@ impl Violations { _ => None, } } + + /// The broken `world.yaml` rules, when these are a world document's. + #[must_use] + pub fn world(&self) -> Option<&[world::v0::ValidationError]> { + match self { + Self::World(errors) => Some(errors), + _ => None, + } + } } impl fmt::Display for Violations { @@ -124,6 +139,7 @@ impl fmt::Display for Violations { Self::Robot(errors) => join(errors, formatter), Self::Component(errors) => join(errors, formatter), Self::Simulation(errors) => join(errors, formatter), + Self::World(errors) => join(errors, formatter), } } } diff --git a/phoxal/src/authoring/source/mod.rs b/phoxal/src/authoring/source/mod.rs index 4db757b7..a6d6782f 100644 --- a/phoxal/src/authoring/source/mod.rs +++ b/phoxal/src/authoring/source/mod.rs @@ -31,6 +31,7 @@ mod strict_yaml; pub mod component; pub mod robot; pub mod simulation; +pub mod world; pub use document::{ComposeError, DocumentKind, Origin, SourceError, Violations}; pub use strict_yaml::{ReservedMarker, StrictYamlError}; diff --git a/phoxal/src/authoring/source/robot/v0.rs b/phoxal/src/authoring/source/robot/v0.rs index 36d0233b..2cafb4de 100644 --- a/phoxal/src/authoring/source/robot/v0.rs +++ b/phoxal/src/authoring/source/robot/v0.rs @@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize}; // definition. This document describes the shape they take in authored YAML; it // does not re-export them, so `crate::model::robot` stays their only path. use crate::model::CapabilityRole; -use crate::model::robot::{KinematicConfig, MotionLimits}; +use crate::model::kinematics::{KinematicConfig, MotionLimits}; // The driver block is shared across robot document generations rather than // owned by this one, so it is named here at its established authored path and diff --git a/phoxal/src/authoring/source/world/mod.rs b/phoxal/src/authoring/source/world/mod.rs new file mode 100644 index 00000000..0078d0b8 --- /dev/null +++ b/phoxal/src/authoring/source/world/mod.rs @@ -0,0 +1,175 @@ +//! Versioned authored `world.yaml` documents. + +pub mod v0; + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::authoring::source::document::{Document, DocumentKind, Origin, SourceError}; +use crate::authoring::source::{Violations, strict_yaml}; + +/// A versioned authored world document selected by its schema tag. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(tag = "schema")] +pub enum Manifest { + #[serde(rename = "phoxal/world/v0")] + V0(v0::Manifest), +} + +impl Document for Manifest { + const KIND: DocumentKind = DocumentKind::World; + + fn check(&self) -> Result<(), Violations> { + let Self::V0(body) = self; + body.validate().map_err(Violations::World) + } + + fn precheck(text: &str, origin: &Origin) -> Result<(), SourceError> { + strict_yaml::check(text).map_err(|source| SourceError::StrictYaml { + kind: Self::KIND, + origin: origin.clone(), + source, + }) + } +} + +impl Manifest { + /// Parse and validate one complete world document from text. + pub fn parse(text: &str) -> Result { + Self::read_text(text, Origin::Text) + } + + /// Load one explicit `world.yaml` file. + pub fn load(path: impl AsRef) -> Result { + Self::read_path(path.as_ref()) + } + + /// Write the document into `directory` as `world.yaml`. + pub fn write_to_dir(&self, directory: impl AsRef) -> Result<(), SourceError> { + self.write_dir(directory.as_ref()) + } + + pub(crate) fn normalize(self) -> crate::authoring::normalized::World { + use crate::authoring::normalized::{World, WorldAsset, WorldEntity}; + let Self::V0(body) = self; + World { + id: body.world.id, + time_step_ms: body.world.time_step_ms, + gravity_mps2: body.world.gravity_mps2.map(normalize_float), + assets: body + .assets + .into_iter() + .map(|(name, asset)| { + let geometry = normalize_geometry(asset.geometry); + let collision = asset + .collision + .map(normalize_geometry) + .unwrap_or_else(|| geometry.clone()); + ( + name, + WorldAsset { + geometry, + collision, + }, + ) + }) + .collect(), + spawn_points: body + .world + .spawn_points + .into_iter() + .map(|(name, pose)| (name, normalize_pose(pose))) + .collect(), + entities: body + .world + .entities + .into_iter() + .map(|(name, entity)| { + ( + name, + WorldEntity { + asset: entity.asset, + instances: entity + .instances + .into_iter() + .map(|instance| normalize_pose(instance.pose)) + .collect(), + }, + ) + }) + .collect(), + } + } +} + +fn normalize_float(value: f64) -> f64 { + if value == 0.0 { 0.0 } else { value } +} + +fn normalize_pose(pose: v0::Pose) -> crate::model::structure::Pose { + crate::model::structure::Pose::from_validated_parts( + pose.xyz.map(normalize_float), + pose.rpy.map(normalize_float), + ) +} + +fn normalize_geometry(geometry: v0::Geometry) -> crate::authoring::normalized::WorldGeometry { + use crate::authoring::normalized::WorldGeometry; + use crate::model::geometry::Geometry; + WorldGeometry::Primitive(match geometry { + v0::Geometry::Box { size } => Geometry::Box { + size: size.map(normalize_float), + }, + v0::Geometry::Cylinder { radius, length } => Geometry::Cylinder { radius, length }, + v0::Geometry::Capsule { radius, length } => Geometry::Capsule { radius, length }, + v0::Geometry::Sphere { radius } => Geometry::Sphere { radius }, + v0::Geometry::Mesh { path, scale } => { + return WorldGeometry::Mesh { + path, + scale: scale.map(|scale| scale.map(normalize_float)), + }; + } + }) +} + +#[cfg(test)] +mod tests { + use super::Manifest; + + const WORLD: &str = r#" +schema: phoxal/world/v0 +assets: + floor: + geometry: { kind: box, size: [10.0, 10.0, 0.1] } +world: + id: warehouse + time_step_ms: 12 + gravity_mps2: [0.0, 0.0, -9.81] + spawn_points: + loading-bay: { xyz: [0.0, 0.0, 0.0], rpy: [0.0, 0.0, 0.0] } + entities: + floor: + asset: floor + instances: + - pose: { xyz: [0.0, 0.0, -0.05], rpy: [0.0, 0.0, 0.0] } +"#; + + #[test] + fn world_round_trips_through_its_exact_schema() -> anyhow::Result<()> { + let parsed = Manifest::parse(WORLD)?; + let directory = tempfile::tempdir()?; + parsed.write_to_dir(directory.path())?; + assert_eq!(Manifest::load(directory.path())?, parsed); + Ok(()) + } + + #[test] + fn world_rejects_single_pose_and_unknown_fields() { + let invalid = WORLD.replace( + "instances:\n - pose: { xyz: [0.0, 0.0, -0.05], rpy: [0.0, 0.0, 0.0] }", + "pose: { xyz: [0.0, 0.0, -0.05], rpy: [0.0, 0.0, 0.0] }", + ); + assert!(Manifest::parse(&invalid).is_err()); + } +} diff --git a/phoxal/src/authoring/source/world/v0.rs b/phoxal/src/authoring/source/world/v0.rs new file mode 100644 index 00000000..bd3ed72a --- /dev/null +++ b/phoxal/src/authoring/source/world/v0.rs @@ -0,0 +1,216 @@ +//! Exact `phoxal/world/v0` authored grammar. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::model::identity::is_valid_token; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Manifest { + pub assets: BTreeMap, + pub world: World, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Asset { + pub geometry: Geometry, + pub collision: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Geometry { + Box { + size: [f64; 3], + }, + Cylinder { + radius: f64, + length: f64, + }, + Capsule { + radius: f64, + length: f64, + }, + Sphere { + radius: f64, + }, + Mesh { + path: PathBuf, + scale: Option<[f64; 3]>, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct World { + pub id: String, + pub time_step_ms: u64, + pub gravity_mps2: [f64; 3], + #[serde(default)] + pub spawn_points: BTreeMap, + #[serde(default)] + pub entities: BTreeMap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Pose { + pub xyz: [f64; 3], + pub rpy: [f64; 3], +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EntityDeclaration { + pub asset: String, + #[schemars(length(min = 1))] + pub instances: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EntityInstance { + pub pose: Pose, +} + +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum ValidationError { + #[error("{kind} '{name}' must use the normalized topology-token grammar")] + InvalidName { kind: &'static str, name: String }, + #[error("world.time_step_ms must be positive and exactly convertible to nanoseconds")] + InvalidTimeStep, + #[error("{path} must contain only finite values")] + NonFinite { path: String }, + #[error("{path} dimensions and absolute scale components must be positive")] + InvalidDimensions { path: String }, + #[error("{path} mesh path must be relative and name a .glb file")] + InvalidMeshPath { path: String }, + #[error("world.entities.{declaration}.asset references unknown asset '{asset}'")] + UnknownAsset { declaration: String, asset: String }, + #[error("world.entities.{declaration}.instances must contain at least one pose")] + EmptyInstances { declaration: String }, + #[error("world.entities.{declaration}.instances exceeds the supported u32 index range")] + TooManyInstances { declaration: String }, +} + +impl Manifest { + pub fn validate(&self) -> Result<(), Vec> { + let mut errors = Vec::new(); + validate_name("world id", &self.world.id, &mut errors); + for name in self.assets.keys() { + validate_name("world asset name", name, &mut errors); + } + for name in self.world.spawn_points.keys() { + validate_name("world spawn name", name, &mut errors); + } + for name in self.world.entities.keys() { + validate_name("world entity declaration name", name, &mut errors); + } + if self.world.time_step_ms == 0 || self.world.time_step_ms.checked_mul(1_000_000).is_none() + { + errors.push(ValidationError::InvalidTimeStep); + } + finite("world.gravity_mps2", self.world.gravity_mps2, &mut errors); + for (name, pose) in &self.world.spawn_points { + validate_pose(&format!("world.spawn_points.{name}"), *pose, &mut errors); + } + for (name, asset) in &self.assets { + validate_geometry( + &format!("assets.{name}.geometry"), + &asset.geometry, + &mut errors, + ); + if let Some(collision) = &asset.collision { + validate_geometry(&format!("assets.{name}.collision"), collision, &mut errors); + } + } + for (name, declaration) in &self.world.entities { + if !self.assets.contains_key(&declaration.asset) { + errors.push(ValidationError::UnknownAsset { + declaration: name.clone(), + asset: declaration.asset.clone(), + }); + } + if declaration.instances.is_empty() { + errors.push(ValidationError::EmptyInstances { + declaration: name.clone(), + }); + } + if u32::try_from(declaration.instances.len()).is_err() { + errors.push(ValidationError::TooManyInstances { + declaration: name.clone(), + }); + } + for (index, instance) in declaration.instances.iter().enumerate() { + validate_pose( + &format!("world.entities.{name}.instances[{index}].pose"), + instance.pose, + &mut errors, + ); + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } +} + +fn validate_name(kind: &'static str, name: &str, errors: &mut Vec) { + if !is_valid_token(name) { + errors.push(ValidationError::InvalidName { + kind, + name: name.to_owned(), + }); + } +} + +fn validate_pose(path: &str, pose: Pose, errors: &mut Vec) { + finite(&format!("{path}.xyz"), pose.xyz, errors); + finite(&format!("{path}.rpy"), pose.rpy, errors); +} + +fn finite(path: &str, values: [f64; N], errors: &mut Vec) { + if !values.into_iter().all(f64::is_finite) { + errors.push(ValidationError::NonFinite { + path: path.to_owned(), + }); + } +} + +fn validate_geometry(path: &str, geometry: &Geometry, errors: &mut Vec) { + let dimensions: &[f64] = match geometry { + Geometry::Box { size } => size, + Geometry::Cylinder { radius, length } | Geometry::Capsule { radius, length } => { + &[*radius, *length] + } + Geometry::Sphere { radius } => &[*radius], + Geometry::Mesh { path: mesh, scale } => { + if mesh.is_absolute() + || mesh + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + || mesh.extension().and_then(|extension| extension.to_str()) != Some("glb") + { + errors.push(ValidationError::InvalidMeshPath { + path: path.to_owned(), + }); + } + scale.as_ref().map_or(&[], |values| values.as_slice()) + } + }; + if dimensions.iter().any(|value| !value.is_finite()) { + errors.push(ValidationError::NonFinite { + path: path.to_owned(), + }); + } else if dimensions.iter().any(|value| *value <= 0.0) { + errors.push(ValidationError::InvalidDimensions { + path: path.to_owned(), + }); + } +} diff --git a/phoxal/src/authoring/source_generation_proof.rs b/phoxal/src/authoring/source_generation_proof.rs index f0b2804b..a5640f61 100644 --- a/phoxal/src/authoring/source_generation_proof.rs +++ b/phoxal/src/authoring/source_generation_proof.rs @@ -21,7 +21,7 @@ use serde::Deserialize; use crate::model::CapabilityRole; use crate::model::component::capability::CapabilityKind; -use crate::model::robot::KinematicConfig; +use crate::model::kinematics::KinematicConfig; use crate::authoring::normalized; @@ -124,7 +124,7 @@ impl TestAltRobotDto { id: self.identity, structure: self.frame, kinematic: self.drive, - motion_limits: crate::model::robot::MotionLimits { + motion_limits: crate::model::kinematics::MotionLimits { max_linear_speed_mps: self.limits.linear_mps, max_angular_speed_radps: self.limits.angular_radps, }, diff --git a/phoxal/src/authoring/world.rs b/phoxal/src/authoring/world.rs new file mode 100644 index 00000000..26ba91f6 --- /dev/null +++ b/phoxal/src/authoring/world.rs @@ -0,0 +1,675 @@ +//! Compilation of one explicit `world.yaml` into a closed [`WorldBundle`]. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Component, Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +use crate::authoring::normalized::{World as NormalizedWorld, WorldGeometry}; +use crate::bundle::{WorldBundle, WorldBundleError}; +use crate::model::asset::AssetId; +use crate::model::geometry::Geometry; +use crate::model::identity::{EntityDeclarationId, SpawnId, WorldAssetName, WorldId}; +use crate::model::world::{compiled_entity, compiled_world}; + +/// Compile one explicit `world.yaml` path. +/// +/// The file's parent is the only source root. +/// Meshes are read once during compilation, validated as closed GLB files, deduplicated by bytes, and replaced by canonical [`AssetId`] values. +/// +/// # Errors +/// +/// Returns [`WorldCompileError`] when the path, source document, mesh closure, or canonical bundle is invalid. +pub fn compile(path: impl AsRef) -> Result { + let supplied = path.as_ref(); + if supplied.file_name().and_then(|name| name.to_str()) != Some("world.yaml") { + return Err(WorldCompileError::ExplicitWorldPath(supplied.to_path_buf())); + } + let metadata = std::fs::symlink_metadata(supplied).map_err(|source| WorldCompileError::Io { + path: supplied.to_path_buf(), + source, + })?; + if metadata.file_type().is_symlink() { + return Err(WorldCompileError::SymlinkWorld(supplied.to_path_buf())); + } + let source = supplied + .canonicalize() + .map_err(|source| WorldCompileError::Io { + path: supplied.to_path_buf(), + source, + })?; + let root = source + .parent() + .ok_or_else(|| WorldCompileError::ExplicitWorldPath(source.clone()))? + .to_path_buf(); + let authored = crate::authoring::source::world::Manifest::load(&source) + .map_err(WorldCompileError::Source)? + .normalize(); + compile_manifest(&root, authored) +} + +fn compile_manifest( + root: &Path, + authored: NormalizedWorld, +) -> Result { + let referenced = authored + .entities + .values() + .map(|entity| entity.asset.as_str()) + .collect::>(); + let mut bytes_by_id = BTreeMap::new(); + let mut assets = BTreeMap::new(); + for name in referenced { + let authored_asset = authored + .assets + .get(name) + .ok_or_else(|| WorldCompileError::UnknownAsset(name.to_owned()))?; + let asset_name = WorldAssetName::new(name)?; + let geometry = compile_geometry(root, &authored_asset.geometry, &mut bytes_by_id)?; + let collision = compile_geometry(root, &authored_asset.collision, &mut bytes_by_id)?; + assets.insert(asset_name, (geometry, collision)); + } + + let mut spawn_points = BTreeMap::new(); + for (name, pose) in authored.spawn_points { + spawn_points.insert(SpawnId::new(name)?, pose); + } + + let mut entities = Vec::new(); + for (name, declaration) in authored.entities { + let declaration_id = EntityDeclarationId::new(name)?; + let asset_name = WorldAssetName::new(declaration.asset)?; + let (geometry, collision) = assets + .get(&asset_name) + .ok_or_else(|| WorldCompileError::UnknownAsset(asset_name.to_string()))?; + for (index, instance) in declaration.instances.into_iter().enumerate() { + entities.push(compiled_entity( + declaration_id.clone(), + u32::try_from(index).map_err(|_| WorldCompileError::TooManyInstances)?, + instance, + geometry.clone(), + collision.clone(), + )); + } + } + + let time_step_ns = authored + .time_step_ms + .checked_mul(1_000_000) + .ok_or(WorldCompileError::InvalidTimeStep)?; + let world = compiled_world( + WorldId::new(authored.id)?, + time_step_ns, + authored.gravity_mps2, + spawn_points, + entities, + ); + WorldBundle::from_compiler(world, bytes_by_id).map_err(WorldCompileError::Bundle) +} + +fn compile_geometry( + root: &Path, + authored: &WorldGeometry, + assets: &mut BTreeMap>, +) -> Result { + Ok(match authored { + WorldGeometry::Primitive(geometry) => geometry.clone(), + WorldGeometry::Mesh { path, scale } => { + let source = fenced_mesh(root, path)?; + let bytes = std::fs::read(&source).map_err(|source_error| WorldCompileError::Io { + path: source.clone(), + source: source_error, + })?; + validate_glb(&source, &bytes)?; + let digest = Sha256::digest(&bytes); + let id = AssetId::new(format!("sha256/{digest:x}.glb"))?; + if let Some(existing) = assets.get(&id) { + if existing != &bytes { + return Err(WorldCompileError::DigestCollision(id)); + } + } else { + assets.insert(id.clone(), bytes); + } + Geometry::Mesh { + asset: id, + scale: *scale, + } + } + }) +} + +fn fenced_mesh(root: &Path, relative: &Path) -> Result { + if relative.is_absolute() + || relative + .components() + .any(|component| !matches!(component, Component::Normal(_) | Component::CurDir)) + { + return Err(WorldCompileError::EscapedMesh(relative.to_path_buf())); + } + let mut cursor = root.to_path_buf(); + for component in relative.components() { + if let Component::Normal(segment) = component { + cursor.push(segment); + let metadata = + std::fs::symlink_metadata(&cursor).map_err(|source| WorldCompileError::Io { + path: cursor.clone(), + source, + })?; + if metadata.file_type().is_symlink() { + return Err(WorldCompileError::SymlinkMesh(cursor)); + } + } + } + let canonical = cursor + .canonicalize() + .map_err(|source| WorldCompileError::Io { + path: cursor.clone(), + source, + })?; + if !canonical.starts_with(root) { + return Err(WorldCompileError::EscapedMesh(relative.to_path_buf())); + } + Ok(canonical) +} + +fn validate_glb(path: &Path, bytes: &[u8]) -> Result<(), WorldCompileError> { + crate::bundle::glb::validate_closed(bytes).map_err(|source| WorldCompileError::Glb { + path: path.to_path_buf(), + detail: source.to_string(), + }) +} + +/// A world source that cannot become one closed deterministic bundle. +#[derive(Debug, thiserror::Error)] +pub enum WorldCompileError { + #[error("simulation requires one explicit path named world.yaml, got {}", .0.display())] + ExplicitWorldPath(PathBuf), + #[error("failed to read world source {}: {source}", path.display())] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to read world.yaml: {0}")] + Source(#[source] crate::authoring::source::SourceError), + #[error("world references unknown asset '{0}'")] + UnknownAsset(String), + #[error("world contains too many instances in one declaration")] + TooManyInstances, + #[error("world time step cannot be represented in nanoseconds")] + InvalidTimeStep, + #[error("mesh path escapes the world source root: {}", .0.display())] + EscapedMesh(PathBuf), + #[error("world document is a forbidden symlink: {}", .0.display())] + SymlinkWorld(PathBuf), + #[error("mesh path contains a forbidden symlink: {}", .0.display())] + SymlinkMesh(PathBuf), + #[error("GLB {} is not self-contained and supported: {detail}", path.display())] + Glb { path: PathBuf, detail: String }, + #[error("two different mesh byte sequences produced the same asset id '{0:?}'")] + DigestCollision(AssetId), + #[error("invalid canonical identity: {0}")] + Model(#[from] crate::model::ModelError), + #[error("failed to build canonical world bundle: {0}")] + Bundle(#[source] WorldBundleError), +} + +#[cfg(test)] +mod tests { + use super::*; + + fn glb(json: &str) -> Vec { + glb_with_bin(json, None) + } + + fn glb_with_bin(json: &str, binary: Option<&[u8]>) -> Vec { + let mut json = json.as_bytes().to_vec(); + while !json.len().is_multiple_of(4) { + json.push(b' '); + } + let mut binary = binary.map(<[u8]>::to_vec); + if let Some(binary) = &mut binary { + while !binary.len().is_multiple_of(4) { + binary.push(0); + } + } + let mut total = 12_u32 + .checked_add(8) + .and_then(|length| length.checked_add(u32::try_from(json.len()).unwrap())) + .unwrap(); + if let Some(binary) = &binary { + total = total + .checked_add(8) + .and_then(|length| length.checked_add(u32::try_from(binary.len()).unwrap())) + .unwrap(); + } + let mut bytes = Vec::with_capacity(total as usize); + bytes.extend_from_slice(b"glTF"); + bytes.extend_from_slice(&2_u32.to_le_bytes()); + bytes.extend_from_slice(&total.to_le_bytes()); + bytes.extend_from_slice(&(json.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&0x4E4F_534A_u32.to_le_bytes()); + bytes.extend_from_slice(&json); + if let Some(binary) = binary { + bytes.extend_from_slice(&(binary.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&0x004E_4942_u32.to_le_bytes()); + bytes.extend_from_slice(&binary); + } + bytes + } + + fn write_world(root: &Path, body: &str) -> PathBuf { + let path = root.join("world.yaml"); + std::fs::write(&path, body).unwrap(); + path + } + + fn rewrite_bundle_asset(root: &Path, old: &AssetId, bytes: &[u8]) { + let replacement = format!("sha256/{:x}.glb", Sha256::digest(bytes)); + let old_path = root.join("assets").join(old.as_str()); + let replacement_path = root.join("assets").join(&replacement); + std::fs::rename(&old_path, &replacement_path).unwrap(); + std::fs::write(&replacement_path, bytes).unwrap(); + + fn replace(value: &mut serde_json::Value, old: &str, replacement: &str) -> usize { + match value { + serde_json::Value::String(value) if value == old => { + *value = replacement.to_owned(); + 1 + } + serde_json::Value::Array(values) => values + .iter_mut() + .map(|value| replace(value, old, replacement)) + .sum(), + serde_json::Value::Object(values) => values + .values_mut() + .map(|value| replace(value, old, replacement)) + .sum(), + _ => 0, + } + } + + let document_path = root.join("world.json"); + let mut document: serde_json::Value = + serde_json::from_slice(&std::fs::read(&document_path).unwrap()).unwrap(); + assert!(replace(&mut document, old.as_str(), &replacement) > 0); + std::fs::write(document_path, serde_json::to_vec_pretty(&document).unwrap()).unwrap(); + } + + fn primitive(collision: &str) -> String { + format!( + r#"schema: phoxal/world/v0 +assets: + floor: + geometry: {{ kind: box, size: [10.0, 10.0, 0.1] }} +{collision} +world: + id: warehouse + time_step_ms: 12 + gravity_mps2: [-0.0, 0.0, -9.81] + spawn_points: + bay: {{ xyz: [0.0, 0.0, 0.0], rpy: [0.0, 0.0, 0.0] }} + entities: + floor: + asset: floor + instances: + - pose: {{ xyz: [0.0, 0.0, -0.05], rpy: [0.0, 0.0, 0.0] }} +"# + ) + } + + fn mesh_world(visual: &str, collision: &str) -> String { + format!( + r#"schema: phoxal/world/v0 +assets: + tree: + geometry: {{ kind: mesh, path: {visual}, scale: [1.0, 2.0, 3.0] }} +{collision} +world: + id: woodland + time_step_ms: 24 + gravity_mps2: [0.0, 0.0, -9.81] + entities: + forest: + asset: tree + instances: + - pose: {{ xyz: [1.0, 2.0, 0.0], rpy: [0.0, 0.0, 0.0] }} + - pose: {{ xyz: [4.0, 8.0, 0.0], rpy: [0.0, 0.0, 1.2] }} +"# + ) + } + + #[test] + fn primitive_world_compiles_deterministically_and_expands_collision_default() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let implicit = compile(write_world(first.path(), &primitive(""))).unwrap(); + let explicit = compile(write_world( + second.path(), + &primitive(" collision: { kind: box, size: [10.0, 10.0, 0.1] }"), + )) + .unwrap(); + assert_eq!(implicit.digest(), explicit.digest()); + assert_eq!(implicit.assets().len(), 0); + assert_eq!( + implicit.digest().to_string(), + "965dfa661626eb06304f638aec7b915b079ba3838558a25d120e36dbd0039339" + ); + let entity = implicit.world().entities().next().unwrap(); + assert_eq!(entity.geometry(), entity.collision()); + assert_eq!(implicit.world().time_step_ns(), 12_000_000); + assert_eq!( + implicit.world().gravity_mps2()[0].to_bits(), + 0.0_f64.to_bits() + ); + } + + #[test] + fn bundle_round_trip_preserves_digest() { + let source = tempfile::tempdir().unwrap(); + let bundle = compile(write_world(source.path(), &primitive(""))).unwrap(); + let target_parent = tempfile::tempdir().unwrap(); + let target = target_parent.path().join("compiled"); + bundle.write(&target).unwrap(); + let reopened = WorldBundle::open(&target).unwrap(); + assert_eq!(reopened.digest(), bundle.digest()); + assert_eq!(reopened.canonical_archive(), bundle.canonical_archive()); + } + + #[test] + fn changed_world_bytes_change_the_digest() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let one = compile(write_world(first.path(), &primitive(""))).unwrap(); + let two = compile(write_world( + second.path(), + &primitive("").replace("time_step_ms: 12", "time_step_ms: 24"), + )) + .unwrap(); + assert_ne!(one.digest(), two.digest()); + } + + #[test] + fn self_contained_glb_paths_become_deduplicated_asset_ids() { + let source = tempfile::tempdir().unwrap(); + let assets = source.path().join("assets"); + std::fs::create_dir(&assets).unwrap(); + let mesh = glb(r#"{"asset":{"version":"2.0"}}"#); + std::fs::write(assets.join("tree.glb"), &mesh).unwrap(); + std::fs::write(assets.join("tree-collision.glb"), &mesh).unwrap(); + let authored = mesh_world( + "assets/tree.glb", + " collision: { kind: mesh, path: assets/tree-collision.glb }", + ); + + let bundle = compile(write_world(source.path(), &authored)).unwrap(); + + assert_eq!(bundle.assets().len(), 1, "equal bytes are stored once"); + let (asset, stored) = bundle.assets().next().unwrap(); + assert_eq!(stored, mesh); + assert_eq!( + asset.as_str(), + format!("sha256/{:x}.glb", Sha256::digest(&mesh)) + ); + let entities = bundle.world().entities().collect::>(); + assert_eq!(entities.len(), 2); + assert_eq!(entities[0].declaration().as_str(), "forest"); + assert_eq!(entities[0].instance(), 0); + assert_eq!(entities[1].instance(), 1); + assert_eq!(entities[0].geometry().asset_id(), Some(asset)); + assert_eq!(entities[0].collision().asset_id(), Some(asset)); + } + + #[test] + fn committed_glb_acceptance_world_covers_implicit_and_overridden_collision() { + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../fixture/world.yaml"); + let bundle = compile(source).expect("committed GLB world compiles"); + assert_eq!( + bundle.assets().len(), + 1, + "the shared GLB is bundled exactly once" + ); + let entities = bundle.world().entities().collect::>(); + assert_eq!(entities.len(), 3); + + let implicit = entities + .iter() + .find(|entity| entity.declaration() == "implicit-mesh") + .expect("implicit collision entity"); + assert_eq!(implicit.geometry(), implicit.collision()); + assert!(implicit.geometry().asset_id().is_some()); + + let overridden = entities + .iter() + .find(|entity| entity.declaration() == "detailed-visual") + .expect("explicit collision entity"); + assert!(overridden.geometry().asset_id().is_some()); + assert!(matches!( + overridden.collision(), + crate::model::geometry::Geometry::Box { .. } + )); + } + + #[test] + fn external_glb_resources_are_rejected() { + let source = tempfile::tempdir().unwrap(); + let assets = source.path().join("assets"); + std::fs::create_dir(&assets).unwrap(); + std::fs::write( + assets.join("tree.glb"), + glb(r#"{"asset":{"version":"2.0"},"buffers":[{"uri":"tree.bin","byteLength":4}]}"#), + ) + .unwrap(); + let error = compile(write_world( + source.path(), + &mesh_world("assets/tree.glb", ""), + )) + .unwrap_err(); + assert!(matches!(error, WorldCompileError::Glb { .. })); + assert!(error.to_string().contains("external URI 'tree.bin'")); + } + + #[test] + fn embedded_glb_buffer_requires_one_covering_binary_chunk() { + let invalid = [ + glb(r#"{"asset":{"version":"2.0"},"buffers":[{"byteLength":4}]}"#), + glb_with_bin( + r#"{"asset":{"version":"2.0"},"buffers":[{"byteLength":8}]}"#, + Some(&[1, 2, 3, 4]), + ), + glb_with_bin(r#"{"asset":{"version":"2.0"}}"#, Some(&[1, 2, 3, 4])), + ]; + for mesh in invalid { + let source = tempfile::tempdir().unwrap(); + let assets = source.path().join("assets"); + std::fs::create_dir(&assets).unwrap(); + std::fs::write(assets.join("tree.glb"), mesh).unwrap(); + + let error = compile(write_world( + source.path(), + &mesh_world("assets/tree.glb", ""), + )) + .unwrap_err(); + assert!(matches!(error, WorldCompileError::Glb { .. })); + } + + let source = tempfile::tempdir().unwrap(); + let assets = source.path().join("assets"); + std::fs::create_dir(&assets).unwrap(); + std::fs::write( + assets.join("tree.glb"), + glb_with_bin( + r#"{"asset":{"version":"2.0"},"buffers":[{"byteLength":3}]}"#, + Some(&[1, 2, 3]), + ), + ) + .unwrap(); + compile(write_world( + source.path(), + &mesh_world("assets/tree.glb", ""), + )) + .unwrap(); + } + + #[test] + fn malformed_glb_chunks_are_rejected_during_world_compilation() { + let source = tempfile::tempdir().unwrap(); + let assets = source.path().join("assets"); + std::fs::create_dir(&assets).unwrap(); + let mut mesh = glb(r#"{"asset":{"version":"2.0"}}"#); + mesh.extend_from_slice(&[4, 0, 0, 0]); + let declared = u32::try_from(mesh.len()).unwrap().to_le_bytes(); + mesh[8..12].copy_from_slice(&declared); + std::fs::write(assets.join("tree.glb"), mesh).unwrap(); + + let error = compile(write_world( + source.path(), + &mesh_world("assets/tree.glb", ""), + )) + .unwrap_err(); + assert!(matches!(error, WorldCompileError::Glb { .. })); + assert!(error.to_string().contains("truncated GLB chunk header")); + } + + #[test] + fn reopened_bundle_rejects_asset_bytes_that_do_not_match_their_id() { + let source = tempfile::tempdir().unwrap(); + let assets = source.path().join("assets"); + std::fs::create_dir(&assets).unwrap(); + std::fs::write( + assets.join("tree.glb"), + glb(r#"{"asset":{"version":"2.0"}}"#), + ) + .unwrap(); + let bundle = compile(write_world( + source.path(), + &mesh_world("assets/tree.glb", ""), + )) + .unwrap(); + let output_parent = tempfile::tempdir().unwrap(); + let output = output_parent.path().join("bundle"); + bundle.write(&output).unwrap(); + let asset = bundle.assets().next().unwrap().0; + std::fs::write(output.join("assets").join(asset.as_str()), b"corrupt").unwrap(); + + assert!(matches!( + WorldBundle::open(&output), + Err(WorldBundleError::AssetDigestMismatch { .. }) + )); + } + + #[test] + fn reopened_bundle_revalidates_closed_glb_bytes_after_digest_consistency() { + for invalid in [ + b"not a glb".to_vec(), + glb(r#"{"asset":{"version":"2.0"},"images":[{"uri":"texture.png"}]}"#), + glb(r#"{"asset":{"version":"2.0"},"buffers":[{"byteLength":4}]}"#), + glb_with_bin( + r#"{"asset":{"version":"2.0"},"buffers":[{"byteLength":8}]}"#, + Some(&[1, 2, 3, 4]), + ), + glb_with_bin(r#"{"asset":{"version":"2.0"}}"#, Some(&[1, 2, 3, 4])), + ] { + let source = tempfile::tempdir().unwrap(); + let assets = source.path().join("assets"); + std::fs::create_dir(&assets).unwrap(); + std::fs::write( + assets.join("tree.glb"), + glb(r#"{"asset":{"version":"2.0"}}"#), + ) + .unwrap(); + let bundle = compile(write_world( + source.path(), + &mesh_world("assets/tree.glb", ""), + )) + .unwrap(); + let old = bundle.assets().next().unwrap().0.clone(); + let output_parent = tempfile::tempdir().unwrap(); + let output = output_parent.path().join("bundle"); + bundle.write(&output).unwrap(); + rewrite_bundle_asset(&output, &old, &invalid); + + let error = WorldBundle::open(&output).unwrap_err(); + assert!(matches!(error, WorldBundleError::InvalidAsset { .. })); + } + } + + #[test] + fn reopened_bundle_rejects_noncanonical_root_entries_and_negative_zero() { + let source = tempfile::tempdir().unwrap(); + let bundle = compile(write_world(source.path(), &primitive(""))).unwrap(); + + let extra_parent = tempfile::tempdir().unwrap(); + let extra = extra_parent.path().join("bundle"); + bundle.write(&extra).unwrap(); + std::fs::write(extra.join("unexpected"), b"not part of the bundle").unwrap(); + assert!(matches!( + WorldBundle::open(&extra), + Err(WorldBundleError::Invalid(_)) + )); + + let negative_parent = tempfile::tempdir().unwrap(); + let negative = negative_parent.path().join("bundle"); + bundle.write(&negative).unwrap(); + let document_path = negative.join("world.json"); + let document = std::fs::read_to_string(&document_path).unwrap(); + let document = document.replacen("0.0", "-0.0", 1); + std::fs::write(&document_path, document).unwrap(); + assert!(matches!( + WorldBundle::open(&negative), + Err(WorldBundleError::Invalid(_)) + )); + } + + #[test] + fn compilation_requires_an_explicit_world_yaml_path() { + let source = tempfile::tempdir().unwrap(); + let path = source.path().join("scene.yaml"); + std::fs::write(&path, primitive("")).unwrap(); + + assert!(matches!( + compile(&path), + Err(WorldCompileError::ExplicitWorldPath(rejected)) if rejected == path + )); + } + + #[cfg(unix)] + #[test] + fn compilation_refuses_a_symlinked_world_document() { + use std::os::unix::fs::symlink; + + let source = tempfile::tempdir().unwrap(); + let target = source.path().join("authored.yaml"); + std::fs::write(&target, primitive("")).unwrap(); + let path = source.path().join("world.yaml"); + symlink(&target, &path).unwrap(); + + assert!(matches!( + compile(&path), + Err(WorldCompileError::SymlinkWorld(rejected)) if rejected == path + )); + } + + #[cfg(unix)] + #[test] + fn compilation_refuses_mesh_symlinks_even_inside_the_source_root() { + use std::os::unix::fs::symlink; + + let source = tempfile::tempdir().unwrap(); + let assets = source.path().join("assets"); + std::fs::create_dir(&assets).unwrap(); + std::fs::write( + assets.join("real.glb"), + glb(r#"{"asset":{"version":"2.0"}}"#), + ) + .unwrap(); + symlink("real.glb", assets.join("linked.glb")).unwrap(); + + let error = compile(write_world( + source.path(), + &mesh_world("assets/linked.glb", ""), + )) + .unwrap_err(); + assert!(matches!(error, WorldCompileError::SymlinkMesh(_))); + } +} diff --git a/phoxal/src/bundle/asset.rs b/phoxal/src/bundle/asset.rs index 20236430..1a43acf6 100644 --- a/phoxal/src/bundle/asset.rs +++ b/phoxal/src/bundle/asset.rs @@ -1,8 +1,17 @@ //! Participant-facing asset reads. +use std::collections::HashMap; +use std::fs::File; use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use crate::bus::{BusHandle, DEFAULT_QUERY_TIMEOUT, Querier}; +use crate::identity::ExecutionId; use crate::model::AssetId; +use crate::supervisor::api as supervisor; +use tokio::io::AsyncWriteExt; +use tokio::sync::{Mutex as AsyncMutex, OnceCell}; use crate::bundle::{ASSETS_DIR, BundleError, BundlePath, BundleRoot, open_bundle_file}; @@ -12,40 +21,606 @@ use crate::bundle::{ASSETS_DIR, BundleError, BundlePath, BundleRoot, open_bundle /// validated relative forward-slash path with no `.` or `..` segment, and /// The bundle path type validates the joined path again, so a read cannot name /// anything outside `assets/`. That pair of checks is the whole fence. -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct ParticipantAssets { - root: BundleRoot, + source: AssetSource, +} +impl std::fmt::Debug for ParticipantAssets { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.source { + AssetSource::Local { root } => formatter + .debug_struct("ParticipantAssets") + .field("source", &"local") + .field("root", &root.path()) + .finish(), + AssetSource::Remote(_) => formatter + .debug_struct("ParticipantAssets") + .field("source", &"supervisor") + .finish(), + } + } +} + +#[derive(Clone)] +enum AssetSource { + Local { root: BundleRoot }, + Remote(Arc), +} + +struct RemoteAssets { + reader: Querier, + execution: ExecutionId, + cache: OnceCell, +} + +/// One private cache retained by the participant runner. Its entries are +/// scoped to their execution before the asset path, so concurrent in-process +/// tests cannot reuse another execution's asset. +struct AssetCache { + root: tempfile::TempDir, + gates: Mutex>>>, +} + +impl AssetCache { + fn new(root: tempfile::TempDir) -> Self { + Self { + root, + gates: Mutex::new(HashMap::new()), + } + } + + fn target(&self, execution: ExecutionId, path: &BundlePath) -> PathBuf { + path.filesystem_path(&self.root.path().join(execution.to_string())) + } + + fn gate(&self, path: PathBuf) -> Arc> { + self.gates + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .entry(path) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))) + .clone() + } } impl ParticipantAssets { pub(crate) const fn new(root: BundleRoot) -> Self { - Self { root } + Self { + source: AssetSource::Local { root }, + } } pub(crate) fn relocate(&mut self, path: std::path::PathBuf) { - self.root.relocate(path); + if let AssetSource::Local { root } = &mut self.source { + root.relocate(path); + } + } + + /// Bind supervisor-backed asset materialization to this process. + /// + /// Each asset is fetched lazily into one private process-local cache. The + /// completed file is atomically published and remains available for the + /// lifetime of this participant process. + pub(crate) fn from_supervisor(bus: BusHandle) -> Result { + let reader = Querier::new( + bus.clone(), + &supervisor::topics().bundle().get().client(), + DEFAULT_QUERY_TIMEOUT, + ) + .map_err(|error| BundleError::Remote { + detail: error.to_string(), + })?; + Ok(Self { + source: AssetSource::Remote(Arc::new(RemoteAssets { + reader, + execution: bus.execution(), + cache: OnceCell::const_new(), + })), + }) } - /// Read one asset out of the bundle. + /// Materialize one asset as a stable local file path. + /// + /// Local bundles return their validated file directly. Supervisor-backed + /// assets download lazily into this process-local cache and return the same + /// path for the participant process lifetime. /// /// # Errors /// - /// Returns a bundle error when the bundle carries no such asset, or when - /// it carries one that cannot be read. - pub fn read(&self, id: &AssetId) -> Result, BundleError> { + /// Returns `BundleError` when the asset is missing, cannot be read from a + /// local bundle, or cannot be fetched and completely materialized from the + /// supervisor. + pub async fn materialize(&self, id: &AssetId) -> Result { + match &self.source { + AssetSource::Local { root } => { + let (path, _) = Self::open_local_async(root.clone(), id.clone()).await?; + Ok(path) + } + AssetSource::Remote(remote) => remote.materialize(id).await, + } + } + + /// Open one materialized asset for native or streaming consumers. + /// + /// # Errors + /// + /// Returns `BundleError` when the asset cannot be materialized or its + /// local file cannot be opened. + pub async fn open(&self, id: &AssetId) -> Result { + let (_, file) = match &self.source { + AssetSource::Local { root } => Self::open_local_async(root.clone(), id.clone()).await?, + AssetSource::Remote(remote) => remote.open(id).await?, + }; + Ok(file) + } + + /// Read one materialized asset into memory. + /// + /// # Errors + /// + /// Returns `BundleError` when the asset is missing, cannot be + /// materialized, or its local file cannot be read. + pub async fn read(&self, id: &AssetId) -> Result, BundleError> { + let (path, mut file) = match &self.source { + AssetSource::Local { root } => Self::open_local_async(root.clone(), id.clone()).await?, + AssetSource::Remote(remote) => remote.open(id).await?, + }; + tokio::task::spawn_blocking(move || { + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .map_err(|source| BundleError::ReadFile { path, source })?; + Ok(bytes) + }) + .await + .map_err(|error| BundleError::AssetWorker { + detail: error.to_string(), + })? + } + + pub(crate) fn read_local(&self, id: &AssetId) -> Result, BundleError> { + match &self.source { + AssetSource::Local { root } => Self::read_from_local(root, id), + AssetSource::Remote(_) => Err(BundleError::Remote { + detail: "a supervisor-backed asset must be read asynchronously".to_owned(), + }), + } + } + + fn read_from_local(root: &BundleRoot, id: &AssetId) -> Result, BundleError> { let path = Self::path(id)?; - let mut file = open_bundle_file(&self.root, &path)?; + let mut file = open_bundle_file(root, &path)?; let mut bytes = Vec::new(); file.read_to_end(&mut bytes) .map_err(|source| BundleError::ReadFile { - path: path.filesystem_path(self.root.path()), + path: path.filesystem_path(root.path()), source, })?; Ok(bytes) } + async fn open_local_async( + root: BundleRoot, + id: AssetId, + ) -> Result<(PathBuf, File), BundleError> { + tokio::task::spawn_blocking(move || { + let path = Self::path(&id)?; + let filesystem_path = path.filesystem_path(root.path()); + let file = open_bundle_file(&root, &path)?; + Ok((filesystem_path, file)) + }) + .await + .map_err(|error| BundleError::AssetWorker { + detail: error.to_string(), + })? + } + /// Where one logical asset sits in the bundle. pub(crate) fn path(id: &AssetId) -> Result { Ok(BundlePath::new(format!("{ASSETS_DIR}/{}", id.as_str()))?) } } + +impl RemoteAssets { + async fn materialize(&self, id: &AssetId) -> Result { + let path = ParticipantAssets::path(id)?; + let cache = self.cache().await?; + let target = cache.target(self.execution, &path); + if cached_file(&target).await? { + return Ok(target); + } + + let gate = cache.gate(target.clone()); + let _materialization = gate.lock().await; + if cached_file(&target).await? { + return Ok(target); + } + + self.download_to(&path, &target).await?; + Ok(target) + } + + /// Lazily create the runner-owned cache. Every clone of this remote asset + /// resolver shares the same `RemoteAssets`, so the root survives setup and + /// shutdown but is removed when the runner releases its final clone. + async fn cache(&self) -> Result<&AssetCache, BundleError> { + self.cache + .get_or_try_init(|| async { + let root = tokio::task::spawn_blocking(tempfile::tempdir) + .await + .map_err(|error| BundleError::AssetWorker { + detail: error.to_string(), + })? + .map_err(|source| BundleError::Cache { + path: std::env::temp_dir(), + source, + })?; + Ok(AssetCache::new(root)) + }) + .await + } + + async fn open(&self, id: &AssetId) -> Result<(PathBuf, File), BundleError> { + let path = self.materialize(id).await?; + let opened_path = path.clone(); + let file = tokio::task::spawn_blocking(move || { + File::open(&opened_path).map_err(|source| BundleError::ReadFile { + path: opened_path, + source, + }) + }) + .await + .map_err(|error| BundleError::AssetWorker { + detail: error.to_string(), + })??; + Ok((path, file)) + } + + /// Fetch bounded supervisor ranges into a temporary sibling and atomically + /// publish the completed cache file. + async fn download_to(&self, path: &BundlePath, target: &Path) -> Result<(), BundleError> { + let parent = target.parent().ok_or_else(|| BundleError::Cache { + path: target.to_path_buf(), + source: std::io::Error::other("an asset cache target has no parent directory"), + })?; + tokio::fs::create_dir_all(parent) + .await + .map_err(|source| BundleError::Cache { + path: parent.to_path_buf(), + source, + })?; + let temporary_parent = parent.to_path_buf(); + let temporary = tokio::task::spawn_blocking(move || { + tempfile::NamedTempFile::new_in(temporary_parent).map(|file| file.into_temp_path()) + }) + .await + .map_err(|error| BundleError::AssetWorker { + detail: error.to_string(), + })? + .map_err(|source| BundleError::Cache { + path: parent.to_path_buf(), + source, + })?; + let mut file = tokio::fs::OpenOptions::new() + .write(true) + .open(&temporary) + .await + .map_err(|source| BundleError::Cache { + path: temporary.to_path_buf(), + source, + })?; + let mut offset = 0_u64; + loop { + let response = self + .reader + .query(supervisor::bundle::GetRequest { + path: path.clone(), + offset, + }) + .await + .map_err(|error| BundleError::Remote { + detail: error.to_string(), + })?; + match response { + supervisor::bundle::GetResponse::Chunk { bytes: chunk, eof } => { + if !eof && chunk.is_empty() { + return Err(BundleError::Remote { + detail: format!( + "supervisor returned a non-final empty chunk for {} at offset {offset}", + path + ), + }); + } + offset = offset.checked_add(chunk.len() as u64).ok_or_else(|| { + BundleError::Remote { + detail: format!("asset offset overflow while reading {path}"), + } + })?; + file.write_all(&chunk) + .await + .map_err(|source| BundleError::Cache { + path: temporary.to_path_buf(), + source, + })?; + if eof { + break; + } + } + supervisor::bundle::GetResponse::Missing => { + return Err(BundleError::MissingFile { + path: PathBuf::from(path.as_str()), + }); + } + supervisor::bundle::GetResponse::InvalidPath => { + return Err(BundleError::Remote { + detail: format!("supervisor rejected invalid asset path {path}"), + }); + } + supervisor::bundle::GetResponse::Refused => { + return Err(BundleError::Remote { + detail: format!("supervisor refused asset path {path}"), + }); + } + } + } + file.sync_all().await.map_err(|source| BundleError::Cache { + path: temporary.to_path_buf(), + source, + })?; + drop(file); + let published_target = target.to_path_buf(); + tokio::task::spawn_blocking(move || { + temporary + .persist(&published_target) + .map_err(|error| error.error) + }) + .await + .map_err(|error| BundleError::AssetWorker { + detail: error.to_string(), + })? + .map_err(|source| BundleError::Cache { + path: target.to_path_buf(), + source, + })?; + Ok(()) + } +} + +async fn cached_file(path: &Path) -> Result { + match tokio::fs::symlink_metadata(path).await { + Ok(metadata) if metadata.file_type().is_file() => Ok(true), + Ok(_) => Err(BundleError::Cache { + path: path.to_path_buf(), + source: std::io::Error::other("asset cache path is not a regular file"), + }), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(source) => Err(BundleError::Cache { + path: path.to_path_buf(), + source, + }), + } +} + +#[cfg(test)] +mod tests { + use std::io::Read; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use crate::bus::{BusConfig, BusOwner, Codec, MessagePack, SourceLabel}; + use crate::identity::ExecutionId; + + use super::*; + + /// Concurrent first use atomically materializes every supervisor-sized + /// range once; later reads and native opens reuse that stable local file. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn remote_assets_materialize_once_for_concurrent_reads_and_opens() { + let (owner, bus) = BusOwner::open(BusConfig::for_external( + ExecutionId::mint(), + Some(SourceLabel::new("asset-test").expect("a valid label")), + Vec::new(), + )) + .await + .expect("the in-process bus opens"); + let server = bus + .declare_server(supervisor::topics().bundle().get().owner().key()) + .await + .expect("the bundle server declares"); + let requests = Arc::new(AtomicUsize::new(0)); + let server_bus = bus.clone(); + let server_requests = Arc::clone(&requests); + let server_task = tokio::spawn(async move { + for (offset, bytes, eof) in + [(0, b"abc".as_slice(), false), (3, b"def".as_slice(), true)] + { + let incoming = server.recv().await.expect("the client queries the server"); + let request = MessagePack::decode::( + &incoming.request_bytes().expect("the request has bytes"), + ) + .expect("the request decodes"); + assert_eq!(request.path.as_str(), "assets/mesh.bin"); + assert_eq!(request.offset, offset); + server_requests.fetch_add(1, Ordering::Relaxed); + incoming + .reply( + &server_bus, + MessagePack::encode(&supervisor::bundle::GetResponse::Chunk { + bytes: bytes.to_vec(), + eof, + }) + .expect("the response encodes"), + ) + .await + .expect("the response reaches the client"); + } + }); + + let assets = ParticipantAssets::from_supervisor(bus.clone()) + .expect("the participant reader binds to the supervisor"); + let cloned_assets = assets.clone(); + let runner_assets = assets.clone(); + let id = AssetId::new("mesh.bin").expect("a valid asset id"); + let (first_path, second_path) = + tokio::join!(assets.materialize(&id), cloned_assets.materialize(&id),); + let materialized = first_path.expect("the first materialization succeeds"); + assert_eq!( + materialized, + second_path.expect("the concurrent materialization reuses the same file") + ); + assert_eq!(requests.load(Ordering::Relaxed), 2); + assert_eq!( + std::fs::read(&materialized).expect("the cached file reads"), + b"abcdef" + ); + let (first_read, second_read) = tokio::join!(assets.read(&id), cloned_assets.read(&id)); + assert_eq!( + first_read.expect("the original reader uses the cache"), + b"abcdef" + ); + assert_eq!(second_read.expect("the clone uses the cache"), b"abcdef"); + let mut opened = cloned_assets + .open(&id) + .await + .expect("a native consumer opens the cached file"); + let mut opened_bytes = Vec::new(); + opened + .read_to_end(&mut opened_bytes) + .expect("the opened cached file reads"); + assert_eq!(opened_bytes, b"abcdef"); + server_task.await.expect("the server completes"); + assert_eq!( + requests.load(Ordering::Relaxed), + 2, + "one shared materialization serves every later read and open" + ); + drop(opened); + drop(assets); + drop(cloned_assets); + assert_eq!( + std::fs::read(&materialized) + .expect("the runner cache outlives the participant asset facade"), + b"abcdef" + ); + drop(runner_assets); + assert!( + !materialized.exists(), + "the temporary cache must be removed after the participant runner releases it" + ); + + owner.close().await; + } + + /// A supervisor response that claims the file continues but makes no + /// progress cannot cause an unbounded retry loop in a participant. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn remote_assets_refuse_a_nonfinal_empty_range() { + let (owner, bus) = BusOwner::open(BusConfig::for_external( + ExecutionId::mint(), + Some(SourceLabel::new("asset-test").expect("a valid label")), + Vec::new(), + )) + .await + .expect("the in-process bus opens"); + let server = bus + .declare_server(supervisor::topics().bundle().get().owner().key()) + .await + .expect("the bundle server declares"); + let server_bus = bus.clone(); + let server_task = tokio::spawn(async move { + let incoming = server.recv().await.expect("the client queries the server"); + incoming + .reply( + &server_bus, + MessagePack::encode(&supervisor::bundle::GetResponse::Chunk { + bytes: Vec::new(), + eof: false, + }) + .expect("the response encodes"), + ) + .await + .expect("the response reaches the client"); + }); + + let assets = ParticipantAssets::from_supervisor(bus.clone()) + .expect("the participant reader binds to the supervisor"); + let id = AssetId::new("mesh.bin").expect("a valid asset id"); + let error = assets + .read(&id) + .await + .expect_err("a non-final empty range is invalid"); + assert!( + error.to_string().contains("non-final empty chunk"), + "{error}" + ); + server_task.await.expect("the server completes"); + owner.close().await; + } + + /// A failed partial transfer never publishes a cache file, so a later read + /// starts again from byte zero rather than returning truncated data. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn remote_assets_do_not_publish_an_interrupted_download() { + let (owner, bus) = BusOwner::open(BusConfig::for_external( + ExecutionId::mint(), + Some(SourceLabel::new("asset-test").expect("a valid label")), + Vec::new(), + )) + .await + .expect("the in-process bus opens"); + let server = bus + .declare_server(supervisor::topics().bundle().get().owner().key()) + .await + .expect("the bundle server declares"); + let server_bus = bus.clone(); + let server_task = tokio::spawn(async move { + for (offset, response) in [ + ( + 0, + supervisor::bundle::GetResponse::Chunk { + bytes: b"partial".to_vec(), + eof: false, + }, + ), + (7, supervisor::bundle::GetResponse::Missing), + ( + 0, + supervisor::bundle::GetResponse::Chunk { + bytes: b"complete".to_vec(), + eof: true, + }, + ), + ] { + let incoming = server.recv().await.expect("the client queries the server"); + let request = MessagePack::decode::( + &incoming.request_bytes().expect("the request has bytes"), + ) + .expect("the request decodes"); + assert_eq!(request.offset, offset); + incoming + .reply( + &server_bus, + MessagePack::encode(&response).expect("the response encodes"), + ) + .await + .expect("the response reaches the client"); + } + }); + + let assets = ParticipantAssets::from_supervisor(bus.clone()) + .expect("the participant reader binds to the supervisor"); + let id = AssetId::new("mesh.bin").expect("a valid asset id"); + assert!(matches!( + assets.read(&id).await, + Err(BundleError::MissingFile { .. }) + )); + assert_eq!( + assets + .read(&id) + .await + .expect("the second download completes"), + b"complete" + ); + server_task.await.expect("the server completes"); + owner.close().await; + } +} diff --git a/phoxal/src/bundle/error.rs b/phoxal/src/bundle/error.rs index 45469fb0..236780f7 100644 --- a/phoxal/src/bundle/error.rs +++ b/phoxal/src/bundle/error.rs @@ -42,6 +42,16 @@ pub enum BundleError { #[source] source: std::io::Error, }, + #[error("supervisor bundle reader failed: {detail}")] + Remote { detail: String }, + #[error("asset I/O worker ended before it completed: {detail}")] + AssetWorker { detail: String }, + #[error("failed to materialize cached asset {path}: {source}", path = path.display())] + Cache { + path: PathBuf, + #[source] + source: std::io::Error, + }, #[error(transparent)] Path(#[from] BundlePathError), } diff --git a/phoxal/src/bundle/glb.rs b/phoxal/src/bundle/glb.rs new file mode 100644 index 00000000..765dcc78 --- /dev/null +++ b/phoxal/src/bundle/glb.rs @@ -0,0 +1,195 @@ +//! Validation for the closed binary glTF form accepted in world bundles. + +/// Validate the one closed binary glTF form accepted by world compilation and reopening. +pub(crate) fn validate_closed(bytes: &[u8]) -> Result<(), ClosedGlbError> { + const JSON_CHUNK: u32 = 0x4E4F_534A; + const BIN_CHUNK: u32 = 0x004E_4942; + + if bytes.len() < 20 || bytes.get(0..4) != Some(b"glTF") { + return Err(ClosedGlbError("missing the GLB header".to_owned())); + } + let version = glb_u32(bytes, 4, "truncated GLB version")?; + let declared = glb_u32(bytes, 8, "truncated GLB length")?; + if version != 2 || usize::try_from(declared).ok() != Some(bytes.len()) { + return Err(ClosedGlbError(format!( + "expected GLB version 2 with declared length {}, found version {version} and length {declared}", + bytes.len() + ))); + } + + let mut offset = 12_usize; + let mut json = None; + let mut binary = None; + while offset < bytes.len() { + let header_end = offset + .checked_add(8) + .ok_or_else(|| ClosedGlbError("chunk header offset overflows".to_owned()))?; + let header = bytes + .get(offset..header_end) + .ok_or_else(|| ClosedGlbError("truncated GLB chunk header".to_owned()))?; + let length = glb_u32(header, 0, "invalid GLB chunk length")? as usize; + let kind = glb_u32(header, 4, "invalid GLB chunk type")?; + if !length.is_multiple_of(4) { + return Err(ClosedGlbError( + "GLB chunk length is not four-byte aligned".to_owned(), + )); + } + let end = header_end + .checked_add(length) + .ok_or_else(|| ClosedGlbError("GLB chunk length overflows".to_owned()))?; + let chunk = bytes + .get(header_end..end) + .ok_or_else(|| ClosedGlbError("truncated GLB chunk".to_owned()))?; + match kind { + JSON_CHUNK if offset == 12 && json.is_none() => json = Some(chunk), + JSON_CHUNK => { + return Err(ClosedGlbError( + "GLB JSON must be the first and only JSON chunk".to_owned(), + )); + } + BIN_CHUNK if json.is_some() && binary.is_none() => binary = Some(chunk), + BIN_CHUNK => { + return Err(ClosedGlbError( + "GLB may contain at most one binary chunk after JSON".to_owned(), + )); + } + _ => { + return Err(ClosedGlbError(format!( + "unsupported GLB chunk type {kind:#010x}" + ))); + } + } + offset = end; + } + + let json = json.ok_or_else(|| ClosedGlbError("GLB has no JSON chunk".to_owned()))?; + let json = std::str::from_utf8(json) + .map_err(|source| ClosedGlbError(format!("JSON chunk is not UTF-8: {source}")))?; + let json = json + .trim_end_matches(|character: char| character == '\0' || character.is_ascii_whitespace()); + let document: serde_json::Value = serde_json::from_str(json) + .map_err(|source| ClosedGlbError(format!("JSON chunk is invalid: {source}")))?; + if document + .get("asset") + .and_then(|asset| asset.get("version")) + .and_then(serde_json::Value::as_str) + != Some("2.0") + { + return Err(ClosedGlbError( + "JSON asset.version must be exactly '2.0'".to_owned(), + )); + } + let buffers = match document.get("buffers") { + Some(serde_json::Value::Array(buffers)) => buffers.as_slice(), + Some(_) => { + return Err(ClosedGlbError("JSON buffers must be an array".to_owned())); + } + None => &[], + }; + let mut embedded_buffer_length = None; + for (index, buffer) in buffers.iter().enumerate() { + let buffer = buffer + .as_object() + .ok_or_else(|| ClosedGlbError(format!("buffers[{index}] must be an object")))?; + let byte_length = buffer + .get("byteLength") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| { + ClosedGlbError(format!( + "buffers[{index}].byteLength must be a non-negative integer" + )) + })?; + let byte_length = usize::try_from(byte_length).map_err(|_| { + ClosedGlbError(format!( + "buffers[{index}].byteLength exceeds the supported size" + )) + })?; + match buffer.get("uri") { + Some(serde_json::Value::String(uri)) if uri.starts_with("data:") => {} + Some(serde_json::Value::String(uri)) => { + return Err(ClosedGlbError(format!( + "buffers contains external URI '{uri}'" + ))); + } + Some(_) => { + return Err(ClosedGlbError(format!( + "buffers[{index}].uri must be a string" + ))); + } + None if index == 0 => embedded_buffer_length = Some(byte_length), + None => { + return Err(ClosedGlbError( + "only buffers[0] may omit uri and use the GLB binary chunk".to_owned(), + )); + } + } + } + match (embedded_buffer_length, binary) { + (None, None) => {} + (None, Some(_)) => { + return Err(ClosedGlbError( + "GLB binary chunk has no matching buffers[0] without uri".to_owned(), + )); + } + (Some(_), None) => { + return Err(ClosedGlbError( + "buffers[0] omits uri but the GLB binary chunk is missing".to_owned(), + )); + } + (Some(byte_length), Some(binary)) => { + let maximum = byte_length + .checked_add(3) + .ok_or_else(|| ClosedGlbError("buffer byte length overflows".to_owned()))?; + if binary.len() < byte_length || binary.len() > maximum { + return Err(ClosedGlbError(format!( + "GLB binary chunk length {} does not cover buffers[0].byteLength {byte_length} with at most three padding bytes", + binary.len() + ))); + } + if binary[byte_length..].iter().any(|byte| *byte != 0) { + return Err(ClosedGlbError( + "GLB binary chunk padding bytes must be zero".to_owned(), + )); + } + } + } + if let Some(images) = document.get("images") { + let images = images + .as_array() + .ok_or_else(|| ClosedGlbError("JSON images must be an array".to_owned()))?; + for (index, image) in images.iter().enumerate() { + let image = image + .as_object() + .ok_or_else(|| ClosedGlbError(format!("images[{index}] must be an object")))?; + if let Some(uri) = image.get("uri") { + let uri = uri.as_str().ok_or_else(|| { + ClosedGlbError(format!("images[{index}].uri must be a string")) + })?; + if !uri.starts_with("data:") { + return Err(ClosedGlbError(format!( + "images contains external URI '{uri}'" + ))); + } + } + } + } + Ok(()) +} + +fn glb_u32(bytes: &[u8], offset: usize, detail: &'static str) -> Result { + let end = offset + .checked_add(4) + .ok_or_else(|| ClosedGlbError(detail.to_owned()))?; + let value = bytes + .get(offset..end) + .ok_or_else(|| ClosedGlbError(detail.to_owned()))?; + Ok(u32::from_le_bytes( + value + .try_into() + .map_err(|_| ClosedGlbError(detail.to_owned()))?, + )) +} + +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +pub(crate) struct ClosedGlbError(String); diff --git a/phoxal/src/bundle/mod.rs b/phoxal/src/bundle/mod.rs index 2f6116f6..fcf10330 100644 --- a/phoxal/src/bundle/mod.rs +++ b/phoxal/src/bundle/mod.rs @@ -16,13 +16,13 @@ //! derive, and every participant reads its own configuration out of the same //! document. A binary is found in `bin/` by the id it was launched under. //! -//! [`RuntimeBundle::open`] parses the manifest and does nothing else - the -//! supervisor and every participant use the same reader, and a process the -//! manifest never mentions opens the bundle exactly like one it does. -//! [`ParticipantAssets::read`] reads a file below `assets/` by its -//! [`AssetId`](crate::model::AssetId); that id is already a validated relative -//! path and [`BundlePath`] validates the join again, so a read cannot leave -//! `assets/`. +//! [`RuntimeBundle::open`] parses the manifest and does nothing else. The +//! supervisor owns that local reader, while launched participants receive the +//! manifest through `supervisor/info` and lazily materialize assets from its +//! bounded reader. [`ParticipantAssets::read`] reads the cached file below +//! `assets/` by its [`AssetId`](crate::model::AssetId); that id is already a +//! validated relative path and [`BundlePath`] validates the join again, so a +//! read cannot leave `assets/`. //! //! [`BundleWriter::write`] takes the manifest, the assets, and a map from //! bundle-relative destination to the executable to copy there. It assembles the @@ -46,7 +46,10 @@ mod error; pub use error::BundleError; mod writer; pub use writer::BundleWriter; +mod world; +pub use world::{WorldBundle, WorldBundleError}; mod fs; +pub(crate) mod glb; pub(crate) use fs::{ BundleRoot, copy_executable_source, create_staging_root, ensure_staging_directory, open_bundle_file, prepare_publish_parent, publish_staging_root, read_manifest_document, diff --git a/phoxal/src/bundle/reader.rs b/phoxal/src/bundle/reader.rs index 7f51a822..8bc1820f 100644 --- a/phoxal/src/bundle/reader.rs +++ b/phoxal/src/bundle/reader.rs @@ -1,4 +1,4 @@ -//! The one bundle reader, used by the supervisor and by every participant. +//! The supervisor's local bundle reader. use std::path::Path; @@ -10,11 +10,10 @@ use crate::bundle::{BundleError, BundleRoot, ParticipantAssets, read_manifest_do /// An opened bundle: its manifest, and access to its assets. /// -/// Opening one parses `manifest.json` and does nothing else. A participant not -/// named in the manifest opens the bundle exactly as one that is - the manifest -/// is the robot model plus, for those that have one, their own configuration - -/// so there is no selection step and no way for a launched process to be refused -/// by the bundle it was pointed at. +/// Opening one parses `manifest.json` and does nothing else. It is only for a +/// process that owns a local bundle root, such as the supervisor or explicit +/// in-process harness. Launched participants receive their model and assets +/// through the supervisor instead of opening this directory themselves. #[derive(Clone, Debug)] pub struct RuntimeBundle { root: BundleRoot, @@ -69,9 +68,9 @@ impl RuntimeBundle { /// /// # Errors /// - /// Returns the same failures as [`ParticipantAssets::read`]. + /// Returns the same failures as local [`ParticipantAssets::read`]. pub fn asset(&self, id: &AssetId) -> Result, BundleError> { - self.assets.read(id) + self.assets.read_local(id) } /// The asset reader, for a consumer that keeps it beyond this value. diff --git a/phoxal/src/bundle/world.rs b/phoxal/src/bundle/world.rs new file mode 100644 index 00000000..51317f6d --- /dev/null +++ b/phoxal/src/bundle/world.rs @@ -0,0 +1,362 @@ +//! Canonical compiled world bundle storage and validation. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::model::asset::AssetId; +use crate::model::world::{World, WorldDigest, WorldProgressError}; + +const WORLD_FILE: &str = "world.json"; +const ASSETS_DIRECTORY: &str = "assets"; +const ARCHIVE_MAGIC: &[u8] = b"phoxal-world-bundle-v0\0"; + +/// A canonical expanded world plus every asset byte it can reach. +#[derive(Clone, Debug)] +pub struct WorldBundle { + world: World, + assets: BTreeMap>, + canonical_archive: Vec, + digest: WorldDigest, +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "schema", deny_unknown_fields)] +enum WorldDocument { + #[serde(rename = "phoxal/world-bundle/v0")] + V0(World), +} + +impl WorldBundle { + pub(crate) fn from_compiler( + world: World, + assets: BTreeMap>, + ) -> Result { + world.validate_intrinsic()?; + validate_assets(&world, &assets)?; + let canonical_archive = canonical_archive(&world, &assets)?; + let digest = WorldDigest::of(&canonical_archive); + Ok(Self { + world, + assets, + canonical_archive, + digest, + }) + } + + /// Open and validate one inspectable `world.json` plus `assets/` bundle. + /// + /// # Errors + /// + /// Returns [`WorldBundleError`] for I/O, document, path, or closure failures. + pub fn open(root: impl AsRef) -> Result { + let root = root + .as_ref() + .canonicalize() + .map_err(|source| WorldBundleError::Io { + path: root.as_ref().to_path_buf(), + source, + })?; + validate_root_layout(&root)?; + let document_path = root.join(WORLD_FILE); + let document = std::fs::read(&document_path).map_err(|source| WorldBundleError::Io { + path: document_path.clone(), + source, + })?; + let WorldDocument::V0(world) = + serde_json::from_slice(&document).map_err(|source| WorldBundleError::Document { + path: document_path, + source, + })?; + let assets = read_assets(&root.join(ASSETS_DIRECTORY))?; + Self::from_compiler(world, assets) + } + + /// Atomically write this bundle into a new target directory. + /// + /// # Errors + /// + /// Returns [`WorldBundleError::TargetExists`] when `root` already exists or an I/O error while staging. + pub fn write(&self, root: impl AsRef) -> Result<(), WorldBundleError> { + let root = root.as_ref(); + if root.exists() { + return Err(WorldBundleError::TargetExists(root.to_path_buf())); + } + let parent = root.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent).map_err(|source| WorldBundleError::Io { + path: parent.to_path_buf(), + source, + })?; + let staging = tempfile::Builder::new() + .prefix(".phoxal-world-") + .tempdir_in(parent) + .map_err(|source| WorldBundleError::Io { + path: parent.to_path_buf(), + source, + })?; + let assets_root = staging.path().join(ASSETS_DIRECTORY); + std::fs::create_dir(&assets_root).map_err(|source| WorldBundleError::Io { + path: assets_root.clone(), + source, + })?; + let document = serde_json::to_vec_pretty(&WorldDocument::V0(self.world.clone()))?; + let document_path = staging.path().join(WORLD_FILE); + std::fs::write(&document_path, document).map_err(|source| WorldBundleError::Io { + path: document_path, + source, + })?; + for (id, bytes) in &self.assets { + let path = asset_path(&assets_root, id)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|source| WorldBundleError::Io { + path: parent.to_path_buf(), + source, + })?; + } + std::fs::write(&path, bytes).map_err(|source| WorldBundleError::Io { path, source })?; + } + std::fs::rename(staging.path(), root).map_err(|source| WorldBundleError::Io { + path: root.to_path_buf(), + source, + })?; + Ok(()) + } + + #[must_use] + pub const fn world(&self) -> &World { + &self.world + } + + #[must_use] + pub const fn digest(&self) -> WorldDigest { + self.digest + } + + pub fn assets(&self) -> impl ExactSizeIterator { + self.assets.iter().map(|(id, bytes)| (id, bytes.as_slice())) + } + + #[must_use] + pub fn asset(&self, id: &AssetId) -> Option<&[u8]> { + self.assets.get(id).map(Vec::as_slice) + } + + /// Deterministic bytes over which [`WorldDigest`] is computed. + #[must_use] + pub fn canonical_archive(&self) -> &[u8] { + &self.canonical_archive + } +} + +fn validate_assets( + world: &World, + assets: &BTreeMap>, +) -> Result<(), WorldBundleError> { + let referenced = world.referenced_assets(); + let present = assets.keys().cloned().collect::>(); + if referenced != present { + return Err(WorldBundleError::AssetClosure { + referenced: referenced + .into_iter() + .map(|id| id.as_str().to_owned()) + .collect(), + present: present + .into_iter() + .map(|id| id.as_str().to_owned()) + .collect(), + }); + } + for (id, bytes) in assets { + let expected = format!("sha256/{:x}.glb", Sha256::digest(bytes)); + if id.as_str() != expected { + return Err(WorldBundleError::AssetDigestMismatch { + asset: id.as_str().to_owned(), + expected, + }); + } + super::glb::validate_closed(bytes).map_err(|source| WorldBundleError::InvalidAsset { + asset: id.as_str().to_owned(), + detail: source.to_string(), + })?; + } + Ok(()) +} + +fn validate_root_layout(root: &Path) -> Result<(), WorldBundleError> { + let mut world = false; + let mut assets = false; + let entries = std::fs::read_dir(root) + .and_then(Iterator::collect::>>) + .map_err(|source| WorldBundleError::Io { + path: root.to_path_buf(), + source, + })?; + for entry in entries { + let path = entry.path(); + let metadata = std::fs::symlink_metadata(&path).map_err(|source| WorldBundleError::Io { + path: path.clone(), + source, + })?; + match entry.file_name().to_str() { + Some(WORLD_FILE) if metadata.is_file() && !metadata.file_type().is_symlink() => { + world = true; + } + Some(ASSETS_DIRECTORY) if metadata.is_dir() && !metadata.file_type().is_symlink() => { + assets = true; + } + _ => { + return Err(WorldBundleError::Invalid(format!( + "world bundle contains an unsupported root entry: {}", + path.display() + ))); + } + } + } + if !world || !assets { + return Err(WorldBundleError::Invalid( + "world bundle must contain exactly world.json and assets/".to_owned(), + )); + } + Ok(()) +} + +fn canonical_archive( + world: &World, + assets: &BTreeMap>, +) -> Result, WorldBundleError> { + let document = serde_json::to_vec(&WorldDocument::V0(world.clone()))?; + let mut archive = Vec::new(); + archive.extend_from_slice(ARCHIVE_MAGIC); + append_archive_entry(&mut archive, WORLD_FILE.as_bytes(), &document)?; + for (id, bytes) in assets { + let name = format!("{ASSETS_DIRECTORY}/{}", id.as_str()); + append_archive_entry(&mut archive, name.as_bytes(), bytes)?; + } + Ok(archive) +} + +fn append_archive_entry( + archive: &mut Vec, + name: &[u8], + bytes: &[u8], +) -> Result<(), WorldBundleError> { + let name_len = u64::try_from(name.len()).map_err(|_| WorldBundleError::ArchiveTooLarge)?; + let bytes_len = u64::try_from(bytes.len()).map_err(|_| WorldBundleError::ArchiveTooLarge)?; + archive.extend_from_slice(&name_len.to_be_bytes()); + archive.extend_from_slice(name); + archive.extend_from_slice(&bytes_len.to_be_bytes()); + archive.extend_from_slice(bytes); + Ok(()) +} + +fn asset_path(root: &Path, id: &AssetId) -> Result { + let path = root.join(id.as_str()); + if !path.starts_with(root) { + return Err(WorldBundleError::InvalidAssetPath(id.as_str().to_owned())); + } + Ok(path) +} + +fn read_assets(root: &Path) -> Result>, WorldBundleError> { + if !root.is_dir() { + return Err(WorldBundleError::Invalid(format!( + "world bundle is missing {}", + root.display() + ))); + } + let mut assets = BTreeMap::new(); + read_asset_directory(root, root, &mut assets)?; + Ok(assets) +} + +fn read_asset_directory( + root: &Path, + current: &Path, + assets: &mut BTreeMap>, +) -> Result<(), WorldBundleError> { + let mut entries = std::fs::read_dir(current) + .and_then(Iterator::collect::>>) + .map_err(|source| WorldBundleError::Io { + path: current.to_path_buf(), + source, + })?; + entries.sort_by_key(std::fs::DirEntry::file_name); + for entry in entries { + let path = entry.path(); + let metadata = std::fs::symlink_metadata(&path).map_err(|source| WorldBundleError::Io { + path: path.clone(), + source, + })?; + if metadata.file_type().is_symlink() { + return Err(WorldBundleError::Invalid(format!( + "world bundle asset is a forbidden symlink: {}", + path.display() + ))); + } + if metadata.is_dir() { + read_asset_directory(root, &path, assets)?; + continue; + } + if !metadata.is_file() { + return Err(WorldBundleError::Invalid(format!( + "world bundle contains an unsupported asset entry: {}", + path.display() + ))); + } + let relative = path + .strip_prefix(root) + .map_err(|_| WorldBundleError::InvalidAssetPath(path.display().to_string()))?; + let relative = relative + .to_str() + .ok_or_else(|| WorldBundleError::InvalidAssetPath(path.display().to_string()))? + .replace(std::path::MAIN_SEPARATOR, "/"); + let id = AssetId::new(relative) + .map_err(|_| WorldBundleError::InvalidAssetPath(path.display().to_string()))?; + let bytes = std::fs::read(&path).map_err(|source| WorldBundleError::Io { + path: path.clone(), + source, + })?; + assets.insert(id, bytes); + } + Ok(()) +} + +/// A compiled world bundle that is not closed and canonical. +#[derive(Debug, thiserror::Error)] +pub enum WorldBundleError { + #[error("world bundle target already exists: {}", .0.display())] + TargetExists(PathBuf), + #[error("world bundle I/O failed at {}: {source}", path.display())] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("world bundle document {} is invalid: {source}", path.display())] + Document { + path: PathBuf, + #[source] + source: serde_json::Error, + }, + #[error("world bundle JSON could not be encoded: {0}")] + Json(#[from] serde_json::Error), + #[error("invalid world bundle: {0}")] + Invalid(String), + #[error("invalid world bundle asset path '{0}'")] + InvalidAssetPath(String), + #[error("world bundle asset '{asset}' is not a closed GLB v2 file: {detail}")] + InvalidAsset { asset: String, detail: String }, + #[error("world bundle asset closure differs: referenced {referenced:?}, present {present:?}")] + AssetClosure { + referenced: Vec, + present: Vec, + }, + #[error("world bundle asset '{asset}' does not match its byte digest; expected '{expected}'")] + AssetDigestMismatch { asset: String, expected: String }, + #[error(transparent)] + Progress(#[from] WorldProgressError), + #[error("world bundle canonical archive exceeds supported length")] + ArchiveTooLarge, +} diff --git a/phoxal/src/bus/contract.rs b/phoxal/src/bus/contract.rs index bbfa3427..56123f8b 100644 --- a/phoxal/src/bus/contract.rs +++ b/phoxal/src/bus/contract.rs @@ -12,19 +12,13 @@ //! # Semantics, not markers //! //! [`EndpointSemantics`] is a closed set - [`State`], [`Sample`], [`Event`], -//! [`Setpoint`], [`Stream`](Stream), [`Stream`](Stream), [`Query`], -//! [`WorldClock`] - and each member fixes the wire [`EndpointKind`], the -//! transport [`DeliveryFamily`], and the two side brands a client and an owner -//! respectively receive. A handle bounds itself on the semantics it serves +//! [`Setpoint`], [`Stream`](Stream), [`Stream`](Stream), and [`Query`]. +//! Each member fixes the wire [`EndpointKind`], the transport +//! [`DeliveryFamily`], and the two side brands a client and an owner respectively +//! receive. A handle bounds itself on the semantics it serves //! (`StatePublisher>`), so taking the wrong //! operation on an endpoint is a compile error naming the endpoint's own //! semantics. -//! -//! [`WorldClock`] is the one semantic whose authority differs from its wire -//! shape: it rides the same ordered stream transport as an [`Event`] and -//! declares the same [`EndpointKind::Event`], but it is a distinct semantic so -//! that the ordinary state publisher every participant has cannot mint a world -//! step. use std::marker::PhantomData; @@ -147,7 +141,7 @@ impl EndpointKind { /// participants is the compatibility line of the framework trains they were /// built from, so no key or endpoint carries a per-API version. /// -/// The three members are the whole set, and the trait is sealed: a family is a +/// The members are the whole set, and the trait is sealed: a family is a /// wire namespace this framework owns, not an extension point. pub trait Family: sealed::Family + 'static { /// The family's wire identifier, such as `"robot"`. @@ -166,6 +160,13 @@ pub enum Runtime {} /// `phoxal::supervisor::api` by the profiles that publish it. pub enum Supervisor {} +/// World-progress contracts, reached as `phoxal::simulation::api` by host +/// profiles. This is distinct from `phoxal::simulator`, the Rust host SDK. +pub enum Simulation {} + +/// Backend-neutral local world-session control and observation. +pub enum World {} + impl sealed::Family for Robot {} impl Family for Robot { const ID: &'static str = "robot"; @@ -181,6 +182,16 @@ impl Family for Supervisor { const ID: &'static str = "supervisor"; } +impl sealed::Family for Simulation {} +impl Family for Simulation { + const ID: &'static str = "simulation"; +} + +impl sealed::Family for World {} +impl Family for World { + const ID: &'static str = "world"; +} + /// The stand-in family the bus's own unit tests declare endpoints in. /// /// The bus is the ABI floor and has to be exercisable without the generated @@ -229,13 +240,6 @@ pub struct Stream(PhantomData D>); /// A bounded request/reply exchange. pub enum Query {} -/// The framework's one world-clock hand. -/// -/// A distinct authority semantic with the wire shape of an [`Event`]: it is -/// excluded from every ordinary publisher bound, so the only way to mint a -/// world step is the dedicated world-clock publisher no participant reaches. -pub enum WorldClock {} - /// What one endpoint semantic fixes. /// /// The wire kind, the transport lane, and the two side brands all follow from @@ -312,16 +316,6 @@ impl EndpointSemantics for Query { type Owner = ServeQuery; } -impl sealed::Semantics for WorldClock {} -impl EndpointSemantics for WorldClock { - // The wire kind is unchanged from the ordinary event it has always been; - // only the Rust-level authority differs. - const KIND: EndpointKind = EndpointKind::Event; - type Client = Subscribe; - type Owner = Publish; -} -impl StreamDelivered for WorldClock {} - /// One endpoint: the payload (or request) type it carries, plus the family and /// semantics attached to it by its own `endpoints!` declaration. /// @@ -389,29 +383,11 @@ mod tests { ::KIND, ::DELIVERY, ), - ( - ::KIND, - ::DELIVERY, - ), ] { assert_eq!(kind.delivery_family(), delivery); } } - /// The world clock keeps the wire shape of the event it has always been: - /// its distinctness is a Rust-level authority, not a wire change. - #[test] - fn the_world_clock_keeps_the_event_wire_kind() { - assert_eq!( - ::KIND, - ::KIND - ); - assert_eq!( - ::DELIVERY, - DeliveryFamily::Stream - ); - } - /// Every family is rooted at its own name, which is the leading segment of /// every key below it. #[test] @@ -419,5 +395,6 @@ mod tests { assert_eq!(::ID, "robot"); assert_eq!(::ID, "runtime"); assert_eq!(::ID, "supervisor"); + assert_eq!(::ID, "world"); } } diff --git a/phoxal/src/bus/error.rs b/phoxal/src/bus/error.rs index eaf84be9..ac58dce4 100644 --- a/phoxal/src/bus/error.rs +++ b/phoxal/src/bus/error.rs @@ -27,15 +27,6 @@ pub enum BusError { problem: KeyProblem, }, - /// A second timeline authority was requested in one process. - /// - /// Exactly one participant may own a timeline's coordinate, so the second - /// request is refused rather than silently sharing authority. - #[error( - "a second timeline authority was requested; exactly one participant may own a timeline" - )] - DuplicateTimelineAuthority, - /// This session's producer ran out of sequence numbers. The allocator fails /// closed rather than wrapping to zero, which every receiver would read as /// a replay from the same producer. @@ -216,17 +207,17 @@ pub enum MetadataProblem { /// The encoding string is not a Phoxal encoding string. #[error("malformed encoding string: {0}")] MalformedEncoding(#[from] EncodingError), - /// The sample carried no [`BusMetadata`](crate::bus::metadata::BusMetadata) - /// attachment. - #[error("missing a BusMetadata attachment")] + /// The sample carried no bus metadata attachment. + #[error("missing a bus metadata attachment")] MissingAttachment, - /// The attachment bytes are not a `BusMetadata`. - #[error("malformed BusMetadata: {0}")] + /// The attachment bytes are not the metadata envelope required on this + /// transport leg. + #[error("malformed bus metadata: {0}")] MalformedAttachment(#[from] rmp_serde::decode::Error), /// The encoding string and the attachment name different codecs, so the /// sample does not agree with itself about how to read its own body. #[error( - "encoding/BusMetadata codec mismatch: encoding codec={encoding}, metadata codec={attachment}" + "encoding/metadata codec mismatch: encoding codec={encoding}, metadata codec={attachment}" )] CodecMismatch { /// The codec the encoding string named. @@ -235,7 +226,7 @@ pub enum MetadataProblem { attachment: u8, }, /// The outbound attachment could not be encoded. - #[error("failed to encode BusMetadata: {0}")] + #[error("failed to encode bus metadata: {0}")] Encode(#[from] rmp_serde::encode::Error), } @@ -243,7 +234,7 @@ pub enum MetadataProblem { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum OutboundBound { /// The ordered lane already holds its maximum number of values. - Sample, + Count, /// The queue already holds its maximum number of bytes. Byte, } @@ -251,7 +242,7 @@ pub enum OutboundBound { impl std::fmt::Display for OutboundBound { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - OutboundBound::Sample => formatter.write_str("sample"), + OutboundBound::Count => formatter.write_str("count"), OutboundBound::Byte => formatter.write_str("byte"), } } diff --git a/phoxal/src/bus/handle/mod.rs b/phoxal/src/bus/handle/mod.rs index 6fd5645b..7e815e96 100644 --- a/phoxal/src/bus/handle/mod.rs +++ b/phoxal/src/bus/handle/mod.rs @@ -2,10 +2,9 @@ //! //! The handles are grouped by what they own: //! -//! - [`stamp`] - the step tokens that let a publisher express robot time, and -//! the timeline authority that mints world steps. -//! - [`publisher`] - the endpoint-kind publisher handles, plus the framework's -//! own world-clock publisher. +//! - [`stamp`] - sealed participant and Live transition stamps that let a +//! publisher express robot time. +//! - [`publisher`] - the endpoint-kind publisher handles. //! - [`subscriber`] - the receiving side: [`Observed`](subscriber::Observed), //! [`StateView`](subscriber::StateView), and the delivery-specific //! [`SetpointReceiver`](subscriber::SetpointReceiver), @@ -25,10 +24,9 @@ //! one is a compile error: //! //! - [`StatePublisher`](publisher::StatePublisher) publishes at a step, and -//! the step instant comes from a [`StepToken`](stamp::StepToken) the runner -//! mints for every scheduled participant, or a -//! [`WorldStepToken`](stamp::WorldStepToken) that the crate-private timeline -//! authority mints for the world authority alone. +//! the step instant comes from a sealed [`StepStamp`](stamp::StepStamp): a +//! [`StepToken`](stamp::StepToken) minted by the participant runner or the +//! Live transition stamp issued by the simulator SDK. //! - [`SamplePublisher`](publisher::SamplePublisher) publishes with //! a [`CaptureStamp`](crate::bus::time::CaptureStamp) the driver derived from its //! device clock, and honestly represents an untranslated capture rather than @@ -83,7 +81,7 @@ use zenoh::sample::Sample; use crate::bus::abi::{Codec, CodecId, EncodingError, EncodingMetadata, MessagePack}; use crate::bus::contract::{Endpoint, Payload}; use crate::bus::error::{BusError, MetadataProblem, Result}; -use crate::bus::metadata::BusMetadata; +use crate::bus::metadata::{BusMetadata, DeliveryMetadata}; /// Decode one Zenoh sample into the payload of endpoint `E`, validating the codec before /// touching the payload. @@ -91,11 +89,48 @@ use crate::bus::metadata::BusMetadata; /// Contract identity is not checked here: it is guaranteed by the Zenoh key /// itself, and this function is only ever invoked for samples received on a /// subscription already scoped to `E`'s family-rooted topic. -pub(crate) fn decode_sample(sample: &Sample, topic: &str) -> Result<(E, BusMetadata)> { - decode_payload::(sample, topic) +pub(crate) fn decode_sample( + sample: &Sample, + topic: &str, +) -> Result<(E, DeliveryMetadata)> { + decode_payload::(sample, topic) } -pub(crate) fn decode_payload(sample: &Sample, topic: &str) -> Result<(B, BusMetadata)> { +/// Decode a query reply, which deliberately retains the frozen +/// [`BusMetadata`] attachment rather than the delivery-only envelope. +pub(crate) fn decode_query_payload( + sample: &Sample, + topic: &str, +) -> Result<(B, BusMetadata)> { + decode_payload::(sample, topic) +} + +trait WireMetadata: Sized { + fn decode(bytes: &[u8]) -> std::result::Result; + fn bus(&self) -> &BusMetadata; +} + +impl WireMetadata for BusMetadata { + fn decode(bytes: &[u8]) -> std::result::Result { + Self::decode(bytes) + } + + fn bus(&self) -> &BusMetadata { + self + } +} + +impl WireMetadata for DeliveryMetadata { + fn decode(bytes: &[u8]) -> std::result::Result { + Self::decode(bytes) + } + + fn bus(&self) -> &BusMetadata { + &self.bus + } +} + +fn decode_payload(sample: &Sample, topic: &str) -> Result<(B, M)> { let malformed = |problem: MetadataProblem| BusError::metadata(topic, problem); let encoding: EncodingMetadata = sample @@ -113,18 +148,18 @@ pub(crate) fn decode_payload(sample: &Sample, topic: &str) -> Result let attachment = sample .attachment() .ok_or_else(|| malformed(MetadataProblem::MissingAttachment))?; - let metadata = - BusMetadata::decode(attachment.to_bytes().as_ref()).map_err(|e| malformed(e.into()))?; + let metadata = M::decode(attachment.to_bytes().as_ref()).map_err(|e| malformed(e.into()))?; + let bus = metadata.bus(); - if metadata.codec != encoding.codec { + if bus.codec != encoding.codec { return Err(malformed(MetadataProblem::CodecMismatch { encoding: encoding.codec, - attachment: metadata.codec, + attachment: bus.codec, })); } - if metadata.codec_id() != Some(CodecId::MessagePack) { + if bus.codec_id() != Some(CodecId::MessagePack) { return Err(BusError::UnsupportedCodec { - codec: metadata.codec, + codec: bus.codec, topic: topic.to_string(), }); } diff --git a/phoxal/src/bus/handle/publisher.rs b/phoxal/src/bus/handle/publisher.rs index de1e2609..f7cb61cf 100644 --- a/phoxal/src/bus/handle/publisher.rs +++ b/phoxal/src/bus/handle/publisher.rs @@ -9,7 +9,6 @@ use std::marker::PhantomData; use crate::bus::abi::{Codec, MessagePack}; use crate::bus::contract::{ DeliveryFamily, Endpoint, EndpointSemantics, Event, Sample, Setpoint, State, StreamDelivered, - WorldClock, }; use crate::bus::error::{BusError, Result}; use crate::bus::handle::stamp::StepStamp; @@ -63,12 +62,13 @@ impl Outbox { }) } - /// Encode `body`, build the [`BusMetadata`](crate::bus::metadata::BusMetadata), - /// and admit it to the family-specific outbound lane. Returns immediately; - /// no publisher path blocks the step loop. + /// Encode `body`, build the + /// [`DeliveryMetadata`](crate::bus::metadata::DeliveryMetadata), and admit + /// it to the family-specific outbound lane. Returns immediately; no + /// publisher path blocks the step loop. fn emit(&self, produced_at: Option, body: E) -> Result<()> { let payload = MessagePack::encode(&body)?; - let metadata = self.bus.metadata(produced_at)?; + let metadata = self.bus.delivery_metadata(self.family, produced_at)?; self.bus.enqueue( self.key.clone(), MessagePack::ID.encoding_string(), @@ -78,6 +78,41 @@ impl Outbox { self.metric.clone(), ) } + + /// Emit only while `controller` and `revision` are the exact Active + /// simulation binding, stamping that requested revision rather than + /// whichever revision might replace it concurrently. + #[allow( + dead_code, + reason = "only the simulator consumer profile publishes the controller SDK" + )] + fn emit_active_simulation( + &self, + controller: crate::identity::ProducerId, + revision: u64, + produced_at: Option, + body: E, + ) -> Result { + let payload = MessagePack::encode(&body)?; + let Some(metadata) = self.bus.active_simulation_delivery_metadata( + controller, + revision, + self.family, + produced_at, + )? + else { + return Ok(false); + }; + self.bus.enqueue( + self.key.clone(), + MessagePack::ID.encoding_string(), + payload, + metadata, + self.family, + self.metric.clone(), + )?; + Ok(true) + } } const fn outbound_capacity(family: DeliveryFamily) -> usize { @@ -172,52 +207,29 @@ where } } -/// Publishes the framework's own world-clock contract at a logical step. -/// -/// A near-twin of [`StatePublisher`] - same step-stamped publish path - kept -/// as its own type rather than folded into `StatePublisher` precisely so -/// `StatePublisher`'s bound can stay the exact -/// [`State`] semantic. The world clock's semantic is -/// [`WorldClock`], a sibling rather than a subtype, which is what makes the -/// ordinary state publisher reject it at compile time. -/// -/// Crate-private, like the authority that stamps it: publishing the world -/// clock is world ownership, and the only holder is [`crate::simulator`]'s -/// world time, which the external world adapter drives. -#[allow( - dead_code, - reason = "compiled in every profile because a domain module never asks which profile it is in; its only consumer is a module one profile declares" -)] -pub(crate) struct WorldClockPublisher>(Outbox); - -impl> Clone for WorldClockPublisher { - fn clone(&self) -> Self { - WorldClockPublisher(self.0.clone()) - } -} - impl> StatePublisher { /// Publish `body` as the state this step produced. pub fn publish(&self, step: &impl StepStamp, body: E) -> Result<()> { self.0.emit(Some(TimeWindow::exact(step.instant())), body) } -} -#[allow( - dead_code, - reason = "compiled in every profile because a domain module never asks which profile it is in; its only consumer is a module one profile declares" -)] -impl> WorldClockPublisher { - /// Build the world-clock publisher over a topic. - /// - /// [`crate::simulator`]'s world time is its only caller. - pub(crate) fn mint(bus: BusHandle, topic: &Topic>) -> Result { - Ok(WorldClockPublisher(Outbox::new(bus, topic)?)) - } - - /// Publish `body` as the state this step produced. - pub fn publish(&self, step: &impl StepStamp, body: B) -> Result<()> { - self.0.emit(Some(TimeWindow::exact(step.instant())), body) + #[allow( + dead_code, + reason = "only the simulator consumer profile publishes the controller SDK" + )] + pub(crate) fn publish_active_simulation( + &self, + controller: crate::identity::ProducerId, + revision: u64, + step: &impl StepStamp, + body: E, + ) -> Result { + self.0.emit_active_simulation( + controller, + revision, + Some(TimeWindow::exact(step.instant())), + body, + ) } } @@ -226,6 +238,21 @@ impl> SamplePublisher { pub fn publish(&self, stamp: CaptureStamp, body: E) -> Result<()> { self.0.emit(stamp.into_window(), body) } + + #[allow( + dead_code, + reason = "only the simulator consumer profile publishes the controller SDK" + )] + pub(crate) fn publish_active_simulation( + &self, + controller: crate::identity::ProducerId, + revision: u64, + stamp: CaptureStamp, + body: E, + ) -> Result { + self.0 + .emit_active_simulation(controller, revision, stamp.into_window(), body) + } } impl> SetpointPublisher { @@ -276,6 +303,30 @@ impl> EventPublisher { error => error, }) } + + #[allow( + dead_code, + reason = "only the simulator consumer profile publishes the controller SDK" + )] + pub(crate) fn publish_active_simulation( + &self, + controller: crate::identity::ProducerId, + revision: u64, + step: &impl StepStamp, + body: E, + ) -> Result { + self.0 + .emit_active_simulation( + controller, + revision, + Some(TimeWindow::exact(step.instant())), + body, + ) + .map_err(|error| match error { + BusError::Saturated { topic, .. } => BusError::WouldBlock { topic }, + error => error, + }) + } } #[cfg(test)] @@ -291,12 +342,14 @@ mod tests { use serial_test::serial; const STREAM_TOPIC: &str = "yTEST/stream/chunk"; + const EVENT_TOPIC: &str = "yTEST/stream/event"; const STATE_TOPIC: &str = "yTEST/stream/state"; const SAMPLE_TOPIC: &str = "yTEST/stream/sample"; const SETPOINT_TOPIC: &str = "yTEST/stream/setpoint"; - /// One stand-in endpoint per delivery family, so the admission rules below - /// are exercised on four genuinely different lanes. + /// One stand-in endpoint per semantic publication kind, so the admission + /// rules below exercise all four scheduler lanes and both ordered-lossless + /// handles. macro_rules! stand_in { ($name:ident ( $body:ty ), $semantics:ty) => { #[derive(phoxal_macros::DescribeWire, Debug, serde::Serialize, serde::Deserialize)] @@ -312,6 +365,7 @@ mod tests { } stand_in!(StreamChunk(Vec), crate::bus::Stream); + stand_in!(EventChunk(u16), Event); stand_in!(StateChunk(u16), State); stand_in!(SampleChunk(u16), Sample); stand_in!(SetpointChunk(u16), Setpoint); @@ -388,6 +442,11 @@ mod tests { &bound::(STREAM_TOPIC).owner(), ) .unwrap(); + let event = EventPublisher::::new( + bus.clone(), + &bound::(EVENT_TOPIC).owner(), + ) + .unwrap(); for value in 0..3 { state @@ -402,9 +461,16 @@ mod tests { .publish(CaptureStamp::Untranslated, SampleChunk(value as u16)) .unwrap(); } - for value in 0..OUTBOUND_CAPACITY { + for value in 0..OUTBOUND_CAPACITY - 1 { stream.send(StreamChunk(vec![value as u8])).unwrap(); } + event + .publish(&step(1, 10), EventChunk(10)) + .expect("the final ordered slot admits an event without waiting for the drain"); + assert!(matches!( + event.publish(&step(1, 11), EventChunk(11)).unwrap_err(), + BusError::WouldBlock { .. } + )); assert!(matches!( stream.send(StreamChunk(vec![0xff])).unwrap_err(), BusError::WouldBlock { .. } @@ -416,12 +482,16 @@ mod tests { .into_iter() .map(|metadata| { metadata + .bus .stream_position .expect("an accepted stream has a position") .sequence }) .collect(); - assert_eq!(positions, (0..OUTBOUND_CAPACITY as u64).collect::>()); + assert_eq!( + positions, + (0..OUTBOUND_CAPACITY as u64 - 1).collect::>() + ); let rows = bus.take_runtime_metrics().unwrap(); let row = |topic: &str| { @@ -441,14 +511,16 @@ mod tests { assert_eq!(row(SAMPLE_TOPIC).count, OUTBOUND_CAPACITY as u64 + 1); assert_eq!(row(SAMPLE_TOPIC).bounded_evictions, 1); assert_eq!(row(SAMPLE_TOPIC).high_water_depth, OUTBOUND_CAPACITY as u64); - assert_eq!(row(STREAM_TOPIC).count, OUTBOUND_CAPACITY as u64); + assert_eq!(row(STREAM_TOPIC).count, OUTBOUND_CAPACITY as u64 - 1); assert_eq!(row(STREAM_TOPIC).drops, 1); + assert_eq!(row(EVENT_TOPIC).count, 1); + assert_eq!(row(EVENT_TOPIC).drops, 1); assert_eq!( bus.health() .outbound_drops .load(std::sync::atomic::Ordering::Relaxed), - 2, - "one sample eviction and one refused stream are both live evidence" + 3, + "one sample eviction plus refused Event and Stream values are live evidence" ); drop(pause); diff --git a/phoxal/src/bus/handle/querier.rs b/phoxal/src/bus/handle/querier.rs index 0f7c6e39..8d21a6ac 100644 --- a/phoxal/src/bus/handle/querier.rs +++ b/phoxal/src/bus/handle/querier.rs @@ -10,7 +10,7 @@ use zenoh::sample::Sample; use crate::bus::abi::{Codec, MessagePack}; use crate::bus::contract::{Payload, QueryEndpoint}; use crate::bus::error::Result; -use crate::bus::handle::decode_payload; +use crate::bus::handle::decode_query_payload; use crate::bus::query::{QueryError, QueryFailure}; use crate::bus::session::BusHandle; use crate::bus::topic::{AskQuery, Topic}; @@ -188,7 +188,7 @@ fn decode_reply_result( topic: &str, ) -> std::result::Result { match result { - Ok(sample) => decode_payload::(&sample, topic) + Ok(sample) => decode_query_payload::(&sample, topic) .map(|(body, _)| body) .map_err(|e| QueryError::Decode(e.to_string())), Err(reply_error) => { diff --git a/phoxal/src/bus/handle/stamp.rs b/phoxal/src/bus/handle/stamp.rs index fdc03738..24ab34f4 100644 --- a/phoxal/src/bus/handle/stamp.rs +++ b/phoxal/src/bus/handle/stamp.rs @@ -1,13 +1,11 @@ -//! Proof that a step happened, and the authority that mints world steps. +//! Proof that a participant step happened. //! //! # Every minter is `pub(crate)` //! -//! `StepToken::mint`, the timeline authority and its world-step minter have -//! exactly two legitimate callers, and both are in this crate: the participant -//! runner, which releases a scheduled step, and `phoxal::simulator`'s world -//! time, which completes a world advance. Nothing outside `phoxal` may express -//! a robot instant it did not reach, because nothing outside `phoxal` can name -//! a minter. +//! `StepToken::mint` has exactly one legitimate caller: the participant runner, +//! which releases a scheduled step. Nothing outside `phoxal` may express a +//! participant step instant it did not reach, because nothing outside `phoxal` +//! can name the minter. //! //! That is a change of kind, not of degree. While the framework was six //! packages these constructors had to be `pub` for the runner and the @@ -15,25 +13,15 @@ //! was stated as "not by accident". One crate makes `pub(crate)` say exactly //! what was meant, so the deliberate route is closed too. //! -//! [`WorldStepToken`] itself stays public: `phoxal::simulator` hands one to -//! the world adapter for every completed step, which is how that adapter -//! stamps the step's outputs. Holding one is proof, never authority - there is -//! no public way to make one. - -use std::sync::atomic::{AtomicBool, Ordering}; - -use crate::identity::TimelineId; - -use crate::bus::error::{BusError, Result}; use crate::bus::time::RobotInstant; -mod sealed { +pub(crate) mod sealed { pub trait Sealed {} } /// The robot instant a completed step stamps its outputs with. /// -/// Implemented only by [`StepToken`] and [`WorldStepToken`], and sealed, so no +/// Implemented only by framework-issued transition stamps, and sealed, so no /// other type can ever stamp a checked publication. pub trait StepStamp: sealed::Sealed { /// The instant this step completed at. @@ -66,115 +54,3 @@ impl StepStamp for StepToken { self.at } } - -/// Proof that the world authority completed one world step. -/// -/// The externally driven simulation controller has no framework -/// `Participant::step`: it is driven by the simulator's own advance call, so no -/// runner-minted [`StepToken`] can cover it. The crate-private timeline -/// authority behind `phoxal::simulator`'s world time mints this token once per -/// completed world advance, for all outputs of that advance. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct WorldStepToken { - at: RobotInstant, -} - -impl sealed::Sealed for WorldStepToken {} - -impl StepStamp for WorldStepToken { - fn instant(&self) -> RobotInstant { - self.at - } -} - -/// Ownership of exactly one timeline's coordinate. -/// -/// This is the narrowly scoped answer to "who may say what time it is in a -/// world nobody schedules". A second authority in one process is rejected at -/// mint (a per-process runtime backstop). Across processes the invariant is a -/// selection-time one: exactly one world-authority client is attached to a -/// simulated execution, and that selection is enforced by whatever launches the -/// run, not by anything this process can observe. -/// -/// **What the type system closes.** Nothing outside this crate can name an -/// authority, let alone mint one: minting a world clock is not a participant -/// capability and not an SDK capability either. The one holder is -/// [`crate::simulator`]'s world time, which hands its owner completed steps -/// and never the authority behind them. -#[allow( - dead_code, - reason = "compiled in every profile because a domain module never asks which profile it is in; its only consumer is a module one profile declares" -)] -pub(crate) struct TimelineAuthority { - timeline: TimelineId, -} - -/// One authority per process: the runtime backstop. The cross-process "exactly -/// one authority" rule is a selection-time property of which participants are -/// launched, not something this process can observe. -#[allow( - dead_code, - reason = "compiled in every profile because a domain module never asks which profile it is in; its only consumer is a module one profile declares" -)] -static TIMELINE_AUTHORITY_HELD: AtomicBool = AtomicBool::new(false); - -#[allow( - dead_code, - reason = "compiled in every profile because a domain module never asks which profile it is in; its only consumer is a module one profile declares" -)] -impl TimelineAuthority { - /// Take this process's single timeline authority, or fail if it is already - /// held. [`crate::simulator`] is its only caller. - pub(crate) fn mint(timeline: TimelineId) -> Result { - if TIMELINE_AUTHORITY_HELD.swap(true, Ordering::AcqRel) { - return Err(BusError::DuplicateTimelineAuthority); - } - Ok(TimelineAuthority { timeline }) - } - - /// The timeline this authority owns. - pub const fn timeline(&self) -> TimelineId { - self.timeline - } - - /// Begin a new world history on this authority (a reset or replay branch). - /// - /// The authority itself is unique for the process; the *timeline* it owns - /// is replaced, which is exactly the "simulation reset creates a new - /// timeline within the same execution" lifecycle rule. - pub fn replace_timeline(&mut self, timeline: TimelineId) { - self.timeline = timeline; - } - - /// Mint the token for one completed world step at `ticks` on this - /// authority's timeline. - pub const fn completed_step(&self, ticks: u64) -> WorldStepToken { - WorldStepToken { - at: RobotInstant::new(self.timeline, ticks), - } - } -} - -impl Drop for TimelineAuthority { - fn drop(&mut self) { - TIMELINE_AUTHORITY_HELD.store(false, Ordering::Release); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::bus::test_support::timeline; - - #[test] - fn only_one_timeline_authority_exists_at_a_time() { - let first = TimelineAuthority::mint(timeline(1)).expect("first authority should mint"); - assert!( - TimelineAuthority::mint(timeline(2)).is_err(), - "a second authority must be rejected at startup" - ); - assert_eq!(first.completed_step(50).instant().ticks(), 50); - drop(first); - TimelineAuthority::mint(timeline(3)).expect("the slot is released on drop"); - } -} diff --git a/phoxal/src/bus/handle/subscriber.rs b/phoxal/src/bus/handle/subscriber.rs index e49233fb..a7078490 100644 --- a/phoxal/src/bus/handle/subscriber.rs +++ b/phoxal/src/bus/handle/subscriber.rs @@ -17,7 +17,7 @@ use crate::bus::contract::{ use crate::bus::error::{BusError, KeyProblem, Result}; use crate::bus::handle::decode_sample; use crate::bus::lock::lock; -use crate::bus::metadata::BusMetadata; +use crate::bus::metadata::DeliveryMetadata; use crate::bus::runtime_metrics::RuntimeMetricHandle; use crate::bus::session::{BusFault, BusHandle}; use crate::bus::time::{LocalInstant, RetiredTimelines, TimeWindow}; @@ -91,13 +91,14 @@ impl TerminalState { /// over and **before** decode, so ring residence and decode cost are inside /// every consumer's measured age. It is process-local and receiver-specific - /// two receivers of the same sample legitimately observe it at different -/// instants - which is exactly why it lives here and never in [`BusMetadata`]. +/// instants - which is exactly why it lives here and never in +/// [`DeliveryMetadata`]. #[derive(Clone, Debug)] pub struct Observed { /// The decoded wire body. pub body: B, /// The sample's bus metadata. - pub metadata: BusMetadata, + pub metadata: DeliveryMetadata, /// When this receiver observed the sample, on the host's suspend-aware /// monotonic boot clock. pub observed_at: LocalInstant, @@ -1620,11 +1621,28 @@ where continue; }; match decode_sample::(&sample, &topic_owned) { - Ok((body, metadata)) => on_sample(Observed { - body, - metadata, - observed_at, - }), + Ok((body, metadata)) => { + if !health_bus.admits_inbound_delivery::(&metadata) { + metric.record_drop(); + health_bus + .health() + .inbound_drops + .fetch_add(1, Ordering::Relaxed); + tracing::debug!( + target: "phoxal.bus", + topic = %topic_owned, + producer = %metadata.source.producer(), + revision = ?metadata.attachment_revision, + "dropped external simulator delivery outside the exact Active binding" + ); + continue; + } + on_sample(Observed { + body, + metadata, + observed_at, + }); + } Err(err) => { metric.record_decode_error(); health_bus @@ -1663,7 +1681,7 @@ mod tests { use crate::bus::handle::publisher::{SetpointPublisher, StatePublisher}; use crate::bus::lease::{FixedSourceLease, LeaseDecision, LeaseRejection}; use crate::bus::liveliness::ParticipantReadyStatus; - use crate::bus::metadata::{ParticipantSourceIdentity, SourceAttribution}; + use crate::bus::metadata::{BusMetadata, ParticipantSourceIdentity, SourceAttribution}; use crate::bus::runtime_metrics::{RuntimeDirection, RuntimeMetrics}; use crate::bus::session::BusOwner; use crate::bus::test_support::{ @@ -1700,17 +1718,20 @@ mod tests { fn observed(body: u8, line: Option) -> Observed { Observed { body, - metadata: BusMetadata { - codec: CodecId::MessagePack.as_u8(), - sequence: u64::from(body), - stream_position: None, - produced_at: line - .map(|line| TimeWindow::exact(RobotInstant::new(timeline(line), 0))), - source: SourceAttribution::Participant(ParticipantSourceIdentity::new( - crate::identity::ParticipantId::new("test").expect("test participant"), - producer(1), - )), - }, + metadata: DeliveryMetadata::new( + BusMetadata { + codec: CodecId::MessagePack.as_u8(), + sequence: u64::from(body), + stream_position: None, + produced_at: line + .map(|line| TimeWindow::exact(RobotInstant::new(timeline(line), 0))), + source: SourceAttribution::Participant(ParticipantSourceIdentity::new( + crate::identity::ParticipantId::new("test").expect("test participant"), + producer(1), + )), + }, + None, + ), observed_at: LocalInstant::try_now().expect("test host clock"), } } diff --git a/phoxal/src/bus/metadata.rs b/phoxal/src/bus/metadata.rs index 5f26f045..5968fefe 100644 --- a/phoxal/src/bus/metadata.rs +++ b/phoxal/src/bus/metadata.rs @@ -1,17 +1,24 @@ -//! `BusMetadata` - the per-sample attachment. +//! Metadata carried beside bus bodies. //! //! The wire body is the plain MessagePack payload; provenance rides here, in //! the Zenoh attachment. Identity is not carried in the envelope at all - it //! lives in the Zenoh key itself, the concrete key the api tree rendered for //! the endpoint, so a receiver's per-key subscription is the whole fast-reject. //! -//! Provenance is a [`SourceAttribution`] plus a per-producer sequence, and the -//! production instant is an explicit `Option<`[`TimeWindow`]`>` - a sample that -//! expresses no robot time carries `None`, never a sentinel. Stream contracts -//! additionally carry an optional position scoped to that producer and -//! concrete topic. Participant and producer identity are one source pair; -//! external sources carry the producer with an optional bounded diagnostic -//! label. No admissibility decision reads the diagnostic label. +//! [`BusMetadata`] is the frozen query/bootstrap envelope and the common base +//! embedded in [`DeliveryMetadata`]. Provenance is a [`SourceAttribution`] plus +//! a per-producer sequence, and the production instant is an explicit +//! `Option<`[`TimeWindow`]`>` - a body that expresses no robot time carries +//! `None`, never a sentinel. Stream contracts additionally carry an optional +//! position scoped to that producer and concrete topic. Participant and +//! producer identity are one source pair; external sources carry the producer +//! with an optional bounded diagnostic label. No admissibility decision reads +//! the diagnostic label. +//! +//! Pub/sub samples use the distinct [`DeliveryMetadata`] attachment. Its +//! optional attachment revision is deliberately outside the frozen envelope, +//! so Live delivery admission can evolve without changing the metadata an +//! attaching client must decode before compatibility has been established. //! //! Receiver-side observation time is deliberately absent: it is process-local //! and receiver-specific, so it belongs on @@ -196,6 +203,68 @@ pub struct BusMetadata { pub source: SourceAttribution, } +/// Metadata encoded on ordinary pub/sub delivery attachments. +/// +/// The nested [`BusMetadata`] is the common provenance and timing base. The +/// Live attachment revision belongs only to delivery and therefore never rides +/// a query request, query reply, or frozen supervisor bootstrap reply. +#[derive(phoxal_macros::DescribeWire, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeliveryMetadata { + /// The common codec, sequence, timing, and source metadata. + pub bus: BusMetadata, + /// The active supervisor attachment revision under which this delivery was + /// admitted, when the delivery requires attachment-bound admission. + /// + /// This is a standard delivery-envelope fact rather than a payload field. + /// Live v0 stamps setpoint deliveries while Active so a controller can + /// reject missing, retained, or delayed commands from another revision. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attachment_revision: Option, +} + +impl DeliveryMetadata { + /// Construct delivery metadata from the frozen base and optional Live + /// attachment correlation. + #[must_use] + pub fn new(bus: BusMetadata, attachment_revision: Option) -> Self { + Self { + bus, + attachment_revision, + } + } + + /// Encode to ordinary pub/sub attachment bytes. + pub fn encode(&self) -> std::result::Result, rmp_serde::encode::Error> { + let encoded = rmp_serde::to_vec_named(self)?; + debug_assert!(encoded.len() <= MAX_METADATA_BYTES); + Ok(encoded) + } + + /// Decode from ordinary pub/sub attachment bytes. + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() > MAX_METADATA_BYTES { + return Err(rmp_serde::decode::Error::Syntax(format!( + "DeliveryMetadata exceeds the {MAX_METADATA_BYTES}-byte limit" + ))); + } + rmp_serde::from_slice(bytes) + } +} + +impl std::ops::Deref for DeliveryMetadata { + type Target = BusMetadata; + + fn deref(&self) -> &Self::Target { + &self.bus + } +} + +impl std::ops::DerefMut for DeliveryMetadata { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.bus + } +} + impl BusMetadata { /// Encode to the MessagePack attachment bytes. /// @@ -349,6 +418,28 @@ mod tests { assert_eq!(decoded.stream_position.unwrap().sequence, 41); } + #[test] + fn a_delivery_attachment_revision_is_optional_and_round_trips_when_present() { + let original = DeliveryMetadata::new(metadata(None), Some(9)); + let encoded = original.encode().expect("delivery metadata encodes"); + let decoded = DeliveryMetadata::decode(&encoded).unwrap(); + assert_eq!(decoded, original); + assert_eq!(decoded.attachment_revision, Some(9)); + assert_eq!(decoded.sequence, 7); + } + + #[test] + fn delivery_metadata_keeps_live_correlation_outside_the_frozen_base() { + let delivery = DeliveryMetadata::new(metadata(None), Some(9)); + let json = serde_json::to_value(&delivery).expect("delivery metadata serializes"); + let fields = json.as_object().expect("delivery metadata is a map"); + assert_eq!( + fields.keys().map(String::as_str).collect::>(), + ["attachment_revision", "bus"] + ); + assert!(fields["bus"].get("attachment_revision").is_none()); + } + #[test] fn an_exact_production_instant_round_trips_without_collapsing_a_window() { let timeline = TimelineId::mint(); diff --git a/phoxal/src/bus/mod.rs b/phoxal/src/bus/mod.rs index 1dcae9b2..6c31ebe7 100644 --- a/phoxal/src/bus/mod.rs +++ b/phoxal/src/bus/mod.rs @@ -5,8 +5,9 @@ //! //! - a key, `phoxal//`, where the topic is the //! family-rooted contract name; -//! - an encoding string naming the codec, plus a [`BusMetadata`] attachment -//! carrying codec and provenance only - no schema, family, or api identity; +//! - an encoding string naming the codec, plus a [`DeliveryMetadata`] +//! attachment carrying codec, provenance, and optional Live attachment +//! correlation - no schema, family, or api identity; //! - a plain MessagePack body payload. //! //! There is no Phoxal frame independent of Zenoh and no version tag anywhere: @@ -37,11 +38,10 @@ //! Code *inside* the framework always imports from the owning module, never //! through this facade. //! -//! Three things this module owns are absent from that surface on purpose, and -//! are named only by the modules that own them: `BusOwner` and `BusConfig` -//! (opening a session), `TimelineAuthority` and `WorldClockPublisher` (owning -//! a world), and the embedded `Router`. No consumer profile receives raw -//! transport, world, or fabric ownership - `phoxal::session`, +//! Two things this module owns are absent from that surface on purpose, and are +//! named only by the modules that own them: `BusOwner` and `BusConfig` (opening +//! a session), and the embedded `Router`. No consumer profile receives raw +//! transport or fabric ownership - `phoxal::session`, //! `phoxal::simulator` and `phoxal::supervisor::host` each own one on their //! consumer's behalf. @@ -80,15 +80,15 @@ pub use crate::identity::{ExecutionId, ParticipantId, ProducerId, TimelineId}; pub use abi::{Codec, CodecError, CodecId, EncodingError, EncodingMetadata, MessagePack}; pub use contract::{ DeliveryFamily, Direction, Endpoint, EndpointKind, EndpointSemantics, Event, Family, In, Out, - Payload, Query, QueryEndpoint, Robot, RobotEndpoint, Runtime, Sample, Setpoint, State, Stream, - StreamDelivered, Supervisor, WorldClock, + Payload, Query, QueryEndpoint, Robot, RobotEndpoint, Runtime, Sample, Setpoint, Simulation, + State, Stream, StreamDelivered, Supervisor, World, }; pub use error::{BusError, KeyProblem, MetadataProblem, OutboundBound, Result, SessionIdRole}; pub use handle::publisher::{ EventPublisher, SamplePublisher, SetpointPublisher, StatePublisher, StreamPublisher, }; pub use handle::querier::{DEFAULT_QUERY_TIMEOUT, Querier}; -pub use handle::stamp::{StepStamp, StepToken, WorldStepToken}; +pub use handle::stamp::{StepStamp, StepToken}; pub use handle::subscriber::{ EventReceiver, MAX_SETPOINT_SOURCES, MAX_STREAM_SOURCES, Observed, ReceiveTerminal, SampleReceiver, SetpointReceiver, StateView, StreamEvent, StreamReceiver, TimelineRetention, @@ -103,8 +103,8 @@ pub use liveliness::{ ParticipantReadyToken, }; pub use metadata::{ - BusMetadata, ParticipantSourceIdentity, SourceAttribution, SourceLabel, SourceLabelError, - StreamPosition, + BusMetadata, DeliveryMetadata, ParticipantSourceIdentity, SourceAttribution, SourceLabel, + SourceLabelError, StreamPosition, }; pub use query::{QueryCode, QueryError, QueryFailure, QueryResult}; pub use runtime_metrics::{ @@ -154,7 +154,7 @@ pub mod __compat { use crate::bus::abi::CodecId; use crate::bus::liveliness::PARTICIPANT_LIVELINESS_PREFIX; - use crate::bus::metadata::BusMetadata; + use crate::bus::metadata::{BusMetadata, DeliveryMetadata}; use crate::bus::query::QueryFailure; use crate::bus::session::{BUS_KEY_PREFIX, ZENOH_WIRE_PROTOCOL_VERSION}; @@ -169,9 +169,13 @@ pub mod __compat { /// This module's records, for the crate aggregate. pub(crate) fn contract_records(out: &mut Vec) { out.extend([ - // The per-sample attachment: provenance rides here rather than in - // the body, so it is a wire contract in its own right. + // The query attachment and common delivery base. This exact record + // is frozen because attachment-bootstrap requests and replies use + // it before compatibility is known. ContractRecord::envelope("BusMetadata", BusMetadata::wire_schema()), + // Ordinary pub/sub samples add delivery-only attachment state in a + // separate envelope, leaving the frozen query envelope unchanged. + ContractRecord::envelope("DeliveryMetadata", DeliveryMetadata::wire_schema()), // A handler error rides Zenoh's native error reply leg with this // body, which no endpoint declaration mentions. ContractRecord::envelope("QueryFailure", QueryFailure::wire_schema()), @@ -221,6 +225,7 @@ pub mod __compat { assert_eq!(contract_surface(), rendered); for expected in [ r#""name":"BusMetadata""#, + r#""name":"DeliveryMetadata""#, r#""name":"QueryFailure""#, r#""value":"phoxal/{execution}""#, r#""value":"phoxal/{execution}/{topic}""#, @@ -247,7 +252,8 @@ pub mod __compat { use crate::bus::abi::CodecId; use crate::bus::metadata::{ - BusMetadata, ParticipantSourceIdentity, SourceAttribution, StreamPosition, + BusMetadata, DeliveryMetadata, ParticipantSourceIdentity, SourceAttribution, + StreamPosition, }; use crate::bus::query::{QueryCode, QueryFailure}; use crate::bus::test_support::producer; @@ -266,6 +272,10 @@ pub mod __compat { let json = serde_json::to_value(&metadata).expect("the attachment serializes"); assert_eq!(BusMetadata::wire_schema().conforms(&json), Ok(())); + let delivery = DeliveryMetadata::new(metadata, Some(2)); + let json = serde_json::to_value(&delivery).expect("delivery metadata serializes"); + assert_eq!(DeliveryMetadata::wire_schema().conforms(&json), Ok(())); + let failure = QueryFailure::new(QueryCode::NotFound, "no such entity"); let json = serde_json::to_value(&failure).expect("a query failure serializes"); assert_eq!(QueryFailure::wire_schema().conforms(&json), Ok(())); diff --git a/phoxal/src/bus/outbound.rs b/phoxal/src/bus/outbound.rs index e377ba71..fb13f63d 100644 --- a/phoxal/src/bus/outbound.rs +++ b/phoxal/src/bus/outbound.rs @@ -110,6 +110,23 @@ impl OutboundScheduler { .collect() } + #[cfg(test)] + pub(crate) fn delivery_attachments(&self) -> Vec<(String, DeliveryFamily, Vec)> { + self.state + .values() + .chain(self.setpoint.values()) + .chain(self.sample.iter()) + .chain(self.stream.iter()) + .map(|outbound| { + ( + outbound.key.clone(), + outbound.family, + outbound.attachment.clone(), + ) + }) + .collect() + } + /// Return the next position without mutating it. The caller commits this /// only after the corresponding stream item has been admitted. pub(crate) fn next_stream_position(&self, key: &str) -> u64 { @@ -147,7 +164,7 @@ impl OutboundScheduler { // Query traffic does not use this scheduler. Keeping this // branch defensive makes an accidental future call fail as a // bounded admission rather than silently choosing a lane. - Err(OutboundBound::Sample) + Err(OutboundBound::Count) } } } @@ -212,7 +229,7 @@ impl OutboundScheduler { } if evict_count == self.sample.len() { let bound = if !count_fits { - OutboundBound::Sample + OutboundBound::Count } else { OutboundBound::Byte }; @@ -242,7 +259,7 @@ impl OutboundScheduler { outbound: Outbound, ) -> std::result::Result { if self.stream.len() >= self.lane_capacity { - return Err(OutboundBound::Sample); + return Err(OutboundBound::Count); } let Some(next_bytes) = self.queued_bytes.checked_add(outbound.bytes) else { return Err(OutboundBound::Byte); @@ -411,7 +428,7 @@ mod tests { let before = scheduler.next_stream_position("stream"); let result = scheduler.admit(outbound(&metrics, DeliveryFamily::Stream, "stream", 3)); - assert!(matches!(result, Err(OutboundBound::Sample))); + assert!(matches!(result, Err(OutboundBound::Count))); assert_eq!(scheduler.next_stream_position("stream"), before); assert_eq!(body(&scheduler.pop_next().unwrap()), 1); assert_eq!(body(&scheduler.pop_next().unwrap()), 2); diff --git a/phoxal/src/bus/session.rs b/phoxal/src/bus/session.rs index a726825f..f17fecca 100644 --- a/phoxal/src/bus/session.rs +++ b/phoxal/src/bus/session.rs @@ -27,11 +27,12 @@ use zenoh::key_expr::OwnedKeyExpr; use zenoh::qos::CongestionControl; use crate::bus::abi::truncate_utf8; -use crate::bus::contract::DeliveryFamily; +use crate::bus::contract::{DeliveryFamily, Endpoint, EndpointKind, EndpointSemantics, Family}; use crate::bus::error::{BusError, KeyProblem, OutboundBound, Result, SessionIdRole}; use crate::bus::lock::lock; use crate::bus::metadata::{ - BusMetadata, MAX_SOURCE_LABEL_BYTES, SourceAttribution, SourceLabel, StreamPosition, + BusMetadata, DeliveryMetadata, MAX_SOURCE_LABEL_BYTES, SourceAttribution, SourceLabel, + StreamPosition, }; use crate::bus::outbound::{Outbound, OutboundScheduler}; use crate::bus::runtime_metrics::{RuntimeMetricHandle, RuntimeMetricSnapshot, RuntimeMetrics}; @@ -65,14 +66,14 @@ pub(crate) const BUS_KEY_PREFIX: &str = "phoxal"; /// cannot go stale behind a Zenoh upgrade. pub(crate) const ZENOH_WIRE_PROTOCOL_VERSION: u8 = 9; -/// Capacity (in samples) of each ordered outbound lane. Coalesced state and +/// Capacity (in values) of each ordered outbound lane. Coalesced state and /// setpoint lanes retain one pending slot per concrete topic instead. pub(crate) const OUTBOUND_CAPACITY: usize = 1024; -/// Byte bound of the outbound queue. The queue is bounded in samples AND bytes, -/// because either alone lets a conforming publisher exhaust the other. A publish -/// A sample/stream admission that would exceed it is refused or, for samples, -/// evicts older sample values until the newest item fits; no caller blocks. +/// Byte bound of the outbound queue. The queue is bounded in count and bytes, +/// because either alone lets a conforming publisher exhaust the other. A +/// sample, event, or stream admission that would exceed it is refused or, for +/// samples, evicts older values until the newest item fits; no caller blocks. pub(crate) const OUTBOUND_MAX_BYTES: usize = 16 * 1024 * 1024; /// Connection inputs for opening a bus session. @@ -240,10 +241,17 @@ struct BusIdentity { attribution: SourceAttribution, producer: ProducerId, seq: AtomicU64, + active_simulation_binding: std::sync::Mutex>, health: BusHealth, runtime_metrics: Arc, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ActiveSimulationBinding { + controller: ProducerId, + revision: u64, +} + #[derive(Default)] struct TransportErrors { entries: Vec, @@ -497,6 +505,7 @@ impl BusOwner { attribution: config.attribution(producer), producer, seq: AtomicU64::new(0), + active_simulation_binding: std::sync::Mutex::new(None), health: BusHealth::default(), runtime_metrics: Arc::new(RuntimeMetrics::default()), }), @@ -590,6 +599,21 @@ impl BusOwner { } impl BusHandle { + /// Whether the execution's directly connected router still exists on this session. + /// This reads local transport state, without a query or reconnect attempt. + #[allow( + dead_code, + reason = "the simulator profile observes its execution router" + )] + pub(crate) async fn execution_router_connected(&self) -> Result { + let session = self.session()?; + Ok(session + .info() + .routers_zid() + .await + .any(|id| execution_from_zid(id).is_ok_and(|execution| execution == self.execution()))) + } + fn live_inner(&self) -> Result> { let liveness = self.liveness.upgrade().ok_or(BusError::Closed)?; if !liveness.load(Ordering::Acquire) { @@ -647,6 +671,37 @@ impl BusHandle { /// Build the provenance for one outbound sample: this producer, its next /// sequence, and the production instant the caller's temporal role permits. pub(crate) fn metadata(&self, produced_at: Option) -> Result { + self.metadata_for(produced_at) + } + + /// Build outbound metadata for a delivery lane. Participant setpoints and + /// outputs from the bound external simulation controller inherit the + /// supervisor-managed Active revision. + pub(crate) fn delivery_metadata( + &self, + family: DeliveryFamily, + produced_at: Option, + ) -> Result { + let binding = *lock(&self.identity.active_simulation_binding); + let attachment_revision = binding.and_then(|binding| { + let is_setpoint = family == DeliveryFamily::Setpoint; + let is_controller_output = matches!( + self.identity.attribution, + SourceAttribution::External { .. } + ) && self.identity.producer == binding.controller + && matches!( + family, + DeliveryFamily::State | DeliveryFamily::Sample | DeliveryFamily::Stream + ); + (is_setpoint || is_controller_output).then_some(binding.revision) + }); + Ok(DeliveryMetadata::new( + self.metadata_for(produced_at)?, + attachment_revision, + )) + } + + fn metadata_for(&self, produced_at: Option) -> Result { self.live_inner()?; Ok(BusMetadata { codec: crate::bus::abi::CodecId::MessagePack.as_u8(), @@ -657,6 +712,82 @@ impl BusHandle { }) } + /// Install the exact controller and revision from current supervisor + /// attachment state. `None` makes all external simulator output + /// inadmissible immediately. + pub(crate) fn set_active_simulation_binding(&self, binding: Option<(ProducerId, u64)>) { + *lock(&self.identity.active_simulation_binding) = + binding.map(|(controller, revision)| ActiveSimulationBinding { + controller, + revision, + }); + } + + /// Build delivery metadata only if the caller still owns the exact Active + /// controller binding. The binding check and revision selection share one + /// lock, so a phase replacement can only make the resulting old revision + /// rejectable, never relabel an old transition as a new one. + #[allow( + dead_code, + reason = "only the simulator consumer profile publishes the controller SDK" + )] + pub(crate) fn active_simulation_delivery_metadata( + &self, + controller: ProducerId, + revision: u64, + family: DeliveryFamily, + produced_at: Option, + ) -> Result> { + let binding = lock(&self.identity.active_simulation_binding); + let exact = *binding + == Some(ActiveSimulationBinding { + controller, + revision, + }) + && self.identity.producer == controller + && matches!( + &self.identity.attribution, + SourceAttribution::External { .. } + ) + && matches!( + family, + DeliveryFamily::State | DeliveryFamily::Sample | DeliveryFamily::Stream + ); + if !exact { + return Ok(None); + } + let metadata = DeliveryMetadata::new(self.metadata_for(produced_at)?, Some(revision)); + drop(binding); + Ok(Some(metadata)) + } + + /// Admit external simulator delivery only from the exact controller and + /// Active revision currently installed by the supervisor feed. + pub(crate) fn admits_inbound_delivery(&self, metadata: &DeliveryMetadata) -> bool { + let external_producer = match &metadata.source { + SourceAttribution::External { producer, .. } => *producer, + SourceAttribution::Participant(_) => return true, + }; + let family = ::ID; + let kind = ::KIND; + let requires_active_binding = (family == "robot" + && matches!( + kind, + EndpointKind::State + | EndpointKind::Sample + | EndpointKind::Event + | EndpointKind::Stream + )) + || (family == "simulation" && kind == EndpointKind::Event); + if !requires_active_binding { + return true; + } + lock(&self.identity.active_simulation_binding).is_some_and(|binding| { + external_producer == binding.controller + && metadata.attachment_revision == Some(binding.revision) + }) + } + /// Live health counters. pub fn health(&self) -> &BusHealth { &self.identity.health @@ -754,7 +885,7 @@ impl BusHandle { } #[cfg(test)] - pub(crate) fn test_queued_stream_metadata(&self, key: &str) -> Vec { + pub(crate) fn test_queued_stream_metadata(&self, key: &str) -> Vec { self.owner .upgrade() .map(|inner| { @@ -762,7 +893,28 @@ impl BusHandle { .stream_attachments(key) .into_iter() .map(|attachment| { - BusMetadata::decode(&attachment).expect("queued metadata must decode") + DeliveryMetadata::decode(&attachment) + .expect("queued delivery metadata must decode") + }) + .collect() + }) + .unwrap_or_default() + } + + #[cfg(test)] + pub(crate) fn test_queued_delivery_metadata( + &self, + ) -> Vec<(String, DeliveryFamily, DeliveryMetadata)> { + self.owner + .upgrade() + .map(|inner| { + lock(&inner.outbound) + .delivery_attachments() + .into_iter() + .map(|(key, family, attachment)| { + let metadata = DeliveryMetadata::decode(&attachment) + .expect("queued delivery metadata must decode"); + (key, family, metadata) }) .collect() }) @@ -863,7 +1015,7 @@ impl BusHandle { key: String, encoding: String, payload: Vec, - mut metadata: BusMetadata, + mut metadata: DeliveryMetadata, family: DeliveryFamily, metric: RuntimeMetricHandle, ) -> Result<()> { @@ -877,7 +1029,7 @@ impl BusHandle { } let mut scheduler = lock(&inner.outbound); if family == DeliveryFamily::Stream { - metadata.stream_position = Some(StreamPosition { + metadata.bus.stream_position = Some(StreamPosition { sequence: scheduler.next_stream_position(&key), }); } @@ -1403,9 +1555,9 @@ fn signal_fatal(inner: &BusInner, fault: BusFault) { /// ([`crate::bus::outbound::OutboundScheduler::admit`]), and a receiver treats a /// missing position as fatal by design. A transport-level drop therefore /// manufactures exactly the gap the whole family is built to make impossible, -/// and it lands on every subscriber simultaneously - one dropped world-clock -/// chunk faulted twelve supervised participants at once, eight seconds after -/// the graph reached Ready. [`CongestionControl::Block`] turns that loss into +/// and it lands on every subscriber simultaneously. A single dropped stream +/// chunk can therefore fault every subscriber long after startup. +/// [`CongestionControl::Block`] turns that loss into /// backpressure on the drain loop instead, where the outbox's own bounded /// refusal reports it to the publisher as a bounded, attributable failure. const fn congestion_control_for(family: DeliveryFamily) -> CongestionControl { @@ -1587,6 +1739,46 @@ mod tests { use crate::bus::handle::subscriber::{Latest, Subscriber}; use crate::bus::test_support::{TARGET_TOPIC, Target, bound, participant_config, step}; + #[serial] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn execution_router_loss_is_visible_without_a_liveliness_delete() { + let directory = tempfile::tempdir().unwrap(); + let endpoint = format!( + "unixsock-stream/{}", + directory.path().join("router.sock").display() + ); + let execution = ExecutionId::mint(); + let mut config = zenoh::Config::default(); + apply_phoxal_transport_policy(&mut config).unwrap(); + config.insert_json5("mode", "\"router\"").unwrap(); + config + .insert_json5( + "id", + &serde_json::to_string(&execution.to_string()).unwrap(), + ) + .unwrap(); + config + .insert_json5( + "listen/endpoints", + &serde_json::to_string(&[&endpoint]).unwrap(), + ) + .unwrap(); + let router = zenoh::open(config).await.unwrap(); + let (owner, bus) = BusOwner::open(BusConfig::for_external(execution, None, vec![endpoint])) + .await + .unwrap(); + assert!(bus.execution_router_connected().await.unwrap()); + router.close().await.unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + while bus.execution_router_connected().await.unwrap() { + tokio::task::yield_now().await; + } + }) + .await + .expect("loss is local transport state, not a remote token update"); + let _ = owner.close().await; + } + fn test_producer(value: u128) -> ProducerId { ProducerId::try_from((1_u128 << 124) | value).expect("canonical test producer") } @@ -1833,6 +2025,83 @@ mod tests { owner.close().await; } + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn simulator_ingress_requires_the_exact_active_controller_and_revision() { + fn delivery(producer: ProducerId, revision: Option) -> DeliveryMetadata { + DeliveryMetadata::new( + BusMetadata { + codec: crate::bus::CodecId::MessagePack.as_u8(), + sequence: 0, + stream_position: None, + produced_at: None, + source: SourceAttribution::External { + producer, + label: None, + }, + }, + revision, + ) + } + + let controller = test_producer(71); + let other = test_producer(72); + let (owner, bus) = BusOwner::open(BusConfig::for_external( + ExecutionId::mint(), + None, + Vec::new(), + )) + .await + .expect("test bus opens"); + + assert!( + !bus.admits_inbound_delivery::(&delivery( + controller, + Some(9), + )), + "Preparing and Removing admit no external simulation output" + ); + bus.set_active_simulation_binding(Some((controller, 9))); + assert!( + bus.admits_inbound_delivery::(&delivery( + controller, + Some(9), + )) + ); + assert!( + bus.admits_inbound_delivery::(&delivery( + controller, + Some(9), + )), + "Robot stream delivery uses the same exact admission" + ); + assert!( + !bus.admits_inbound_delivery::(&delivery( + other, + Some(9), + )) + ); + assert!( + !bus.admits_inbound_delivery::(&delivery( + controller, + Some(8), + )) + ); + assert!( + !bus.admits_inbound_delivery::(&delivery( + controller, None, + )) + ); + bus.set_active_simulation_binding(None); + assert!( + !bus.admits_inbound_delivery::(&delivery( + controller, + Some(9), + )) + ); + + let _ = owner.close().await; + } + #[serial] #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn ready_lease_is_owner_only_and_carries_exact_participant_and_producer() { diff --git a/phoxal/src/bus/test_support.rs b/phoxal/src/bus/test_support.rs index 19e2dea6..908cf200 100644 --- a/phoxal/src/bus/test_support.rs +++ b/phoxal/src/bus/test_support.rs @@ -23,7 +23,9 @@ use zenoh::sample::{Sample, SampleBuilder}; use crate::bus::abi::CodecId; use crate::bus::contract::{Endpoint, TestFamily}; use crate::bus::handle::stamp::StepToken; -use crate::bus::metadata::{BusMetadata, ParticipantSourceIdentity, SourceAttribution}; +use crate::bus::metadata::{ + BusMetadata, DeliveryMetadata, ParticipantSourceIdentity, SourceAttribution, +}; use crate::bus::session::BusConfig; use crate::bus::time::{RobotInstant, TimeWindow}; use crate::bus::tree::BoundEndpoint; @@ -156,9 +158,10 @@ pub(crate) fn sample_with_encoding(codec: u8, encoding: String, payload: Vec meta.codec = codec; let key: KeyExpr<'static> = KeyExpr::try_from("phoxal/dead/yTEST/drive/target").expect("a legal test key"); + let delivery = DeliveryMetadata::new(meta, None); SampleBuilder::put(key, payload) .encoding(encoding) - .attachment(meta.encode().expect("test metadata encodes")) + .attachment(delivery.encode().expect("test delivery metadata encodes")) .into() } diff --git a/phoxal/src/bus/tree.rs b/phoxal/src/bus/tree.rs index 0a9ee04a..937b62a0 100644 --- a/phoxal/src/bus/tree.rs +++ b/phoxal/src/bus/tree.rs @@ -323,9 +323,6 @@ macro_rules! endpoint { ( $leaf:tt, Setpoint, $body:tt ) => { crate::bus::tree::endpoint_declare!($leaf, crate::bus::Setpoint, $body); }; - ( $leaf:tt, WorldClock, $body:tt ) => { - crate::bus::tree::endpoint_declare!($leaf, crate::bus::WorldClock, $body); - }; ( $leaf:tt, Stream, $body:tt, In ) => { crate::bus::tree::endpoint_declare!($leaf, crate::bus::Stream, $body); }; diff --git a/phoxal/src/drive/authority.rs b/phoxal/src/drive/authority.rs new file mode 100644 index 00000000..023d22a6 --- /dev/null +++ b/phoxal/src/drive/authority.rs @@ -0,0 +1,114 @@ +//! Authority policy for the framework's built-in drive service. + +use std::time::Duration; + +use crate::bus::FixedSourceLease; +use crate::identity::ParticipantId; +use crate::model::Robot; +use crate::model::identity::CapabilityRef; + +const COMMAND_SILENCE: Duration = Duration::from_millis(150); + +/// The fixed source authorized to provide built-in drive motor commands. +#[derive(Clone, Debug)] +pub struct DriveCommandAuthority { + source: ParticipantId, +} + +/// A motor is not present exactly once in the compiled drive topology. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[error( + "motor '{capability}' must occur exactly once in the compiled kinematic actuator topology for the built-in drive authority; found {count} occurrences" +)] +pub struct DriveAuthorityError { + pub capability: CapabilityRef, + pub count: usize, +} + +impl DriveCommandAuthority { + /// Construct the fixed authority represented by the built-in drive service. + pub fn standard() -> Result { + Ok(Self { + source: ParticipantId::new("drive")?, + }) + } + + #[must_use] + pub const fn silence() -> Duration { + COMMAND_SILENCE + } + + #[must_use] + pub fn source(&self) -> &ParticipantId { + &self.source + } + + #[must_use] + pub fn motor_lease(&self) -> FixedSourceLease { + FixedSourceLease::new( + "component/motor/command", + self.source.clone(), + COMMAND_SILENCE, + Duration::MAX, + ) + } + + /// Verify that this framework authority may command one compiled motor. + pub fn validate_motor( + robot: &Robot, + capability: &CapabilityRef, + ) -> Result<(), DriveAuthorityError> { + let count = robot.motion().kinematic().actuator_occurrences(capability); + if count == 1 { + Ok(()) + } else { + Err(DriveAuthorityError { + capability: capability.clone(), + count, + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::component::motor::Command; + use crate::bus::{ + LeaseDecision, LocalInstant, ParticipantReadyStatus, ParticipantSourceIdentity, + }; + use crate::identity::ProducerId; + + #[test] + fn paused_drive_intent_expires_by_host_time_and_a_later_command_stays_fresh() { + let authority = DriveCommandAuthority::standard().expect("drive authority"); + let producer = ProducerId::try_from(0x1000_0000_0000_0000_0000_0000_0000_0001) + .expect("canonical producer"); + let source = ParticipantSourceIdentity::new(authority.source().clone(), producer); + let mut lease = authority.motor_lease(); + lease.update_ready(&source, ParticipantReadyStatus::Ready); + let paused_at = LocalInstant::from_boot_ns(1_000_000_000); + assert_eq!( + lease.offer(Some(&source), 1, paused_at, Command::Velocity(1.0)), + LeaseDecision::Acquired + ); + assert!( + lease + .live_host(paused_at.saturating_add(DriveCommandAuthority::silence())) + .is_none() + ); + + let later = paused_at.saturating_add(Duration::from_millis(250)); + assert_eq!( + lease.offer(Some(&source), 2, later, Command::Velocity(2.0)), + LeaseDecision::Renewed + ); + assert!( + lease + .live_host(later.saturating_add(Duration::from_millis(149))) + .is_some() + ); + lease.update_ready(&source, ParticipantReadyStatus::Lost); + assert!(lease.live_host(later).is_none()); + } +} diff --git a/phoxal/src/drive/mod.rs b/phoxal/src/drive/mod.rs new file mode 100644 index 00000000..970cca63 --- /dev/null +++ b/phoxal/src/drive/mod.rs @@ -0,0 +1,3 @@ +//! Framework-owned drive policy and runtime authority. + +pub mod authority; diff --git a/phoxal/src/execution.rs b/phoxal/src/execution.rs new file mode 100644 index 00000000..2c9f93ab --- /dev/null +++ b/phoxal/src/execution.rs @@ -0,0 +1,417 @@ +//! The one private attachment sequence for a running execution. +//! +//! This module is deliberately outside the optional public `session` surface. +//! Participants, external sessions, and simulator hosts all need the same +//! bootstrap, while their owned transport lifetimes and role-specific work +//! remain separate. + +use crate::bus::{BusError, BusHandle, DEFAULT_QUERY_TIMEOUT, Querier, QueryError, StreamReceiver}; +use crate::identity::ExecutionId; +use crate::supervisor::api; +use crate::supervisor::api::connect::{ConnectReply, ConnectRequest}; +use crate::supervisor::api::simulation::SimulationAttachmentState; +use crate::supervisor::api::time_domain::TimeDomain; +use crate::version::FrameworkVersion; + +/// Facts obtained during the one framework-owned attachment bootstrap. +/// +/// The stream is subscribed before `current` is asked, then every buffered +/// replacement is reconciled before this primitive returns. Role-specific +/// startup owns the stream after that gap-free initial snapshot. +pub(crate) struct ExecutionBootstrap { + #[allow( + dead_code, + reason = "the optional session surface retains this immutable attachment fact" + )] + pub(crate) execution: ExecutionId, + #[allow( + dead_code, + reason = "the optional session surface retains this immutable attachment fact" + )] + pub(crate) framework: FrameworkVersion, + pub(crate) info: api::info::InfoResponse, + pub(crate) time_domain: TimeDomain, + pub(crate) time_domains: StreamReceiver, + pub(crate) attachment: Option, + pub(crate) attachments: StreamReceiver, +} + +/// A failure while attaching to a supervisor before role-specific startup. +#[derive(Debug, thiserror::Error)] +pub(crate) enum BootstrapError { + #[error("no Phoxal execution is reachable at {endpoint}")] + NoExecution { endpoint: String }, + #[error( + "{count} Phoxal executions are reachable at {endpoint}, which must identify exactly one: {executions:?}" + )] + MultipleExecutions { + endpoint: String, + count: usize, + executions: Vec, + }, + #[error("remote framework {remote} is incompatible with local framework {local}: {refusal}")] + IncompatibleFramework { + remote: FrameworkVersion, + local: FrameworkVersion, + refusal: CompatibilityRefusal, + }, + #[error("the frozen supervisor bootstrap reply could not be decoded: {detail}")] + UnreadableBootstrap { detail: String }, + #[error(transparent)] + Bus(#[from] BusError), + #[error(transparent)] + Query(#[from] QueryError), +} + +/// Which compatible peer is on the newer framework line. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum CompatibilityRefusal { + RemoteNewer, + LocalNewer, +} + +impl std::fmt::Display for CompatibilityRefusal { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::RemoteNewer => formatter.write_str("remote framework line is newer"), + Self::LocalNewer => formatter.write_str("local framework line is newer"), + } + } +} + +/// Resolve the execution identity the rendezvous endpoint currently exposes. +pub(crate) async fn resolve_execution(endpoint: &str) -> Result { + let executions = crate::bus::BusOwner::probe_routers(endpoint).await?; + exactly_one_execution(endpoint, executions) +} + +/// Enforce the one-execution rendezvous rule with stable diagnostics. +fn exactly_one_execution( + endpoint: &str, + mut executions: Vec, +) -> Result { + match executions.as_slice() { + [execution] => Ok(*execution), + [] => Err(BootstrapError::NoExecution { + endpoint: endpoint.to_owned(), + }), + _ => { + executions.sort_by_key(ToString::to_string); + Err(BootstrapError::MultipleExecutions { + endpoint: endpoint.to_owned(), + count: executions.len(), + executions, + }) + } + } +} + +/// Complete the frozen supervisor bootstrap on a caller-owned bus session. +pub(crate) async fn attach_execution( + bus: &BusHandle, +) -> Result { + let framework = remote_framework(bus).await?; + ensure_compatible_framework(framework, FrameworkVersion::CURRENT)?; + + let info = Querier::new( + bus.clone(), + &api::topics().info().client(), + DEFAULT_QUERY_TIMEOUT, + )? + .query(api::info::InfoRequest {}) + .await?; + + let time_domains = StreamReceiver::new(bus, &api::topics().time_domain().client()).await?; + let current = Querier::new( + bus.clone(), + &api::topics().time_domain().current().client(), + DEFAULT_QUERY_TIMEOUT, + )? + .query(api::time_domain::CurrentRequest {}) + .await?; + let mut time_domain = current.domain; + reconcile_time_domain(&mut time_domain, &time_domains)?; + + let attachments = + StreamReceiver::new(bus, &api::topics().simulation().attachment().client()).await?; + let current_attachment = Querier::new( + bus.clone(), + &api::topics().simulation().attachment().current().client(), + DEFAULT_QUERY_TIMEOUT, + )? + .query(api::simulation::attachment::CurrentRequest {}) + .await?; + let mut attachment = current_attachment.attachment; + reconcile_attachment(&mut attachment, &attachments)?; + + Ok(ExecutionBootstrap { + execution: bus.execution(), + framework, + info, + time_domain, + time_domains, + attachment, + attachments, + }) +} + +fn reconcile_attachment( + current: &mut Option, + updates: &StreamReceiver, +) -> Result<(), BootstrapError> { + while let Some(update) = updates.try_recv()? { + let replacement = update.body.attachment; + match (replacement, *current) { + (Some(replacement), Some(installed)) if replacement.revision > installed.revision => { + *current = Some(replacement); + } + (Some(replacement), None) => *current = Some(replacement), + // The stream's initial `None` may still be buffered after a newer + // current query returned an attachment. It carries no revision and + // must never erase that source-bound state. + (None, _) | (Some(_), Some(_)) => {} + } + } + Ok(()) +} + +/// Install every already-buffered replacement that is newer than `current`. +/// +/// Subscribing before the query closes the transport race, but a replacement +/// can still arrive after the query response was produced. Draining without an +/// await makes the bootstrap return the newest known domain while preserving +/// later arrivals for the role-specific lifecycle. +fn reconcile_time_domain( + current: &mut TimeDomain, + updates: &StreamReceiver, +) -> Result<(), BootstrapError> { + while let Some(update) = updates.try_recv()? { + if update.body.domain.revision > current.revision { + *current = update.body.domain; + } + } + Ok(()) +} + +async fn remote_framework(bus: &BusHandle) -> Result { + let reply = Querier::new( + bus.clone(), + &api::topics().connect().client(), + DEFAULT_QUERY_TIMEOUT, + )? + .query(ConnectRequest::V0 {}) + .await + .map_err(|error| match error { + QueryError::Decode(detail) => BootstrapError::UnreadableBootstrap { detail }, + other => BootstrapError::Query(other), + })?; + let ConnectReply::V0 { framework } = reply; + Ok(framework) +} + +pub(crate) fn ensure_compatible_framework( + remote: FrameworkVersion, + local: FrameworkVersion, +) -> Result<(), BootstrapError> { + if remote.is_compatible_with(local) { + return Ok(()); + } + let refusal = if version_key(remote) > version_key(local) { + CompatibilityRefusal::RemoteNewer + } else { + CompatibilityRefusal::LocalNewer + }; + Err(BootstrapError::IncompatibleFramework { + remote, + local, + refusal, + }) +} + +const fn version_key(version: FrameworkVersion) -> (u16, u16, u16) { + (version.major(), version.minor(), version.patch()) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::{BootstrapError, attach_execution, exactly_one_execution}; + use crate::bus::{BusConfig, BusOwner, Codec, MessagePack, StreamPublisher, StreamReceiver}; + use crate::identity::{ExecutionId, ParticipantId, TimelineId}; + use crate::model::builder::RobotBuilder; + use crate::model::manifest::ManifestDocument; + use crate::supervisor::api; + use crate::supervisor::api::connect::{ConnectReply, ConnectRequest}; + use crate::supervisor::api::time_domain::{TimeDomain, TimeDomainStream, TimeMode}; + use crate::version::FrameworkVersion; + + fn domain(revision: u64, timeline: u64, mode: TimeMode) -> TimeDomain { + TimeDomain { + revision, + timeline: TimelineId::from_raw(timeline).expect("a nonzero test timeline"), + mode, + } + } + + #[test] + fn ambiguous_execution_diagnostics_are_deterministic() { + let lower = ExecutionId::parse("10000000000000000000000000000001") + .expect("a canonical execution id"); + let higher = ExecutionId::parse("20000000000000000000000000000002") + .expect("a canonical execution id"); + let error = exactly_one_execution("tcp/router:7447", vec![higher, lower]) + .expect_err("two executions are ambiguous"); + let BootstrapError::MultipleExecutions { + count, executions, .. + } = error + else { + panic!("the ambiguity must retain both execution identities"); + }; + assert_eq!(count, 2); + assert_eq!(executions, vec![lower, higher]); + } + + /// The attachment stream subscribes before `current`, so replacements that + /// happen during that query are reconciled without consuming later updates. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn attachment_reconciles_buffered_time_domains_without_a_receive_gap() { + let execution = ExecutionId::mint(); + let participant = ParticipantId::new("bootstrap-domain").expect("valid participant id"); + let (owner, bus) = BusOwner::open(BusConfig::for_participant( + execution, + participant, + Vec::new(), + )) + .await + .expect("the in-process bus opens"); + let connect = bus + .declare_server(api::topics().connect().owner().key()) + .await + .expect("the bootstrap server attaches"); + let info = bus + .declare_server(api::topics().info().owner().key()) + .await + .expect("the info server attaches"); + let current = bus + .declare_server(api::topics().time_domain().current().owner().key()) + .await + .expect("the time-domain server attaches"); + let attachment_current = bus + .declare_server( + api::topics() + .simulation() + .attachment() + .current() + .owner() + .key(), + ) + .await + .expect("the attachment current server attaches"); + let _attachment_publisher = StreamPublisher::new( + bus.clone(), + &api::topics().simulation().attachment().owner(), + ) + .expect("the attachment publisher attaches"); + let publisher = StreamPublisher::new(bus.clone(), &api::topics().time_domain().owner()) + .expect("the time-domain publisher attaches"); + let delivery = + StreamReceiver::::new(&bus, &api::topics().time_domain().client()) + .await + .expect("the delivery observer subscribes"); + + let initial = domain(10, 1, TimeMode::Monotonic); + let stale = domain(9, 2, TimeMode::Simulated); + let first = domain(11, 3, TimeMode::Simulated); + let duplicate = domain(11, 4, TimeMode::Monotonic); + let second = domain(12, 5, TimeMode::Monotonic); + let buffered = [stale, first, duplicate, second]; + let manifest = ManifestDocument::new( + RobotBuilder::new("rover") + .build() + .expect("a minimal robot is valid"), + ); + let server_bus = bus.clone(); + let server_publisher = publisher.clone(); + let serving = tokio::spawn(async move { + let incoming = connect.recv().await?; + assert_eq!( + MessagePack::decode::(&incoming.request_bytes()?)?, + ConnectRequest::V0 {} + ); + incoming + .reply( + &server_bus, + MessagePack::encode(&ConnectReply::V0 { + framework: FrameworkVersion::CURRENT, + })?, + ) + .await?; + + let incoming = info.recv().await?; + let _: api::info::InfoRequest = MessagePack::decode(&incoming.request_bytes()?)?; + incoming + .reply( + &server_bus, + MessagePack::encode(&api::info::InfoResponse { manifest })?, + ) + .await?; + + let incoming = current.recv().await?; + let _: api::time_domain::CurrentRequest = + MessagePack::decode(&incoming.request_bytes()?)?; + for domain in buffered { + server_publisher.send(TimeDomainStream { domain })?; + } + for expected in buffered { + let delivered = tokio::time::timeout(Duration::from_secs(2), delivery.recv()) + .await + .expect("each buffered replacement reaches the observer")?; + assert_eq!(delivered.body.domain, expected); + } + incoming + .reply( + &server_bus, + MessagePack::encode(&api::time_domain::CurrentResponse { domain: initial })?, + ) + .await?; + + let incoming = attachment_current.recv().await?; + let _: api::simulation::attachment::CurrentRequest = + MessagePack::decode(&incoming.request_bytes()?)?; + incoming + .reply( + &server_bus, + MessagePack::encode(&api::simulation::attachment::CurrentResponse { + attachment: None, + })?, + ) + .await?; + Ok::<(), anyhow::Error>(()) + }); + + let bootstrap = attach_execution(&bus) + .await + .expect("the attachment bootstrap succeeds"); + serving + .await + .expect("the bootstrap server does not panic") + .expect("the bootstrap server succeeds"); + assert_eq!(bootstrap.execution, bus.execution()); + assert_eq!(bootstrap.time_domain, second); + + let later = domain(13, 6, TimeMode::Simulated); + publisher + .send(TimeDomainStream { domain: later }) + .expect("a later replacement is admitted"); + let observed = tokio::time::timeout(Duration::from_secs(2), bootstrap.time_domains.recv()) + .await + .expect("later replacements remain for the caller") + .expect("the later replacement decodes"); + assert_eq!(observed.body.domain, later); + + drop(bootstrap); + drop(publisher); + let _ = owner.close().await; + } +} diff --git a/phoxal/src/identity.rs b/phoxal/src/identity.rs index 474a0839..337474e3 100644 --- a/phoxal/src/identity.rs +++ b/phoxal/src/identity.rs @@ -431,12 +431,12 @@ impl ExecutionId { /// leading digit to the odd half of the alphabet; leaving a nonzero draw /// alone keeps the full nonzero leading-digit range that the transport's /// own session ids cover. - pub fn mint() -> Self { + pub(crate) fn mint() -> Self { ExecutionId(mint_canonical_value()) } - /// Parse a rendered execution identity (as it appears in the launch - /// contract, the key root, and the router session id). + /// Parse a rendered execution identity as discovered from a router, read + /// from a key root, or restored from an attachment record. /// /// Only the canonical form is accepted: exactly [`ExecutionId::LEN`] /// lowercase hexadecimal characters, the first of which is not `0`. diff --git a/phoxal/src/lib.rs b/phoxal/src/lib.rs index e4aaa6d9..64c23b3f 100644 --- a/phoxal/src/lib.rs +++ b/phoxal/src/lib.rs @@ -160,8 +160,8 @@ //! encoder, and the embedded participant-metadata reader. //! - **`simulator`** - `phoxal::simulator`: stand an external world process in //! for a robot's component drivers. `SimulatorSession` owns typed component -//! IO, delegated presence, and the world clock, without handing out the raw -//! transport underneath. +//! IO, delegated presence, and passive step progress without handing out the +//! raw transport underneath. //! - **`authoring`** - `phoxal::authoring`: the authored-source layer //! (`robot.yaml`, `component.yaml`, `simulation.yaml`, URDF), its JSON //! schemas, and the compiler that turns them into a [`model::Robot`]. A @@ -216,6 +216,7 @@ mod sample_schedule; // the profile that *is* one - a participant author reaches the engine through // the crate-root facade below, and the role attributes reach it through // `__private`, the macro ABI, which is the only path either needs. +mod execution; #[cfg(any(feature = "session", feature = "supervisor", feature = "authoring"))] #[cfg_attr( docsrs, @@ -292,6 +293,7 @@ pub mod bundle; profile that does publish it is where these lints have something to say." )] mod bundle; +pub mod drive; // Build/source tooling only. A launched participant reads the compiled // `manifest.json`, never an authored document, so the YAML/TOML/URDF readers @@ -305,9 +307,8 @@ pub mod identity; pub mod version; // A participant emits its logs and telemetry through the runner and never names -// the runtime family, so the family is a host-role surface: the applications -// that read a running execution, the simulator that publishes its world clock, -// and the supervisor that retains both. +// the runtime family, so the family is a host-role surface: applications that +// read a running execution and the supervisor that retains its live evidence. #[cfg(any(feature = "session", feature = "simulator", feature = "supervisor"))] #[cfg_attr( docsrs, @@ -325,6 +326,39 @@ pub mod runtime; )] mod runtime; +// Simulation progress is a fourth semantic wire family. A participant runner +// consumes it internally, but participant-authored code never receives this +// module as a public surface. World hosts, sessions, and the supervisor +// publish or inspect it through the one canonical path below. +#[cfg(any(feature = "session", feature = "simulator", feature = "supervisor"))] +#[cfg_attr( + docsrs, + doc(cfg(any(feature = "session", feature = "simulator", feature = "supervisor"))) +)] +pub mod simulation; +#[cfg(not(any(feature = "session", feature = "simulator", feature = "supervisor")))] +#[allow( + dead_code, + unused_imports, + reason = "the compatibility aggregate reads simulation progress while the participant profile keeps the host contract family private" +)] +mod simulation; + +/// Backend-neutral world-session documents and local client/server wire. +#[cfg(any(feature = "session", feature = "simulator", feature = "supervisor"))] +#[cfg_attr( + docsrs, + doc(cfg(any(feature = "session", feature = "simulator", feature = "supervisor"))) +)] +pub mod world; +#[cfg(not(any(feature = "session", feature = "simulator", feature = "supervisor")))] +#[allow( + dead_code, + unused_imports, + reason = "every profile compiles the complete compatibility surface" +)] +mod world; + /// Declare the `supervisor` boundary at the visibility this profile gives it. /// /// The tree is written once, here, because `supervisor::host` is gated on its diff --git a/phoxal/src/model/asset.rs b/phoxal/src/model/asset.rs index 547e534c..a93b3f4e 100644 --- a/phoxal/src/model/asset.rs +++ b/phoxal/src/model/asset.rs @@ -41,6 +41,12 @@ impl AssetId { } } +impl std::fmt::Display for AssetId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} + impl Serialize for AssetId { fn serialize(&self, serializer: S) -> Result { serializer.serialize_str(self.as_str()) diff --git a/phoxal/src/model/builder.rs b/phoxal/src/model/builder.rs index 0bbba32e..641aeade 100644 --- a/phoxal/src/model/builder.rs +++ b/phoxal/src/model/builder.rs @@ -97,13 +97,15 @@ use crate::model::component::capability::{ }; use crate::model::connection::Connection; use crate::model::error::ModelError; +use crate::model::geometry::Geometry; use crate::model::identity::{ CapabilityId, CapabilityRef, ComponentInstanceId, ComponentTypeId, JointId, LinkId, RobotId, ServiceId, }; -use crate::model::robot::{Driver, KinematicConfig, MotionLimits, Robot}; +use crate::model::kinematics::{KinematicConfig, MotionLimits}; +use crate::model::robot::{Driver, Robot}; use crate::model::simulation; -use crate::model::structure::{BASE_FOOTPRINT_LINK, BASE_LINK, Geometry, JointKind, Structure}; +use crate::model::structure::{BASE_FOOTPRINT_LINK, BASE_LINK, JointKind, Structure}; /// The root link of every component structure this module generates. pub const COMPONENT_ROOT_LINK: &str = "mount"; @@ -477,7 +479,7 @@ impl Default for Inertia { /// ``` /// use phoxal::model::AssetId; /// use phoxal::model::builder::{Link, Material, RobotBuilder, Visual}; -/// use phoxal::model::structure::Geometry; +/// use phoxal::model::geometry::Geometry; /// /// let robot = RobotBuilder::new("rover") /// .link(Link { @@ -825,7 +827,7 @@ impl RobotBuilder { /// use phoxal::model::builder::{ /// Collision, Inertia, Inertial, Link, Material, RobotBuilder, Visual, /// }; - /// use phoxal::model::structure::Geometry; + /// use phoxal::model::geometry::Geometry; /// /// let robot = RobotBuilder::new("rover") /// .link(Link { @@ -1096,7 +1098,7 @@ impl ComponentTypeBuilder { /// /// ``` /// use phoxal::model::builder::{Link, RobotBuilder, Visual}; - /// use phoxal::model::structure::Geometry; + /// use phoxal::model::geometry::Geometry; /// /// let robot = RobotBuilder::new("rover") /// .component_type("rgbd", |camera| { @@ -1821,10 +1823,11 @@ mod tests { Capability, CapabilityKind, Motor, MotorCommand, StructuralTarget, }; use crate::model::error::{IdentifierKind, ModelError, StructureError}; + use crate::model::geometry::Geometry; use crate::model::identity::{CapabilityRef, JointId, LinkId}; - use crate::model::robot::{DriveKinematics, KinematicConfig, MotionLimits}; + use crate::model::kinematics::{DriveKinematics, KinematicConfig, MotionLimits}; use crate::model::simulation; - use crate::model::structure::{Geometry, JointKind}; + use crate::model::structure::JointKind; fn reference(value: &str) -> CapabilityRef { value.parse().expect("a well formed capability reference") @@ -2410,6 +2413,30 @@ mod tests { )); } + #[test] + fn a_simulation_cannot_name_a_link_the_component_does_not_define() { + let rejected = RobotBuilder::new("rover") + .component_type("drive_motor", |motor| { + motor + .motor("spin", "axle") + .simulated( + "spin", + simulation::Capability::Motor(simulation::Motor::default()), + ) + .contact_material("ghost", "rubber") + }) + .component("left_drive", "drive_motor") + .build(); + + assert!(matches!( + rejected, + Err(ModelError::SimulationWithoutLink { + component_type, + link, + }) if component_type.as_str() == "drive_motor" && link.as_str() == "ghost" + )); + } + /// Every rejection is a typed value the caller can match on, not a panic. #[test] fn a_rejected_robot_returns_the_condition_it_violated() { diff --git a/phoxal/src/model/compiler.rs b/phoxal/src/model/compiler.rs index 144e96ac..310727da 100644 --- a/phoxal/src/model/compiler.rs +++ b/phoxal/src/model/compiler.rs @@ -23,9 +23,8 @@ use crate::model::error::ModelError; use crate::model::identity::{ CapabilityId, ComponentInstanceId, ComponentTypeId, LinkId, RobotId, ServiceId, }; -use crate::model::robot::{ - ComponentInstance, Driver, KinematicConfig, MotionLimits, Robot, Service, -}; +use crate::model::kinematics::{KinematicConfig, MotionLimits}; +use crate::model::robot::{ComponentInstance, Driver, Robot, Service}; use crate::model::simulation::{self, Simulation}; use crate::model::structure::Structure; diff --git a/phoxal/src/model/component/capability.rs b/phoxal/src/model/component/capability.rs index 7286770a..0b17c178 100644 --- a/phoxal/src/model/component/capability.rs +++ b/phoxal/src/model/component/capability.rs @@ -222,7 +222,10 @@ pub enum GnssCoordinateSystem { /// A component declares the kind and a simulation models it; the two must /// agree, which is why the kind is one shared type rather than two parallel /// vocabularies compared as strings. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive( + serde::Serialize, serde::Deserialize, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, +)] +#[serde(rename_all = "snake_case")] pub enum CapabilityKind { Motor, Encoder, diff --git a/phoxal/src/model/error.rs b/phoxal/src/model/error.rs index 6f76a207..22b3811d 100644 --- a/phoxal/src/model/error.rs +++ b/phoxal/src/model/error.rs @@ -12,7 +12,7 @@ use crate::model::identity::{ CapabilityId, CapabilityRef, ComponentInstanceId, ComponentTypeId, JointId, LinkId, MODULE_INSTANCE_SEPARATOR, }; -use crate::model::robot::KinematicKind; +use crate::model::kinematics::KinematicKind; use crate::model::structure::JointKind; /// A canonical robot model that violates the runtime model's invariants. @@ -151,6 +151,13 @@ pub enum ModelError { declared: CapabilityKind, }, + /// A simulated link has no counterpart in the component type's structure. + #[error("simulation link '{component_type}.{link}' has no component structural link")] + SimulationWithoutLink { + component_type: ComponentTypeId, + link: LinkId, + }, + /// A joint uses a kind the runtime cannot drive. #[error("{owner} joint '{joint}' uses unsupported runtime kind '{kind:?}'")] UnsupportedJointKind { @@ -265,6 +272,10 @@ pub enum StructureError { #[error("link '{link}' geometry dimensions must be finite and positive")] Geometry { link: LinkId }, + /// A render material color is not a finite normalized RGBA value. + #[error("material '{name}' color must contain finite values in [0, 1]")] + MaterialColor { name: String }, + /// A joint axis has a non-finite component. #[error("joint '{joint}' axis must be finite")] AxisNotFinite { joint: JointId }, @@ -311,6 +322,10 @@ pub enum StructureError { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum IdentifierKind { RobotId, + WorldId, + WorldAsset, + WorldSpawn, + WorldEntityDeclaration, RobotLink, RobotJoint, ComponentType, @@ -323,6 +338,10 @@ impl fmt::Display for IdentifierKind { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { Self::RobotId => "robot id", + Self::WorldId => "world id", + Self::WorldAsset => "world asset name", + Self::WorldSpawn => "world spawn name", + Self::WorldEntityDeclaration => "world entity declaration name", Self::RobotLink => "robot link", Self::RobotJoint => "robot joint", Self::ComponentType => "component type", diff --git a/phoxal/src/model/footprint.rs b/phoxal/src/model/footprint.rs index bdf435ad..54039e7c 100644 --- a/phoxal/src/model/footprint.rs +++ b/phoxal/src/model/footprint.rs @@ -14,9 +14,10 @@ use std::collections::BTreeMap; use crate::model::ModelError; use crate::model::component::Component; +use crate::model::geometry::Geometry; use crate::model::identity::ComponentInstanceId; use crate::model::robot::ComponentInstance; -use crate::model::structure::{Collision, Geometry, Joint, JointKind, Structure}; +use crate::model::structure::{Collision, Joint, JointKind, Structure}; const AXIS_INVARIANCE_TOLERANCE: f64 = 1.0e-9; diff --git a/phoxal/src/model/geometry.rs b/phoxal/src/model/geometry.rs new file mode 100644 index 00000000..1ad71af1 --- /dev/null +++ b/phoxal/src/model/geometry.rs @@ -0,0 +1,57 @@ +//! Canonical geometry shared by robot structures and compiled worlds. + +use serde::{Deserialize, Serialize}; + +use crate::model::asset::AssetId; + +/// Complete canonical geometry vocabulary. +#[derive(phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Geometry { + Box { + size: [f64; 3], + }, + Cylinder { + radius: f64, + length: f64, + }, + Capsule { + radius: f64, + length: f64, + }, + Sphere { + radius: f64, + }, + Mesh { + #[serde(rename = "filename")] + asset: AssetId, + scale: Option<[f64; 3]>, + }, +} + +impl Geometry { + /// The bundled asset this geometry references, when it is a mesh. + #[must_use] + pub fn asset_id(&self) -> Option<&AssetId> { + match self { + Self::Mesh { asset, .. } => Some(asset), + _ => None, + } + } + + /// Whether every authored dimension is finite and strictly positive. + #[must_use] + pub fn has_valid_dimensions(&self) -> bool { + let dimensions: &[f64] = match self { + Self::Box { size } => size, + Self::Cylinder { radius, length } | Self::Capsule { radius, length } => { + &[*radius, *length] + } + Self::Sphere { radius } => &[*radius], + Self::Mesh { scale, .. } => scale.as_ref().map_or(&[], |values| values.as_slice()), + }; + dimensions + .iter() + .all(|value| value.is_finite() && *value > 0.0) + } +} diff --git a/phoxal/src/model/identity.rs b/phoxal/src/model/identity.rs index e44ba8b6..d14c01e2 100644 --- a/phoxal/src/model/identity.rs +++ b/phoxal/src/model/identity.rs @@ -163,6 +163,30 @@ token_identifier!( IdentifierKind::Capability ); +token_identifier!( + /// The stable authored identity of one compiled world. + WorldId, + IdentifierKind::WorldId +); + +token_identifier!( + /// The name of one reusable asset declaration in an authored world. + WorldAssetName, + IdentifierKind::WorldAsset +); + +token_identifier!( + /// The name of one spawn point in an authored world. + SpawnId, + IdentifierKind::WorldSpawn +); + +token_identifier!( + /// The name of one entity declaration expanded into a compiled world. + EntityDeclarationId, + IdentifierKind::WorldEntityDeclaration +); + /// Declare a structural identifier newtype. /// /// Structural names come from authored URDF, whose grammar is wider than diff --git a/phoxal/src/model/kinematics/mod.rs b/phoxal/src/model/kinematics/mod.rs new file mode 100644 index 00000000..a422e946 --- /dev/null +++ b/phoxal/src/model/kinematics/mod.rs @@ -0,0 +1,834 @@ +//! Canonical motion limits and drive kinematics. + +use crate::model::error::{KinematicScalarField, ModelError, MotionLimitField}; +use crate::model::identity::CapabilityRef; +use std::fmt; + +/// Canonical motion facts. +#[derive(Debug, Clone)] +pub struct MotionModel { + kinematic: KinematicConfig, + limits: MotionLimits, +} + +/// The outer envelope every motion command is clamped to. +#[derive( + phoxal_macros::DescribeWire, + serde::Serialize, + serde::Deserialize, + Debug, + Clone, + Copy, + PartialEq, + schemars::JsonSchema, +)] +#[serde(deny_unknown_fields)] +pub struct MotionLimits { + pub max_linear_speed_mps: f64, + pub max_angular_speed_radps: f64, +} + +/// The drive geometry, and the capabilities that realize it. +#[derive( + phoxal_macros::DescribeWire, + serde::Serialize, + serde::Deserialize, + Debug, + Clone, + PartialEq, + schemars::JsonSchema, +)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum KinematicConfig { + Differential { + left_actuators: Vec, + right_actuators: Vec, + left_encoders: Vec, + right_encoders: Vec, + wheel_radius_m: f64, + wheel_base_m: f64, + }, + Mecanum { + front_left_actuator: CapabilityRef, + front_right_actuator: CapabilityRef, + rear_left_actuator: CapabilityRef, + rear_right_actuator: CapabilityRef, + wheel_radius_m: f64, + wheel_base_m: f64, + track_m: f64, + }, + Ackermann { + steering_actuator: CapabilityRef, + drive_actuator: CapabilityRef, + steering_encoder: Option, + drive_encoder: Option, + wheel_base_m: f64, + track_m: f64, + max_steering_angle_rad: f64, + }, + Omnidirectional { + actuators: Vec, + encoders: Vec, + }, +} + +impl KinematicConfig { + /// Number of times an actuator occurs in this compiled drive topology. + #[must_use] + pub fn actuator_occurrences(&self, capability: &CapabilityRef) -> usize { + match self { + Self::Differential { + left_actuators, + right_actuators, + .. + } => left_actuators + .iter() + .chain(right_actuators) + .filter(|candidate| *candidate == capability) + .count(), + Self::Mecanum { + front_left_actuator, + front_right_actuator, + rear_left_actuator, + rear_right_actuator, + .. + } => [ + front_left_actuator, + front_right_actuator, + rear_left_actuator, + rear_right_actuator, + ] + .into_iter() + .filter(|candidate| *candidate == capability) + .count(), + Self::Ackermann { + steering_actuator, + drive_actuator, + .. + } => [steering_actuator, drive_actuator] + .into_iter() + .filter(|candidate| *candidate == capability) + .count(), + Self::Omnidirectional { actuators, .. } => actuators + .iter() + .filter(|candidate| *candidate == capability) + .count(), + } + } + + /// The drive geometry this config describes, with its scalars validated. + /// + /// This is the one place the authored kinematic fields are turned into + /// geometry, so every consumer that derives motion from the robot works from + /// the same reading of the document. + /// + /// # Errors + /// + /// Returns [`ModelError::KinematicScalar`] when a declared scalar is not + /// finite and positive. + pub fn drive_kinematics(&self) -> Result { + Ok(match self { + Self::Differential { + wheel_radius_m, + wheel_base_m, + .. + } => DriveKinematics::Differential( + DifferentialDrive::new(*wheel_radius_m, *wheel_base_m).validate()?, + ), + Self::Mecanum { + wheel_radius_m, + wheel_base_m, + track_m, + .. + } => DriveKinematics::Mecanum( + MecanumDrive::new(*wheel_radius_m, *wheel_base_m, *track_m).validate()?, + ), + Self::Ackermann { + wheel_base_m, + track_m, + max_steering_angle_rad, + .. + } => DriveKinematics::Ackermann( + AckermannDrive::new(*wheel_base_m, *track_m, *max_steering_angle_rad).validate()?, + ), + Self::Omnidirectional { .. } => DriveKinematics::Omnidirectional, + }) + } +} + +/// A planar body twist in the robot's base frame. +/// +/// `linear_y_mps` is only meaningful for a holonomic geometry. A differential or +/// Ackermann robot cannot translate sideways at all, so those geometries ignore +/// it rather than approximating it: silently turning a commanded sideways +/// velocity into yaw would move the robot somewhere its caller did not ask for. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct BodyTwist { + /// Forward velocity, in metres per second. + pub linear_x_mps: f64, + /// Leftward velocity, in metres per second. Zero for a non-holonomic drive. + pub linear_y_mps: f64, + /// Yaw rate, in radians per second, positive counter-clockwise. + pub angular_z_radps: f64, +} + +impl BodyTwist { + /// A twist a non-holonomic drive can realize: forward and yaw only. + #[must_use] + pub const fn planar(linear_x_mps: f64, angular_z_radps: f64) -> Self { + Self { + linear_x_mps, + linear_y_mps: 0.0, + angular_z_radps, + } + } + + /// A full holonomic twist. + #[must_use] + pub const fn new(linear_x_mps: f64, linear_y_mps: f64, angular_z_radps: f64) -> Self { + Self { + linear_x_mps, + linear_y_mps, + angular_z_radps, + } + } + + /// Whether every component is finite. + #[must_use] + pub fn is_finite(&self) -> bool { + self.linear_x_mps.is_finite() + && self.linear_y_mps.is_finite() + && self.angular_z_radps.is_finite() + } +} + +/// The wheel angular speeds of a differential drive, in radians per second. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DifferentialWheelSpeeds { + pub left_radps: f64, + pub right_radps: f64, +} + +/// The wheel angular speeds of a mecanum drive, in radians per second. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MecanumWheelSpeeds { + pub front_left_radps: f64, + pub front_right_radps: f64, + pub rear_left_radps: f64, + pub rear_right_radps: f64, +} + +/// What an Ackermann drive is commanded with. +/// +/// This is a linear speed rather than a wheel angular speed because +/// [`KinematicConfig::Ackermann`] authors no wheel radius: the document has no +/// value that could convert one to the other, and inventing one here would put a +/// number on the wire that nobody authored. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AckermannCommand { + /// Speed of the driven axle, in metres per second. + pub drive_speed_mps: f64, + /// Steering angle, in radians, positive counter-clockwise. + pub steering_angle_rad: f64, +} + +/// The drive geometry of a robot, in the form its kinematics need. +/// +/// This is the single dispatch point over every geometry [`KinematicConfig`] +/// can declare. Each variant carries its own geometry type with its own +/// statically typed wheel commands, because the four do not share a command +/// shape: a differential drive is commanded with two wheel speeds, a mecanum +/// with four, and an Ackermann with a speed and a steering angle. Collapsing +/// them behind one signature would mean either an erased command vector or a +/// lowest-common-denominator twist, and both lose exactly the information the +/// caller needs. +/// +/// Obtained from [`KinematicConfig::drive_kinematics`], which validates the +/// scalars first, so a value of this type always has usable geometry. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DriveKinematics { + Differential(DifferentialDrive), + Mecanum(MecanumDrive), + Ackermann(AckermannDrive), + /// An omnidirectional drive, whose kinematics are not derivable from the + /// authored document. + /// + /// [`KinematicConfig::Omnidirectional`] carries actuator and encoder lists + /// and no geometry at all - no wheel radius, no wheel mounting angles, no + /// distance from the rotation centre - and every one of those is required to + /// relate wheel speeds to a body twist. The variant is carried here so the + /// enum covers every geometry the model can declare, and so a consumer + /// matching on it is told the geometry is unavailable rather than silently + /// falling through to another drive's math. + Omnidirectional, +} + +/// The differential-drive geometry, separated from the capabilities realizing it. +/// +/// [`KinematicConfig::Differential`] carries the wheel geometry alongside the +/// actuator and encoder lists, but the two directions of the wheel/twist +/// relation depend only on the geometry. They live together here because they +/// are one relation read two ways: a robot whose commanded twist and whose +/// measured twist disagreed about wheel radius would drive one distance and +/// report another, and nothing downstream could detect it. Keeping the pair on +/// one type is what makes them impossible to change independently. +/// +/// This is a derived value, not part of the canonical document, so it carries no +/// serde representation. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DifferentialDrive { + /// Driven wheel radius, in metres. + pub wheel_radius_m: f64, + /// Distance between the driven wheels, in metres. + pub wheel_base_m: f64, +} + +impl DifferentialDrive { + /// The geometry with the given wheel radius and track width, both in metres. + #[must_use] + pub const fn new(wheel_radius_m: f64, wheel_base_m: f64) -> Self { + Self { + wheel_radius_m, + wheel_base_m, + } + } + + /// Check the geometry is usable. + /// + /// Both scalars divide in [`Self::wheel_speeds`] and [`Self::body_twist`], + /// so a zero or non-finite value does not fail loudly - it yields an + /// infinite or `NaN` wheel command, which is why every consumer must run + /// this before deriving anything from the geometry. + /// + /// # Errors + /// + /// Returns [`ModelError::KinematicScalar`] when a scalar is not finite and + /// positive. + pub fn validate(self) -> Result { + for (value, field) in [ + (self.wheel_radius_m, KinematicScalarField::WheelRadiusM), + (self.wheel_base_m, KinematicScalarField::WheelBaseM), + ] { + if !(value.is_finite() && value > 0.0) { + return Err(ModelError::KinematicScalar { + kinematics: KinematicKind::Differential, + field, + }); + } + } + Ok(self) + } + + /// The wheel speeds that produce `twist`. + /// + /// This is the inverse of [`Self::body_twist`]. `twist.linear_y_mps` is + /// ignored: a differential drive cannot translate sideways. + /// + /// It does not reject a non-finite result: what a caller must do about a + /// geometry that turns a finite twist into an uncommandable speed depends on + /// what it is about to do with it, so that judgment stays with the caller. + #[must_use] + pub fn wheel_speeds(self, twist: BodyTwist) -> DifferentialWheelSpeeds { + let half_track = self.wheel_base_m / 2.0; + let left = twist.linear_x_mps - twist.angular_z_radps * half_track; + let right = twist.linear_x_mps + twist.angular_z_radps * half_track; + DifferentialWheelSpeeds { + left_radps: left / self.wheel_radius_m, + right_radps: right / self.wheel_radius_m, + } + } + + /// The body twist a pair of wheel angular speeds implies. + /// + /// The inverse of [`Self::wheel_speeds`]. `linear_y_mps` is always zero. + #[must_use] + pub fn body_twist(self, speeds: DifferentialWheelSpeeds) -> BodyTwist { + let left = speeds.left_radps * self.wheel_radius_m; + let right = speeds.right_radps * self.wheel_radius_m; + BodyTwist::planar((left + right) / 2.0, (right - left) / self.wheel_base_m) + } +} + +/// The mecanum-drive geometry: four independently driven wheels with 45-degree +/// rollers, in the standard X configuration. +/// +/// Unlike a differential drive this geometry is holonomic, so it realizes +/// `linear_y_mps` directly rather than ignoring it. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MecanumDrive { + /// Driven wheel radius, in metres. + pub wheel_radius_m: f64, + /// Front-to-rear axle separation, in metres. + pub wheel_base_m: f64, + /// Left-to-right wheel separation, in metres. + pub track_m: f64, +} + +impl MecanumDrive { + /// The geometry with the given wheel radius, wheel base and track, in metres. + #[must_use] + pub const fn new(wheel_radius_m: f64, wheel_base_m: f64, track_m: f64) -> Self { + Self { + wheel_radius_m, + wheel_base_m, + track_m, + } + } + + /// Half the wheel base plus half the track: the lever arm that converts yaw + /// rate into the differential wheel speed a mecanum uses to rotate. + const fn yaw_lever_m(self) -> f64 { + (self.wheel_base_m + self.track_m) / 2.0 + } + + /// Check the geometry is usable. + /// + /// # Errors + /// + /// Returns [`ModelError::KinematicScalar`] when a scalar is not finite and + /// positive. + pub fn validate(self) -> Result { + for (value, field) in [ + (self.wheel_radius_m, KinematicScalarField::WheelRadiusM), + (self.wheel_base_m, KinematicScalarField::WheelBaseM), + (self.track_m, KinematicScalarField::TrackM), + ] { + if !(value.is_finite() && value > 0.0) { + return Err(ModelError::KinematicScalar { + kinematics: KinematicKind::Mecanum, + field, + }); + } + } + Ok(self) + } + + /// The four wheel speeds that produce `twist`. + /// + /// The inverse of [`Self::body_twist`]. + #[must_use] + pub fn wheel_speeds(self, twist: BodyTwist) -> MecanumWheelSpeeds { + let yaw = twist.angular_z_radps * self.yaw_lever_m(); + let scale = 1.0 / self.wheel_radius_m; + MecanumWheelSpeeds { + front_left_radps: scale * (twist.linear_x_mps - twist.linear_y_mps - yaw), + front_right_radps: scale * (twist.linear_x_mps + twist.linear_y_mps + yaw), + rear_left_radps: scale * (twist.linear_x_mps + twist.linear_y_mps - yaw), + rear_right_radps: scale * (twist.linear_x_mps - twist.linear_y_mps + yaw), + } + } + + /// The body twist four wheel angular speeds imply. + /// + /// The inverse of [`Self::wheel_speeds`]. Four wheel speeds over-determine a + /// three-component twist, so this is the least-squares solution: a set of + /// speeds that no rigid twist can produce (the wheels fighting each other) + /// yields the twist closest to what they describe rather than an error. + #[must_use] + pub fn body_twist(self, speeds: MecanumWheelSpeeds) -> BodyTwist { + let MecanumWheelSpeeds { + front_left_radps: fl, + front_right_radps: fr, + rear_left_radps: rl, + rear_right_radps: rr, + } = speeds; + BodyTwist::new( + (fl + fr + rl + rr) * self.wheel_radius_m / 4.0, + (-fl + fr + rl - rr) * self.wheel_radius_m / 4.0, + (-fl + fr - rl + rr) * self.wheel_radius_m / (4.0 * self.yaw_lever_m()), + ) + } +} + +/// The Ackermann-steering geometry: one steered axle and one driven axle. +/// +/// The relation used is the bicycle model taken at the centre of the driven +/// axle, which is what a single steering actuator can express. `track_m` is +/// carried because the authored document declares it, but a true per-wheel +/// Ackermann split needs two independently steered wheels, which this config +/// does not describe. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AckermannDrive { + /// Front-to-rear axle separation, in metres. + pub wheel_base_m: f64, + /// Left-to-right wheel separation, in metres. + pub track_m: f64, + /// The largest steering angle the mechanism reaches, in radians. + pub max_steering_angle_rad: f64, +} + +impl AckermannDrive { + /// The geometry with the given wheel base, track and steering limit. + #[must_use] + pub const fn new(wheel_base_m: f64, track_m: f64, max_steering_angle_rad: f64) -> Self { + Self { + wheel_base_m, + track_m, + max_steering_angle_rad, + } + } + + /// Check the geometry is usable. + /// + /// # Errors + /// + /// Returns [`ModelError::KinematicScalar`] when a scalar is not finite and + /// positive. + pub fn validate(self) -> Result { + for (value, field) in [ + (self.wheel_base_m, KinematicScalarField::WheelBaseM), + (self.track_m, KinematicScalarField::TrackM), + ( + self.max_steering_angle_rad, + KinematicScalarField::MaxSteeringAngleRad, + ), + ] { + if !(value.is_finite() && value > 0.0) { + return Err(ModelError::KinematicScalar { + kinematics: KinematicKind::Ackermann, + field, + }); + } + } + Ok(self) + } + + /// The drive speed and steering angle that produce `twist`. + /// + /// The inverse of [`Self::body_twist`]. `twist.linear_y_mps` is ignored: a + /// steered drive cannot translate sideways. + /// + /// A stationary robot has no steering angle that produces yaw, so a zero + /// forward speed yields a zero steering angle. The returned angle is **not** + /// clamped to [`Self::max_steering_angle_rad`]: a caller that must refuse an + /// unreachable request needs to see that it was unreachable, which + /// [`Self::steering_is_reachable`] answers. + #[must_use] + pub fn command(self, twist: BodyTwist) -> AckermannCommand { + let steering_angle_rad = if twist.linear_x_mps == 0.0 { + 0.0 + } else { + (twist.angular_z_radps * self.wheel_base_m / twist.linear_x_mps).atan() + }; + AckermannCommand { + drive_speed_mps: twist.linear_x_mps, + steering_angle_rad, + } + } + + /// The body twist a drive speed and steering angle imply. + /// + /// The inverse of [`Self::command`]. `linear_y_mps` is always zero. + #[must_use] + pub fn body_twist(self, command: AckermannCommand) -> BodyTwist { + BodyTwist::planar( + command.drive_speed_mps, + command.drive_speed_mps * command.steering_angle_rad.tan() / self.wheel_base_m, + ) + } + + /// Whether the mechanism can actually reach `steering_angle_rad`. + #[must_use] + pub fn steering_is_reachable(self, steering_angle_rad: f64) -> bool { + steering_angle_rad.abs() <= self.max_steering_angle_rad + } +} + +/// Which drive geometry a [`KinematicConfig`] describes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum KinematicKind { + Differential, + Mecanum, + Ackermann, + Omnidirectional, +} + +/// Fully normalized runtime-facing robot model. +/// +/// This is the whole of what `manifest.json` carries: the robot's identity and +/// structure, the motion it may make, the services it runs, and the components +/// it mounts together with the types behind them. Everything a launched +/// participant needs to know about the robot - including its own configuration - +impl MotionModel { + #[must_use] + pub const fn kinematic(&self) -> &KinematicConfig { + &self.kinematic + } + + #[must_use] + pub const fn limits(&self) -> MotionLimits { + self.limits + } +} + +impl MotionLimits { + /// Check the envelope is usable. + /// + /// # Errors + /// + /// Returns [`ModelError::MotionLimit`] when a limit is not finite, + /// positive, and representable as `f32`. + pub fn validate(self) -> Result { + for (value, field) in [ + ( + self.max_linear_speed_mps, + MotionLimitField::MaxLinearSpeedMps, + ), + ( + self.max_angular_speed_radps, + MotionLimitField::MaxAngularSpeedRadps, + ), + ] { + if !(value.is_finite() && value > 0.0 && value <= f64::from(f32::MAX)) { + return Err(ModelError::MotionLimit { field }); + } + } + Ok(self) + } +} + +impl KinematicConfig { + /// Which drive geometry this configuration describes. + #[must_use] + pub const fn kind(&self) -> KinematicKind { + match self { + Self::Differential { .. } => KinematicKind::Differential, + Self::Mecanum { .. } => KinematicKind::Mecanum, + Self::Ackermann { .. } => KinematicKind::Ackermann, + Self::Omnidirectional { .. } => KinematicKind::Omnidirectional, + } + } +} + +impl fmt::Display for KinematicKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Differential => "differential", + Self::Mecanum => "mecanum", + Self::Ackermann => "ackermann", + Self::Omnidirectional => "omnidirectional", + }) + } +} +impl MotionModel { + pub(crate) const fn new(kinematic: KinematicConfig, limits: MotionLimits) -> Self { + Self { kinematic, limits } + } +} + +#[cfg(test)] +mod kinematics_tests { + use super::{ + AckermannDrive, BodyTwist, DifferentialDrive, DriveKinematics, KinematicConfig, + KinematicScalarField, MecanumDrive, ModelError, + }; + use crate::model::identity::CapabilityRef; + + const DIFFERENTIAL: DifferentialDrive = DifferentialDrive::new(0.1, 0.5); + const MECANUM: MecanumDrive = MecanumDrive::new(0.1, 0.4, 0.6); + const ACKERMANN: AckermannDrive = AckermannDrive::new(2.5, 1.5, 0.6); + + fn close(left: f64, right: f64, what: &str) { + assert!((left - right).abs() < 1e-9, "{what}: {left} vs {right}"); + } + + /// Forward and inverse are one relation read two ways. A twist that survives + /// the round trip is the property that matters: if the two ever disagreed, a + /// robot would drive one distance and report another, and nothing downstream + /// could detect it. + #[test] + fn a_differential_twist_survives_the_round_trip() { + for twist in [ + BodyTwist::planar(0.0, 0.0), + BodyTwist::planar(1.0, 0.0), + BodyTwist::planar(0.0, 2.0), + BodyTwist::planar(0.75, -1.25), + ] { + let back = DIFFERENTIAL.body_twist(DIFFERENTIAL.wheel_speeds(twist)); + close(back.linear_x_mps, twist.linear_x_mps, "linear x"); + close(back.angular_z_radps, twist.angular_z_radps, "angular z"); + assert_eq!(back.linear_y_mps, 0.0, "a differential drive has no sway"); + } + } + + #[test] + fn a_mecanum_twist_survives_the_round_trip_including_sideways() { + for twist in [ + BodyTwist::new(0.0, 0.0, 0.0), + BodyTwist::new(1.0, 0.0, 0.0), + BodyTwist::new(0.0, 1.0, 0.0), + BodyTwist::new(0.0, 0.0, 1.5), + BodyTwist::new(0.4, -0.7, 0.9), + ] { + let back = MECANUM.body_twist(MECANUM.wheel_speeds(twist)); + close(back.linear_x_mps, twist.linear_x_mps, "linear x"); + close(back.linear_y_mps, twist.linear_y_mps, "linear y"); + close(back.angular_z_radps, twist.angular_z_radps, "angular z"); + } + } + + #[test] + fn an_ackermann_twist_survives_the_round_trip() { + for twist in [ + BodyTwist::planar(1.0, 0.0), + BodyTwist::planar(2.0, 0.4), + BodyTwist::planar(-1.5, -0.3), + ] { + let back = ACKERMANN.body_twist(ACKERMANN.command(twist)); + close(back.linear_x_mps, twist.linear_x_mps, "linear x"); + close(back.angular_z_radps, twist.angular_z_radps, "angular z"); + } + } + + #[test] + fn driving_straight_turns_both_differential_wheels_at_the_same_speed() { + let speeds = DIFFERENTIAL.wheel_speeds(BodyTwist::planar(1.0, 0.0)); + assert_eq!(speeds.left_radps, speeds.right_radps); + assert_eq!(speeds.left_radps, 1.0 / DIFFERENTIAL.wheel_radius_m); + } + + #[test] + fn turning_in_place_turns_the_differential_wheels_in_opposite_directions() { + let speeds = DIFFERENTIAL.wheel_speeds(BodyTwist::planar(0.0, 1.0)); + assert_eq!(speeds.left_radps, -speeds.right_radps); + assert!( + speeds.right_radps > 0.0, + "a positive yaw rate drives the right wheel forward" + ); + } + + /// Strafing left is the motion a differential drive cannot make, so it is + /// the one that proves the mecanum roller signs are right: the diagonal + /// pairs must counter-rotate. + #[test] + fn strafing_counter_rotates_the_mecanum_diagonals() { + let speeds = MECANUM.wheel_speeds(BodyTwist::new(0.0, 1.0, 0.0)); + assert_eq!(speeds.front_left_radps, -speeds.front_right_radps); + assert_eq!(speeds.rear_left_radps, -speeds.rear_right_radps); + assert_eq!(speeds.front_left_radps, speeds.rear_right_radps); + assert!( + speeds.front_right_radps > 0.0, + "left sway drives FR forward" + ); + } + + /// A non-holonomic geometry ignores sway rather than approximating it, so a + /// sideways request must not leak into the wheels. + #[test] + fn non_holonomic_geometries_ignore_a_sideways_request() { + let straight = BodyTwist::planar(1.0, 0.0); + let swaying = BodyTwist::new(1.0, 5.0, 0.0); + assert_eq!( + DIFFERENTIAL.wheel_speeds(straight), + DIFFERENTIAL.wheel_speeds(swaying) + ); + assert_eq!(ACKERMANN.command(straight), ACKERMANN.command(swaying)); + } + + /// A stationary robot has no steering angle that produces yaw, so asking for + /// one must not divide by zero into a `NaN` the caller would then command. + #[test] + fn a_stationary_ackermann_has_a_defined_steering_angle() { + let command = ACKERMANN.command(BodyTwist::planar(0.0, 1.0)); + assert_eq!(command.drive_speed_mps, 0.0); + assert_eq!(command.steering_angle_rad, 0.0); + } + + #[test] + fn the_steering_limit_is_reported_rather_than_silently_clamped() { + let command = ACKERMANN.command(BodyTwist::planar(0.5, 2.0)); + assert!( + command.steering_angle_rad.abs() > ACKERMANN.max_steering_angle_rad, + "this request should exceed the mechanism" + ); + assert!(!ACKERMANN.steering_is_reachable(command.steering_angle_rad)); + assert!(ACKERMANN.steering_is_reachable(0.0)); + } + + fn reference() -> CapabilityRef { + "base.motor".parse().expect("a well formed capability ref") + } + + #[test] + fn every_authored_geometry_resolves_to_its_kinematics() { + let differential = KinematicConfig::Differential { + left_actuators: vec![reference()], + right_actuators: vec![reference()], + left_encoders: Vec::new(), + right_encoders: Vec::new(), + wheel_radius_m: 0.1, + wheel_base_m: 0.5, + }; + assert_eq!( + differential.drive_kinematics().expect("valid geometry"), + DriveKinematics::Differential(DIFFERENTIAL) + ); + + let mecanum = KinematicConfig::Mecanum { + front_left_actuator: reference(), + front_right_actuator: reference(), + rear_left_actuator: reference(), + rear_right_actuator: reference(), + wheel_radius_m: 0.1, + wheel_base_m: 0.4, + track_m: 0.6, + }; + assert_eq!( + mecanum.drive_kinematics().expect("valid geometry"), + DriveKinematics::Mecanum(MECANUM) + ); + + let ackermann = KinematicConfig::Ackermann { + steering_actuator: reference(), + drive_actuator: reference(), + steering_encoder: None, + drive_encoder: None, + wheel_base_m: 2.5, + track_m: 1.5, + max_steering_angle_rad: 0.6, + }; + assert_eq!( + ackermann.drive_kinematics().expect("valid geometry"), + DriveKinematics::Ackermann(ACKERMANN) + ); + + // An omnidirectional document authors actuators and encoders but no + // geometry, so there is nothing to resolve and the variant says so + // rather than borrowing another drive's math. + let omnidirectional = KinematicConfig::Omnidirectional { + actuators: vec![reference()], + encoders: Vec::new(), + }; + assert_eq!( + omnidirectional + .drive_kinematics() + .expect("carries no scalars to reject"), + DriveKinematics::Omnidirectional + ); + } + + #[test] + fn a_non_positive_scalar_is_refused_by_the_geometry_it_belongs_to() { + assert!(matches!( + DifferentialDrive::new(0.0, 0.5).validate(), + Err(ModelError::KinematicScalar { + field: KinematicScalarField::WheelRadiusM, + .. + }) + )); + assert!(matches!( + MecanumDrive::new(0.1, 0.4, f64::NAN).validate(), + Err(ModelError::KinematicScalar { + field: KinematicScalarField::TrackM, + .. + }) + )); + assert!(matches!( + AckermannDrive::new(2.5, 1.5, -0.1).validate(), + Err(ModelError::KinematicScalar { + field: KinematicScalarField::MaxSteeringAngleRad, + .. + }) + )); + } +} diff --git a/phoxal/src/model/mod.rs b/phoxal/src/model/mod.rs index b5129b41..1bf7ba4d 100644 --- a/phoxal/src/model/mod.rs +++ b/phoxal/src/model/mod.rs @@ -18,8 +18,8 @@ //! # Paths //! //! Concepts live in the module that owns them: [`asset`], [`builder`], -//! [`component`], [`connection`], [`identity`], [`manifest`], [`robot`], -//! [`simulation`], [`structure`]. +//! [`component`], [`connection`], [`identity`], [`kinematics`], [`manifest`], +//! [`robot`], [`simulation`], [`structure`]. //! //! This module's root is a deliberate facade over the handful of names a consumer //! meets first, so that loading, reading or composing a robot does not require @@ -38,11 +38,14 @@ pub mod builder; pub mod component; pub mod connection; pub mod footprint; +pub mod geometry; pub mod identity; +pub mod kinematics; pub mod manifest; pub mod robot; pub mod simulation; pub mod structure; +pub mod world; #[doc(hidden)] pub mod compiler; diff --git a/phoxal/src/model/robot.rs b/phoxal/src/model/robot.rs index e2a696d5..121d83e6 100644 --- a/phoxal/src/model/robot.rs +++ b/phoxal/src/model/robot.rs @@ -1,24 +1,21 @@ //! Canonical immutable robot model. -use std::collections::{BTreeMap, BTreeSet}; -use std::fmt; - use crate::model::compiler::RobotParts; use crate::model::component::Component; use crate::model::component::capability::{ Capability, CapabilityKind, CapabilityRole, Encoder, Motor, StructuralKind, StructuralTarget, }; use crate::model::connection::Connection; -use crate::model::error::{ - IdentifierKind, JointOwner, KinematicScalarField, ModelError, MotionLimitField, -}; +use crate::model::error::{IdentifierKind, JointOwner, ModelError}; use crate::model::footprint::FootprintEnvelope; use crate::model::identity::{ CapabilityId, CapabilityRef, ComponentInstanceId, ComponentTypeId, LinkId, MODULE_INSTANCE_SEPARATOR, RobotId, ServiceId, }; +use crate::model::kinematics::{KinematicConfig, MotionLimits, MotionModel}; use crate::model::simulation::Simulation; use crate::model::structure::{Joint, JointKind, Structure}; +use std::collections::{BTreeMap, BTreeSet}; /// One service this robot runs. /// @@ -148,508 +145,6 @@ impl<'de> serde::Deserialize<'de> for ComponentInstance { } } -/// Canonical motion facts. -#[derive(Debug, Clone)] -pub struct MotionModel { - kinematic: KinematicConfig, - limits: MotionLimits, -} - -/// The outer envelope every motion command is clamped to. -#[derive( - phoxal_macros::DescribeWire, - serde::Serialize, - serde::Deserialize, - Debug, - Clone, - Copy, - PartialEq, - schemars::JsonSchema, -)] -#[serde(deny_unknown_fields)] -pub struct MotionLimits { - pub max_linear_speed_mps: f64, - pub max_angular_speed_radps: f64, -} - -/// The drive geometry, and the capabilities that realize it. -#[derive( - phoxal_macros::DescribeWire, - serde::Serialize, - serde::Deserialize, - Debug, - Clone, - PartialEq, - schemars::JsonSchema, -)] -#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] -pub enum KinematicConfig { - Differential { - left_actuators: Vec, - right_actuators: Vec, - left_encoders: Vec, - right_encoders: Vec, - wheel_radius_m: f64, - wheel_base_m: f64, - }, - Mecanum { - front_left_actuator: CapabilityRef, - front_right_actuator: CapabilityRef, - rear_left_actuator: CapabilityRef, - rear_right_actuator: CapabilityRef, - wheel_radius_m: f64, - wheel_base_m: f64, - track_m: f64, - }, - Ackermann { - steering_actuator: CapabilityRef, - drive_actuator: CapabilityRef, - steering_encoder: Option, - drive_encoder: Option, - wheel_base_m: f64, - track_m: f64, - max_steering_angle_rad: f64, - }, - Omnidirectional { - actuators: Vec, - encoders: Vec, - }, -} - -impl KinematicConfig { - /// The drive geometry this config describes, with its scalars validated. - /// - /// This is the one place the authored kinematic fields are turned into - /// geometry, so every consumer that derives motion from the robot works from - /// the same reading of the document. - /// - /// # Errors - /// - /// Returns [`ModelError::KinematicScalar`] when a declared scalar is not - /// finite and positive. - pub fn drive_kinematics(&self) -> Result { - Ok(match self { - Self::Differential { - wheel_radius_m, - wheel_base_m, - .. - } => DriveKinematics::Differential( - DifferentialDrive::new(*wheel_radius_m, *wheel_base_m).validate()?, - ), - Self::Mecanum { - wheel_radius_m, - wheel_base_m, - track_m, - .. - } => DriveKinematics::Mecanum( - MecanumDrive::new(*wheel_radius_m, *wheel_base_m, *track_m).validate()?, - ), - Self::Ackermann { - wheel_base_m, - track_m, - max_steering_angle_rad, - .. - } => DriveKinematics::Ackermann( - AckermannDrive::new(*wheel_base_m, *track_m, *max_steering_angle_rad).validate()?, - ), - Self::Omnidirectional { .. } => DriveKinematics::Omnidirectional, - }) - } -} - -/// A planar body twist in the robot's base frame. -/// -/// `linear_y_mps` is only meaningful for a holonomic geometry. A differential or -/// Ackermann robot cannot translate sideways at all, so those geometries ignore -/// it rather than approximating it: silently turning a commanded sideways -/// velocity into yaw would move the robot somewhere its caller did not ask for. -#[derive(Debug, Clone, Copy, Default, PartialEq)] -pub struct BodyTwist { - /// Forward velocity, in metres per second. - pub linear_x_mps: f64, - /// Leftward velocity, in metres per second. Zero for a non-holonomic drive. - pub linear_y_mps: f64, - /// Yaw rate, in radians per second, positive counter-clockwise. - pub angular_z_radps: f64, -} - -impl BodyTwist { - /// A twist a non-holonomic drive can realize: forward and yaw only. - #[must_use] - pub const fn planar(linear_x_mps: f64, angular_z_radps: f64) -> Self { - Self { - linear_x_mps, - linear_y_mps: 0.0, - angular_z_radps, - } - } - - /// A full holonomic twist. - #[must_use] - pub const fn new(linear_x_mps: f64, linear_y_mps: f64, angular_z_radps: f64) -> Self { - Self { - linear_x_mps, - linear_y_mps, - angular_z_radps, - } - } - - /// Whether every component is finite. - #[must_use] - pub fn is_finite(&self) -> bool { - self.linear_x_mps.is_finite() - && self.linear_y_mps.is_finite() - && self.angular_z_radps.is_finite() - } -} - -/// The wheel angular speeds of a differential drive, in radians per second. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct DifferentialWheelSpeeds { - pub left_radps: f64, - pub right_radps: f64, -} - -/// The wheel angular speeds of a mecanum drive, in radians per second. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct MecanumWheelSpeeds { - pub front_left_radps: f64, - pub front_right_radps: f64, - pub rear_left_radps: f64, - pub rear_right_radps: f64, -} - -/// What an Ackermann drive is commanded with. -/// -/// This is a linear speed rather than a wheel angular speed because -/// [`KinematicConfig::Ackermann`] authors no wheel radius: the document has no -/// value that could convert one to the other, and inventing one here would put a -/// number on the wire that nobody authored. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct AckermannCommand { - /// Speed of the driven axle, in metres per second. - pub drive_speed_mps: f64, - /// Steering angle, in radians, positive counter-clockwise. - pub steering_angle_rad: f64, -} - -/// The drive geometry of a robot, in the form its kinematics need. -/// -/// This is the single dispatch point over every geometry [`KinematicConfig`] -/// can declare. Each variant carries its own geometry type with its own -/// statically typed wheel commands, because the four do not share a command -/// shape: a differential drive is commanded with two wheel speeds, a mecanum -/// with four, and an Ackermann with a speed and a steering angle. Collapsing -/// them behind one signature would mean either an erased command vector or a -/// lowest-common-denominator twist, and both lose exactly the information the -/// caller needs. -/// -/// Obtained from [`KinematicConfig::drive_kinematics`], which validates the -/// scalars first, so a value of this type always has usable geometry. -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum DriveKinematics { - Differential(DifferentialDrive), - Mecanum(MecanumDrive), - Ackermann(AckermannDrive), - /// An omnidirectional drive, whose kinematics are not derivable from the - /// authored document. - /// - /// [`KinematicConfig::Omnidirectional`] carries actuator and encoder lists - /// and no geometry at all - no wheel radius, no wheel mounting angles, no - /// distance from the rotation centre - and every one of those is required to - /// relate wheel speeds to a body twist. The variant is carried here so the - /// enum covers every geometry the model can declare, and so a consumer - /// matching on it is told the geometry is unavailable rather than silently - /// falling through to another drive's math. - Omnidirectional, -} - -/// The differential-drive geometry, separated from the capabilities realizing it. -/// -/// [`KinematicConfig::Differential`] carries the wheel geometry alongside the -/// actuator and encoder lists, but the two directions of the wheel/twist -/// relation depend only on the geometry. They live together here because they -/// are one relation read two ways: a robot whose commanded twist and whose -/// measured twist disagreed about wheel radius would drive one distance and -/// report another, and nothing downstream could detect it. Keeping the pair on -/// one type is what makes them impossible to change independently. -/// -/// This is a derived value, not part of the canonical document, so it carries no -/// serde representation. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct DifferentialDrive { - /// Driven wheel radius, in metres. - pub wheel_radius_m: f64, - /// Distance between the driven wheels, in metres. - pub wheel_base_m: f64, -} - -impl DifferentialDrive { - /// The geometry with the given wheel radius and track width, both in metres. - #[must_use] - pub const fn new(wheel_radius_m: f64, wheel_base_m: f64) -> Self { - Self { - wheel_radius_m, - wheel_base_m, - } - } - - /// Check the geometry is usable. - /// - /// Both scalars divide in [`Self::wheel_speeds`] and [`Self::body_twist`], - /// so a zero or non-finite value does not fail loudly - it yields an - /// infinite or `NaN` wheel command, which is why every consumer must run - /// this before deriving anything from the geometry. - /// - /// # Errors - /// - /// Returns [`ModelError::KinematicScalar`] when a scalar is not finite and - /// positive. - pub fn validate(self) -> Result { - for (value, field) in [ - (self.wheel_radius_m, KinematicScalarField::WheelRadiusM), - (self.wheel_base_m, KinematicScalarField::WheelBaseM), - ] { - if !(value.is_finite() && value > 0.0) { - return Err(ModelError::KinematicScalar { - kinematics: KinematicKind::Differential, - field, - }); - } - } - Ok(self) - } - - /// The wheel speeds that produce `twist`. - /// - /// This is the inverse of [`Self::body_twist`]. `twist.linear_y_mps` is - /// ignored: a differential drive cannot translate sideways. - /// - /// It does not reject a non-finite result: what a caller must do about a - /// geometry that turns a finite twist into an uncommandable speed depends on - /// what it is about to do with it, so that judgment stays with the caller. - #[must_use] - pub fn wheel_speeds(self, twist: BodyTwist) -> DifferentialWheelSpeeds { - let half_track = self.wheel_base_m / 2.0; - let left = twist.linear_x_mps - twist.angular_z_radps * half_track; - let right = twist.linear_x_mps + twist.angular_z_radps * half_track; - DifferentialWheelSpeeds { - left_radps: left / self.wheel_radius_m, - right_radps: right / self.wheel_radius_m, - } - } - - /// The body twist a pair of wheel angular speeds implies. - /// - /// The inverse of [`Self::wheel_speeds`]. `linear_y_mps` is always zero. - #[must_use] - pub fn body_twist(self, speeds: DifferentialWheelSpeeds) -> BodyTwist { - let left = speeds.left_radps * self.wheel_radius_m; - let right = speeds.right_radps * self.wheel_radius_m; - BodyTwist::planar((left + right) / 2.0, (right - left) / self.wheel_base_m) - } -} - -/// The mecanum-drive geometry: four independently driven wheels with 45-degree -/// rollers, in the standard X configuration. -/// -/// Unlike a differential drive this geometry is holonomic, so it realizes -/// `linear_y_mps` directly rather than ignoring it. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct MecanumDrive { - /// Driven wheel radius, in metres. - pub wheel_radius_m: f64, - /// Front-to-rear axle separation, in metres. - pub wheel_base_m: f64, - /// Left-to-right wheel separation, in metres. - pub track_m: f64, -} - -impl MecanumDrive { - /// The geometry with the given wheel radius, wheel base and track, in metres. - #[must_use] - pub const fn new(wheel_radius_m: f64, wheel_base_m: f64, track_m: f64) -> Self { - Self { - wheel_radius_m, - wheel_base_m, - track_m, - } - } - - /// Half the wheel base plus half the track: the lever arm that converts yaw - /// rate into the differential wheel speed a mecanum uses to rotate. - const fn yaw_lever_m(self) -> f64 { - (self.wheel_base_m + self.track_m) / 2.0 - } - - /// Check the geometry is usable. - /// - /// # Errors - /// - /// Returns [`ModelError::KinematicScalar`] when a scalar is not finite and - /// positive. - pub fn validate(self) -> Result { - for (value, field) in [ - (self.wheel_radius_m, KinematicScalarField::WheelRadiusM), - (self.wheel_base_m, KinematicScalarField::WheelBaseM), - (self.track_m, KinematicScalarField::TrackM), - ] { - if !(value.is_finite() && value > 0.0) { - return Err(ModelError::KinematicScalar { - kinematics: KinematicKind::Mecanum, - field, - }); - } - } - Ok(self) - } - - /// The four wheel speeds that produce `twist`. - /// - /// The inverse of [`Self::body_twist`]. - #[must_use] - pub fn wheel_speeds(self, twist: BodyTwist) -> MecanumWheelSpeeds { - let yaw = twist.angular_z_radps * self.yaw_lever_m(); - let scale = 1.0 / self.wheel_radius_m; - MecanumWheelSpeeds { - front_left_radps: scale * (twist.linear_x_mps - twist.linear_y_mps - yaw), - front_right_radps: scale * (twist.linear_x_mps + twist.linear_y_mps + yaw), - rear_left_radps: scale * (twist.linear_x_mps + twist.linear_y_mps - yaw), - rear_right_radps: scale * (twist.linear_x_mps - twist.linear_y_mps + yaw), - } - } - - /// The body twist four wheel angular speeds imply. - /// - /// The inverse of [`Self::wheel_speeds`]. Four wheel speeds over-determine a - /// three-component twist, so this is the least-squares solution: a set of - /// speeds that no rigid twist can produce (the wheels fighting each other) - /// yields the twist closest to what they describe rather than an error. - #[must_use] - pub fn body_twist(self, speeds: MecanumWheelSpeeds) -> BodyTwist { - let MecanumWheelSpeeds { - front_left_radps: fl, - front_right_radps: fr, - rear_left_radps: rl, - rear_right_radps: rr, - } = speeds; - BodyTwist::new( - (fl + fr + rl + rr) * self.wheel_radius_m / 4.0, - (-fl + fr + rl - rr) * self.wheel_radius_m / 4.0, - (-fl + fr - rl + rr) * self.wheel_radius_m / (4.0 * self.yaw_lever_m()), - ) - } -} - -/// The Ackermann-steering geometry: one steered axle and one driven axle. -/// -/// The relation used is the bicycle model taken at the centre of the driven -/// axle, which is what a single steering actuator can express. `track_m` is -/// carried because the authored document declares it, but a true per-wheel -/// Ackermann split needs two independently steered wheels, which this config -/// does not describe. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct AckermannDrive { - /// Front-to-rear axle separation, in metres. - pub wheel_base_m: f64, - /// Left-to-right wheel separation, in metres. - pub track_m: f64, - /// The largest steering angle the mechanism reaches, in radians. - pub max_steering_angle_rad: f64, -} - -impl AckermannDrive { - /// The geometry with the given wheel base, track and steering limit. - #[must_use] - pub const fn new(wheel_base_m: f64, track_m: f64, max_steering_angle_rad: f64) -> Self { - Self { - wheel_base_m, - track_m, - max_steering_angle_rad, - } - } - - /// Check the geometry is usable. - /// - /// # Errors - /// - /// Returns [`ModelError::KinematicScalar`] when a scalar is not finite and - /// positive. - pub fn validate(self) -> Result { - for (value, field) in [ - (self.wheel_base_m, KinematicScalarField::WheelBaseM), - (self.track_m, KinematicScalarField::TrackM), - ( - self.max_steering_angle_rad, - KinematicScalarField::MaxSteeringAngleRad, - ), - ] { - if !(value.is_finite() && value > 0.0) { - return Err(ModelError::KinematicScalar { - kinematics: KinematicKind::Ackermann, - field, - }); - } - } - Ok(self) - } - - /// The drive speed and steering angle that produce `twist`. - /// - /// The inverse of [`Self::body_twist`]. `twist.linear_y_mps` is ignored: a - /// steered drive cannot translate sideways. - /// - /// A stationary robot has no steering angle that produces yaw, so a zero - /// forward speed yields a zero steering angle. The returned angle is **not** - /// clamped to [`Self::max_steering_angle_rad`]: a caller that must refuse an - /// unreachable request needs to see that it was unreachable, which - /// [`Self::steering_is_reachable`] answers. - #[must_use] - pub fn command(self, twist: BodyTwist) -> AckermannCommand { - let steering_angle_rad = if twist.linear_x_mps == 0.0 { - 0.0 - } else { - (twist.angular_z_radps * self.wheel_base_m / twist.linear_x_mps).atan() - }; - AckermannCommand { - drive_speed_mps: twist.linear_x_mps, - steering_angle_rad, - } - } - - /// The body twist a drive speed and steering angle imply. - /// - /// The inverse of [`Self::command`]. `linear_y_mps` is always zero. - #[must_use] - pub fn body_twist(self, command: AckermannCommand) -> BodyTwist { - BodyTwist::planar( - command.drive_speed_mps, - command.drive_speed_mps * command.steering_angle_rad.tan() / self.wheel_base_m, - ) - } - - /// Whether the mechanism can actually reach `steering_angle_rad`. - #[must_use] - pub fn steering_is_reachable(self, steering_angle_rad: f64) -> bool { - steering_angle_rad.abs() <= self.max_steering_angle_rad - } -} - -/// Which drive geometry a [`KinematicConfig`] describes. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum KinematicKind { - Differential, - Mecanum, - Ackermann, - Omnidirectional, -} - -/// Fully normalized runtime-facing robot model. -/// -/// This is the whole of what `manifest.json` carries: the robot's identity and -/// structure, the motion it may make, the services it runs, and the components -/// it mounts together with the types behind them. Everything a launched -/// participant needs to know about the robot - including its own configuration - /// is read from here, so there is no second persisted document to agree with. #[derive(Debug, Clone)] pub struct Robot { @@ -696,8 +191,8 @@ impl serde::Serialize for Robot { fn serialize(&self, serializer: S) -> Result { RobotWire { id: self.id.clone(), - kinematic: self.motion.kinematic.clone(), - motion_limits: self.motion.limits, + kinematic: self.motion.kinematic().clone(), + motion_limits: self.motion.limits(), services: self.services.clone(), components: self.components.clone(), component_types: self.component_types.clone(), @@ -837,68 +332,6 @@ impl ComponentInstance { } } -impl MotionModel { - #[must_use] - pub const fn kinematic(&self) -> &KinematicConfig { - &self.kinematic - } - - #[must_use] - pub const fn limits(&self) -> MotionLimits { - self.limits - } -} - -impl MotionLimits { - /// Check the envelope is usable. - /// - /// # Errors - /// - /// Returns [`ModelError::MotionLimit`] when a limit is not finite, - /// positive, and representable as `f32`. - pub fn validate(self) -> Result { - for (value, field) in [ - ( - self.max_linear_speed_mps, - MotionLimitField::MaxLinearSpeedMps, - ), - ( - self.max_angular_speed_radps, - MotionLimitField::MaxAngularSpeedRadps, - ), - ] { - if !(value.is_finite() && value > 0.0 && value <= f64::from(f32::MAX)) { - return Err(ModelError::MotionLimit { field }); - } - } - Ok(self) - } -} - -impl KinematicConfig { - /// Which drive geometry this configuration describes. - #[must_use] - pub const fn kind(&self) -> KinematicKind { - match self { - Self::Differential { .. } => KinematicKind::Differential, - Self::Mecanum { .. } => KinematicKind::Mecanum, - Self::Ackermann { .. } => KinematicKind::Ackermann, - Self::Omnidirectional { .. } => KinematicKind::Omnidirectional, - } - } -} - -impl fmt::Display for KinematicKind { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(match self { - Self::Differential => "differential", - Self::Mecanum => "mecanum", - Self::Ackermann => "ackermann", - Self::Omnidirectional => "omnidirectional", - }) - } -} - impl Robot { pub(crate) fn new( parts: RobotParts, @@ -1160,7 +593,7 @@ impl Robot { } fn validate(&self) -> Result<(), ModelError> { - self.motion.limits.validate()?; + self.motion.limits().validate()?; self.validate_robot_structure()?; self.validate_component_types()?; self.validate_components()?; @@ -1226,6 +659,14 @@ impl Robot { let Some(simulation) = component.simulation() else { return Ok(()); }; + for (link, _) in simulation.links() { + if component.structure().link(link.as_str()).is_none() { + return Err(ModelError::SimulationWithoutLink { + component_type: component_type.clone(), + link: link.clone(), + }); + } + } for (capability_id, simulated) in simulation.capabilities() { let capability = component .capability(capability_id.as_str()) @@ -1396,229 +837,6 @@ impl Robot { } } -impl MotionModel { - pub(crate) const fn new(kinematic: KinematicConfig, limits: MotionLimits) -> Self { - Self { kinematic, limits } - } -} - -#[cfg(test)] -mod kinematics_tests { - use super::{ - AckermannDrive, BodyTwist, DifferentialDrive, DriveKinematics, KinematicConfig, - KinematicScalarField, MecanumDrive, ModelError, - }; - use crate::model::identity::CapabilityRef; - - const DIFFERENTIAL: DifferentialDrive = DifferentialDrive::new(0.1, 0.5); - const MECANUM: MecanumDrive = MecanumDrive::new(0.1, 0.4, 0.6); - const ACKERMANN: AckermannDrive = AckermannDrive::new(2.5, 1.5, 0.6); - - fn close(left: f64, right: f64, what: &str) { - assert!((left - right).abs() < 1e-9, "{what}: {left} vs {right}"); - } - - /// Forward and inverse are one relation read two ways. A twist that survives - /// the round trip is the property that matters: if the two ever disagreed, a - /// robot would drive one distance and report another, and nothing downstream - /// could detect it. - #[test] - fn a_differential_twist_survives_the_round_trip() { - for twist in [ - BodyTwist::planar(0.0, 0.0), - BodyTwist::planar(1.0, 0.0), - BodyTwist::planar(0.0, 2.0), - BodyTwist::planar(0.75, -1.25), - ] { - let back = DIFFERENTIAL.body_twist(DIFFERENTIAL.wheel_speeds(twist)); - close(back.linear_x_mps, twist.linear_x_mps, "linear x"); - close(back.angular_z_radps, twist.angular_z_radps, "angular z"); - assert_eq!(back.linear_y_mps, 0.0, "a differential drive has no sway"); - } - } - - #[test] - fn a_mecanum_twist_survives_the_round_trip_including_sideways() { - for twist in [ - BodyTwist::new(0.0, 0.0, 0.0), - BodyTwist::new(1.0, 0.0, 0.0), - BodyTwist::new(0.0, 1.0, 0.0), - BodyTwist::new(0.0, 0.0, 1.5), - BodyTwist::new(0.4, -0.7, 0.9), - ] { - let back = MECANUM.body_twist(MECANUM.wheel_speeds(twist)); - close(back.linear_x_mps, twist.linear_x_mps, "linear x"); - close(back.linear_y_mps, twist.linear_y_mps, "linear y"); - close(back.angular_z_radps, twist.angular_z_radps, "angular z"); - } - } - - #[test] - fn an_ackermann_twist_survives_the_round_trip() { - for twist in [ - BodyTwist::planar(1.0, 0.0), - BodyTwist::planar(2.0, 0.4), - BodyTwist::planar(-1.5, -0.3), - ] { - let back = ACKERMANN.body_twist(ACKERMANN.command(twist)); - close(back.linear_x_mps, twist.linear_x_mps, "linear x"); - close(back.angular_z_radps, twist.angular_z_radps, "angular z"); - } - } - - #[test] - fn driving_straight_turns_both_differential_wheels_at_the_same_speed() { - let speeds = DIFFERENTIAL.wheel_speeds(BodyTwist::planar(1.0, 0.0)); - assert_eq!(speeds.left_radps, speeds.right_radps); - assert_eq!(speeds.left_radps, 1.0 / DIFFERENTIAL.wheel_radius_m); - } - - #[test] - fn turning_in_place_turns_the_differential_wheels_in_opposite_directions() { - let speeds = DIFFERENTIAL.wheel_speeds(BodyTwist::planar(0.0, 1.0)); - assert_eq!(speeds.left_radps, -speeds.right_radps); - assert!( - speeds.right_radps > 0.0, - "a positive yaw rate drives the right wheel forward" - ); - } - - /// Strafing left is the motion a differential drive cannot make, so it is - /// the one that proves the mecanum roller signs are right: the diagonal - /// pairs must counter-rotate. - #[test] - fn strafing_counter_rotates_the_mecanum_diagonals() { - let speeds = MECANUM.wheel_speeds(BodyTwist::new(0.0, 1.0, 0.0)); - assert_eq!(speeds.front_left_radps, -speeds.front_right_radps); - assert_eq!(speeds.rear_left_radps, -speeds.rear_right_radps); - assert_eq!(speeds.front_left_radps, speeds.rear_right_radps); - assert!( - speeds.front_right_radps > 0.0, - "left sway drives FR forward" - ); - } - - /// A non-holonomic geometry ignores sway rather than approximating it, so a - /// sideways request must not leak into the wheels. - #[test] - fn non_holonomic_geometries_ignore_a_sideways_request() { - let straight = BodyTwist::planar(1.0, 0.0); - let swaying = BodyTwist::new(1.0, 5.0, 0.0); - assert_eq!( - DIFFERENTIAL.wheel_speeds(straight), - DIFFERENTIAL.wheel_speeds(swaying) - ); - assert_eq!(ACKERMANN.command(straight), ACKERMANN.command(swaying)); - } - - /// A stationary robot has no steering angle that produces yaw, so asking for - /// one must not divide by zero into a `NaN` the caller would then command. - #[test] - fn a_stationary_ackermann_has_a_defined_steering_angle() { - let command = ACKERMANN.command(BodyTwist::planar(0.0, 1.0)); - assert_eq!(command.drive_speed_mps, 0.0); - assert_eq!(command.steering_angle_rad, 0.0); - } - - #[test] - fn the_steering_limit_is_reported_rather_than_silently_clamped() { - let command = ACKERMANN.command(BodyTwist::planar(0.5, 2.0)); - assert!( - command.steering_angle_rad.abs() > ACKERMANN.max_steering_angle_rad, - "this request should exceed the mechanism" - ); - assert!(!ACKERMANN.steering_is_reachable(command.steering_angle_rad)); - assert!(ACKERMANN.steering_is_reachable(0.0)); - } - - fn reference() -> CapabilityRef { - "base.motor".parse().expect("a well formed capability ref") - } - - #[test] - fn every_authored_geometry_resolves_to_its_kinematics() { - let differential = KinematicConfig::Differential { - left_actuators: vec![reference()], - right_actuators: vec![reference()], - left_encoders: Vec::new(), - right_encoders: Vec::new(), - wheel_radius_m: 0.1, - wheel_base_m: 0.5, - }; - assert_eq!( - differential.drive_kinematics().expect("valid geometry"), - DriveKinematics::Differential(DIFFERENTIAL) - ); - - let mecanum = KinematicConfig::Mecanum { - front_left_actuator: reference(), - front_right_actuator: reference(), - rear_left_actuator: reference(), - rear_right_actuator: reference(), - wheel_radius_m: 0.1, - wheel_base_m: 0.4, - track_m: 0.6, - }; - assert_eq!( - mecanum.drive_kinematics().expect("valid geometry"), - DriveKinematics::Mecanum(MECANUM) - ); - - let ackermann = KinematicConfig::Ackermann { - steering_actuator: reference(), - drive_actuator: reference(), - steering_encoder: None, - drive_encoder: None, - wheel_base_m: 2.5, - track_m: 1.5, - max_steering_angle_rad: 0.6, - }; - assert_eq!( - ackermann.drive_kinematics().expect("valid geometry"), - DriveKinematics::Ackermann(ACKERMANN) - ); - - // An omnidirectional document authors actuators and encoders but no - // geometry, so there is nothing to resolve and the variant says so - // rather than borrowing another drive's math. - let omnidirectional = KinematicConfig::Omnidirectional { - actuators: vec![reference()], - encoders: Vec::new(), - }; - assert_eq!( - omnidirectional - .drive_kinematics() - .expect("carries no scalars to reject"), - DriveKinematics::Omnidirectional - ); - } - - #[test] - fn a_non_positive_scalar_is_refused_by_the_geometry_it_belongs_to() { - assert!(matches!( - DifferentialDrive::new(0.0, 0.5).validate(), - Err(ModelError::KinematicScalar { - field: KinematicScalarField::WheelRadiusM, - .. - }) - )); - assert!(matches!( - MecanumDrive::new(0.1, 0.4, f64::NAN).validate(), - Err(ModelError::KinematicScalar { - field: KinematicScalarField::TrackM, - .. - }) - )); - assert!(matches!( - AckermannDrive::new(2.5, 1.5, -0.1).validate(), - Err(ModelError::KinematicScalar { - field: KinematicScalarField::MaxSteeringAngleRad, - .. - }) - )); - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/phoxal/src/model/simulation.rs b/phoxal/src/model/simulation.rs index 8a6678e6..ac6967fb 100644 --- a/phoxal/src/model/simulation.rs +++ b/phoxal/src/model/simulation.rs @@ -5,10 +5,233 @@ //! capability the component type already declares, of the same //! [`CapabilityKind`]. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use crate::model::asset::AssetId; use crate::model::component::capability::CapabilityKind; -use crate::model::identity::{CapabilityId, LinkId}; +use crate::model::identity::{CapabilityId, CapabilityRef, ComponentInstanceId, LinkId}; +use crate::model::robot::Robot; + +/// The backend-neutral, all-or-nothing simulation plan for one compiled robot. +/// +/// Deriving this value proves that every mounted component has simulation data, +/// every declared typed capability has exactly one matching simulation entry, +/// and every physical driver participant has exactly one adapter substitution. +/// It also records the complete asset closure that an adapter must make +/// available before mutating its native world. +/// +/// Native device naming, supported simulator facts, geometry conversion, and +/// backend-version checks remain adapter-owned admission work. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FullSimulationPlan { + substitutions: Vec, + capabilities: Vec, + required_assets: Vec, +} + +/// One physical driver participant that a full-simulation adapter must replace. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DriverSubstitution { + participant: ComponentInstanceId, + capabilities: Vec, +} + +/// A locally provable reason why a compiled robot cannot enter full simulation. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum FullSimulationError { + #[error("component '{component}' has no compiled simulation data")] + MissingSimulation { component: ComponentInstanceId }, + + #[error("component capability '{capability}' has no simulation binding")] + MissingCapability { capability: CapabilityRef }, + + #[error("component simulation contains unknown capability '{capability}'")] + ExtraCapability { capability: CapabilityRef }, + + #[error("component '{component}' simulation references unknown structural link '{link}'")] + UnknownLink { + component: ComponentInstanceId, + link: LinkId, + }, + + #[error( + "component capability '{capability}' is {declared}, but its simulation binding is {simulated}" + )] + CapabilityKindMismatch { + capability: CapabilityRef, + declared: CapabilityKind, + simulated: CapabilityKind, + }, + + #[error("simulation capability '{capability}' references invalid asset id '{value}'")] + InvalidAssetId { + capability: CapabilityRef, + value: String, + }, + + #[error("required simulation asset '{asset}' is absent from the compiled bundle")] + MissingAsset { asset: AssetId }, + + #[error("required simulation asset '{asset}' is empty")] + EmptyAsset { asset: AssetId }, +} + +impl FullSimulationPlan { + /// Derive the complete backend-neutral plan from one canonical robot. + /// + /// # Errors + /// + /// Returns [`FullSimulationError`] when a component has no simulation, + /// capability coverage is partial or inconsistent, or a simulator asset + /// reference is not a canonical [`AssetId`]. + pub fn derive(robot: &Robot) -> Result { + let mut substitutions = Vec::new(); + let mut capabilities = Vec::new(); + let mut required_assets = robot + .structure() + .asset_ids() + .cloned() + .collect::>(); + + for component in robot.components() { + let component_id = component.id(); + let declared = component.component_type(); + let simulation = + component + .simulation() + .ok_or_else(|| FullSimulationError::MissingSimulation { + component: component_id.clone(), + })?; + required_assets.extend(declared.structure().asset_ids().cloned()); + + for (link, _) in simulation.links() { + if declared.structure().link(link.as_str()).is_none() { + return Err(FullSimulationError::UnknownLink { + component: component_id.clone(), + link: link.clone(), + }); + } + } + + let mut substituted_capabilities = Vec::new(); + for (capability_id, capability) in declared.capabilities() { + let reference = CapabilityRef::new(component_id.clone(), capability_id.clone()); + let simulated = simulation + .capability(capability_id.as_str()) + .ok_or_else(|| FullSimulationError::MissingCapability { + capability: reference.clone(), + })?; + if capability.kind() != simulated.kind() { + return Err(FullSimulationError::CapabilityKindMismatch { + capability: reference, + declared: capability.kind(), + simulated: simulated.kind(), + }); + } + collect_capability_assets(simulated, &reference, &mut required_assets)?; + substituted_capabilities.push(reference.clone()); + capabilities.push(reference); + } + for (capability_id, _) in simulation.capabilities() { + if declared.capability(capability_id.as_str()).is_none() { + return Err(FullSimulationError::ExtraCapability { + capability: CapabilityRef::new(component_id.clone(), capability_id.clone()), + }); + } + } + if component.instance().driver().is_some() { + substitutions.push(DriverSubstitution { + participant: component_id.clone(), + capabilities: substituted_capabilities, + }); + } + } + + Ok(Self { + substitutions, + capabilities, + required_assets: required_assets.into_iter().collect(), + }) + } + + /// Every omitted physical driver, ordered by participant identity. + pub fn substitutions(&self) -> impl ExactSizeIterator { + self.substitutions.iter() + } + + /// Every typed capability that the adapter must bind, ordered by component + /// and capability identity. + pub fn capabilities(&self) -> impl ExactSizeIterator { + self.capabilities.iter() + } + + /// Every geometry or simulator asset needed by this robot, ordered by id. + pub fn required_assets(&self) -> impl ExactSizeIterator { + self.required_assets.iter() + } + + /// Prove that every required asset is present and non-empty. + /// + /// The callback returns the asset byte length, or `None` when the closed + /// bundle does not contain the requested id. + /// + /// # Errors + /// + /// Returns [`FullSimulationError::MissingAsset`] or + /// [`FullSimulationError::EmptyAsset`] for the first incomplete asset. + pub fn validate_assets( + &self, + mut asset_len: impl FnMut(&AssetId) -> Option, + ) -> Result<(), FullSimulationError> { + for asset in &self.required_assets { + match asset_len(asset) { + None => { + return Err(FullSimulationError::MissingAsset { + asset: asset.clone(), + }); + } + Some(0) => { + return Err(FullSimulationError::EmptyAsset { + asset: asset.clone(), + }); + } + Some(_) => {} + } + } + Ok(()) + } +} + +impl DriverSubstitution { + /// The participant id omitted from a full-simulation execution. + #[must_use] + pub const fn participant(&self) -> &ComponentInstanceId { + &self.participant + } + + /// Every typed capability supplied by this participant's adapter replacement. + pub fn capabilities(&self) -> impl ExactSizeIterator { + self.capabilities.iter() + } +} + +fn collect_capability_assets( + capability: &Capability, + reference: &CapabilityRef, + assets: &mut BTreeSet, +) -> Result<(), FullSimulationError> { + if let Capability::Camera(camera) = capability + && let Some(value) = &camera.noise_mask_url + { + let asset = + AssetId::new(value.clone()).map_err(|_| FullSimulationError::InvalidAssetId { + capability: reference.clone(), + value: value.clone(), + })?; + assets.insert(asset); + } + Ok(()) +} /// The simulated behaviour of one component type. #[derive( @@ -387,6 +610,9 @@ pub struct Microphone { #[cfg(test)] mod tests { + use crate::model::builder::RobotBuilder; + use crate::model::connection::{Connection, Serial}; + use super::*; #[test] @@ -423,4 +649,83 @@ mod tests { CapabilityKind::Encoder ); } + + #[test] + fn full_simulation_rejects_missing_and_partial_component_mappings() { + let missing = RobotBuilder::new("missing") + .component_type("wheel", |wheel| wheel.encoder("turns", "axle")) + .component("left", "wheel") + .build() + .expect("the hardware model is valid without simulation data"); + assert!(matches!( + FullSimulationPlan::derive(&missing), + Err(FullSimulationError::MissingSimulation { .. }) + )); + + let partial = RobotBuilder::new("partial") + .component_type("wheel", |wheel| { + wheel + .motor("spin", "axle") + .encoder("turns", "axle") + .simulated("spin", Capability::Motor(Motor::default())) + }) + .component("left", "wheel") + .build() + .expect("the canonical model permits a partial simulation mapping"); + assert!(matches!( + FullSimulationPlan::derive(&partial), + Err(FullSimulationError::MissingCapability { capability }) + if capability.to_string() == "left.turns" + )); + } + + #[test] + fn full_simulation_plans_each_driver_once_and_closes_simulator_assets() { + let mask = "components/camera/meshes/noise-mask.png"; + let robot = RobotBuilder::new("camera-bot") + .component_type("camera", |camera| { + camera.camera("image", "lens").simulated( + "image", + Capability::Camera(Camera { + noise_mask_url: Some(mask.to_owned()), + ..Camera::default() + }), + ) + }) + .component_with("front", "camera", |front| { + front.driver( + Connection::Serial(Serial { + port: "/dev/camera".to_owned(), + baud: 115_200, + }), + None, + ) + }) + .build() + .expect("the complete simulated robot is valid"); + + let plan = FullSimulationPlan::derive(&robot).expect("the plan is complete"); + let substitutions = plan.substitutions().collect::>(); + assert_eq!(substitutions.len(), 1); + assert_eq!(substitutions[0].participant().as_str(), "front"); + assert_eq!( + substitutions[0] + .capabilities() + .map(ToString::to_string) + .collect::>(), + ["front.image"] + ); + assert_eq!( + plan.required_assets() + .map(AssetId::as_str) + .collect::>(), + [mask] + ); + assert!(matches!( + plan.validate_assets(|_| None), + Err(FullSimulationError::MissingAsset { asset }) if asset.as_str() == mask + )); + plan.validate_assets(|asset| (asset.as_str() == mask).then_some(17)) + .expect("the bundled mask closes the simulation plan"); + } } diff --git a/phoxal/src/model/structure.rs b/phoxal/src/model/structure.rs index 2749e018..a8127ff4 100644 --- a/phoxal/src/model/structure.rs +++ b/phoxal/src/model/structure.rs @@ -7,6 +7,7 @@ use std::collections::{HashMap, HashSet}; use crate::model::asset::AssetId; use crate::model::component::capability::StructuralKind; use crate::model::error::{LinkRole, PoseOwner, StructureError}; +use crate::model::geometry::Geometry; use crate::model::identity::{JointId, LinkId}; const MIN_AXIS_NORM_SQUARED: f64 = 1.0e-16; @@ -57,7 +58,7 @@ pub struct Joint { } /// A normalized rigid transform. -#[derive(phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Serialize)] +#[derive(phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct Pose { xyz: [f64; 3], @@ -113,31 +114,6 @@ pub struct Material { texture: Option, } -/// Complete canonical geometry vocabulary. -#[derive(phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Serialize)] -#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] -pub enum Geometry { - Box { - size: [f64; 3], - }, - Cylinder { - radius: f64, - length: f64, - }, - Capsule { - radius: f64, - length: f64, - }, - Sphere { - radius: f64, - }, - Mesh { - #[serde(rename = "filename")] - asset: AssetId, - scale: Option<[f64; 3]>, - }, -} - /// Canonical joint limits. #[derive(phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -380,6 +356,9 @@ impl Structure { fn from_summary(summary: Summary) -> Result { let document = serde_json::to_value(&summary)?; + for material in &summary.materials { + material.validate()?; + } let links = summary .links .into_iter() @@ -447,6 +426,9 @@ impl Link { .origin .validate(PoseOwner::LinkVisual(self.name.clone()))?; visual.geometry.validate(&self.name)?; + if let Some(material) = &visual.material { + material.validate()?; + } } for collision in &self.collisions { collision @@ -545,6 +527,14 @@ impl Joint { } impl Pose { + #[allow( + dead_code, + reason = "the authoring profile constructs validated canonical poses; runtime profiles only read them" + )] + pub(crate) const fn from_validated_parts(xyz: [f64; 3], rpy: [f64; 3]) -> Self { + Self { xyz, rpy } + } + #[must_use] pub const fn xyz(self) -> [f64; 3] { self.xyz @@ -679,30 +669,25 @@ impl Material { pub fn texture(&self) -> Option<&AssetId> { self.texture.as_ref() } -} -impl Geometry { - #[must_use] - pub fn asset_id(&self) -> Option<&AssetId> { - match self { - Self::Mesh { asset, .. } => Some(asset), - _ => None, + fn validate(&self) -> Result<(), StructureError> { + if self.color.is_none_or(|color| { + color + .iter() + .all(|value| value.is_finite() && (0.0..=1.0).contains(value)) + }) { + Ok(()) + } else { + Err(StructureError::MaterialColor { + name: self.name.clone(), + }) } } +} +impl Geometry { fn validate(&self, link: &LinkId) -> Result<(), StructureError> { - let dimensions: &[f64] = match self { - Self::Box { size } => size, - Self::Cylinder { radius, length } | Self::Capsule { radius, length } => { - &[*radius, *length] - } - Self::Sphere { radius } => &[*radius], - Self::Mesh { scale, .. } => scale.as_ref().map_or(&[], |values| values.as_slice()), - }; - if dimensions - .iter() - .all(|value| value.is_finite() && *value > 0.0) - { + if self.has_valid_dimensions() { Ok(()) } else { Err(StructureError::Geometry { link: link.clone() }) @@ -1009,6 +994,36 @@ mod tests { ); } + #[test] + fn material_colors_are_finite_normalized_rgba() { + let mut catalog = tree(); + catalog["materials"] = json!([{ + "name": "too-bright", + "color": [1.1, 0.0, 0.0, 1.0], + "texture": null + }]); + assert!(matches!( + Structure::from_compiler_value(catalog), + Err(StructureError::MaterialColor { name }) if name == "too-bright" + )); + + let mut visual = tree(); + visual["links"][1]["visuals"] = json!([{ + "name": null, + "origin": { "xyz": [0.0, 0.0, 0.0], "rpy": [0.0, 0.0, 0.0] }, + "geometry": { "kind": "box", "size": [1.0, 1.0, 1.0] }, + "material": { + "name": "negative", + "color": [-0.1, 0.0, 0.0, 1.0], + "texture": null + } + }]); + assert!(matches!( + Structure::from_compiler_value(visual), + Err(StructureError::MaterialColor { name }) if name == "negative" + )); + } + /// Every structural fact the canonical model carries must be statable /// through [`crate::model::builder::RobotBuilder`]. A field the builder cannot /// state is a robot that can only be described by authored documents, and diff --git a/phoxal/src/model/world.rs b/phoxal/src/model/world.rs new file mode 100644 index 00000000..32f04486 --- /dev/null +++ b/phoxal/src/model/world.rs @@ -0,0 +1,648 @@ +//! Canonical compiled worlds, identities, progress, and runtime provenance. +//! +//! Authored paths end at the world compiler. +//! A runtime adapter receives one `WorldBundle` +//! containing a canonical expanded world and every reachable asset byte. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::bundle::WorldBundleError; +use crate::model::asset::AssetId; +use crate::model::geometry::Geometry; +use crate::model::identity::{EntityDeclarationId, SpawnId, WorldId}; +use crate::model::structure::Pose; +use crate::version::FrameworkVersion; + +const SESSION_ID_BYTES: usize = 16; +const SESSION_ID_HEX_LEN: usize = SESSION_ID_BYTES * 2; +const CANONICAL_TOP_NIBBLE: u128 = 1 << 124; + +/// One live world-session identity minted by its session host. +#[derive(Clone, Copy, Eq, Hash, PartialEq)] +pub struct WorldInstanceId(u128); + +impl WorldInstanceId { + /// The exact rendered width of a world-session identity. + pub const LEN: usize = SESSION_ID_HEX_LEN; + + /// Mint one random canonical identity. + #[must_use] + pub fn mint() -> Self { + loop { + let mut bytes = [0_u8; SESSION_ID_BYTES]; + #[expect( + clippy::expect_used, + reason = "a world session cannot start safely without a unique identity" + )] + getrandom::fill(&mut bytes).expect("the host must provide randomness"); + let value = u128::from_be_bytes(bytes); + if value >= CANONICAL_TOP_NIBBLE { + return Self(value); + } + } + } + + /// Parse the exact lowercase hexadecimal representation. + /// + /// # Errors + /// + /// Returns [`WorldIdentityError`] when the spelling is not canonical. + pub fn parse(value: &str) -> Result { + if value.len() != SESSION_ID_HEX_LEN + || value.starts_with('0') + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(WorldIdentityError(value.to_owned())); + } + u128::from_str_radix(value, 16) + .map(Self) + .map_err(|_| WorldIdentityError(value.to_owned())) + } +} + +impl fmt::Display for WorldInstanceId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{:032x}", self.0) + } +} + +impl fmt::Debug for WorldInstanceId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "WorldInstanceId({self})") + } +} + +impl std::str::FromStr for WorldInstanceId { + type Err = WorldIdentityError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } +} + +impl Serialize for WorldInstanceId { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for WorldInstanceId { + fn deserialize>(deserializer: D) -> Result { + Self::parse(&String::deserialize(deserializer)?).map_err(serde::de::Error::custom) + } +} + +impl crate::__compat::wire::DescribeWire for WorldInstanceId { + fn wire_schema() -> crate::__compat::wire::WireSchema { + crate::__compat::wire::WireSchema::opaque( + "WorldInstanceId", + crate::__compat::wire::WireSchema::String, + ) + } +} + +/// A world-session identity that is not in canonical form. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[error( + "world instance id must be exactly 32 lowercase hexadecimal characters with a nonzero leading nibble, got '{0}'" +)] +pub struct WorldIdentityError(String); + +/// Authoritative absolute physics progress in one world session. +#[derive(phoxal_macros::DescribeWire, Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct WorldProgress { + completed_step: u64, + elapsed_ns: u64, +} + +impl WorldProgress { + /// Construct progress from its completed step and declared physics quantum. + /// + /// # Errors + /// + /// Returns [`WorldProgressError`] when the multiplication overflows. + pub fn at(completed_step: u64, time_step_ns: u64) -> Result { + if time_step_ns == 0 { + return Err(WorldProgressError::ZeroQuantum); + } + let elapsed_ns = + completed_step + .checked_mul(time_step_ns) + .ok_or(WorldProgressError::Overflow { + completed_step, + time_step_ns, + })?; + Ok(Self { + completed_step, + elapsed_ns, + }) + } + + /// Zero progress before the first native transition. + pub fn zero(time_step_ns: u64) -> Result { + Self::at(0, time_step_ns) + } + + /// The world-absolute number of completed native transitions. + #[must_use] + pub const fn completed_step(self) -> u64 { + self.completed_step + } + + /// The exact simulated duration represented by the completed transitions. + #[must_use] + pub const fn elapsed_ns(self) -> u64 { + self.elapsed_ns + } + + /// Validate this progress against a world's declared quantum. + /// + /// # Errors + /// + /// Returns [`WorldProgressError`] when the fields disagree. + pub fn validate(self, time_step_ns: u64) -> Result<(), WorldProgressError> { + let expected = Self::at(self.completed_step, time_step_ns)?; + if expected == self { + Ok(()) + } else { + Err(WorldProgressError::Inconsistent { + completed_step: self.completed_step, + elapsed_ns: self.elapsed_ns, + expected_ns: expected.elapsed_ns, + }) + } + } + + /// Validate that the two wire fields can describe one fixed positive + /// physics quantum, even when that quantum is not known yet. + /// + /// Zero progress is the one boundary that carries no intrinsic quantum: + /// it is valid only with zero elapsed time. Every later boundary must be + /// an exact positive multiple of its completed-step count. + /// + /// # Errors + /// + /// Returns [`WorldProgressError::InvalidRatio`] when no positive integral + /// quantum can produce both fields. + pub fn validate_intrinsic(self) -> Result<(), WorldProgressError> { + let valid = if self.completed_step == 0 { + self.elapsed_ns == 0 + } else { + self.elapsed_ns > 0 && self.elapsed_ns.is_multiple_of(self.completed_step) + }; + if valid { + Ok(()) + } else { + Err(WorldProgressError::InvalidRatio { + completed_step: self.completed_step, + elapsed_ns: self.elapsed_ns, + }) + } + } +} + +impl<'de> Deserialize<'de> for WorldProgress { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Raw { + completed_step: u64, + elapsed_ns: u64, + } + + let raw = Raw::deserialize(deserializer)?; + let progress = Self { + completed_step: raw.completed_step, + elapsed_ns: raw.elapsed_ns, + }; + progress + .validate_intrinsic() + .map_err(serde::de::Error::custom)?; + Ok(progress) + } +} + +/// An invalid world-progress value. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum WorldProgressError { + #[error("world time step must be positive")] + ZeroQuantum, + #[error("world progress overflows for step {completed_step} at {time_step_ns} ns")] + Overflow { + completed_step: u64, + time_step_ns: u64, + }, + #[error( + "world progress step {completed_step} and {elapsed_ns} ns do not imply a positive integral physics quantum" + )] + InvalidRatio { + completed_step: u64, + elapsed_ns: u64, + }, + #[error( + "world progress step {completed_step} carries {elapsed_ns} ns, expected {expected_ns} ns" + )] + Inconsistent { + completed_step: u64, + elapsed_ns: u64, + expected_ns: u64, + }, +} + +/// The immutable correlation recorded when a monotonic execution joins a world. +#[derive( + phoxal_macros::DescribeWire, Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct LiveAttachmentBoundary { + /// The completed native-world boundary observed during attachment. + pub world: WorldProgress, + /// The execution's unchanged monotonic instant at that same boundary. + pub execution: crate::bus::RobotInstant, +} + +/// The SHA-256 identity of a complete canonical world archive. +#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct WorldDigest([u8; 32]); + +impl WorldDigest { + pub(crate) fn of(bytes: &[u8]) -> Self { + Self(Sha256::digest(bytes).into()) + } + + /// Parse the exact lowercase hexadecimal representation. + /// + /// # Errors + /// + /// Returns [`WorldDigestError`] when the spelling is not 64 lowercase hexadecimal characters. + pub fn parse(value: &str) -> Result { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(WorldDigestError(value.to_owned())); + } + let mut bytes = [0_u8; 32]; + for (index, byte) in bytes.iter_mut().enumerate() { + *byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16) + .map_err(|_| WorldDigestError(value.to_owned()))?; + } + Ok(Self(bytes)) + } + + /// The digest bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl fmt::Display for WorldDigest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.0 { + write!(formatter, "{byte:02x}")?; + } + Ok(()) + } +} + +impl fmt::Debug for WorldDigest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "WorldDigest({self})") + } +} + +impl Serialize for WorldDigest { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for WorldDigest { + fn deserialize>(deserializer: D) -> Result { + Self::parse(&String::deserialize(deserializer)?).map_err(serde::de::Error::custom) + } +} + +impl crate::__compat::wire::DescribeWire for WorldDigest { + fn wire_schema() -> crate::__compat::wire::WireSchema { + crate::__compat::wire::WireSchema::opaque( + "WorldDigest", + crate::__compat::wire::WireSchema::String, + ) + } +} + +/// A digest spelling that is not canonical SHA-256 hexadecimal. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[error("world digest must be exactly 64 lowercase hexadecimal characters, got '{0}'")] +pub struct WorldDigestError(String); + +/// One expanded static entity in a compiled world. +#[derive(phoxal_macros::DescribeWire, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorldEntity { + declaration: EntityDeclarationId, + instance: u32, + pose: Pose, + geometry: Geometry, + collision: Geometry, +} + +impl WorldEntity { + /// The declaration this anonymous instance was expanded from. + #[must_use] + pub const fn declaration(&self) -> &EntityDeclarationId { + &self.declaration + } + + /// The zero-based instance order inside the declaration. + #[must_use] + pub const fn instance(&self) -> u32 { + self.instance + } + + /// The canonical world pose. + #[must_use] + pub const fn pose(&self) -> Pose { + self.pose + } + + /// The visible geometry. + #[must_use] + pub const fn geometry(&self) -> &Geometry { + &self.geometry + } + + /// The exact physics geometry after collision defaults were expanded. + #[must_use] + pub const fn collision(&self) -> &Geometry { + &self.collision + } +} + +/// One canonical expanded world. +#[derive(phoxal_macros::DescribeWire, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct World { + id: WorldId, + time_step_ns: u64, + gravity_mps2: [f64; 3], + spawn_points: BTreeMap, + entities: Vec, +} + +impl World { + /// The stable authored world identity. + #[must_use] + pub const fn id(&self) -> &WorldId { + &self.id + } + + /// The exact duration represented by one completed native transition. + #[must_use] + pub const fn time_step_ns(&self) -> u64 { + self.time_step_ns + } + + /// Gravity in SI metres per second squared in the canonical Z-up frame. + #[must_use] + pub const fn gravity_mps2(&self) -> [f64; 3] { + self.gravity_mps2 + } + + /// The authored spawn points in deterministic name order. + pub fn spawn_points(&self) -> impl ExactSizeIterator { + self.spawn_points.iter().map(|(id, pose)| (id, *pose)) + } + + /// Anonymous expanded entities in declaration-name and instance order. + pub fn entities(&self) -> impl ExactSizeIterator { + self.entities.iter() + } + + pub(crate) fn referenced_assets(&self) -> BTreeSet { + self.entities + .iter() + .flat_map(|entity| [entity.geometry.asset_id(), entity.collision.asset_id()]) + .flatten() + .cloned() + .collect() + } + + pub(crate) fn validate_intrinsic(&self) -> Result<(), WorldBundleError> { + if self.time_step_ns == 0 || !self.time_step_ns.is_multiple_of(1_000_000) { + return Err(WorldBundleError::Invalid( + "time_step_ns must be a positive whole number of milliseconds".to_owned(), + )); + } + if !self.gravity_mps2.into_iter().all(canonical_float) { + return Err(WorldBundleError::Invalid( + "gravity_mps2 must contain finite values without negative zero".to_owned(), + )); + } + for (id, pose) in &self.spawn_points { + if !pose + .xyz() + .into_iter() + .chain(pose.rpy()) + .all(canonical_float) + { + return Err(WorldBundleError::Invalid(format!( + "spawn point '{id}' contains a non-canonical pose" + ))); + } + } + let mut previous_declaration: Option<&EntityDeclarationId> = None; + let mut previous_instance = 0_u32; + for entity in &self.entities { + if !entity + .pose + .xyz() + .into_iter() + .chain(entity.pose.rpy()) + .all(canonical_float) + { + return Err(WorldBundleError::Invalid(format!( + "entity '{}[{}]' contains a non-canonical pose", + entity.declaration, entity.instance + ))); + } + if !entity.geometry.has_valid_dimensions() + || !entity.collision.has_valid_dimensions() + || !geometry_is_canonical(&entity.geometry) + || !geometry_is_canonical(&entity.collision) + { + return Err(WorldBundleError::Invalid(format!( + "entity '{}[{}]' contains non-canonical geometry", + entity.declaration, entity.instance + ))); + } + match previous_declaration { + None if entity.instance == 0 => {} + Some(previous) if previous == &entity.declaration => { + let expected = previous_instance.checked_add(1).ok_or_else(|| { + WorldBundleError::Invalid( + "world entity instance index exhausted".to_owned(), + ) + })?; + if entity.instance != expected { + return Err(WorldBundleError::Invalid( + "world entity instances must be contiguous from zero".to_owned(), + )); + } + } + Some(previous) if previous < &entity.declaration && entity.instance == 0 => {} + None | Some(_) => { + return Err(WorldBundleError::Invalid( + "world entities must be ordered by declaration and contiguous instance" + .to_owned(), + )); + } + } + previous_declaration = Some(&entity.declaration); + previous_instance = entity.instance; + } + WorldProgress::zero(self.time_step_ns)?; + Ok(()) + } +} + +/// Immutable facts required to qualify one world run. +#[derive(phoxal_macros::DescribeWire, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorldProvenance { + /// The stable authored world identity. + pub world: WorldId, + /// The complete canonical bundle digest. + pub digest: WorldDigest, + /// The effective simulator seed. + pub random_seed: u64, + /// The framework train used by the world-side processes. + pub framework: FrameworkVersion, + /// The concrete adapter name. + pub adapter: String, + /// The exact adapter package version. + pub adapter_version: String, + /// The observed native simulator version. + pub simulator_version: String, + /// The platform qualification string. + pub platform: String, + /// The exact physics quantum in nanoseconds. + pub time_step_ns: u64, +} + +fn canonical_float(value: f64) -> bool { + value.is_finite() && (value != 0.0 || value.is_sign_positive()) +} + +fn geometry_is_canonical(geometry: &Geometry) -> bool { + match geometry { + Geometry::Box { size } => size.iter().copied().all(canonical_float), + Geometry::Cylinder { radius, length } | Geometry::Capsule { radius, length } => { + [*radius, *length].into_iter().all(canonical_float) + } + Geometry::Sphere { radius } => canonical_float(*radius), + Geometry::Mesh { scale, .. } => scale + .as_ref() + .is_none_or(|values| values.iter().copied().all(canonical_float)), + } +} + +#[allow( + dead_code, + reason = "the authoring profile constructs canonical worlds; runtime profiles only read them" +)] +pub(crate) fn compiled_world( + id: WorldId, + time_step_ns: u64, + gravity_mps2: [f64; 3], + spawn_points: BTreeMap, + entities: Vec, +) -> World { + World { + id, + time_step_ns, + gravity_mps2, + spawn_points, + entities, + } +} + +#[allow( + dead_code, + reason = "the authoring profile expands entities; runtime profiles only read them" +)] +pub(crate) fn compiled_entity( + declaration: EntityDeclarationId, + instance: u32, + pose: Pose, + geometry: Geometry, + collision: Geometry, +) -> WorldEntity { + WorldEntity { + declaration, + instance, + pose, + geometry, + collision, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn world_instance_identity_is_full_width_and_strict() { + let id = WorldInstanceId::mint(); + let text = id.to_string(); + assert_eq!(text.len(), WorldInstanceId::LEN); + assert_eq!(WorldInstanceId::parse(&text), Ok(id)); + for invalid in [ + "0123456789abcdef0123456789abcdef", + "ABCDEF0123456789ABCDEF0123456789", + "1234", + ] { + assert!(WorldInstanceId::parse(invalid).is_err(), "{invalid}"); + } + } + + #[test] + fn progress_is_one_checked_value() { + assert_eq!(WorldProgress::at(4, 12).unwrap().elapsed_ns(), 48); + assert!(WorldProgress::at(u64::MAX, 2).is_err()); + assert!(WorldProgress::at(1, 0).is_err()); + let inconsistent = WorldProgress { + completed_step: 2, + elapsed_ns: 25, + }; + assert!(inconsistent.validate(12).is_err()); + for malformed in [ + serde_json::json!({"completed_step": 0, "elapsed_ns": 1}), + serde_json::json!({"completed_step": 2, "elapsed_ns": 25}), + ] { + assert!( + serde_json::from_value::(malformed).is_err(), + "wire decoding must preserve the progress invariant" + ); + } + } + + #[test] + fn digest_spelling_is_strict() { + let digest = WorldDigest::of(b"world"); + let text = digest.to_string(); + assert_eq!(text.len(), 64); + assert_eq!(WorldDigest::parse(&text), Ok(digest)); + assert!(WorldDigest::parse(&text.to_uppercase()).is_err()); + } +} diff --git a/phoxal/src/participant/clock/mod.rs b/phoxal/src/participant/clock/mod.rs index 747eb959..3c5a274a 100644 --- a/phoxal/src/participant/clock/mod.rs +++ b/phoxal/src/participant/clock/mod.rs @@ -13,9 +13,8 @@ //! cadence depend on a published clock would put the control loop behind a //! transport that is explicitly allowed to drop samples under saturation, and //! one-way published ticks cannot bound offset across hosts anyway. -//! - **Simulation and replay.** Exact discrete steps advanced by the world -//! authority (the simulation controller). No interpolation. Pause means no -//! new step; reset means a new timeline. +//! - **Simulation and replay.** Exact discrete steps advanced by an external +//! logical-time source. No interpolation. Reset means a new timeline. //! //! # Losing clock discipline //! @@ -28,7 +27,7 @@ //! Instead the clock reports [`ClockReading::Unsynchronized`] and the runner //! fails the participant immediately. Teardown runs, so `Participant::shutdown` //! parks the hardware; time-sensitive publication stops because the process -//! stops; leases and actuator permits stop being renewed, so the receiver-side +//! stops; leases and actuator command authority stop being renewed, so the receiver-side //! deadlines and the driver-local watchdogs stop the machine on their own //! clocks. The reason travels in the failure, and the supervisor's ordinary //! restart and start-limit policy decides what happens next - a transient fault @@ -68,26 +67,26 @@ pub enum TimeUnsynchronized { /// The host clock could not be read, or read backwards. #[error("the host boot clock read failed or regressed")] ClockFault, - /// A simulated participant's world authority has not published a first step + /// A simulated participant's logical-time source has not published a first step /// yet, so there is no world history to date anything on. This is a world /// that has not started rather than a clock that was lost, which is why the /// runner's recurring beat deliberately does not fault on it. - #[error("the simulated world authority has published no step yet")] + #[error("the simulated logical-time source has published no step yet")] NoWorldHistory, } /// Which clock a launched participant runs on. /// -/// The launch contract's `--simulation` flag is the whole of this decision: -/// simulation is a launcher -/// choice, never a bundle fact, and there is no third mode. A real participant -/// that declares no `#[phoxal::step]` simply never steps; it does not become a -/// different kind of participant. +/// The supervisor's current time domain is the whole of this decision. +/// Services and the brain follow that authority, while drivers stay real-time +/// because their host-local cadence is independent of the world lifecycle. +/// A real participant that declares no `#[phoxal::step]` simply never steps; +/// it does not become a different kind of participant. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum ClockMode { /// Host boot clock, on the execution's timeline. Real, - /// The world clock published on `runtime/simulation/clock`. + /// Logical time admitted on the supervisor-selected timeline. Simulation, } diff --git a/phoxal/src/participant/clock/simulation.rs b/phoxal/src/participant/clock/simulation.rs index 93719606..68373a68 100644 --- a/phoxal/src/participant/clock/simulation.rs +++ b/phoxal/src/participant/clock/simulation.rs @@ -5,13 +5,13 @@ use tokio::sync::watch; use super::{ClockReading, ClockSource, TimeUnsynchronized}; use crate::bus::RobotInstant; -/// The simulation/replay clock: exact discrete steps advanced by the world -/// authority. +/// The simulation/replay clock: exact discrete steps advanced by an external +/// logical-time source. /// /// It reads the same authoritative instant the /// [`SimulationScheduler`](crate::participant::scheduler::simulation::SimulationScheduler) /// releases ticks from - both share one [`watch`] channel driven by the live -/// clock feed - so "what time is it" and "when does the next `Participant::step` +/// logical-time feed - so "what time is it" and "when does the next `Participant::step` /// fire" never diverge. Before the first sample arrives there is no world /// history at all, which is honestly reported as unsynchronized rather than as /// instant zero of some invented timeline. diff --git a/phoxal/src/participant/context.rs b/phoxal/src/participant/context.rs index 847f3810..7e1b2bdb 100644 --- a/phoxal/src/participant/context.rs +++ b/phoxal/src/participant/context.rs @@ -7,7 +7,6 @@ use std::time::Duration; use crate::__private::surface::{ComponentBoundSurface, TypedIoSurface}; use crate::bundle::ParticipantAssets as ParticipantAssetResolver; -use crate::bundle::RuntimeBundle; use crate::bus::{ AskQuery, DEFAULT_QUERY_TIMEOUT, Endpoint, Event, EventPublisher, EventReceiver, Observed, Publish, Querier, QueryEndpoint, RobotEndpoint, RobotInstant, Sample, SamplePublisher, @@ -23,6 +22,19 @@ use crate::participant::query::QueryRegistration; pub(crate) type TimelineRetention = Box; +/// The one source of setup's model and assets. +/// +/// A harness deliberately has neither source because its tests can drive pure +/// participant IO without a model fixture. Production always receives the +/// model and its matching asset resolver together from the supervisor. +pub(crate) enum SetupSource { + Harness, + Supervisor { + robot: Box, + assets: ParticipantAssetResolver, + }, +} + /// Trusted requester provenance for one admitted query. /// /// The runner constructs this only after decoding and validating the bus @@ -50,10 +62,7 @@ impl QueryContext { /// The sole IO-construction point, handed to `Participant::setup`. pub struct SetupContext { bus: BusHandle, - /// The bundle this participant was launched against, if it was launched - /// with one. Model and assets travel together because they are two views of - /// the same load: there is no launch that binds one without the other. - bundle: Option, + source: SetupSource, /// The identity this process was launched under. It is what a driver's /// component binding is looked up by: a driver's participant id *is* its /// component instance id. @@ -103,14 +112,10 @@ impl SetupContext { .await?) } - pub(crate) fn new( - bus: BusHandle, - bundle: Option, - participant_id: ParticipantId, - ) -> Self { + pub(crate) fn new(bus: BusHandle, source: SetupSource, participant_id: ParticipantId) -> Self { SetupContext { bus, - bundle, + source, participant_id, managed_tasks: ManagedTasks::default(), timeline_retentions: Vec::new(), @@ -188,22 +193,24 @@ impl SetupContext { std::mem::take(&mut self.queries) } - /// The immutable canonical model read from the bundle's `manifest.json`. + /// The immutable canonical model established during bootstrap. pub fn robot(&self) -> crate::Result<&Robot> { - Ok(self.bundle()?.robot()) + match &self.source { + SetupSource::Supervisor { robot, .. } => Ok(robot), + SetupSource::Harness => { + anyhow::bail!("the explicit test harness has no supervisor model") + } + } } - /// The assets under the bundle's `assets/` directory. + /// The execution assets available to setup. pub fn assets(&self) -> crate::Result<&ParticipantAssetResolver> { - Ok(self.bundle()?.assets()) - } - - fn bundle(&self) -> crate::Result<&RuntimeBundle> { - self.bundle.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "no bundle is bound (this participant was launched without a bundle root)" - ) - }) + match &self.source { + SetupSource::Supervisor { assets, .. } => Ok(assets), + SetupSource::Harness => { + anyhow::bail!("the explicit test harness has no supervisor asset resolver") + } + } } } diff --git a/phoxal/src/participant/launch.rs b/phoxal/src/participant/launch.rs index ce16eecd..72a04c87 100644 --- a/phoxal/src/participant/launch.rs +++ b/phoxal/src/participant/launch.rs @@ -5,16 +5,11 @@ //! one contract read in two directions, and the round-trip test below is what //! keeps them one. //! -//! A launched participant receives four facts and nothing else: who it is, the -//! bundle it reads its robot model and its own configuration from, where the -//! router is, and whether this run follows the simulated world clock instead of -//! the host clock. Clap is the -//! sole parser. There is deliberately no environment fallback, JSON launch -//! envelope, or launch-time copy of a fact the participant can learn for -//! itself: the execution identity comes from the router it connects to, and -//! robot time zero is host boot, so neither is an argument. +//! A launched participant receives exactly two facts: who it is and where the +//! supervisor rendezvous is. Clap is the sole parser. There is deliberately no +//! environment fallback, JSON launch envelope, or launch-time copy of a fact +//! the participant learns from the supervisor after attachment. -use std::path::PathBuf; use std::time::Duration; use crate::Result; @@ -32,10 +27,9 @@ pub(crate) const SHUTDOWN_GRACE: Duration = Duration::from_millis(2000); /// The strict process-boundary contract for one launched participant. /// -/// Robot identity, participant configuration, component binding, and scheduler -/// policy are read from `manifest.json` under `--bundle-root`; the participant -/// bus owner mints its own producer identity once the execution is known. No -/// field has an environment fallback. +/// The participant learns its model, configuration, and time domain from the +/// supervisor after opening the rendezvous. The bus owner mints its producer +/// identity once the execution is known. No field has an environment fallback. #[derive(Clone, Debug, Parser)] #[command( name = "phoxal-participant", @@ -49,24 +43,14 @@ pub(crate) struct Launch { #[arg(long, value_name = "ID", value_parser = parse_participant_id)] pub(crate) participant_id: ParticipantId, - /// The installed bundle containing manifest.json, assets/, and bin/. - #[arg(long, value_name = "DIR")] - pub(crate) bundle_root: PathBuf, - - /// A router endpoint. Repeat --connect once for each endpoint. + /// The one rendezvous endpoint for the execution supervisor. #[arg( long = "connect", value_name = "ENDPOINT", required = true, value_parser = parse_connect_endpoint )] - pub(crate) connect_endpoints: Vec, - - /// Follow the world clock on `runtime/simulation/clock` instead of the host - /// clock. Simulation is a launch decision, never a bundle fact, so nothing - /// in the manifest can turn it on. - #[arg(long = "simulation")] - pub(crate) simulation: bool, + pub(crate) connect: String, } impl Launch { @@ -85,16 +69,18 @@ impl Launch { /// device, so the flag spellings would otherwise live in a second repository /// and drift from the parser that has to accept them. /// -/// There is no environment half to encode. A launched participant reads four +/// There is no environment half to encode. A launched participant reads two /// facts from argv and nothing from the environment, deliberately, so this type /// renders argv and stops. /// /// ```ignore /// use phoxal::participant::launch::LaunchCommand; /// -/// let argv = LaunchCommand::new(participant, "/var/lib/phoxal/bundle") -/// .connect("unixsock-stream//run/phoxal/supervisor.sock") -/// .argv(); +/// let argv = LaunchCommand::for_rendezvous( +/// participant, +/// "unixsock-stream//run/phoxal/supervisor.sock", +/// ) +/// .argv(); /// ``` #[allow( dead_code, @@ -103,9 +89,7 @@ impl Launch { #[derive(Clone, Debug, Eq, PartialEq)] pub struct LaunchCommand { participant_id: ParticipantId, - bundle_root: PathBuf, - connect_endpoints: Vec, - simulation: bool, + connect: String, } #[allow( @@ -113,57 +97,29 @@ pub struct LaunchCommand { reason = "the encoder is the host half of this contract; a participant decodes" )] impl LaunchCommand { - /// Begin the argv for `participant_id` reading `bundle_root`. + /// Build the argv for `participant_id` joining one supervisor rendezvous. /// - /// At least one [`connect`](Self::connect) endpoint has to follow: a - /// participant with nowhere to dial is refused by the parser, not by this - /// builder, because the parser is the contract. + /// The explicit name is part of the breaking contract: older releases used + /// a two-argument `new` for `(participant_id, bundle_root)`. Reusing that + /// shape for `(participant_id, connect)` would let a stale launcher compile + /// while silently rendering the endpoint as `--bundle-root`. #[must_use] - pub fn new(participant_id: ParticipantId, bundle_root: impl Into) -> Self { + pub fn for_rendezvous(participant_id: ParticipantId, connect: impl Into) -> Self { Self { participant_id, - bundle_root: bundle_root.into(), - connect_endpoints: Vec::new(), - simulation: false, + connect: connect.into(), } } - /// Add one router endpoint. Repeating it adds a second `--connect`, which - /// is the only encoding of several endpoints: there is no separator to - /// agree on. - #[must_use] - pub fn connect(mut self, endpoint: impl Into) -> Self { - self.connect_endpoints.push(endpoint.into()); - self - } - - /// Follow the world clock instead of the host clock. - /// - /// Simulation is a launch decision and never a bundle fact, which is why it - /// is here and not in `manifest.json`. - #[must_use] - pub const fn simulation(mut self, simulation: bool) -> Self { - self.simulation = simulation; - self - } - /// Render the argv, without the program name. #[must_use] pub fn argv(&self) -> Vec { - let mut argv = vec![ + vec![ "--participant-id".to_owned(), self.participant_id.as_str().to_owned(), - "--bundle-root".to_owned(), - self.bundle_root.display().to_string(), - ]; - for endpoint in &self.connect_endpoints { - argv.push("--connect".to_owned()); - argv.push(endpoint.clone()); - } - if self.simulation { - argv.push("--simulation".to_owned()); - } - argv + "--connect".to_owned(), + self.connect.clone(), + ] } } @@ -193,35 +149,24 @@ mod tests { "participant-bin", "--participant-id", "drive", - "--bundle-root", - "/var/lib/phoxal/bundle", "--connect", "tcp/router-a:7447", ] } #[test] - fn accepts_only_the_four_launch_facts() { + fn accepts_exactly_identity_and_one_rendezvous_endpoint() { let launch = Launch::try_parse_from(args()).expect("valid launch argv"); assert_eq!(launch.participant_id.as_str(), "drive"); - assert_eq!(launch.bundle_root, PathBuf::from("/var/lib/phoxal/bundle")); - assert_eq!(launch.connect_endpoints, ["tcp/router-a:7447"]); - assert!( - !launch.simulation, - "the host clock is the default; simulation is opted into" - ); + assert_eq!(launch.connect, "tcp/router-a:7447"); } #[test] - fn accepts_multiple_connect_endpoints_without_a_comma_encoding() { + fn refuses_repeated_connect_endpoints() { let mut argv = args(); - argv.extend(["--connect", "tcp/router-b:7447", "--simulation"]); - let launch = Launch::try_parse_from(argv).expect("valid repeated endpoints"); - assert_eq!( - launch.connect_endpoints, - ["tcp/router-a:7447", "tcp/router-b:7447"] - ); - assert!(launch.simulation); + argv.extend(["--connect", "tcp/router-b:7447"]); + let error = Launch::try_parse_from(argv).expect_err("one endpoint is the ABI"); + assert_eq!(error.kind(), ErrorKind::ArgumentConflict); } #[test] @@ -247,6 +192,8 @@ mod tests { vec!["--execution-id", "10000000000000000000000000000001"], vec!["--execution-origin", "7:42:9"], vec!["--shutdown-grace-ms", "500"], + vec!["--bundle-root", "/var/lib/phoxal/bundle"], + vec!["--simulation"], ] { let mut argv = args(); argv.extend(retired.iter().copied()); @@ -260,17 +207,14 @@ mod tests { /// spellings. A renamed long option or parser alias would create a second /// launch contract even if the Rust field remained unchanged. #[test] - fn the_long_flag_set_is_exactly_the_four_launch_facts() { + fn the_long_flag_set_is_exactly_the_two_launch_facts() { let command = Launch::command(); let mut longs = command .get_arguments() .filter_map(clap::Arg::get_long) .collect::>(); longs.sort_unstable(); - assert_eq!( - longs, - ["bundle-root", "connect", "participant-id", "simulation"] - ); + assert_eq!(longs, ["connect", "participant-id"]); for argument in command.get_arguments() { assert!( argument @@ -297,7 +241,7 @@ mod tests { #[test] fn empty_connect_endpoint_is_rejected() { let mut argv = args(); - argv[6] = ""; + argv[4] = ""; assert!(Launch::try_parse_from(argv).is_err()); } @@ -306,13 +250,10 @@ mod tests { /// launching a participant this week. #[test] fn the_encoder_writes_exactly_what_the_decoder_accepts() { - let command = LaunchCommand::new( + let command = LaunchCommand::for_rendezvous( ParticipantId::new("drive").expect("a valid participant id"), - "/var/lib/phoxal/bundle", - ) - .connect("tcp/router-a:7447") - .connect("tcp/router-b:7447") - .simulation(true); + "tcp/router-a:7447", + ); let argv = command.argv(); assert_eq!( @@ -320,13 +261,8 @@ mod tests { [ "--participant-id", "drive", - "--bundle-root", - "/var/lib/phoxal/bundle", "--connect", "tcp/router-a:7447", - "--connect", - "tcp/router-b:7447", - "--simulation", ] ); @@ -335,29 +271,7 @@ mod tests { ) .expect("the encoder's argv parses"); assert_eq!(launch.participant_id.as_str(), "drive"); - assert_eq!(launch.bundle_root, PathBuf::from("/var/lib/phoxal/bundle")); - assert_eq!( - launch.connect_endpoints, - ["tcp/router-a:7447", "tcp/router-b:7447"] - ); - assert!(launch.simulation); - } - - /// The host clock is the default at both ends, so an encoder that says - /// nothing about simulation produces argv a decoder reads as real time. - #[test] - fn the_encoder_opts_into_simulation_rather_than_out_of_it() { - let argv = LaunchCommand::new( - ParticipantId::new("drive").expect("a valid participant id"), - "/var/lib/phoxal/bundle", - ) - .connect("tcp/router-a:7447") - .argv(); - assert!(!argv.contains(&"--simulation".to_owned()), "{argv:?}"); - let launch = - Launch::try_parse_from(std::iter::once("participant-bin".to_owned()).chain(argv)) - .expect("the encoder's argv parses"); - assert!(!launch.simulation); + assert_eq!(launch.connect, "tcp/router-a:7447"); } #[test] diff --git a/phoxal/src/participant/query.rs b/phoxal/src/participant/query.rs index 37340553..1cb61bb2 100644 --- a/phoxal/src/participant/query.rs +++ b/phoxal/src/participant/query.rs @@ -136,6 +136,7 @@ where #[cfg(test)] mod tests { use super::QueryRegistration; + use crate::bundle::BundlePath; use crate::bus::{Codec, MessagePack, QueryCode, QueryFailure}; use crate::prelude::*; use crate::supervisor::api as supervisor; @@ -169,11 +170,12 @@ mod tests { request: supervisor::bundle::GetRequest, state: &mut QueryState, ) -> QueryResult { - state.calls.push(request.path.clone()); + state.calls.push(request.path.as_str().to_owned()); state.requesters.push(query.producer()); - if request.path == "ok" { - Ok(supervisor::bundle::GetResponse::Found { + if request.path.as_str() == "ok" { + Ok(supervisor::bundle::GetResponse::Chunk { bytes: vec![1, 2, 3], + eof: true, }) } else { Err(QueryFailure::not_found("no such asset")) @@ -194,7 +196,8 @@ mod tests { crate::bus::ProducerId::try_from((1_u128 << 124) | 2).expect("canonical test producer"); let first = MessagePack::encode(&supervisor::bundle::GetRequest { - path: "ok".to_string(), + path: BundlePath::new("ok").unwrap(), + offset: 0, }) .unwrap(); let reply = registration @@ -210,11 +213,12 @@ mod tests { MessagePack::decode(&reply.payload).unwrap(); assert!(matches!( response, - supervisor::bundle::GetResponse::Found { .. } + supervisor::bundle::GetResponse::Chunk { .. } )); let second = MessagePack::encode(&supervisor::bundle::GetRequest { - path: "missing".to_string(), + path: BundlePath::new("missing").unwrap(), + offset: 0, }) .unwrap(); let failure = registration diff --git a/phoxal/src/participant/runner/event_loop.rs b/phoxal/src/participant/runner/event_loop.rs index 134ca249..009c1365 100644 --- a/phoxal/src/participant/runner/event_loop.rs +++ b/phoxal/src/participant/runner/event_loop.rs @@ -6,12 +6,12 @@ use crate::bus::{LocalInstant, RobotInstant, StepToken, StreamReceiver, Timeline use crate::participant::api::Participant; use crate::participant::clock::{ClockMode, ClockReading, ClockSource, TimeUnsynchronized}; use crate::participant::context::{ResetContext, StepContext, TimelineRetention}; -use crate::participant::scheduler::simulation::{SimulationClockAdvance, SimulationClockHandle}; +use crate::participant::scheduler::simulation::SimulationClockHandle; use crate::participant::scheduler::{SchedulerTick, StepScheduler}; -use crate::runtime::api::simulation::Clock; +use crate::supervisor::api::time_domain::{TimeDomain, TimeMode}; use super::ShutdownController; -use super::lifecycle::{LoopExit, Runner}; +use super::lifecycle::{LoopExit, Runner, runner_clock_for_domain, scheduler_for_domain}; use super::query::QuerySurface; /// How often the runner wakes for work that is not a step: publishing the @@ -25,16 +25,18 @@ impl Runner { { let period = self.schedule.map(|schedule| schedule.period()); let mut step_index: u64 = 0; - let mut active_timeline: Option = None; + let mut active_timeline = self.domain.map(|domain| domain.timeline); let mut simulation_time_rx = self.scheduler.simulation_time_receiver(); - // The simulation clock feed starts before `Participant::setup`. If setup - // takes long enough for the authority's first world step to arrive, a + // The dormant logical-time feed starts before `Participant::setup`. If setup + // takes long enough for the source's first step to arrive, a // newly-cloned watch receiver sees that value as its initial state and // has no change notification to deliver. Establish that already-current // world history without invoking reset: there was no prior participant // execution, but its ingress barrier and first cadence still matter. let initial_time = self.scheduler.now(); - if let Some(initial_time) = initial_time.filter(|_| simulation_time_rx.is_some()) { + if let Some(domain) = self.domain { + retain_timeline(&self.timeline_retentions, domain.timeline); + } else if let Some(initial_time) = initial_time.filter(|_| simulation_time_rx.is_some()) { active_timeline = Some(initial_time.timeline()); retain_timeline(&self.timeline_retentions, initial_time.timeline()); } @@ -77,7 +79,90 @@ impl Runner { ); return LoopExit::ManagedTaskFaulted(exit); } + domain = time_domain_change(&mut self.domain_updates) => { + let domain = match domain { + Ok(domain) => domain, + Err(error) => return LoopExit::StepFailed(error), + }; + let Some(previous) = self.domain else { + continue; + }; + if domain.revision <= previous.revision { + continue; + } + let clock_mode = match domain.mode { + TimeMode::Monotonic => ClockMode::Real, + TimeMode::Simulated => ClockMode::Simulation, + }; + let Some(simulation_clock) = &self.simulation_clock else { + return LoopExit::StepFailed(anyhow::anyhow!( + "a time-domain participant lost its logical-time ingress" + )); + }; + match clock_mode { + ClockMode::Real => simulation_clock.disable(), + ClockMode::Simulation => simulation_clock.replace_timeline(domain.timeline), + } + let now = match clock_mode { + ClockMode::Real => { + let clock = crate::participant::clock::real::RealClock::new(domain.timeline); + let reading = clock.read(); + let Some(now) = reading.instant() else { + let ClockReading::Unsynchronized(reason) = reading else { unreachable!() }; + return LoopExit::ClockDisciplineLost(reason); + }; + self.clock = super::lifecycle::RunnerClock::SupervisorReal(clock); + Some(now) + } + ClockMode::Simulation => None, + }; + let (scheduler, now) = match scheduler_after_domain_change( + clock_mode, + self.schedule, + now, + simulation_clock, + ) { + Ok(scheduler) => scheduler, + Err(error) => return LoopExit::StepFailed(error), + }; + if clock_mode == ClockMode::Simulation { + self.clock = match runner_clock_for_domain::(&scheduler, domain) { + Ok(clock) => clock, + Err(error) => return LoopExit::ClockDisciplineLost(error.reason), + }; + } + self.scheduler = scheduler; + self.clock_mode = clock_mode; + self.domain = Some(domain); + retain_timeline(&self.timeline_retentions, domain.timeline); + let reset = ResetContext { + previous_timeline: previous.timeline, + new_timeline: domain.timeline, + }; + if let Err(error) = self.participant.reset(reset, &self.api, &mut self.state) { + return LoopExit::ResetFailed(error); + } + active_timeline = Some(domain.timeline); + simulation_time_rx = self.scheduler.simulation_time_receiver(); + next_step_target = now.and_then(|at| { + period.map(|period| advance_step_deadline(at, period, 0)) + }); + step_index = 0; + last_step_at = now; + self.runtime_performance.reset(self.schedule); + } fired_at = simulation_time_change(&mut simulation_time_rx) => { + if let Some(domain) = self.domain { + if domain.mode != TimeMode::Simulated || fired_at.timeline() != domain.timeline { + continue; + } + active_timeline = Some(domain.timeline); + if next_step_target.is_none() { + next_step_target = period.map(|period| advance_step_deadline(fired_at, period, 0)); + } + last_step_at.get_or_insert(fired_at); + continue; + } if active_timeline == Some(fired_at.timeline()) { continue; } @@ -115,7 +200,7 @@ impl Runner { // sooner, in its own step arm. // // Simulation is excluded on purpose: there, "unsynchronized" - // means the world authority has not published a first step yet, + // means the logical-time source has not published a first step yet, // which is a world that has not started rather than a clock // that was lost. let faulted = LocalInstant::clock_faulted() @@ -172,7 +257,7 @@ impl Runner { let now = match self.clock.read() { ClockReading::Synchronized(now) if now.timeline() == target.timeline() => now, ClockReading::Synchronized(_) => { - // The clock feed can replace the world history after the + // Logical-time ingress can replace the world history after the // scheduler resolves but before this read. Let the // higher-priority simulation-time arm install the // ingress barrier and run Participant::reset before any step on @@ -239,47 +324,28 @@ impl Runner { } } -/// Subscribe the authoritative `runtime/simulation/clock` hand and drive the live -/// scheduler from exact production instants for the task's lifetime. -pub(crate) async fn simulation_clock_feed( - bus: crate::bus::BusHandle, - handle: SimulationClockHandle, -) -> crate::Result<()> { - let topic = crate::runtime::api::topics().simulation().clock().client(); - let subscriber = match StreamReceiver::::new(&bus, &topic).await { - Ok(subscriber) => subscriber, - Err(error) => return Err(error.into()), +/// Rebuild cadence after a supervisor domain replacement and retain a +/// simulated instant that arrived before the new watch receiver subscribed. +/// +/// Tokio watch receivers consider the value present at subscription already +/// seen. Reading the scheduler immediately closes that edge: a first clock +/// accepted after the timeline fence but before scheduler construction still +/// seeds the next step target instead of waiting forever for a second clock. +fn scheduler_after_domain_change( + clock_mode: ClockMode, + schedule: Option, + now: Option, + simulation_clock: &SimulationClockHandle, +) -> crate::Result<( + crate::participant::scheduler::AnyStepScheduler, + Option, +)> { + let scheduler = scheduler_for_domain(clock_mode, schedule, now, simulation_clock)?; + let now = match clock_mode { + ClockMode::Real => now, + ClockMode::Simulation => scheduler.now(), }; - tracing::info!( - target: "phoxal.runtime", - topic = topic.key(), - "subscribed the live runtime/simulation/clock hand; driving the simulation scheduler from it" - ); - loop { - let observed = subscriber.recv().await.map_err(|error| { - anyhow::anyhow!( - "the world-clock subscriber on {} terminated: {error}", - topic.key() - ) - })?; - let Some(at) = observed.metadata.produced_exactly_at() else { - return Err(anyhow::anyhow!( - "a world-clock sample on {} has no exact production instant", - topic.key() - )); - }; - match handle.advance(at) { - SimulationClockAdvance::Advanced | SimulationClockAdvance::DuplicateOrBackward => {} - SimulationClockAdvance::RetiredTimeline => { - tracing::warn!( - target: "phoxal.runtime", - timeline = %at.timeline(), - ticks = at.ticks(), - "ignoring late simulation clock from a retired world history" - ); - } - } - } + Ok((scheduler, now)) } /// Resolve on the next request when a query surface exists, and never when it @@ -315,6 +381,17 @@ async fn simulation_time_change( } } +/// Await the next ordered supervisor domain replacement, or never resolve for +/// a driver and the explicit harness, which have no execution-time authority. +async fn time_domain_change( + updates: &mut Option>, +) -> crate::Result { + let Some(updates) = updates else { + return std::future::pending().await; + }; + Ok(updates.recv().await?.body.domain) +} + /// The instant the step after the one due at `target` is due at: one period on, /// plus one for each period a released tick collapsed. pub(crate) fn advance_step_deadline( @@ -330,3 +407,42 @@ pub(crate) fn retain_timeline(retentions: &[TimelineRetention], timeline: Timeli retention(timeline); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::participant::scheduler::simulation::SimulationClockAdvance; + use crate::participant::scheduler::{StepSchedule, StepScheduler}; + + /// A clock can arrive after the new timeline is enabled but before the + /// replacement scheduler subscribes. That already-current value must seed + /// cadence even though its watch notification is not pending. + #[test] + fn a_first_clock_before_scheduler_subscription_seeds_the_transition() { + let timeline = TimelineId::from_raw(7).expect("a nonzero test timeline"); + let first = RobotInstant::new(timeline, 11); + let simulation_clock = SimulationClockHandle::source(); + simulation_clock.replace_timeline(timeline); + assert_eq!( + simulation_clock.advance(first), + SimulationClockAdvance::Advanced + ); + + let schedule = StepSchedule::hz(10.0); + let (scheduler, now) = scheduler_after_domain_change( + ClockMode::Simulation, + Some(schedule), + None, + &simulation_clock, + ) + .expect("the replacement scheduler builds"); + + assert_eq!(now, Some(first)); + assert_eq!(scheduler.now(), Some(first)); + assert_eq!( + now.map(|at| advance_step_deadline(at, schedule.period(), 0)), + Some(first.saturating_add(schedule.period())), + "the first accepted clock must arm the replacement cadence", + ); + } +} diff --git a/phoxal/src/participant/runner/harness.rs b/phoxal/src/participant/runner/harness.rs index 72f25260..2d3b712d 100644 --- a/phoxal/src/participant/runner/harness.rs +++ b/phoxal/src/participant/runner/harness.rs @@ -29,9 +29,9 @@ pub struct TestHarness { impl TestHarness { /// Construct an explicit test-harness input for a participant id. /// - /// A launched participant derives its real timeline from the execution the - /// router reports; a harness has no router, so it mints one here and lets a - /// test name it explicitly when two harnesses have to share a world history. + /// A production participant receives its timeline from the supervisor's + /// time domain; a harness has no supervisor, so it mints one here and lets + /// a test name it explicitly when two harnesses share a world history. /// /// # Errors /// @@ -54,16 +54,13 @@ impl TestHarness { self } - /// Supply the configuration a launched participant would read out of the - /// manifest. + /// Supply explicit configuration for this harness participant. /// - /// A harness binds no bundle, so there is no `services..config` or - /// `components..driver.config` for it to read; without this a - /// participant declaring a required config could never be driven by its own - /// tests. The value goes through exactly the deserialization step the runner - /// performs, so a config the launch would reject is rejected here too - and - /// omitting it still means JSON `null`, the same absent-config rule a - /// missing manifest key follows. + /// A harness has no supervisor-attached robot model, so it cannot select a + /// service or driver configuration from one. Without this value, a + /// participant declaring required config could not be driven by its own + /// tests. The value goes through the runner's deserialization step, so an + /// invalid config is rejected here too. Omitting it still means JSON `null`. #[must_use] pub fn with_config(mut self, config: serde_json::Value) -> Self { self.config = Some(config); diff --git a/phoxal/src/participant/runner/inputs.rs b/phoxal/src/participant/runner/inputs.rs index 7ecf64c8..259f0016 100644 --- a/phoxal/src/participant/runner/inputs.rs +++ b/phoxal/src/participant/runner/inputs.rs @@ -1,17 +1,21 @@ -//! What the strict launch resolves into before `Participant::setup` runs: the -//! opened bundle, and the participant's own configuration read out of it. +//! Participant configuration selected from the supervisor-established model. +#[cfg(test)] use crate::bundle::RuntimeBundle; use crate::identity::ParticipantId; use crate::model::Robot; +use crate::participant::api::Participant; use crate::participant::metadata::ParticipantKind; use anyhow::Context; -/// Open the bundle the launch points at. +const BRAIN_ID: &str = "brain"; + +/// Open one local fixture bundle for an in-process test. /// -/// There is no selection step: the manifest is the robot model plus, for those -/// that have one, each participant's own configuration, so a participant the -/// manifest never mentions opens the bundle exactly like one it does. +/// Production participants receive the model and remote asset reader from the +/// supervisor during bootstrap. This helper keeps the explicit fixture path +/// available to tests that exercise the same setup API without a supervisor. +#[cfg(test)] pub(crate) fn open_bundle(root: &std::path::Path) -> crate::Result { RuntimeBundle::open(root) .with_context(|| format!("failed to open the runtime bundle at {}", root.display())) @@ -34,15 +38,59 @@ pub(crate) fn open_bundle(root: &std::path::Path) -> crate::Result` launch with no /// configuration at all, while one declaring a required struct fails with /// serde's own `invalid type: null` rather than a bespoke error. -pub(crate) fn participant_config( +pub(crate) fn participant_config( robot: &Robot, participant_id: &ParticipantId, - kind: ParticipantKind, -) -> crate::Result { - let config = match kind { - ParticipantKind::Driver => driver_block(robot, participant_id)?.config(), - ParticipantKind::Service | ParticipantKind::Brain => { - robot.service_config(participant_id.as_str()) +) -> crate::Result { + let config = match R::KIND { + ParticipantKind::Driver => { + let component = robot.component(participant_id.as_str()).with_context(|| { + format!( + "driver participant '{participant_id}' is not a component instance of robot '{}'", + robot.id() + ) + })?; + let driver = component.instance().driver().with_context(|| { + format!( + "driver participant '{participant_id}' names a component instance of robot '{}' that declares no driver block", + robot.id() + ) + })?; + let component_type = component.instance().component_type(); + anyhow::ensure!( + R::ID == component_type.as_str(), + "driver artifact '{}' cannot launch for component instance '{participant_id}' of type '{component_type}'", + R::ID + ); + driver.config() + } + ParticipantKind::Service => { + anyhow::ensure!( + participant_id.as_str() == R::ID, + "service artifact '{}' cannot launch as participant '{participant_id}'", + R::ID + ); + robot + .service(participant_id.as_str()) + .with_context(|| { + format!( + "service participant '{participant_id}' is not declared by robot '{}'", + robot.id() + ) + })? + .config() + } + ParticipantKind::Brain => { + anyhow::ensure!( + R::ID == BRAIN_ID, + "brain artifact '{}' does not declare the canonical '{BRAIN_ID}' identity", + R::ID + ); + anyhow::ensure!( + participant_id.as_str() == BRAIN_ID, + "brain artifact cannot launch as participant '{participant_id}'" + ); + None } }; deserialize_config(config) @@ -84,8 +132,83 @@ pub(crate) fn deserialize_config( mod tests { use super::*; + use crate::participant::context::SetupContext; use phoxal_fixture::staged_bundle; + #[derive(Debug, Eq, PartialEq, phoxal::Config, serde::Deserialize)] + struct ReductionConfig { + reduction: u64, + } + + #[phoxal::driver( + id = "drive_motor", + config = Option, + connection = can + )] + struct DriveMotor; + + impl Participant for DriveMotor { + async fn setup( + &self, + _ctx: &mut SetupContext, + _config: Self::Config, + ) -> crate::Result<(Self::State, Self::Api)> { + Ok(((), ())) + } + } + + #[phoxal::driver(id = "other_driver")] + struct OtherDriver; + + impl Participant for OtherDriver { + async fn setup( + &self, + _ctx: &mut SetupContext, + _config: Self::Config, + ) -> crate::Result<(Self::State, Self::Api)> { + Ok(((), ())) + } + } + + #[phoxal::service(id = "drive", config = Option)] + struct DriveService; + + impl Participant for DriveService { + async fn setup( + &self, + _ctx: &mut SetupContext, + _config: Self::Config, + ) -> crate::Result<(Self::State, Self::Api)> { + Ok(((), ())) + } + } + + #[phoxal::service(id = "unknown-service")] + struct UnknownService; + + impl Participant for UnknownService { + async fn setup( + &self, + _ctx: &mut SetupContext, + _config: Self::Config, + ) -> crate::Result<(Self::State, Self::Api)> { + Ok(((), ())) + } + } + + #[phoxal::brain] + struct Brain; + + impl Participant for Brain { + async fn setup( + &self, + _ctx: &mut SetupContext, + _config: Self::Config, + ) -> crate::Result<(Self::State, Self::Api)> { + Ok(((), ())) + } + } + fn participant(id: &str) -> ParticipantId { ParticipantId::new(id).expect("a test participant id") } @@ -154,13 +277,9 @@ mod tests { // A driver reads the `config` half and only that half: the connection // beside it is the framework's, and never reaches the participant's own // `Config` where a driver could mistake it for authored settings. - let driver = participant_config::( - robot, - &participant("front_left_drive"), - ParticipantKind::Driver, - ) - .expect("a driven component's authored driver config deserializes"); - assert_eq!(driver, serde_json::json!({"reduction": 20}), "{driver}"); + let driver = participant_config::(robot, &participant("front_left_drive")) + .expect("a driven component's authored driver config deserializes"); + assert_eq!(driver, Some(ReductionConfig { reduction: 20 })); assert_eq!( driver_block(robot, &participant("front_left_drive")) .expect("the fixture mounts a driven component") @@ -172,29 +291,21 @@ mod tests { // A driven instance that authors no `config` reads null, the same // absent-config rule a missing service key follows. assert!( - participant_config::( - robot, - &participant("front_right_drive"), - ParticipantKind::Driver, - ) - .expect("a driven component with no authored driver config reads null") - .is_null() + participant_config::(robot, &participant("front_right_drive")) + .expect("a driven component with no authored driver config reads null") + .is_none() ); // The fixture authors no service config, so an official service reads // JSON null - the same absent-config rule a missing key follows. assert!( - participant_config::( - robot, - &participant("drive"), - ParticipantKind::Service, - ) - .expect("a service with no authored config reads null") - .is_null() + participant_config::(robot, &participant("drive")) + .expect("a service with no authored config reads null") + .is_none() ); // The brain never appears under `services`, so it reads null too. - participant_config::<()>(robot, &participant("brain"), ParticipantKind::Brain) + participant_config::(robot, &participant("brain")) .expect("the root brain has no configuration side channel"); } @@ -204,12 +315,9 @@ mod tests { fn a_driver_launched_under_a_non_component_id_fails_locally() { let staged = staged_bundle(); let bundle = open_bundle(staged.path()).expect("the staged bundle opens"); - let error = participant_config::( - bundle.robot(), - &participant("not-a-component"), - ParticipantKind::Driver, - ) - .expect_err("a driver must name a component instance"); + let error = + participant_config::(bundle.robot(), &participant("not-a-component")) + .expect_err("a driver must name a component instance"); assert!( format!("{error:#}").contains("is not a component instance"), "{error:#}" @@ -223,21 +331,67 @@ mod tests { fn a_driver_launched_for_an_undriven_instance_fails_locally() { let staged = staged_bundle(); let bundle = open_bundle(staged.path()).expect("the staged bundle opens"); - let error = participant_config::( - bundle.robot(), - &participant("imu"), - ParticipantKind::Driver, - ) - .expect_err("an undriven component instance runs no driver"); + let error = participant_config::(bundle.robot(), &participant("imu")) + .expect_err("an undriven component instance runs no driver"); assert!( format!("{error:#}").contains("declares no driver block"), "{error:#}" ); } - /// The bundle binds the model and the assets a participant reads through - /// `ctx.robot()` and `ctx.assets()`, and a directory that is not a bundle - /// fails the launch instead of binding nothing. + /// The launch identity selects one expected process descriptor. A binary + /// whose compiled role or artifact identity disagrees with that descriptor + /// is refused before its config can be mistaken for another participant's. + #[test] + fn wrong_compiled_roles_and_artifacts_fail_before_ready() { + let staged = staged_bundle(); + let bundle = open_bundle(staged.path()).expect("the staged bundle opens"); + let robot = bundle.robot(); + + for (error, expected) in [ + ( + participant_config::(robot, &participant("front_left_drive")) + .expect_err("a service artifact cannot impersonate a driver instance"), + "service artifact 'drive'", + ), + ( + participant_config::(robot, &participant("drive")) + .expect_err("a driver artifact cannot impersonate a service"), + "is not a component instance", + ), + ( + participant_config::(robot, &participant("drive")) + .expect_err("the brain cannot launch under a service identity"), + "brain artifact cannot launch", + ), + ( + participant_config::(robot, &participant("front_left_drive")) + .expect_err("a driver artifact must match the mounted component type"), + "driver artifact 'other_driver'", + ), + ] { + assert!(format!("{error:#}").contains(expected), "{error:#}"); + } + } + + /// A configless service still has to exist in the supervisor model. Null + /// configuration is not evidence that an unknown participant is valid. + #[test] + fn an_unknown_configless_service_fails_before_ready() { + let staged = staged_bundle(); + let bundle = open_bundle(staged.path()).expect("the staged bundle opens"); + let error = + participant_config::(bundle.robot(), &participant("unknown-service")) + .expect_err("an unknown service must not be treated as configless"); + assert!( + format!("{error:#}").contains("is not declared"), + "{error:#}" + ); + } + + /// A fixture bundle binds the model and local assets a test reads through + /// `ctx.robot()` and `ctx.assets()`, and a malformed directory refuses + /// fixture setup instead of binding nothing. #[test] fn the_bundle_binds_the_model_and_its_assets() { let staged = staged_bundle(); @@ -246,7 +400,7 @@ mod tests { assert!( bundle .assets() - .read( + .read_local( &crate::AssetId::new("components/drive_motor/meshes/drive_motor.obj") .expect("a canonical asset id") ) @@ -257,14 +411,14 @@ mod tests { assert!( bundle .assets() - .read(&crate::AssetId::new("bin/brain").expect("a canonical asset id")) + .read_local(&crate::AssetId::new("bin/brain").expect("a canonical asset id")) .is_err() ); let missing = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../fixture/robot"); assert!( open_bundle(&missing).is_err(), - "a directory that is not a bundle must fail the launch, not bind nothing" + "a directory that is not a bundle must fail fixture setup, not bind nothing" ); } } diff --git a/phoxal/src/participant/runner/lifecycle.rs b/phoxal/src/participant/runner/lifecycle.rs index 084f7d8a..a0fccb5b 100644 --- a/phoxal/src/participant/runner/lifecycle.rs +++ b/phoxal/src/participant/runner/lifecycle.rs @@ -7,22 +7,23 @@ use std::future::Future; use std::time::Duration; -use crate::bundle::RuntimeBundle; use crate::bus::{BusFault, BusHandle, BusOwner, ParticipantReadyToken}; use crate::identity::ParticipantId; use crate::participant::api::Participant; use crate::participant::bus_log::{self, BusLogTask}; use crate::participant::clock::simulation::SimulationClock; use crate::participant::clock::{ClockMode, ClockReading, ClockSource, TimeUnsynchronized}; -use crate::participant::context::{SetupContext, TimelineRetention}; +use crate::participant::context::{SetupContext, SetupSource, TimelineRetention}; use crate::participant::managed::{ManagedTaskExit, ManagedTaskPolicy, ManagedTasks}; use crate::participant::runtime_performance::{RuntimePerformance, RuntimePerformancePublisher}; use crate::participant::scheduler::simulation::SimulationClockHandle; use crate::participant::scheduler::{AnyStepScheduler, StepSchedule}; +use crate::supervisor::api::time_domain::{TimeDomain, TimeMode}; use super::ShutdownController; +use super::event_loop::retain_timeline; use super::query::QuerySurface; -use super::startup::PreparedRun; +use super::startup::{AttachmentSubscription, DomainSubscription, PreparedRun}; use super::teardown::{ ShutdownDeadline, Teardown, TeardownReport, abandon_setup, abandon_startup, combine, }; @@ -132,6 +133,7 @@ impl std::error::Error for ParticipantFault { /// The runner's effective timestamp clock, chosen once the scheduler is built. pub(crate) enum RunnerClock { Delegated(C), + SupervisorReal(crate::participant::clock::real::RealClock), Simulation(SimulationClock), } @@ -139,17 +141,18 @@ impl ClockSource for RunnerClock { fn read(&self) -> ClockReading { match self { Self::Delegated(clock) => clock.read(), + Self::SupervisorReal(clock) => clock.read(), Self::Simulation(clock) => clock.read(), } } } -/// Select the runner's timestamp clock from the scheduler shape and the -/// launch-validated clock. +/// Select the runner's timestamp clock from the scheduler shape and preflight +/// clock. /// /// A disabled scheduler does not mean a disabled clock: a stepless real -/// participant still dates the state it serves, so it keeps the host clock the -/// launch built. Only a simulated participant reads its instants from somewhere +/// participant still dates the state it serves, so it keeps the host clock +/// preflight supplied. Only a simulated participant reads its instants from somewhere /// else, and the scheduler it runs on is that source. pub(crate) fn runner_clock( scheduler: &AnyStepScheduler, @@ -159,8 +162,8 @@ pub(crate) fn runner_clock( AnyStepScheduler::Simulation(simulation) => { Ok(RunnerClock::Simulation(simulation.simulation_clock())) } - // Both remaining shapes are real launches - one with a cadence, one - // without - and a real launch always carries a host clock. Reaching the + // Both remaining shapes are real runs - one with a cadence, one + // without - and a real run always carries a host clock. Reaching the // `None` arm means the caller assembled a real run without one, which // reads as a clock this process cannot read; the participant fails // rather than dating its state on an invented instant. @@ -170,6 +173,180 @@ pub(crate) fn runner_clock( reason: TimeUnsynchronized::ClockFault, }), }, + #[cfg(test)] + AnyStepScheduler::TestMonotonic(_) => match clock { + Some(clock) => Ok(RunnerClock::Delegated(clock)), + None => Err(ClockDisciplineLost { + reason: TimeUnsynchronized::ClockFault, + }), + }, + } +} + +pub(crate) fn runner_clock_for_domain( + scheduler: &AnyStepScheduler, + domain: TimeDomain, +) -> Result, ClockDisciplineLost> { + match scheduler { + AnyStepScheduler::Simulation(simulation) => { + Ok(RunnerClock::Simulation(simulation.simulation_clock())) + } + AnyStepScheduler::Real(_) | AnyStepScheduler::Disabled => Ok(RunnerClock::SupervisorReal( + crate::participant::clock::real::RealClock::new(domain.timeline), + )), + #[cfg(test)] + AnyStepScheduler::TestMonotonic(_) => Ok(RunnerClock::SupervisorReal( + crate::participant::clock::real::RealClock::new(domain.timeline), + )), + } +} + +pub(crate) fn scheduler_for_domain( + mode: ClockMode, + schedule: Option, + now: Option, + simulation_clock: &SimulationClockHandle, +) -> crate::Result { + AnyStepScheduler::validate_clock_mode(mode, schedule, now)?; + let period = schedule.map(|schedule| schedule.period()); + match mode { + ClockMode::Real if schedule.is_none() => Ok(AnyStepScheduler::Disabled), + ClockMode::Real => { + let now = now.ok_or_else(|| { + anyhow::anyhow!("a monotonic domain cannot anchor cadence without a host clock") + })?; + let scheduler = crate::participant::scheduler::real::RealScheduler::new(period, now) + .ok_or_else(|| { + anyhow::anyhow!("the host boot clock could not be read to anchor cadence") + })?; + Ok(AnyStepScheduler::Real(scheduler)) + } + ClockMode::Simulation => Ok(AnyStepScheduler::Simulation( + simulation_clock.scheduler(period), + )), + } +} + +fn clock_mode_for_domain(domain: TimeDomain) -> ClockMode { + match domain.mode { + TimeMode::Monotonic => ClockMode::Real, + TimeMode::Simulated => ClockMode::Simulation, + } +} + +/// Rebuild the runner's clock and scheduler for one newer time domain before +/// the participant becomes Ready. +fn reconfigure_start_domain( + current: TimeDomain, + schedule: Option, + simulation_clock: Option<&SimulationClockHandle>, + clock_mode: &mut ClockMode, + scheduler: &mut AnyStepScheduler, + clock: &mut RunnerClock, +) -> crate::Result<()> { + let simulation_clock = simulation_clock.ok_or_else(|| { + anyhow::anyhow!("a time-domain participant lost its logical-time ingress") + })?; + let mode = clock_mode_for_domain(current); + match mode { + ClockMode::Real => simulation_clock.disable(), + ClockMode::Simulation => simulation_clock.replace_timeline(current.timeline), + } + let now = match mode { + ClockMode::Real => { + let clock = crate::participant::clock::real::RealClock::new(current.timeline); + match clock.read() { + ClockReading::Synchronized(instant) => Some(instant), + ClockReading::Unsynchronized(reason) => { + return Err(ClockDisciplineLost { reason }.into()); + } + } + } + ClockMode::Simulation => None, + }; + *scheduler = scheduler_for_domain(mode, schedule, now, simulation_clock)?; + *clock = runner_clock_for_domain::(scheduler, current)?; + *clock_mode = mode; + Ok(()) +} + +/// Drain every already-buffered replacement at a lifecycle boundary. +fn reconcile_start_domain( + domain: &mut Option, +) -> crate::Result> { + let Some(domain) = domain else { + return Ok(Vec::new()); + }; + domain.reconcile() +} + +/// Whether a just-acquired Ready lease still represents the newest known time +/// domain. +pub(super) enum ReadyDomainFence { + /// No replacement arrived while readiness was acquired. + Stable, + /// A replacement arrived, so the lease must be revoked and startup retried. + Reconfigure(Vec<(TimeDomain, TimeDomain)>), +} + +/// Fence a Ready lease against stream updates that raced its declaration. +pub(super) fn fence_ready_domain( + domain: &mut Option, +) -> crate::Result { + let transitions = reconcile_start_domain(domain)?; + if transitions.is_empty() { + Ok(ReadyDomainFence::Stable) + } else { + Ok(ReadyDomainFence::Reconfigure(transitions)) + } +} + +/// Await one replacement while Ready is being declared. +async fn next_start_domain( + domain: &mut Option, +) -> crate::Result<(TimeDomain, TimeDomain)> { + let Some(domain) = domain else { + return std::future::pending().await; + }; + domain.next_replacement().await +} + +/// Mutable runtime state used to apply one scheduling-history replacement. +struct StartDomainTransition<'a, R: Participant, C: ClockSource> { + schedule: Option, + simulation_clock: Option<&'a SimulationClockHandle>, + clock_mode: &'a mut ClockMode, + scheduler: &'a mut AnyStepScheduler, + clock: &'a mut RunnerClock, + participant: &'a R, + api: &'a R::Api, + state: &'a mut R::State, + timeline_retentions: &'a [TimelineRetention], +} + +impl StartDomainTransition<'_, R, C> { + /// Apply a scheduling-history replacement and notify the participant before + /// it can declare Ready under that newer authority. + fn apply(&mut self, previous: TimeDomain, current: TimeDomain) -> crate::Result<()> { + reconfigure_start_domain( + current, + self.schedule, + self.simulation_clock, + self.clock_mode, + self.scheduler, + self.clock, + )?; + retain_timeline(self.timeline_retentions, current.timeline); + self.participant + .reset( + crate::participant::context::ResetContext { + previous_timeline: previous.timeline, + new_timeline: current.timeline, + }, + self.api, + self.state, + ) + .map_err(|error| ParticipantFault::Reset(error).into()) } } @@ -181,14 +358,16 @@ pub(crate) struct RunnerTasks { } /// Inputs for the single startup transition. Grouping the ownership boundary -/// here keeps the scheduler, clock, selected runtime record, and task set +/// here keeps the supervisor-attached model, scheduler, clock, and task set /// explicit without threading a long parameter list through `Runner::start`. pub(crate) struct StartInputs { pub(crate) bus: BusHandle, pub(crate) session: BusLease, pub(crate) participant_id: ParticipantId, pub(crate) shutdown_grace: Duration, - pub(crate) bundle: Option, + pub(crate) source: SetupSource, + pub(crate) domain: Option, + pub(crate) attachment: Option, pub(crate) config: R::Config, pub(crate) clock: RunnerClock, pub(crate) scheduler: AnyStepScheduler, @@ -257,19 +436,50 @@ where session, participant_id, shutdown_grace, - bundle, + source, + domain, + attachment, config, clock_mode, clock, query_reply_delay, } = prepared; + let mut domain = domain; + let mut attachment = attachment; + let mut clock_mode = clock_mode; + if let Some(domain) = &mut domain { + if let Err(error) = domain.reconcile() { + return close_session_with_result( + Err(error), + session, + ShutdownDeadline::from_now(shutdown_grace), + ) + .await; + } + clock_mode = clock_mode_for_domain(domain.current); + } + if let Some(attachment) = &mut attachment + && let Err(error) = attachment.reconcile(&bus) + { + return close_session_with_result( + Err(error), + session, + ShutdownDeadline::from_now(shutdown_grace), + ) + .await; + } let (bus_logs, bus_log_task) = bus_log::attach(bus.clone()); let schedule = R::__step_schedule(); let now = if clock_mode == ClockMode::Real { - let reading = clock - .as_ref() - .map(ClockSource::read) - .unwrap_or(ClockReading::Unsynchronized(TimeUnsynchronized::ClockFault)); + let reading = match &domain { + Some(domain) => { + crate::participant::clock::real::RealClock::new(domain.current.timeline).read() + } + None => clock + .as_ref() + .map(ClockSource::read) + .unwrap_or(ClockReading::Unsynchronized(TimeUnsynchronized::ClockFault)), + }; match reading { ClockReading::Synchronized(_) => reading.instant(), ClockReading::Unsynchronized(reason) => { @@ -286,8 +496,32 @@ where } else { None }; - let (scheduler, clock_handle) = - match AnyStepScheduler::for_clock_mode(clock_mode, schedule, now) { + let dynamic_simulation_clock = domain.as_ref().map(|_| SimulationClockHandle::source()); + if let (Some(simulation_clock), Some(domain)) = (&dynamic_simulation_clock, &domain) { + match domain.current.mode { + crate::supervisor::api::time_domain::TimeMode::Monotonic => simulation_clock.disable(), + crate::supervisor::api::time_domain::TimeMode::Simulated => { + simulation_clock.replace_timeline(domain.current.timeline); + } + } + } + let (scheduler, clock_handle) = match &dynamic_simulation_clock { + Some(simulation_clock) => { + match scheduler_for_domain(clock_mode, schedule, now, simulation_clock) { + Ok(scheduler) => (scheduler, Some(simulation_clock.clone())), + Err(error) => { + let result = close_session_with_result( + Err(error), + session, + ShutdownDeadline::from_now(shutdown_grace), + ) + .await; + bus_logs.shutdown(); + return result; + } + } + } + None => match AnyStepScheduler::for_clock_mode(clock_mode, schedule, now) { Ok(value) => value, Err(error) => { let result = close_session_with_result( @@ -299,8 +533,12 @@ where bus_logs.shutdown(); return result; } - }; - let effective_clock = match runner_clock(&scheduler, clock) { + }, + }; + let effective_clock = match match domain.as_ref() { + Some(domain) => runner_clock_for_domain::(&scheduler, domain.current), + None => runner_clock(&scheduler, clock), + } { Ok(clock) => clock, Err(error) => { let result = close_session_with_result( @@ -319,7 +557,9 @@ where session, participant_id, shutdown_grace, - bundle, + source, + domain, + attachment, config, clock: effective_clock, scheduler, @@ -357,11 +597,19 @@ pub(crate) struct Runner { pub(crate) scheduler: AnyStepScheduler, pub(crate) schedule: Option, pub(crate) clock_mode: ClockMode, + pub(crate) domain: Option, + pub(crate) domain_updates: + Option>, + pub(crate) simulation_clock: Option, pub(crate) timeline_retentions: Vec, pub(crate) queries: Option>, pub(crate) runtime_performance_publisher: RuntimePerformancePublisher, pub(crate) runtime_performance: RuntimePerformance, pub(crate) managed_tasks: ManagedTasks, + /// Retains supervisor-materialized asset paths through participant + /// shutdown. Participant state may keep only the native path returned from + /// setup, so the cache owner must outlive `SetupContext`. + _asset_cache: Option, /// The participant's Ready lease. It is revoked before any shutdown work /// starts, so observers never see Ready while resources unwind. pub(crate) ready: Option, @@ -369,8 +617,8 @@ pub(crate) struct Runner { } impl Runner { - /// Resolve the selected runtime record, run `Participant::setup`, and - /// declare everything the participant announced before returning Ready. + /// Use the supervisor-attached execution inputs, run `Participant::setup`, + /// and declare everything the participant announced before returning Ready. /// /// Every failure after `setup` succeeds still runs full teardown, so a /// server or Ready declaration failure cannot bypass the participant's @@ -387,27 +635,30 @@ impl Runner { session, participant_id, shutdown_grace, - bundle, + source, + mut domain, + attachment, config, - clock, - scheduler, + mut clock, + mut scheduler, schedule, - clock_mode, + mut clock_mode, tasks, } = inputs; - // The bundle (or explicit test harness) was already opened and this - // participant's config deserialized before entering this - // transport-owned startup path. - let mut ctx = SetupContext::::new(bus.clone(), bundle, participant_id.clone()); + let asset_cache = match &source { + SetupSource::Harness => None, + SetupSource::Supervisor { assets, .. } => Some(assets.clone()), + }; + let mut ctx = SetupContext::::new(bus.clone(), source, participant_id.clone()); ctx.spawn_managed_with( "bus-log-drain", ManagedTaskPolicy::Finite, tasks.bus_log.run(), ); - if let Some(handle) = tasks.simulation_clock { + if let Some(attachment) = attachment { ctx.spawn_managed( - "simulation-clock-ingest", - simulation_clock_feed(bus.clone(), handle), + "simulation-attachment-ingest", + attachment_revision_feed(bus.clone(), attachment), ); } let participant = R::__new(); @@ -495,6 +746,58 @@ impl Runner { } }; + // A reset can arrive while setup or query declaration is running. + // Reconcile it before Ready, rebuild the scheduler for the newer + // authority, and notify the participant exactly once about the history + // it did not run in. + let transition = match reconcile_start_domain(&mut domain) { + Ok(transition) => transition, + Err(error) => { + if let Some(queries) = queries.take() { + queries.close(); + } + return startup_teardown( + managed_tasks, + &participant, + &api, + &mut state, + shutdown_grace, + Err(error), + session, + ) + .await; + } + }; + for (previous, current) in transition { + let result = StartDomainTransition { + schedule, + simulation_clock: tasks.simulation_clock.as_ref(), + clock_mode: &mut clock_mode, + scheduler: &mut scheduler, + clock: &mut clock, + participant: &participant, + api: &api, + state: &mut state, + timeline_retentions: &timeline_retentions, + } + .apply(previous, current); + if let Err(error) = result { + if let Some(queries) = queries.take() { + queries.close(); + } + return startup_teardown( + managed_tasks, + &participant, + &api, + &mut state, + shutdown_grace, + Err(error), + session, + ) + .await; + } + } + // Setup and query declaration may have started tasks whose failure is // already ready to observe. Drain those completions before acquiring // the Ready lease so a failed critical task can never pass through a @@ -567,53 +870,123 @@ impl Runner { } None } - BusLease::Owned(owner) => Some(tokio::select! { - biased; - _ = shutdown.wait() => { - if let Some(queries) = queries.take() { - queries.close(); + BusLease::Owned(owner) => Some(loop { + let token = tokio::select! { + biased; + _ = shutdown.wait() => { + if let Some(queries) = queries.take() { + queries.close(); + } + return startup_teardown( + managed_tasks, + &participant, + &api, + &mut state, + shutdown_grace, + Ok(()), + session, + ).await; } - return startup_teardown( - managed_tasks, - &participant, - &api, - &mut state, - shutdown_grace, - Ok(()), - session, - ).await; - } - fault = bus.wait_for_fatal() => { - if let Some(queries) = queries.take() { - queries.close(); + fault = bus.wait_for_fatal() => { + if let Some(queries) = queries.take() { + queries.close(); + } + return startup_teardown( + managed_tasks, + &participant, + &api, + &mut state, + shutdown_grace, + Err(ParticipantFault::Bus(fault).into()), + session, + ).await; } - return startup_teardown( - managed_tasks, - &participant, - &api, - &mut state, - shutdown_grace, - Err(ParticipantFault::Bus(fault).into()), - session, - ).await; - } - exit = managed_tasks.next_unexpected_exit() => { - if let Some(queries) = queries.take() { - queries.close(); + exit = managed_tasks.next_unexpected_exit() => { + if let Some(queries) = queries.take() { + queries.close(); + } + return startup_teardown( + managed_tasks, + &participant, + &api, + &mut state, + shutdown_grace, + Err(exit.into()), + session, + ).await; } - return startup_teardown( - managed_tasks, - &participant, - &api, - &mut state, - shutdown_grace, - Err(exit.into()), - session, - ).await; - } - result = owner.declare_participant_ready() => match result { - Ok(token) => token, + transition = next_start_domain(&mut domain) => { + let (previous, current) = match transition { + Ok(transition) => transition, + Err(error) => { + if let Some(queries) = queries.take() { + queries.close(); + } + return startup_teardown( + managed_tasks, + &participant, + &api, + &mut state, + shutdown_grace, + Err(error), + session, + ).await; + } + }; + let result = StartDomainTransition { + schedule, + simulation_clock: tasks.simulation_clock.as_ref(), + clock_mode: &mut clock_mode, + scheduler: &mut scheduler, + clock: &mut clock, + participant: &participant, + api: &api, + state: &mut state, + timeline_retentions: &timeline_retentions, + } + .apply(previous, current); + if let Err(error) = result { + if let Some(queries) = queries.take() { + queries.close(); + } + return startup_teardown( + managed_tasks, + &participant, + &api, + &mut state, + shutdown_grace, + Err(error), + session, + ).await; + } + continue; + } + result = owner.declare_participant_ready() => match result { + Ok(token) => token, + Err(error) => { + if let Some(queries) = queries.take() { + queries.close(); + } + return startup_teardown( + managed_tasks, + &participant, + &api, + &mut state, + shutdown_grace, + Err(error.into()), + session, + ).await; + } + }, + }; + // A stream update can race readiness after the select has + // chosen the declaration. Revoke this lease and process every + // buffered replacement before trying again, so the returned + // runner never starts under an observed stale domain. + let fence = match fence_ready_domain(&mut domain) { + Ok(fence) => fence, Err(error) => { + drop(token); if let Some(queries) = queries.take() { queries.close(); } @@ -623,11 +996,47 @@ impl Runner { &api, &mut state, shutdown_grace, - Err(error.into()), + Err(error), session, - ).await; + ) + .await; } - }, + }; + match fence { + ReadyDomainFence::Stable => break token, + ReadyDomainFence::Reconfigure(transitions) => { + drop(token); + for (previous, current) in transitions { + let result = StartDomainTransition { + schedule, + simulation_clock: tasks.simulation_clock.as_ref(), + clock_mode: &mut clock_mode, + scheduler: &mut scheduler, + clock: &mut clock, + participant: &participant, + api: &api, + state: &mut state, + timeline_retentions: &timeline_retentions, + } + .apply(previous, current); + if let Err(error) = result { + if let Some(queries) = queries.take() { + queries.close(); + } + return startup_teardown( + managed_tasks, + &participant, + &api, + &mut state, + shutdown_grace, + Err(error), + session, + ) + .await; + } + } + } + } }), }; @@ -647,6 +1056,9 @@ impl Runner { scheduler, schedule, clock_mode, + domain: domain.as_ref().map(|domain| domain.current), + domain_updates: domain.map(|domain| domain.updates), + simulation_clock: tasks.simulation_clock, timeline_retentions, queries, // Portable runtime evidence is measured at runner-owned step and @@ -655,6 +1067,7 @@ impl Runner { runtime_performance_publisher: RuntimePerformancePublisher::attach(bus), runtime_performance: RuntimePerformance::new(schedule), managed_tasks, + _asset_cache: asset_cache, ready, shutdown_grace, }) @@ -736,9 +1149,25 @@ pub(crate) async fn close_session_with_result( combine(primary, close_session(session, deadline).await) } -/// Subscribe and feed the authoritative simulation clock. This task is -/// registered before setup and is cancelled through the ordinary teardown -/// sequence, so logical time cannot advance behind the runner's back. -async fn simulation_clock_feed(bus: BusHandle, handle: SimulationClockHandle) -> crate::Result<()> { - super::event_loop::simulation_clock_feed(bus, handle).await +/// Keep setpoint metadata aligned with the newest supervisor attachment phase. +/// Preparing and Removing clear the revision before any subsequent command is +/// admitted, so retained or delayed pre-activation intent cannot become live. +async fn attachment_revision_feed( + bus: BusHandle, + mut attachment: AttachmentSubscription, +) -> crate::Result<()> { + loop { + let replacement = attachment.updates.recv().await?.body.attachment; + match (replacement, attachment.current) { + (Some(replacement), Some(installed)) if replacement.revision > installed.revision => { + attachment.current = Some(replacement); + super::startup::install_attachment_revision(&bus, attachment.current); + } + (Some(replacement), None) => { + attachment.current = Some(replacement); + super::startup::install_attachment_revision(&bus, attachment.current); + } + (None, _) | (Some(_), Some(_)) => {} + } + } } diff --git a/phoxal/src/participant/runner/mod.rs b/phoxal/src/participant/runner/mod.rs index 69773338..637d7f77 100644 --- a/phoxal/src/participant/runner/mod.rs +++ b/phoxal/src/participant/runner/mod.rs @@ -1,10 +1,10 @@ //! The participant runner entrypoints and lifecycle orchestration. //! -//! The implementation is split by ownership boundary: [`startup`] performs -//! all local launch validation before opening the bus, [`lifecycle`] owns -//! setup/Ready/teardown resources, and [`event_loop`] owns the serialized +//! The implementation is split by ownership boundary: [`startup`] opens the +//! bus and attaches the supervisor-established execution inputs, [`lifecycle`] +//! owns setup/Ready/teardown resources, and [`event_loop`] owns the serialized //! scheduler loop. Query ingress/reply transport, process signals, teardown, -//! and bundle inputs each stay in their focused modules. +//! and explicit fixture inputs each stay in their focused modules. use std::future::Future; use std::pin::Pin; @@ -16,6 +16,7 @@ use crate::participant::bus_log; use crate::participant::clock::ClockMode; use crate::participant::clock::ClockSource; use crate::participant::clock::real::RealClock; +use crate::participant::context::SetupSource; use crate::participant::launch::Launch; use crate::participant::runner::harness::TestHarness; @@ -164,7 +165,9 @@ where session: BusLease::Borrowed, participant_id: harness.participant_id, shutdown_grace: harness.shutdown_grace, - bundle: None, + source: SetupSource::Harness, + domain: None, + attachment: None, config, clock_mode: ClockMode::Real, clock: Some(clock), @@ -203,7 +206,9 @@ where session: BusLease::Borrowed, participant_id: harness.participant_id, shutdown_grace: harness.shutdown_grace, - bundle: None, + source: SetupSource::Harness, + domain: None, + attachment: None, config, clock_mode: ClockMode::Real, clock: Some(clock), diff --git a/phoxal/src/participant/runner/startup.rs b/phoxal/src/participant/runner/startup.rs index d16a1db8..2dab44f0 100644 --- a/phoxal/src/participant/runner/startup.rs +++ b/phoxal/src/participant/runner/startup.rs @@ -1,30 +1,113 @@ //! Local launch validation and the supervised bus-open boundary. //! -//! Everything that can be decided without transport is kept ahead of -//! `BusOwner::open`: opening the bundle, reading this participant's own config -//! out of it, and validating scheduler inputs. The one thing that cannot is the -//! execution identity: it is the router's, so it is learned from the endpoints -//! the launch names rather than handed over in argv. The live scheduler is -//! intentionally built by the -//! lifecycle after the potentially slow bus connection succeeds. +//! Attachment starts from one rendezvous endpoint. The framework resolves the +//! execution, opens its caller-owned bus session, completes the supervisor +//! bootstrap, and only then validates the participant's role and configuration. +//! The live scheduler is intentionally built by the lifecycle after the +//! potentially slow connection succeeds. use std::future::Future; use std::time::Duration; -use crate::bundle::RuntimeBundle; use crate::bus::{BusConfig, BusHandle, BusOwner}; -use crate::identity::{ExecutionId, ParticipantId, TimelineId}; +use crate::execution::{attach_execution, resolve_execution}; +use crate::identity::{ParticipantId, TimelineId}; use crate::participant::api::Participant; use crate::participant::clock::real::RealClock; use crate::participant::clock::{ClockMode, ClockReading, ClockSource}; +use crate::participant::context::SetupSource; use crate::participant::launch::{Launch, SHUTDOWN_GRACE}; +use crate::participant::metadata::ParticipantKind; use crate::participant::scheduler::AnyStepScheduler; +use crate::supervisor::api::simulation::{SimulationAttachmentPhase, SimulationAttachmentState}; +use crate::supervisor::api::time_domain::{TimeDomain, TimeMode}; use anyhow::Context as _; use super::ShutdownController; -use super::inputs::{driver_block, open_bundle, participant_config}; +use super::inputs::{driver_block, participant_config}; use super::lifecycle::{self, BusLease}; +/// The supervisor's initial scheduling authority plus its already-subscribed +/// replacement stream. Only services and the brain retain this; drivers are +/// deliberately independent of execution time mode. +pub(crate) struct DomainSubscription { + pub(crate) current: TimeDomain, + pub(crate) updates: + crate::bus::StreamReceiver, +} + +/// The supervisor's initial attachment plus its already-subscribed ordered +/// replacement stream. +pub(crate) struct AttachmentSubscription { + pub(crate) current: Option, + pub(crate) updates: crate::bus::StreamReceiver< + crate::supervisor::api::simulation::attachment::SimulationAttachmentStream, + >, +} + +impl AttachmentSubscription { + pub(crate) fn reconcile(&mut self, bus: &BusHandle) -> crate::Result<()> { + while let Some(update) = self.updates.try_recv()? { + let replacement = update.body.attachment; + match (replacement, self.current) { + (Some(replacement), Some(installed)) + if replacement.revision > installed.revision => + { + self.current = Some(replacement); + } + (Some(replacement), None) => self.current = Some(replacement), + // The initial empty stream snapshot carries no revision and + // cannot overwrite a newer current-query attachment. + (None, _) | (Some(_), Some(_)) => {} + } + } + install_attachment_revision(bus, self.current); + Ok(()) + } +} + +pub(crate) fn install_attachment_revision( + bus: &BusHandle, + attachment: Option, +) { + let binding = attachment.and_then(|state| { + (state.phase == SimulationAttachmentPhase::Active) + .then_some((state.controller, state.revision)) + }); + bus.set_active_simulation_binding(binding); +} + +impl DomainSubscription { + /// Reconcile each replacement buffered before the next lifecycle boundary. + /// + /// Later arrivals remain in the ordered stream for the runner's serialized + /// event loop, so this establishes an initial domain without creating a + /// receive gap. + pub(crate) fn reconcile(&mut self) -> crate::Result> { + let mut replacements = Vec::new(); + while let Some(update) = self.updates.try_recv()? { + if update.body.domain.revision > self.current.revision { + let previous = self.current; + self.current = update.body.domain; + replacements.push((previous, self.current)); + } + } + Ok(replacements) + } + + /// Wait for the next strictly newer scheduling authority. + pub(crate) async fn next_replacement(&mut self) -> crate::Result<(TimeDomain, TimeDomain)> { + loop { + let update = self.updates.recv().await?.body.domain; + if update.revision > self.current.revision { + let previous = self.current; + self.current = update; + return Ok((previous, update)); + } + } + } +} + /// All validated inputs that the lifecycle needs after the bus exists. /// /// The supervised constructor always fills `session` with an owned @@ -35,7 +118,9 @@ pub(crate) struct PreparedRun { pub(crate) session: BusLease, pub(crate) participant_id: ParticipantId, pub(crate) shutdown_grace: Duration, - pub(crate) bundle: Option, + pub(crate) source: SetupSource, + pub(crate) domain: Option, + pub(crate) attachment: Option, pub(crate) config: R::Config, pub(crate) clock_mode: ClockMode, pub(crate) clock: Option, @@ -49,49 +134,73 @@ where S: Future, { let mut shutdown = ShutdownController::new(shutdown); - let clock_mode = if launch.simulation { - ClockMode::Simulation - } else { - ClockMode::Real - }; - // The bundle and this participant's own config are resolved while the - // process is still local, so a malformed manifest or a config a custom - // `Deserialize` refuses has no producer or wire side effects to clean up. - let bundle = open_bundle(&launch.bundle_root)?; - let config = participant_config::(bundle.robot(), &launch.participant_id, R::KIND)?; - validate_declared_connection::(bundle.robot(), &launch.participant_id)?; - // One line, not a per-attempt one: a participant racing a router that has // not opened its listener yet can take several seconds to connect. Without // this, that gap looks like a silent hang rather than expected startup. tracing::info!( target: "phoxal.runtime", - endpoints = ?launch.connect_endpoints, + endpoint = %launch.connect, "connecting to the bus" ); let execution = tokio::select! { biased; _ = shutdown.wait() => return Ok(()), - result = learn_execution(&launch.connect_endpoints) => result?, + result = resolve_execution(&launch.connect) => result?, }; tracing::info!( target: "phoxal.runtime", execution = %execution, "learned the execution identity from the router" ); - let clock = clock_for_mode(clock_mode, execution); - validate_clock_inputs::(clock_mode, clock.as_ref())?; - let (owner, bus) = tokio::select! { biased; _ = shutdown.wait() => return Ok(()), result = BusOwner::open(BusConfig::for_participant( execution, launch.participant_id.clone(), - launch.connect_endpoints.clone(), + vec![launch.connect.clone()], )) => result?, }; + let bootstrap = match tokio::select! { + biased; + _ = shutdown.wait() => { + let _ = owner.close().await; + return Ok(()); + } + result = attach_execution(&bus) => result, + } { + Ok(bootstrap) => bootstrap, + Err(error) => { + let _ = owner.close().await; + return Err(error.into()); + } + }; + let preflight = async { + let robot = bootstrap.info.manifest.into_robot(); + let config = participant_config::(&robot, &launch.participant_id)?; + validate_declared_connection::(&robot, &launch.participant_id)?; + let assets = crate::bundle::ParticipantAssets::from_supervisor(bus.clone())?; + let clock_mode = clock_mode_for::(bootstrap.time_domain); + let clock = clock_for_mode(clock_mode, bootstrap.time_domain.timeline); + validate_clock_inputs::(clock_mode, clock.as_ref())?; + Ok::<_, anyhow::Error>((robot, config, assets, clock_mode, clock)) + }; + let (robot, config, assets, clock_mode, clock) = match tokio::select! { + biased; + _ = shutdown.wait() => { + let _ = owner.close().await; + return Ok(()); + } + result = preflight => result, + } { + Ok(preflight) => preflight, + Err(error) => { + let _ = owner.close().await; + return Err(error); + } + }; + // Do not construct the live scheduler until this connection boundary has // completed. The preflight above validates its inputs without creating a // scheduler that could outlive a failed or cancelled bus open. @@ -101,7 +210,18 @@ where session: BusLease::Owned(owner), participant_id: launch.participant_id, shutdown_grace: SHUTDOWN_GRACE, - bundle: Some(bundle), + source: SetupSource::Supervisor { + robot: Box::new(robot), + assets, + }, + domain: (R::KIND != ParticipantKind::Driver).then_some(DomainSubscription { + current: bootstrap.time_domain, + updates: bootstrap.time_domains, + }), + attachment: Some(AttachmentSubscription { + current: bootstrap.attachment, + updates: bootstrap.attachments, + }), config, clock_mode, clock, @@ -112,88 +232,36 @@ where .await } -/// Learn the execution identity from the routers the launch points at. +/// Select the initial participant cadence from supervisor authority. /// -/// A router's session id *is* the execution (`bus::session::probe_routers`), -/// which is why the identity is not in argv at all: the process that owns the -/// run is the one that answers on the endpoint. Exactly one is expected. Zero -/// means nothing is running there yet and there is no execution to join; more -/// than one means the endpoints named two different runs, and picking either -/// would silently attach the participant to a graph its peers are not on. -async fn learn_execution(endpoints: &[String]) -> crate::Result { - let mut observed: Vec = Vec::new(); - for endpoint in endpoints { - let reported = BusOwner::probe_routers(endpoint) - .await - .with_context(|| format!("failed to reach a Phoxal router on '{endpoint}'"))?; - for execution in reported { - if !observed.contains(&execution) { - observed.push(execution); - } - } - } - match observed.as_slice() { - [execution] => Ok(*execution), - [] => anyhow::bail!( - "no Phoxal router answered on {}; the execution identity is the router's, so there \ - is nothing for this participant to join", - rendered(endpoints) - ), - many => anyhow::bail!( - "the endpoints {} report {} different executions ({}); a participant joins exactly one", - rendered(endpoints), - many.len(), - many.iter() - .map(ToString::to_string) - .collect::>() - .join(", ") - ), +/// Drivers are deliberately outside this decision: their host-local cadence is +/// independent of a world attaching, pausing, or resetting. +fn clock_mode_for(domain: TimeDomain) -> ClockMode { + if R::KIND == ParticipantKind::Driver || domain.mode == TimeMode::Monotonic { + ClockMode::Real + } else { + ClockMode::Simulation } } -fn rendered(endpoints: &[String]) -> String { - endpoints - .iter() - .map(|endpoint| format!("'{endpoint}'")) - .collect::>() - .join(", ") -} - -/// The real timeline of one execution. -/// -/// The real-clock timeline id *is* the execution, so every process in one run -/// dates its instants on the -/// same world history without publishing anything. The two identities are -/// different widths - an execution is 128 bits, a timeline is 64 - so this -/// derives the timeline deterministically from the execution's high half rather -/// than minting an unrelated one. An execution id's most significant nibble is -/// never zero (`ExecutionId::try_from`), so that half is never zero either and -/// always names a timeline; the fallback exists only because `from_raw` is -/// total, and mints rather than panics. -fn real_timeline(execution: ExecutionId) -> TimelineId { - let high = (u128::from(execution) >> 64) as u64; - TimelineId::from_raw(high).unwrap_or_else(TimelineId::mint) -} - -/// Build the host clock for a real launch. A simulation participant reads its -/// instants from the live world clock instead, so it gets none. -pub(crate) fn clock_for_mode(clock_mode: ClockMode, execution: ExecutionId) -> Option { +/// Build the host clock for one supervisor-minted monotonic timeline. A +/// simulated service or brain reads its instants from logical-time ingress. +pub(crate) fn clock_for_mode(clock_mode: ClockMode, timeline: TimelineId) -> Option { match clock_mode { - ClockMode::Real => Some(RealClock::new(real_timeline(execution))), + ClockMode::Real => Some(RealClock::new(timeline)), ClockMode::Simulation => None, } } -/// Refuse an authored connection this driver does not accept, while the process -/// is still local. +/// Refuse an authored connection this driver does not accept after supervisor +/// attachment but before the participant becomes Ready. /// /// `phoxal validate` is what an author actually meets this rule through: /// it reads the same declaration out of the built binary's embedded metadata /// and compares it against the document, so a mismatch is a build-time failure /// with the document in hand. This is the defence in depth behind it - the -/// binary refusing to drive hardware it was not written for - and it belongs -/// ahead of `BusOwner::open` so it can never become a transport-visible startup -/// failure. +/// binary refusing to drive hardware it was not written for - and it completes +/// before setup, query declaration, or Ready acquisition. /// /// A role that declares no kind, and every role that is not a driver, states /// `CONNECTION = None` and has nothing to check. @@ -213,9 +281,8 @@ fn validate_declared_connection( Ok(()) } -/// Validate scheduler selection and the initial clock discipline before any -/// supervised transport is opened. The lifecycle repeats construction after -/// the bus connects so it retains the live scheduler handle. +/// Validate scheduler selection and initial clock discipline after supervisor +/// attachment and before the lifecycle constructs its retained scheduler. pub(crate) fn validate_clock_inputs( clock_mode: ClockMode, clock: Option<&C>, @@ -245,6 +312,7 @@ where mod tests { use super::*; + use super::super::inputs::open_bundle; use crate::participant::context::SetupContext; use phoxal_fixture::staged_bundle; @@ -289,11 +357,11 @@ mod tests { } } - /// The declared kind is enforced while the process is still local, so a - /// binary wired to hardware it was not written for never reaches the bus - - /// and a driver that declared nothing is not held to a kind it never named. + /// The declared kind is enforced before the participant becomes Ready, so + /// a binary wired to hardware it was not written for cannot serve it and a + /// driver that declared nothing is not held to a kind it never named. #[test] - fn a_declared_connection_kind_is_enforced_before_the_bus_opens() { + fn a_declared_connection_kind_is_enforced_before_ready() { let staged = staged_bundle(); let bundle = open_bundle(staged.path()).expect("the staged bundle opens"); let robot = bundle.robot(); @@ -312,22 +380,24 @@ mod tests { } } - /// The real timeline is a pure function of the execution, so two processes - /// in one run date their instants on the same world history with nothing - /// exchanged, and two runs never share one. + /// The supervisor mints the monotonic timeline before participants attach, + /// so every real scheduler uses that same opaque authority value. #[test] - fn the_real_timeline_is_derived_from_the_execution_and_nothing_else() { - let execution = ExecutionId::mint(); - assert_eq!(real_timeline(execution), real_timeline(execution)); - assert_ne!(real_timeline(execution), real_timeline(ExecutionId::mint())); + fn the_real_scheduler_uses_the_supervisor_timeline() { + let timeline = TimelineId::from_raw(7).expect("a test timeline"); + let clock = clock_for_mode(ClockMode::Real, timeline).expect("a real clock"); + assert_eq!( + clock.read().instant().expect("host clock reads").timeline(), + timeline + ); } - /// Only a real launch carries a host clock; a simulated one reads the world - /// clock the controller publishes. + /// Only the real clock mode carries a host clock; simulated mode reads its + /// separate logical-time ingress. #[test] - fn only_a_real_launch_builds_a_host_clock() { - let execution = ExecutionId::mint(); - assert!(clock_for_mode(ClockMode::Real, execution).is_some()); - assert!(clock_for_mode(ClockMode::Simulation, execution).is_none()); + fn only_the_real_clock_mode_builds_a_host_clock() { + let timeline = TimelineId::from_raw(9).expect("a test timeline"); + assert!(clock_for_mode(ClockMode::Real, timeline).is_some()); + assert!(clock_for_mode(ClockMode::Simulation, timeline).is_none()); } } diff --git a/phoxal/src/participant/runner/tests.rs b/phoxal/src/participant/runner/tests.rs index 8b94bc32..97c396b4 100644 --- a/phoxal/src/participant/runner/tests.rs +++ b/phoxal/src/participant/runner/tests.rs @@ -1,23 +1,31 @@ use super::ShutdownController; use super::event_loop::advance_step_deadline; use super::lifecycle::{ - BusLease, ClockDisciplineLost, LoopExit, ParticipantFault, Runner, RunnerClock, RunnerTasks, - StartOutcome, close_session_with_result, runner_clock, + BusLease, ClockDisciplineLost, LoopExit, ParticipantFault, ReadyDomainFence, Runner, + RunnerClock, RunnerTasks, StartOutcome, close_session_with_result, fence_ready_domain, + runner_clock, runner_clock_for_domain, scheduler_for_domain, +}; +use super::startup::DomainSubscription; +use crate::bus::{ + BusConfig, BusFault, BusOwner, EventPublisher, ParticipantReadyEvents, ParticipantReadyStatus, + RobotInstant, StatePublisher, StepToken, StreamPublisher, StreamReceiver, TimelineId, }; -use crate::bus::{BusConfig, BusFault, BusOwner, ParticipantReadyEvents, ParticipantReadyStatus}; -use crate::bus::{RobotInstant, TimelineId}; use crate::identity::ParticipantId; use crate::participant::api::Participant; use crate::participant::bus_log; use crate::participant::clock::real::RealClock; -use crate::participant::clock::{ClockMode, TimeUnsynchronized}; -use crate::participant::context::SetupContext; +use crate::participant::clock::test::TestClock; +use crate::participant::clock::{ClockMode, ClockReading, ClockSource, TimeUnsynchronized}; +use crate::participant::context::{SetupContext, SetupSource, StepContext}; use crate::participant::managed::{ ManagedTaskExit, ManagedTaskFailure, ManagedTaskPolicy, ManagedTasks, }; use crate::participant::scheduler::AnyStepScheduler; -use std::sync::OnceLock; -use std::sync::atomic::{AtomicBool, Ordering}; +use crate::participant::scheduler::simulation::{SimulationClockAdvance, SimulationClockHandle}; +use crate::supervisor::api; +use crate::supervisor::api::time_domain::{TimeDomain, TimeDomainStream, TimeMode}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Mutex, OnceLock}; use std::time::Duration; use tokio::sync::Notify; @@ -32,6 +40,67 @@ fn test_timeline() -> TimelineId { TimelineId::from_raw(1).expect("test timeline must be nonzero") } +/// A replacement buffered by the already-subscribed stream while Ready is +/// being acquired revokes that lease and requires startup to reconfigure first. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_ready_domain_fence_reconfigures_a_buffered_replacement() { + let participant = ParticipantId::new("ready-domain-fence").expect("valid participant id"); + let (owner, bus) = BusOwner::open(BusConfig::for_participant( + crate::identity::ExecutionId::mint(), + participant, + Vec::new(), + )) + .await + .expect("the in-process bus opens"); + let updates = + StreamReceiver::::new(&bus, &api::topics().time_domain().client()) + .await + .expect("the Ready fence subscribes"); + let publisher = StreamPublisher::new(bus.clone(), &api::topics().time_domain().owner()) + .expect("the supervisor stream publisher attaches"); + let initial = TimeDomain { + revision: 10, + timeline: test_timeline(), + mode: TimeMode::Monotonic, + }; + let replacement = TimeDomain { + revision: 11, + timeline: TimelineId::from_raw(2).expect("a replacement timeline"), + mode: TimeMode::Simulated, + }; + publisher + .send(TimeDomainStream { + domain: replacement, + }) + .expect("the replacement is admitted"); + let mut domain = Some(DomainSubscription { + current: initial, + updates, + }); + let transitions = tokio::time::timeout(Duration::from_secs(2), async { + loop { + match fence_ready_domain(&mut domain) + .expect("the fence reconciles the buffered replacement") + { + ReadyDomainFence::Stable => tokio::task::yield_now().await, + ReadyDomainFence::Reconfigure(transitions) => break transitions, + } + } + }) + .await + .expect("the replacement reaches the Ready-fence subscription"); + assert_eq!(transitions, vec![(initial, replacement)]); + let ReadyDomainFence::Stable = + fence_ready_domain(&mut domain).expect("the drained fence remains healthy") + else { + panic!("the drained fence must be stable"); + }; + + drop(domain); + drop(publisher); + let _ = owner.close().await; +} + #[tokio::test] async fn shutdown_request_remains_sticky_after_source_completes() { let mut shutdown = ShutdownController::new(std::future::ready(())); @@ -219,11 +288,434 @@ async fn ready_declaration_race_prefers_a_task_failure() { } static HANGING_SETUP_STARTED: OnceLock = OnceLock::new(); +static DOMAIN_SETUP_STARTED: OnceLock = OnceLock::new(); +static DOMAIN_SETUP_RELEASE: OnceLock = OnceLock::new(); +static DOMAIN_SETUP_RESETS: AtomicUsize = AtomicUsize::new(0); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct SlowServiceObservation { + instant: RobotInstant, + step_index: u64, + missed_ticks: u32, +} + +struct SlowServiceState { + observations: tokio::sync::mpsc::Sender, + first_step_release: Option>, +} + +static SLOW_SERVICE_FIXTURE: OnceLock>> = OnceLock::new(); +static SLOW_SERVICE_RESETS: AtomicUsize = AtomicUsize::new(0); + +fn slow_service_fixture() -> &'static Mutex> { + SLOW_SERVICE_FIXTURE.get_or_init(|| Mutex::new(None)) +} + +#[phoxal::service(id = "slow-live-service", state = SlowServiceState)] +struct SlowLiveService; + +impl Participant for SlowLiveService { + async fn setup( + &self, + _ctx: &mut SetupContext, + _config: Self::Config, + ) -> crate::Result<(Self::State, Self::Api)> { + let state = slow_service_fixture() + .lock() + .expect("the slow-service fixture lock is healthy") + .take() + .expect("the test installed one slow-service fixture"); + Ok((state, ())) + } + + #[phoxal::step(hz = 100)] + fn step( + &self, + _api: &Self::Api, + step: StepContext, + state: &mut Self::State, + ) -> crate::Result<()> { + state + .observations + .try_send(SlowServiceObservation { + instant: step.now(), + step_index: step.step_index, + missed_ticks: step.missed_ticks, + }) + .expect("the test still observes service steps"); + if let Some(release) = state.first_step_release.take() { + release + .recv() + .expect("the test releases the deliberately slow first invocation"); + } + Ok(()) + } + + fn reset( + &self, + _ctx: crate::participant::context::ResetContext, + _api: &Self::Api, + _state: &mut Self::State, + ) -> crate::Result<()> { + SLOW_SERVICE_RESETS.fetch_add(1, Ordering::Release); + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct WorldProbeSnapshot { + completed_transitions: u64, + output_count: u64, + step_event_count: u64, +} + +enum WorldProbeCommand { + SetRunning { + running: bool, + reply: tokio::sync::oneshot::Sender, + }, + Transition { + reply: tokio::sync::oneshot::Sender>>, + }, + Snapshot { + reply: tokio::sync::oneshot::Sender, + }, + Stop, +} + +fn read_test_clock(clock: &TestClock) -> RobotInstant { + let ClockReading::Synchronized(instant) = clock.read() else { + panic!("the deterministic monotonic clock stays synchronized"); + }; + instant +} + +fn advance_test_monotonic_time( + clock: &TestClock, + cadence: &SimulationClockHandle, + delta: Duration, +) -> RobotInstant { + clock.advance(delta); + let instant = read_test_clock(clock); + assert_eq!( + cadence.advance(instant), + SimulationClockAdvance::Advanced, + "each host-monotonic advance releases at most one collapsed service invocation" + ); + instant +} + +fn spawn_world_probe( + bus: &crate::bus::BusHandle, + clock: TestClock, +) -> ( + tokio::sync::mpsc::Sender, + tokio::task::JoinHandle<()>, +) { + let state = StatePublisher::new(bus.clone(), &crate::api::topics().drive().state().owner()) + .expect("the world probe binds one typed simulator output"); + let step = EventPublisher::new( + bus.clone(), + &crate::simulation::api::topics().step().owner(), + ) + .expect("the world probe binds passive StepEvent progress"); + let (commands, mut received) = tokio::sync::mpsc::channel(8); + let task = tokio::spawn(async move { + let mut running = true; + let mut snapshot = WorldProbeSnapshot::default(); + while let Some(command) = received.recv().await { + match command { + WorldProbeCommand::SetRunning { + running: requested, + reply, + } => { + running = requested; + let _ = reply.send(snapshot); + } + WorldProbeCommand::Transition { reply } => { + if !running { + let _ = reply.send(Ok(None)); + continue; + } + let instant = read_test_clock(&clock); + let token = StepToken::mint(instant); + let next = snapshot.completed_transitions.saturating_add(1); + let result = state + .publish( + &token, + crate::api::drive::State::Stopped { + target: crate::api::drive::Target::stopped(), + reason: crate::api::drive::StopReason::Fault, + }, + ) + .and_then(|()| { + step.publish(&token, crate::simulation::api::StepEvent { index: next }) + }) + .map(|()| { + snapshot.completed_transitions = next; + snapshot.output_count = snapshot.output_count.saturating_add(1); + snapshot.step_event_count = snapshot.step_event_count.saturating_add(1); + Some(instant) + }) + .map_err(anyhow::Error::from); + let _ = reply.send(result); + } + WorldProbeCommand::Snapshot { reply } => { + let _ = reply.send(snapshot); + } + WorldProbeCommand::Stop => break, + } + } + }); + (commands, task) +} + +async fn set_world_probe_running( + commands: &tokio::sync::mpsc::Sender, + running: bool, +) -> WorldProbeSnapshot { + let (reply, result) = tokio::sync::oneshot::channel(); + commands + .send(WorldProbeCommand::SetRunning { running, reply }) + .await + .expect("the independent world probe is running"); + result.await.expect("the world probe acknowledges motion") +} + +async fn advance_world_probe( + commands: &tokio::sync::mpsc::Sender, +) -> crate::Result> { + let (reply, result) = tokio::sync::oneshot::channel(); + commands + .send(WorldProbeCommand::Transition { reply }) + .await + .expect("the independent world probe is running"); + result + .await + .expect("the world probe answers one transition request") +} + +async fn world_probe_snapshot( + commands: &tokio::sync::mpsc::Sender, +) -> WorldProbeSnapshot { + let (reply, result) = tokio::sync::oneshot::channel(); + commands + .send(WorldProbeCommand::Snapshot { reply }) + .await + .expect("the independent world probe is running"); + result.await.expect("the world probe returns its snapshot") +} + +/// Live world progress and monotonic service cadence are independent tasks. +/// +/// The service's first synchronous invocation is held open while the world +/// admits three typed outputs and three passive StepEvents on the same bus. +/// Host time continues to advance, so releasing the service produces one +/// collapsed invocation with ordinary `missed_ticks`, never a catch-up storm. +/// Pausing then suppresses only world production: two on-cadence service +/// invocations still run on the original timeline, with no reset, and resume +/// admits the next output and StepEvent immediately. +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn slow_monotonic_service_and_live_pause_are_independent() { + SLOW_SERVICE_RESETS.store(0, Ordering::Release); + let (observed, mut observations) = tokio::sync::mpsc::channel(8); + let (release_first, first_step_release) = std::sync::mpsc::channel(); + *slow_service_fixture() + .lock() + .expect("the slow-service fixture lock is healthy") = Some(SlowServiceState { + observations: observed, + first_step_release: Some(first_step_release), + }); + + let participant_id = + ParticipantId::new("slow-live-service").expect("the participant id is valid"); + let (owner, bus) = BusOwner::open(BusConfig::for_participant( + crate::identity::ExecutionId::mint(), + participant_id.clone(), + Vec::new(), + )) + .await + .expect("the shared in-process bus opens"); + let clock = TestClock::new(); + let timeline = clock.timeline(); + let schedule = SlowLiveService::__step_schedule().expect("the test service has a cadence"); + let (scheduler, cadence, mut runner_started) = + AnyStepScheduler::test_monotonic(schedule, RobotInstant::new(timeline, 0)); + let (bus_logs, bus_log_task) = bus_log::attach(bus.clone()); + let mut startup_shutdown = ShutdownController::new(std::future::pending()); + let outcome = Runner::::start( + super::lifecycle::StartInputs { + bus: bus.clone(), + session: BusLease::Borrowed, + participant_id, + shutdown_grace: Duration::from_secs(1), + source: SetupSource::Harness, + domain: None, + attachment: None, + config: (), + clock: RunnerClock::Delegated(clock.clone()), + scheduler, + schedule: Some(schedule), + clock_mode: ClockMode::Real, + tasks: RunnerTasks { + simulation_clock: None, + bus_log: bus_log_task, + query_reply_delay: None, + }, + }, + &mut startup_shutdown, + ) + .await; + let StartOutcome::Ready(runner) = outcome else { + panic!("the deterministic monotonic service reaches Ready"); + }; + let (world, world_task) = spawn_world_probe(&bus, clock.clone()); + let (shutdown, shutdown_requested) = tokio::sync::oneshot::channel(); + let runner_task = tokio::spawn(async move { + let mut shutdown = ShutdownController::new(async move { + let _ = shutdown_requested.await; + }); + runner.run(&mut shutdown).await + }); + + if !*runner_started.borrow_and_update() { + runner_started + .changed() + .await + .expect("the deterministic monotonic scheduler remains live"); + } + + advance_test_monotonic_time(&clock, &cadence, Duration::from_millis(10)); + let first = tokio::time::timeout(Duration::from_secs(2), observations.recv()) + .await + .expect("the first service invocation starts") + .expect("the service observation channel stays open"); + assert_eq!( + first, + SlowServiceObservation { + instant: RobotInstant::new(timeline, 10_000_000), + step_index: 0, + missed_ticks: 0, + } + ); + + let mut world_instants = Vec::new(); + for _ in 0..3 { + let instant = advance_test_monotonic_time(&clock, &cadence, Duration::from_millis(12)); + assert_eq!( + advance_world_probe(&world) + .await + .expect("world output admission stays non-blocking"), + Some(instant), + "world progress completes while the service invocation is still held" + ); + world_instants.push(instant); + } + assert_eq!( + world_probe_snapshot(&world).await, + WorldProbeSnapshot { + completed_transitions: 3, + output_count: 3, + step_event_count: 3, + } + ); + + release_first + .send(()) + .expect("the first service invocation is still blocked"); + let collapsed = tokio::time::timeout(Duration::from_secs(2), observations.recv()) + .await + .expect("the service catches up once") + .expect("the service observation channel stays open"); + assert_eq!(collapsed.instant, RobotInstant::new(timeline, 46_000_000)); + assert_eq!(collapsed.step_index, 1); + assert_eq!( + collapsed.missed_ticks, 2, + "the 20 ms target observes 26 ms of overrun as two collapsed periods" + ); + assert!( + world_instants + .iter() + .all(|instant| instant.timeline() == timeline) + ); + + let paused_at = set_world_probe_running(&world, false).await; + assert_eq!(paused_at.completed_transitions, 3); + advance_test_monotonic_time(&clock, &cadence, Duration::from_millis(4)); + let at_fifty = tokio::time::timeout(Duration::from_secs(2), observations.recv()) + .await + .expect("service cadence reaches 50 ms while the world is paused") + .expect("the service observation channel stays open"); + advance_test_monotonic_time(&clock, &cadence, Duration::from_millis(10)); + let at_sixty = tokio::time::timeout(Duration::from_secs(2), observations.recv()) + .await + .expect("service cadence reaches 60 ms while the world is paused") + .expect("the service observation channel stays open"); + for observation in [at_fifty, at_sixty] { + assert_eq!(observation.instant.timeline(), timeline); + assert_eq!(observation.missed_ticks, 0); + } + assert_eq!( + advance_world_probe(&world) + .await + .expect("a paused transition request is handled"), + None, + "pause suppresses both the simulator output and StepEvent" + ); + assert_eq!(world_probe_snapshot(&world).await, paused_at); + + set_world_probe_running(&world, true).await; + let resumed_at = advance_test_monotonic_time(&clock, &cadence, Duration::from_millis(2)); + assert_eq!( + advance_world_probe(&world) + .await + .expect("the first resumed publication is admitted"), + Some(resumed_at) + ); + assert_eq!(resumed_at.timeline(), timeline); + assert_eq!( + world_probe_snapshot(&world).await, + WorldProbeSnapshot { + completed_transitions: 4, + output_count: 4, + step_event_count: 4, + } + ); + assert_eq!( + SLOW_SERVICE_RESETS.load(Ordering::Acquire), + 0, + "Live pause and resume never replace the monotonic timeline" + ); + + world + .send(WorldProbeCommand::Stop) + .await + .expect("the world probe is still running"); + world_task.await.expect("the world probe stops cleanly"); + shutdown + .send(()) + .expect("the participant runner still awaits shutdown"); + runner_task + .await + .expect("the runner task joins") + .expect("the participant shuts down cleanly"); + let _ = owner.close().await; + bus_logs.shutdown(); +} fn hanging_setup_started() -> &'static Notify { HANGING_SETUP_STARTED.get_or_init(Notify::new) } +fn domain_setup_started() -> &'static Notify { + DOMAIN_SETUP_STARTED.get_or_init(Notify::new) +} + +fn domain_setup_release() -> &'static Notify { + DOMAIN_SETUP_RELEASE.get_or_init(Notify::new) +} + /// A stop received while setup is still awaiting must cancel setup-owned tasks /// and return before Ready. The setup barrier makes sure the shutdown trigger /// cannot win merely because the biased select was polled before setup started. @@ -272,7 +764,9 @@ async fn shutdown_during_hanging_setup_never_reaches_ready() { session: BusLease::Owned(owner), participant_id, shutdown_grace: Duration::from_millis(100), - bundle: None, + source: SetupSource::Harness, + domain: None, + attachment: None, config: (), clock: RunnerClock::Delegated(clock), scheduler, @@ -311,6 +805,149 @@ async fn shutdown_during_hanging_setup_never_reaches_ready() { bus_logs.shutdown(); } +/// A domain replacement delivered while setup is pending is reconciled and +/// reset before the participant can acquire Ready. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_domain_change_during_setup_resets_before_ready() { + #[phoxal::service(id = "domain-transition-startup", state = ())] + struct DomainTransitionStartup; + + impl Participant for DomainTransitionStartup { + async fn setup( + &self, + _ctx: &mut SetupContext, + _config: Self::Config, + ) -> crate::Result<(Self::State, Self::Api)> { + domain_setup_started().notify_one(); + domain_setup_release().notified().await; + Ok(((), ())) + } + + fn reset( + &self, + _ctx: crate::participant::context::ResetContext, + _api: &Self::Api, + _state: &mut Self::State, + ) -> crate::Result<()> { + DOMAIN_SETUP_RESETS.fetch_add(1, Ordering::Release); + Ok(()) + } + } + + DOMAIN_SETUP_RESETS.store(0, Ordering::Release); + let participant_id = + ParticipantId::new("domain-transition-startup").expect("a valid participant id"); + let (owner, bus) = BusOwner::open(BusConfig::for_participant( + crate::identity::ExecutionId::mint(), + participant_id.clone(), + Vec::new(), + )) + .await + .expect("the in-process bus opens"); + let updates = + StreamReceiver::::new(&bus, &api::topics().time_domain().client()) + .await + .expect("the runner subscribes before setup"); + let delivery = + StreamReceiver::::new(&bus, &api::topics().time_domain().client()) + .await + .expect("the delivery observer subscribes"); + let publisher = StreamPublisher::new(bus.clone(), &api::topics().time_domain().owner()) + .expect("the supervisor stream publisher attaches"); + let initial = TimeDomain { + revision: 0, + timeline: test_timeline(), + mode: TimeMode::Monotonic, + }; + let replacement = TimeDomain { + revision: 1, + timeline: TimelineId::from_raw(2).expect("a replacement timeline"), + mode: TimeMode::Simulated, + }; + let second_replacement = TimeDomain { + revision: 2, + timeline: TimelineId::from_raw(3).expect("a second replacement timeline"), + mode: TimeMode::Monotonic, + }; + let simulation_clock = SimulationClockHandle::source(); + let initial_clock = RealClock::new(initial.timeline); + let scheduler = scheduler_for_domain( + ClockMode::Real, + None, + initial_clock.read().instant(), + &simulation_clock, + ) + .expect("the initial monotonic scheduler builds"); + let clock = runner_clock_for_domain::(&scheduler, initial) + .expect("the initial runner clock builds"); + let (bus_logs, bus_log_task) = bus_log::attach(bus.clone()); + let setup_started = domain_setup_started().notified(); + let start_task = tokio::spawn(async move { + let mut shutdown = ShutdownController::new(std::future::pending()); + Runner::::start( + super::lifecycle::StartInputs { + bus, + session: BusLease::Owned(owner), + participant_id, + shutdown_grace: Duration::from_millis(100), + source: SetupSource::Harness, + domain: Some(DomainSubscription { + current: initial, + updates, + }), + attachment: None, + config: (), + clock, + scheduler, + schedule: None, + clock_mode: ClockMode::Real, + tasks: RunnerTasks { + simulation_clock: Some(simulation_clock), + bus_log: bus_log_task, + query_reply_delay: None, + }, + }, + &mut shutdown, + ) + .await + }); + setup_started.await; + publisher + .send(TimeDomainStream { + domain: replacement, + }) + .expect("the replacement is admitted"); + publisher + .send(TimeDomainStream { + domain: second_replacement, + }) + .expect("the second replacement is admitted"); + for expected in [replacement, second_replacement] { + let delivered = tokio::time::timeout(Duration::from_secs(2), delivery.recv()) + .await + .expect("the replacement reaches subscribers") + .expect("the replacement decodes"); + assert_eq!(delivered.body.domain, expected); + } + domain_setup_release().notify_one(); + + let outcome = start_task.await.expect("the startup task returns"); + let StartOutcome::Ready(runner) = outcome else { + panic!("a healthy startup must reach Ready after its reset"); + }; + assert_eq!( + DOMAIN_SETUP_RESETS.load(Ordering::Acquire), + 2, + "every queued replacement reset must complete before Ready" + ); + let mut shutdown = ShutdownController::new(std::future::ready(())); + runner + .run(&mut shutdown) + .await + .expect("the runner shuts down cleanly"); + bus_logs.shutdown(); +} + static BUS_FAULT_SHUTDOWN_CALLED: AtomicBool = AtomicBool::new(false); #[phoxal::service(id = "transport-fault-lifecycle", state = ())] @@ -376,7 +1013,9 @@ async fn assert_owner_worker_failure_reaches_lifecycle( session: BusLease::Owned(owner), participant_id, shutdown_grace: Duration::from_secs(1), - bundle: None, + source: SetupSource::Harness, + domain: None, + attachment: None, config: (), clock: RunnerClock::Delegated(RealClock::new(test_timeline())), scheduler, diff --git a/phoxal/src/participant/scheduler/mod.rs b/phoxal/src/participant/scheduler/mod.rs index 7e3750c2..19a72f2f 100644 --- a/phoxal/src/participant/scheduler/mod.rs +++ b/phoxal/src/participant/scheduler/mod.rs @@ -6,7 +6,7 @@ //! it", and every produced instant is read from it. [`StepScheduler`] //! answers a different question: "when should the next participant step fire". //! Real mode answers that from the host monotonic clock, never from a bus -//! message; a simulation clock instead releases ticks only when the world +//! message; a simulated-time source instead releases ticks only when external //! authority advances robot time. Without this split, simulated time could //! label samples but could never drive the loop - the runner would still //! free-run on the host clock underneath a "simulated" label. @@ -36,6 +36,12 @@ pub(crate) mod simulation; use real::RealScheduler; use simulation::{SimulationClockHandle, SimulationScheduler}; +#[cfg(test)] +pub(crate) struct TestMonotonicScheduler { + scheduler: SimulationScheduler, + started: watch::Sender, +} + /// The cadence of a `#[phoxal::step(hz = …)]` loop, as the role macros emit it /// from the attribute. #[derive(Clone, Copy, Debug)] @@ -117,6 +123,12 @@ pub(crate) enum AnyStepScheduler { /// Logical-time scheduling, driven by a /// [`SimulationClockHandle`](simulation::SimulationClockHandle). Simulation(SimulationScheduler), + /// Deterministically driven monotonic cadence for the runner's own unit + /// tests. It reuses the checked logical-time arithmetic, but deliberately + /// exposes no simulation-time receiver: advancing this scheduler models + /// host time passing and can never trigger timeline replacement. + #[cfg(test)] + TestMonotonic(TestMonotonicScheduler), /// No cadence to release: a real participant that declares no /// `#[phoxal::step]`. /// @@ -127,8 +139,35 @@ pub(crate) enum AnyStepScheduler { } impl AnyStepScheduler { + /// Build a deterministically driven monotonic scheduler for a runner test. + /// + /// Unlike [`Self::Simulation`], this test-only shape has an initial host + /// instant and does not expose a world-time receiver to the event loop. + /// The returned handle advances cadence only, while the test advances its + /// injected [`ClockSource`](crate::participant::clock::ClockSource) + /// independently to the same host instant. + #[cfg(test)] + pub(crate) fn test_monotonic( + schedule: StepSchedule, + now: RobotInstant, + ) -> (Self, SimulationClockHandle, watch::Receiver) { + let handle = SimulationClockHandle::source(); + assert_eq!( + handle.advance(now), + simulation::SimulationClockAdvance::Advanced, + "a fresh deterministic monotonic scheduler accepts its initial instant" + ); + let scheduler = handle.scheduler(Some(schedule.period())); + let (started, observed) = watch::channel(false); + ( + Self::TestMonotonic(TestMonotonicScheduler { scheduler, started }), + handle, + observed, + ) + } + /// Validate scheduler facts without allocating a live scheduler or a - /// simulation clock channel. + /// simulated-time channel. /// /// Startup uses this pure check before transport exists. The lifecycle /// calls [`Self::for_clock_mode`] only after the bus connection succeeds, @@ -166,7 +205,7 @@ impl AnyStepScheduler { /// clock and nothing external feeds it. Simulation mode returns /// [`Some`] handle, which is the attachment point anything producing a /// [`RobotInstant`] drives the scheduler through - the runner's live - /// `runtime/simulation/clock` subscription, a test, a REPL. + /// future controlled attachment, a test, or a REPL. pub(crate) fn for_clock_mode( clock_mode: ClockMode, schedule: Option, @@ -213,6 +252,8 @@ impl AnyStepScheduler { match self { AnyStepScheduler::Real(_) | AnyStepScheduler::Disabled => None, AnyStepScheduler::Simulation(scheduler) => Some(scheduler.time_receiver()), + #[cfg(test)] + AnyStepScheduler::TestMonotonic(_) => None, } } @@ -236,6 +277,10 @@ impl StepScheduler for AnyStepScheduler { match self { AnyStepScheduler::Real(scheduler) => scheduler.wait_until(target).await, AnyStepScheduler::Simulation(scheduler) => scheduler.wait_until(target).await, + #[cfg(test)] + AnyStepScheduler::TestMonotonic(scheduler) => { + scheduler.scheduler.wait_until(target).await + } AnyStepScheduler::Disabled => std::future::pending().await, } } @@ -244,6 +289,11 @@ impl StepScheduler for AnyStepScheduler { match self { AnyStepScheduler::Real(scheduler) => scheduler.now(), AnyStepScheduler::Simulation(scheduler) => scheduler.now(), + #[cfg(test)] + AnyStepScheduler::TestMonotonic(scheduler) => { + scheduler.started.send_replace(true); + scheduler.scheduler.now() + } AnyStepScheduler::Disabled => None, } } @@ -277,7 +327,7 @@ mod tests { assert!(matches!(real, AnyStepScheduler::Real(_))); assert!( real_handle.is_none(), - "real mode has no simulation clock handle to drive" + "real mode has no simulated-time handle to drive" ); let (simulation, simulation_handle) = @@ -337,11 +387,9 @@ mod tests { /// The exact scheduler + handle `for_clock_mode` selects for /// [`ClockMode::Simulation`], driven purely by robot time - no real - /// sleeping, no live bus/Webots feed. This is the deterministic proof that - /// simulation mode schedules ticks from robot time; the full live path (the - /// clock feed wiring and the wire-key match with the simulation - /// controller's publisher) needs a bus and belongs to the local end-to-end - /// run. + /// sleeping, no Live bus/Webots feed. This is the deterministic proof that + /// the dormant mode schedules ticks from robot time; a future controlled + /// attachment owns its external ingress contract. #[tokio::test] async fn the_simulation_scheduler_the_runner_selects_schedules_deterministically() { let schedule = StepSchedule::hz(10.0); // 100ms period diff --git a/phoxal/src/participant/scheduler/simulation.rs b/phoxal/src/participant/scheduler/simulation.rs index 2e73f1a3..e6ae2e4a 100644 --- a/phoxal/src/participant/scheduler/simulation.rs +++ b/phoxal/src/participant/scheduler/simulation.rs @@ -1,4 +1,4 @@ -//! Logical-time step scheduling, driven by the world authority. +//! Dormant logical-time step scheduling for a future controlled source. use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -7,21 +7,12 @@ use tokio::sync::watch; use super::{SchedulerTick, StepScheduler}; use crate::bus::RetiredTimelines; -use crate::bus::RobotInstant; +use crate::bus::{RobotInstant, TimelineId}; use crate::participant::clock::simulation::SimulationClock; use crate::participant::{duration_nanos, lock}; -/// Simulation scheduler: releases ticks from **robot** time advanced by the -/// world authority, never a real sleep. -/// -/// # The live seam -/// -/// The simulation controller is the authoritative owner of the -/// `runtime/simulation/clock` hand. In simulation mode the participant runner -/// subscribes that topic and forwards each observed [`RobotInstant`] into this -/// scheduler through [`SimulationClockHandle::advance`]. Tests drive the same -/// handle directly, so live and deterministic test paths share the scheduler -/// boundary. +/// Simulation scheduler: releases ticks from externally advanced **robot** +/// time, never a real sleep. /// /// # Determinism /// @@ -35,7 +26,7 @@ pub(crate) struct SimulationScheduler { /// participant has no `Participant::step` schedule. period: Option, /// Keeps the watch channel open even when the runner has not wired an - /// external `runtime/simulation/clock` feed yet. Without this, dropping the + /// external logical-time source yet. Without this, dropping the /// returned handle would close the channel and make waits resolve /// immediately instead of waiting for logical time. _tx_keepalive: watch::Sender>, @@ -45,24 +36,28 @@ pub(crate) struct SimulationScheduler { struct SimulationClockState { current: Option, retired_timelines: RetiredTimelines, + enabled: bool, + expected_timeline: Option, } /// Result of applying one clock sample to a simulation scheduler. #[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[allow( + dead_code, + reason = "retained as the dormant simulated-time scheduler ingress for the Lockstep follow-up" +)] pub(crate) enum SimulationClockAdvance { Advanced, DuplicateOrBackward, RetiredTimeline, + InactiveTimeline, } /// A cloneable handle that advances the logical time a /// [`SimulationScheduler`] observes. /// -/// This is the seam a live `runtime/simulation/clock` bus subscription attaches to -/// (see [`SimulationScheduler`] docs): a subscriber task calls -/// [`advance`](Self::advance) once per received sample. Tests use the same -/// method to drive the scheduler deterministically, with no bus and no real -/// sleeping. +/// This is the dormant ingress a future controlled-time attachment can drive. +/// Tests use the same method directly, with no bus and no real sleeping. #[derive(Clone)] pub(crate) struct SimulationClockHandle { tx: watch::Sender>, @@ -70,13 +65,71 @@ pub(crate) struct SimulationClockHandle { } impl SimulationClockHandle { + /// Start one reusable logical-time ingress source. + pub(crate) fn source() -> Self { + let (tx, _) = watch::channel(None); + Self { + tx, + state: Arc::new(Mutex::new(SimulationClockState { + current: None, + retired_timelines: RetiredTimelines::default(), + enabled: true, + expected_timeline: None, + })), + } + } + + /// Build a scheduler that follows this source. + pub(crate) fn scheduler(&self, period: Option) -> SimulationScheduler { + SimulationScheduler { + period, + _tx_keepalive: self.tx.clone(), + rx: self.tx.subscribe(), + } + } + + /// Fence every previous simulated history before a new supervisor domain + /// becomes eligible for work. The next accepted clock must name `timeline`. + pub(crate) fn replace_timeline(&self, timeline: TimelineId) { + let mut state = lock(&self.state); + if let Some(previous) = state.current.take() { + state.retired_timelines.retire(previous.timeline()); + } + state.retired_timelines.activate(timeline); + state.enabled = true; + state.expected_timeline = Some(timeline); + self.tx.send_replace(None); + } + + /// Stop accepting world progress while the execution is monotonic. + pub(crate) fn disable(&self) { + let mut state = lock(&self.state); + if let Some(previous) = state.current.take() { + state.retired_timelines.retire(previous.timeline()); + } + state.enabled = false; + state.expected_timeline = None; + self.tx.send_replace(None); + } + /// Advance the observed robot time to `at`. A no-op if `at` is a duplicate /// or backwards within the active timeline. Any different timeline replaces /// the active world history, since timelines are opaque identities with no /// generation order; recently retired timelines are ignored so an in-flight /// clock from a dead controller cannot reactivate old state. + #[allow( + dead_code, + reason = "tests and the future Lockstep attachment drive this dormant simulated-time seam directly" + )] pub(crate) fn advance(&self, at: RobotInstant) -> SimulationClockAdvance { let mut state = lock(&self.state); + if !state.enabled + || state + .expected_timeline + .is_some_and(|expected| expected != at.timeline()) + { + return SimulationClockAdvance::InactiveTimeline; + } match state.current { Some(current) if current.timeline() == at.timeline() => { if at.ticks() <= current.ticks() { @@ -110,19 +163,8 @@ impl SimulationScheduler { // No seed: there is no world history until the authority publishes one, // and an invented instant zero of an invented timeline would be a world // nobody authored. - let (tx, rx) = watch::channel(None); - let scheduler = SimulationScheduler { - period, - _tx_keepalive: tx.clone(), - rx, - }; - let handle = SimulationClockHandle { - tx, - state: Arc::new(Mutex::new(SimulationClockState { - current: None, - retired_timelines: RetiredTimelines::default(), - })), - }; + let handle = SimulationClockHandle::source(); + let scheduler = handle.scheduler(period); (scheduler, handle) } @@ -226,7 +268,7 @@ mod tests { for step in 1..=5u64 { let target = lt(step * 10); // Drive robot time forward from a concurrent task, exactly like - // the live `runtime/simulation/clock` subscriber does. + // a future controlled logical-time source does. let handle = handle.clone(); let advancer = tokio::spawn(async move { handle.advance(target) }); let tick = scheduler.wait_until(target).await; diff --git a/phoxal/src/runtime/api/mod.rs b/phoxal/src/runtime/api/mod.rs index 0ffa7959..920e709d 100644 --- a/phoxal/src/runtime/api/mod.rs +++ b/phoxal/src/runtime/api/mod.rs @@ -1,13 +1,12 @@ //! The `runtime` contract family: what a running Phoxal process says about //! itself. //! -//! Log events, bus and step telemetry, and the authoritative simulation clock. +//! Log events and bus and step telemetry. //! Any process publishes here; the family names no collector. crate::nodes! { family Runtime; logs; - simulation; telemetry; } diff --git a/phoxal/src/runtime/api/simulation.rs b/phoxal/src/runtime/api/simulation.rs deleted file mode 100644 index 67d76f58..00000000 --- a/phoxal/src/runtime/api/simulation.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! The external simulation hand-off: the authoritative world clock. -//! -//! The endpoint's semantic is `WorldClock`, a sibling of `State` rather than a -//! subtype of it: that is what keeps the ordinary state publisher every -//! participant has from minting world steps, so only the dedicated world-clock -//! publisher - which no participant reaches - can take this endpoint. Its wire -//! kind stays `Event`: the hand is stamped at a completed world step, and its -//! ordered stream transport preserves every accepted clock and reports gaps -//! instead of silently coalescing them. - -crate::endpoints! { - clock: WorldClock; -} - -/// The body carried by the authoritative simulation clock hand. -/// -/// The production timeline and exact instant are bus metadata. The body carries -/// only the simulator's monotonic step counter. -#[derive( - phoxal_macros::DescribeWire, - Clone, - Copy, - Debug, - Eq, - PartialEq, - serde::Serialize, - serde::Deserialize, -)] -pub struct Clock { - pub step: u64, -} diff --git a/phoxal/src/runtime/mod.rs b/phoxal/src/runtime/mod.rs index e6595664..dc936c62 100644 --- a/phoxal/src/runtime/mod.rs +++ b/phoxal/src/runtime/mod.rs @@ -1,8 +1,7 @@ //! What a running Phoxal process says about itself. //! -//! [`api`] is the `runtime` contract family: log events, bus and step -//! telemetry, and the authoritative simulation clock. Any process publishes -//! here; the family names no collector. +//! [`api`] is the `runtime` contract family: log events plus bus and step +//! telemetry. Any process publishes here; the family names no collector. /// The `runtime` contract family. pub mod api; diff --git a/phoxal/src/session/connection.rs b/phoxal/src/session/connection.rs index 468d0b10..d1b1be14 100644 --- a/phoxal/src/session/connection.rs +++ b/phoxal/src/session/connection.rs @@ -10,21 +10,18 @@ use tokio::task::{JoinHandle, JoinSet}; use crate::bus::session::{BusConfig, BusOwner}; use crate::bus::{ AskQuery, BusFault, BusHandle, DEFAULT_QUERY_TIMEOUT, Endpoint, Event, EventReceiver, - KeyLivelinessObserver, LivelinessStatus, Publish, Querier, QueryEndpoint, QueryError, Sample, + KeyLivelinessObserver, LivelinessStatus, Publish, Querier, QueryEndpoint, Sample, SampleReceiver, Setpoint, SetpointPublisher, SourceLabel, State, StateView, Stream, StreamDelivered, StreamPublisher, StreamReceiver, Subscribe, Topic, }; use crate::identity::{ExecutionId, RobotId}; use crate::supervisor::api; use crate::supervisor::api::command::{Command, CommandOutcome}; -use crate::supervisor::api::connect::{ConnectReply, ConnectRequest, PRESENCE_KEY}; +use crate::supervisor::api::connect::PRESENCE_KEY; use crate::supervisor::api::execution::{Snapshot, SnapshotDocument}; use crate::version::FrameworkVersion; -use crate::session::error::{ - CloseError, CompatibilityRefusal, ConnectError, DisconnectReason, SessionError, -}; -use crate::session::selection::exactly_one_execution; +use crate::session::error::{CloseError, ConnectError, DisconnectReason, SessionError}; /// Inputs for one direct session against one execution. #[derive(Clone, Debug)] @@ -48,11 +45,9 @@ impl ConnectOptions { /// Immutable facts established while connecting. /// -/// The clock is deliberately absent. A bundle records no time domain: real -/// robot time zero is the host boot and the timeline id is the execution id, -/// while simulation is a launch decision carried per runtime as `--simulation`. -/// The supervisor is handed a bundle root and nothing else, so it does not know -/// how the runtimes around it were started and has no answer to advertise. +/// The clock is deliberately absent. The supervisor owns a dynamic time-domain +/// endpoint for participant lifecycle, while a session only establishes the +/// immutable execution identity, model, and compatible framework train. #[derive(Clone, Debug)] pub struct ConnectedExecution { pub execution: ExecutionId, @@ -310,9 +305,9 @@ impl SessionHandle { /// /// Returns [`SessionError`] when the session is terminal or the query /// fails. - pub async fn manifest(&self) -> Result { + pub async fn manifest(&self) -> Result { self.ensure_connected()?; - Ok(self.info.query(api::info::InfoRequest {}).await?) + Ok(self.info.query(api::info::InfoRequest {}).await?.manifest) } /// One page of the supervisor's retained log view, newest first. @@ -422,8 +417,9 @@ impl Session { /// execution, when the peers were built from different compatibility lines, /// or when the transport or the initial exchange fails. pub async fn connect(options: &ConnectOptions) -> Result { - let executions = BusOwner::probe_routers(&options.endpoint).await?; - let execution = exactly_one_execution(&options.endpoint, &executions)?; + let execution = crate::execution::resolve_execution(&options.endpoint) + .await + .map_err(ConnectError::from)?; let label = SourceLabel::new(options.label.clone())?; let (owner, bus) = BusOwner::open(BusConfig::for_external( execution, @@ -432,7 +428,7 @@ impl Session { )) .await?; - let initialized = match initialize(&bus, execution).await { + let initialized = match initialize(&bus).await { Ok(initialized) => initialized, Err(error) => { let _ = owner.close().await; @@ -538,9 +534,19 @@ struct Initialized { telemetry: Querier, } -async fn initialize(bus: &BusHandle, execution: ExecutionId) -> Result { - let framework = remote_framework(bus).await?; - ensure_compatible_framework(framework, FrameworkVersion::CURRENT)?; +async fn initialize(bus: &BusHandle) -> Result { + let bootstrap = crate::execution::attach_execution(bus) + .await + .map_err(ConnectError::from)?; + let crate::execution::ExecutionBootstrap { + execution, + framework, + info: execution_info, + time_domain: _, + time_domains: _, + attachment: _, + attachments: _, + } = bootstrap; let info = Querier::new( bus.clone(), @@ -550,12 +556,7 @@ async fn initialize(bus: &BusHandle, execution: ExecutionId) -> Result Result Result { - let reply = Querier::new( - bus.clone(), - &api::topics().connect().client(), - DEFAULT_QUERY_TIMEOUT, - )? - .query(ConnectRequest::V0 {}) - .await - .map_err(|error| match error { - QueryError::Decode(detail) => ConnectError::UnreadableBootstrap { detail }, - other => ConnectError::Query(other), - })?; - let ConnectReply::V0 { framework } = reply; - Ok(framework) -} - -pub(crate) fn ensure_compatible_framework( - remote: FrameworkVersion, - local: FrameworkVersion, -) -> Result<(), ConnectError> { - if remote.is_compatible_with(local) { - return Ok(()); - } - let refusal = if version_key(remote) > version_key(local) { - CompatibilityRefusal::RemoteNewer - } else { - CompatibilityRefusal::LocalNewer - }; - Err(ConnectError::IncompatibleFramework { - remote, - local, - refusal, - }) -} - -const fn version_key(version: FrameworkVersion) -> (u16, u16, u16) { - (version.major(), version.minor(), version.patch()) -} - async fn run_lifecycle( owner: BusOwner, bus: BusHandle, @@ -847,8 +809,8 @@ mod tests { FrameworkVersion::new(1, 9, 9), ), ] { - assert!(ensure_compatible_framework(older, newer).is_ok()); - assert!(ensure_compatible_framework(newer, older).is_ok()); + assert!(crate::execution::ensure_compatible_framework(older, newer).is_ok()); + assert!(crate::execution::ensure_compatible_framework(newer, older).is_ok()); } } diff --git a/phoxal/src/session/error.rs b/phoxal/src/session/error.rs index 1fdd1d9c..12052d3d 100644 --- a/phoxal/src/session/error.rs +++ b/phoxal/src/session/error.rs @@ -94,6 +94,46 @@ impl ConnectError { } } +impl From for ConnectError { + fn from(error: crate::execution::BootstrapError) -> Self { + match error { + crate::execution::BootstrapError::NoExecution { endpoint } => { + Self::NoExecution { endpoint } + } + crate::execution::BootstrapError::MultipleExecutions { + endpoint, + count, + executions, + } => Self::MultipleExecutions { + endpoint, + count, + executions, + }, + crate::execution::BootstrapError::IncompatibleFramework { + remote, + local, + refusal, + } => Self::IncompatibleFramework { + remote, + local, + refusal: match refusal { + crate::execution::CompatibilityRefusal::RemoteNewer => { + CompatibilityRefusal::RemoteNewer + } + crate::execution::CompatibilityRefusal::LocalNewer => { + CompatibilityRefusal::LocalNewer + } + }, + }, + crate::execution::BootstrapError::UnreadableBootstrap { detail } => { + Self::UnreadableBootstrap { detail } + } + crate::execution::BootstrapError::Bus(error) => Self::Bus(error), + crate::execution::BootstrapError::Query(error) => Self::Query(error), + } + } +} + /// The terminal fact that ended an established session. /// /// The first observed reason is latched for the session's lifetime. A later @@ -165,7 +205,8 @@ mod tests { use super::*; fn refusal(remote: FrameworkVersion, local: FrameworkVersion) -> ConnectError { - crate::session::connection::ensure_compatible_framework(remote, local) + crate::execution::ensure_compatible_framework(remote, local) + .map_err(ConnectError::from) .expect_err("different lines are incompatible") } diff --git a/phoxal/src/session/mod.rs b/phoxal/src/session/mod.rs index 0bb01d4d..2facc874 100644 --- a/phoxal/src/session/mod.rs +++ b/phoxal/src/session/mod.rs @@ -37,7 +37,9 @@ mod connection; mod error; -mod selection; +pub use crate::world::{ + WorldDiagnosticsSubscription, WorldSessionClient, WorldSessionWireError, WorldStateSubscription, +}; pub use connection::{ConnectOptions, ConnectedExecution, Session, SessionHandle}; pub use error::{CloseError, CompatibilityRefusal, ConnectError, DisconnectReason, SessionError}; diff --git a/phoxal/src/session/selection.rs b/phoxal/src/session/selection.rs deleted file mode 100644 index 351d8e4c..00000000 --- a/phoxal/src/session/selection.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! Resolve one configured endpoint to exactly one execution. - -use crate::identity::ExecutionId; - -use crate::session::ConnectError; - -pub(crate) fn exactly_one_execution( - endpoint: &str, - executions: &[ExecutionId], -) -> Result { - match executions { - [] => Err(ConnectError::NoExecution { - endpoint: endpoint.to_string(), - }), - [only] => Ok(*only), - many => { - let mut executions = many.to_vec(); - executions.sort_by_key(ToString::to_string); - Err(ConnectError::MultipleExecutions { - endpoint: endpoint.to_string(), - count: executions.len(), - executions, - }) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn exactly_one_execution_preserves_ambiguous_identities() { - let first = ExecutionId::mint(); - assert_eq!( - exactly_one_execution("tcp/host:7447", &[first]).expect("one execution resolves"), - first - ); - assert!(matches!( - exactly_one_execution("tcp/host:7447", &[]), - Err(ConnectError::NoExecution { .. }) - )); - - let second = ExecutionId::mint(); - let error = exactly_one_execution("tcp/host:7447", &[first, second]) - .expect_err("several executions are ambiguous"); - assert!(matches!( - error, - ConnectError::MultipleExecutions { - count: 2, - executions, - .. - } if executions.contains(&first) && executions.contains(&second) - )); - } -} diff --git a/phoxal/src/simulation/api/mod.rs b/phoxal/src/simulation/api/mod.rs new file mode 100644 index 00000000..1d4354eb --- /dev/null +++ b/phoxal/src/simulation/api/mod.rs @@ -0,0 +1,14 @@ +//! The `simulation` contract family: passive progress from an attached world. +//! +//! The per-robot simulator controller publishes one [`StepEvent`] after it has +//! admitted the outputs for a completed native transition. The event records +//! producer-local order only. It never advances participant scheduling or +//! claims that another subscriber has observed the corresponding outputs. + +crate::nodes! { + family Simulation; + + step; +} + +pub use step::StepEvent; diff --git a/phoxal/src/simulation/api/step.rs b/phoxal/src/simulation/api/step.rs new file mode 100644 index 00000000..ec02a48d --- /dev/null +++ b/phoxal/src/simulation/api/step.rs @@ -0,0 +1,30 @@ +//! Passive progress for one attached Live simulation controller. + +crate::endpoints! { + self: Event; +} + +/// Notification that one native world transition completed. +/// +/// The exact monotonic [`crate::bus::RobotInstant`] is carried by standard +/// message metadata. The execution bus supplies [`crate::identity::ExecutionId`], and +/// the active supervisor attachment supplies the world identity. This body +/// therefore carries only the world-absolute completed transition index. +/// +/// This event is producer-ordered progress. It is not a receiver-side +/// transaction, an observation fence, or a participant scheduling trigger. +#[derive( + phoxal_macros::DescribeWire, + Clone, + Copy, + Debug, + Eq, + PartialEq, + serde::Serialize, + serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct StepEvent { + /// The world-absolute native step that just completed. + pub index: u64, +} diff --git a/phoxal/src/simulation/mod.rs b/phoxal/src/simulation/mod.rs new file mode 100644 index 00000000..b99b7e36 --- /dev/null +++ b/phoxal/src/simulation/mod.rs @@ -0,0 +1,7 @@ +//! Simulation-owned contracts. +//! +//! This family carries passive world progress rather than generic runtime telemetry. +//! Its wire spelling is deliberately separate from `phoxal::simulator`, which +//! is the Rust host SDK that a concrete world adapter uses. + +pub mod api; diff --git a/phoxal/src/simulator/attachment.rs b/phoxal/src/simulator/attachment.rs new file mode 100644 index 00000000..37247ca6 --- /dev/null +++ b/phoxal/src/simulator/attachment.rs @@ -0,0 +1,111 @@ +//! Source-bound simulation attachment transaction. + +use super::*; + +/// A source-bound host transaction from observed Preparing to Active commit. +pub struct SimulationAttachTransaction { + pub(super) initial: SimulationAttachmentState, + pub(super) request: AttachRequest, + pub(super) host: ProducerId, + pub(super) time_domain: TimeDomain, + pub(super) response: Option, + pub(super) transaction_liveliness: Option, + pub(super) attachment_liveliness: Arc>>, + pub(super) end: Querier, +} + +pub(super) enum AttachTransactionResponse { + Pending(tokio::task::JoinHandle>), + Complete(AttachResponse), +} + +impl SimulationAttachTransaction { + /// The ordered attachment state observed before this handle was returned. + /// A new transaction is Preparing. An idempotent retry may already be + /// Active and completes immediately. + #[must_use] + pub const fn initial(&self) -> SimulationAttachmentState { + self.initial + } + + /// Await and validate the supervisor's Active commit. + pub async fn commit(mut self) -> Result { + let response = match self + .response + .take() + .ok_or_else(|| SimulatorError::AttachmentTask { + detail: "the attachment transaction response was already consumed".to_owned(), + })? { + AttachTransactionResponse::Pending(task) => { + task.await + .map_err(|error| SimulatorError::AttachmentTask { + detail: error.to_string(), + })?? + } + AttachTransactionResponse::Complete(response) => response, + }; + validate_attach_response(response, self.request, self.host, self.time_domain)?; + let lease = + self.transaction_liveliness + .take() + .ok_or_else(|| SimulatorError::AttachmentTask { + detail: "the attachment transaction lease was already consumed".to_owned(), + })?; + *self.attachment_liveliness.lock().await = Some(lease); + Ok(response) + } + + /// Abort this Preparing transaction, revoke its no-late-commit lease, and + /// await the supervisor's source-bound Removing response. + pub async fn abort( + mut self, + reason: SimulationEndReason, + ) -> Result { + if let Some(AttachTransactionResponse::Pending(task)) = self.response.take() { + task.abort(); + } + drop(self.transaction_liveliness.take()); + let response = self.end.query(EndRequest { reason }).await?; + if response.attachment.phase != SimulationAttachmentPhase::Removing + || response.attachment.host != self.host + { + return Err(SimulatorError::AttachmentProtocol { + detail: "attachment abort did not converge to Removing under this host producer" + .to_owned(), + }); + } + Ok(response) + } +} + +impl Drop for SimulationAttachTransaction { + fn drop(&mut self) { + if let Some(AttachTransactionResponse::Pending(task)) = &self.response { + task.abort(); + } + // Dropping this token is the synchronous cancellation fence. The + // supervisor observes it while Preparing and cannot activate after it + // disappears, even though Zenoh may still deliver the abandoned query. + self.transaction_liveliness.take(); + } +} +pub(super) fn validate_attach_response( + response: AttachResponse, + request: AttachRequest, + host: ProducerId, + time_domain: TimeDomain, +) -> Result<(), SimulatorError> { + let attachment = response.attachment; + if response.time_domain != time_domain + || attachment.phase != SimulationAttachmentPhase::Active + || attachment.host != host + || attachment.controller != request.controller() + || attachment.world != request.world() + || attachment.attached_at.world != request.progress() + { + return Err(SimulatorError::AttachmentProtocol { + detail: "attach response did not preserve the requested source binding, progress boundary, and monotonic domain".to_owned(), + }); + } + Ok(()) +} diff --git a/phoxal/src/simulator/bootstrap.rs b/phoxal/src/simulator/bootstrap.rs new file mode 100644 index 00000000..7f7444eb --- /dev/null +++ b/phoxal/src/simulator/bootstrap.rs @@ -0,0 +1,83 @@ +//! Shared transport bootstrap and frozen facts for Live simulator roles. + +use super::*; + +/// Framework-owned transport and immutable facts common to every Live role. +/// +/// Role-specific sessions retain separate authority after this point. The +/// controller owns device I/O, while the host owns attachment management. +pub(super) struct LiveBootstrap { + pub(super) owner: BusOwner, + pub(super) bus: BusHandle, + pub(super) bootstrap: crate::execution::ExecutionBootstrap, + pub(super) robot: crate::model::Robot, + pub(super) assets: crate::bundle::ParticipantAssets, +} + +pub(super) async fn open_live_bootstrap( + connect: String, + label: String, +) -> Result { + let execution = crate::execution::resolve_execution(&connect) + .await + .map_err(simulator_bootstrap_error)?; + let label = SourceLabel::new(label)?; + let (owner, bus) = BusOwner::open(BusConfig::for_external( + execution, + Some(label), + vec![connect], + )) + .await?; + let result = async { + let bootstrap = crate::execution::attach_execution(&bus) + .await + .map_err(|error| SimulatorError::Bootstrap { + detail: error.to_string(), + })?; + if bootstrap.time_domain.mode != TimeMode::Monotonic { + return Err(SimulatorError::NonMonotonicTimeDomain); + } + let robot = bootstrap.info.manifest.clone().into_robot(); + let assets = + crate::bundle::ParticipantAssets::from_supervisor(bus.clone()).map_err(|error| { + SimulatorError::Bootstrap { + detail: error.to_string(), + } + })?; + Ok((bootstrap, robot, assets)) + } + .await; + match result { + Ok((bootstrap, robot, assets)) => Ok(LiveBootstrap { + owner, + bus, + bootstrap, + robot, + assets, + }), + Err(error) => { + let _ = owner.close().await; + Err(error) + } + } +} + +fn simulator_bootstrap_error(error: crate::execution::BootstrapError) -> SimulatorError { + match error { + crate::execution::BootstrapError::NoExecution { endpoint } => { + SimulatorError::NoExecution { connect: endpoint } + } + crate::execution::BootstrapError::MultipleExecutions { + endpoint, + count, + executions, + } => SimulatorError::MultipleExecutions { + connect: endpoint, + count, + executions, + }, + error => SimulatorError::Bootstrap { + detail: error.to_string(), + }, + } +} diff --git a/phoxal/src/simulator/controller.rs b/phoxal/src/simulator/controller.rs new file mode 100644 index 00000000..7811c81f --- /dev/null +++ b/phoxal/src/simulator/controller.rs @@ -0,0 +1,362 @@ +//! Controller-side Live simulator session ownership. + +use super::*; + +/// Inputs for one controller session against one execution. +#[derive(Clone, Debug)] +pub struct SimulatorConnectOptions { + pub connect: String, + pub label: String, +} + +impl SimulatorConnectOptions { + #[must_use] + pub fn new(connect: impl Into, label: impl Into) -> Self { + Self { + connect: connect.into(), + label: label.into(), + } + } +} + +/// One controller process attached to one execution. +pub struct SimulatorSession { + presence: BTreeMap, + preparation: tokio::sync::Mutex>, + attachment: tokio::sync::watch::Receiver>, + attachment_fault: Arc>>, + attachment_task: Option>, + step: EventPublisher, + time_domain: TimeDomain, + progress: Mutex>, + robot: crate::model::Robot, + assets: crate::bundle::ParticipantAssets, + bus: BusHandle, + execution: ExecutionId, + owner: Option, +} + +impl std::fmt::Debug for SimulatorSession { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SimulatorSession") + .field("execution", &self.execution) + .field("presented", &self.presence.len()) + .field("attachment", &*self.attachment.borrow()) + .finish_non_exhaustive() + } +} + +impl SimulatorSession { + pub async fn probe(connect: &str) -> Result, SimulatorError> { + Ok(BusOwner::probe_routers(connect).await?) + } + + /// Join the sole execution at `connect` and complete the frozen supervisor + /// bootstrap before exposing any controller capability. + pub async fn connect(options: SimulatorConnectOptions) -> Result { + let LiveBootstrap { + owner, + bus, + bootstrap, + robot, + assets, + } = open_live_bootstrap(options.connect, options.label).await?; + let execution = bootstrap.execution; + let step = match EventPublisher::new( + bus.clone(), + &crate::simulation::api::topics().step().owner(), + ) { + Ok(step) => step, + Err(error) => { + let _ = owner.close().await; + return Err(error.into()); + } + }; + let (attachment_tx, attachment) = tokio::sync::watch::channel(bootstrap.attachment); + let attachment_fault = Arc::new(Mutex::new(None)); + let task_fault = Arc::clone(&attachment_fault); + let time_domain = bootstrap.time_domain; + install_active_controller_binding(&bus, bootstrap.attachment); + let task_bus = bus.clone(); + let attachment_task = tokio::spawn(async move { + observe_attachment( + attachment_tx, + None, + bootstrap.attachments, + bootstrap.time_domains, + time_domain, + task_fault, + task_bus, + ) + .await; + }); + Ok(Self { + presence: BTreeMap::new(), + preparation: tokio::sync::Mutex::new(None), + attachment, + attachment_fault, + attachment_task: Some(attachment_task), + step, + time_domain, + progress: Mutex::new(None), + robot, + assets, + bus, + execution, + owner: Some(owner), + }) + } + + #[must_use] + pub fn execution(&self) -> ExecutionId { + self.execution + } + + /// The producer identity that a host binds as the attachment controller. + #[must_use] + pub fn producer(&self) -> ProducerId { + self.bus.producer() + } + + /// The immutable robot model returned by the supervisor bootstrap. + #[must_use] + pub fn robot(&self) -> &crate::model::Robot { + &self.robot + } + + /// Lazy supervisor-backed access to the execution bundle's immutable + /// assets. + #[must_use] + pub fn assets(&self) -> &crate::bundle::ParticipantAssets { + &self.assets + } + + /// The newest complete attachment state known to this controller. + pub async fn attachment(&self) -> Result, SimulatorError> { + self.check_attachment_observer()?; + Ok(*self.attachment.borrow()) + } + + /// Acknowledge the current Preparing revision after the controller has + /// bound devices and flushed retained commands. The supervisor holds the + /// attach query until this exact producer-qualified lease exists. + pub async fn acknowledge_preparing(&self) -> Result<(), SimulatorError> { + let attachment = self + .attachment() + .await? + .ok_or(SimulatorError::AttachmentInactive)?; + if attachment.phase != SimulationAttachmentPhase::Preparing { + return Err(SimulatorError::AttachmentInactive); + } + self.ensure_controller(attachment)?; + let mut preparation = self.preparation.lock().await; + if preparation + .as_ref() + .is_some_and(|(revision, _)| *revision == attachment.revision) + { + return Ok(()); + } + let owner = self.owner.as_ref().ok_or(BusError::Closed)?; + let key = crate::supervisor::api::simulation::preparation_liveliness_key( + attachment.revision, + attachment.controller, + ); + let token = owner.declare_liveliness_key(&key).await?; + *preparation = Some((attachment.revision, token)); + Ok(()) + } + + /// Capture one current host-monotonic instant for all outputs of a native + /// transition. This succeeds only for the exact Active controller binding. + pub fn live_transition( + &self, + progress: WorldProgress, + ) -> Result { + self.check_attachment_observer()?; + let attachment = (*self.attachment.borrow()).ok_or(SimulatorError::AttachmentInactive)?; + if attachment.phase != SimulationAttachmentPhase::Active { + return Err(SimulatorError::AttachmentInactive); + } + self.ensure_controller(attachment)?; + let mut cursor = self + .progress + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let previous = cursor + .filter(|(revision, _)| *revision == attachment.revision) + .map_or(attachment.attached_at.world, |(_, progress)| progress); + validate_next_progress(previous, progress)?; + let now = LocalInstant::try_now().ok_or(SimulatorError::ClockUnavailable)?; + *cursor = Some((attachment.revision, progress)); + Ok(LiveTransitionStamp { + instant: RobotInstant::new(self.time_domain.timeline, now.boot_ns()), + world: attachment.world, + revision: attachment.revision, + attached_at: attachment.attached_at, + progress, + }) + } + + /// Capture a current Active monotonic boundary for command selection before + /// a native transition. This does not inspect or advance world progress. + pub fn active_boundary(&self) -> Result { + self.check_attachment_observer()?; + let attachment = (*self.attachment.borrow()).ok_or(SimulatorError::AttachmentInactive)?; + if attachment.phase != SimulationAttachmentPhase::Active { + return Err(SimulatorError::AttachmentInactive); + } + self.ensure_controller(attachment)?; + let local = LocalInstant::try_now().ok_or(SimulatorError::ClockUnavailable)?; + Ok(ActiveBoundaryStamp { + local, + instant: RobotInstant::new(self.time_domain.timeline, local.boot_ns()), + world: attachment.world, + revision: attachment.revision, + attached_at: attachment.attached_at, + }) + } + + /// Publish passive progress after every output for the same transition. + pub fn publish_step( + &self, + transition: &LiveTransitionStamp, + event: StepEvent, + ) -> Result<(), SimulatorError> { + self.validate_transition(transition)?; + let expected = transition.progress.completed_step(); + if event.index != expected { + return Err(SimulatorError::StepIndexMismatch { + expected, + observed: event.index, + }); + } + admit_step_event(&self.step, &self.bus, transition, event) + } + + pub fn sample_publisher( + &self, + topic: Topic>, + ) -> Result, SimulatorError> + where + E: RobotEndpoint + Endpoint, + { + Ok(LiveSamplePublisher { + inner: SamplePublisher::new(self.bus.clone(), &topic)?, + bus: self.bus.clone(), + }) + } + + pub fn state_publisher( + &self, + topic: Topic>, + ) -> Result, SimulatorError> + where + E: RobotEndpoint + Endpoint, + { + Ok(LiveStatePublisher { + inner: StatePublisher::new(self.bus.clone(), &topic)?, + bus: self.bus.clone(), + }) + } + + pub async fn setpoint_receiver( + &self, + topic: Topic>, + ) -> Result, SimulatorError> + where + E: RobotEndpoint + Endpoint, + { + Ok(LiveSetpointReceiver { + inner: SetpointReceiver::new(&self.bus, &topic).await?, + attachment: self.attachment.clone(), + }) + } + + pub async fn participant_ready_events( + &self, + participant: &ParticipantId, + ) -> Result { + Ok(self.bus.participant_ready_events_for(participant).await?) + } + + pub async fn present(&mut self, participant: &ParticipantId) -> Result<(), SimulatorError> { + if self.presence.contains_key(participant) { + return Ok(()); + } + let owner = self.owner.as_ref().ok_or(BusError::Closed)?; + let token = owner.declare_participant_ready_as(participant).await?; + self.presence.insert(participant.clone(), token); + Ok(()) + } + + pub async fn close(mut self) -> Result<(), SimulatorCloseError> { + self.presence.clear(); + *self.preparation.lock().await = None; + if let Some(task) = self.attachment_task.take() { + task.abort(); + let _ = task.await; + } + let Some(owner) = self.owner.take() else { + return Ok(()); + }; + let report = owner.close().await; + if report.is_clean() { + Ok(()) + } else { + Err(SimulatorCloseError { report }) + } + } + + fn validate_transition( + &self, + transition: &LiveTransitionStamp, + ) -> Result { + self.check_attachment_observer()?; + let attachment = (*self.attachment.borrow()).ok_or(SimulatorError::StaleTransition)?; + if attachment.phase != SimulationAttachmentPhase::Active + || attachment.world != transition.world + || attachment.revision != transition.revision + || transition.instant.timeline() != self.time_domain.timeline + { + return Err(SimulatorError::StaleTransition); + } + self.ensure_controller(attachment)?; + Ok(attachment) + } + + fn ensure_controller( + &self, + attachment: SimulationAttachmentState, + ) -> Result<(), SimulatorError> { + let observed = self.bus.producer(); + if attachment.controller == observed { + Ok(()) + } else { + Err(SimulatorError::WrongController { + expected: attachment.controller, + observed, + }) + } + } + + fn check_attachment_observer(&self) -> Result<(), SimulatorError> { + let fault = self + .attachment_fault + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + match fault { + Some(detail) => Err(SimulatorError::AttachmentObserver { detail }), + None => Ok(()), + } + } +} + +impl Drop for SimulatorSession { + fn drop(&mut self) { + if let Some(task) = &self.attachment_task { + task.abort(); + } + } +} diff --git a/phoxal/src/simulator/error.rs b/phoxal/src/simulator/error.rs new file mode 100644 index 00000000..6f27619f --- /dev/null +++ b/phoxal/src/simulator/error.rs @@ -0,0 +1,60 @@ +//! Errors reported by Live simulator sessions. + +use super::*; + +/// A failure while attaching or operating one Live controller session. +#[derive(Debug, thiserror::Error)] +pub enum SimulatorError { + #[error( + "no Phoxal execution is reachable at {connect}; start the supervisor before the simulation" + )] + NoExecution { connect: String }, + #[error( + "{count} Phoxal executions are reachable at {connect}, which must identify exactly one: {executions:?}" + )] + MultipleExecutions { + connect: String, + count: usize, + executions: Vec, + }, + #[error(transparent)] + SourceLabel(#[from] SourceLabelError), + #[error(transparent)] + Bus(#[from] BusError), + #[error(transparent)] + Query(#[from] QueryError), + #[error("execution attachment bootstrap failed: {detail}")] + Bootstrap { detail: String }, + #[error("Live simulation requires an unchanged monotonic execution time domain")] + NonMonotonicTimeDomain, + #[error("the host monotonic clock is unavailable")] + ClockUnavailable, + #[error("the controller has no Active simulation attachment")] + AttachmentInactive, + #[error("attachment is bound to controller {expected}, not this session {observed}")] + WrongController { + expected: crate::identity::ProducerId, + observed: crate::identity::ProducerId, + }, + #[error("the Live attachment observer failed: {detail}")] + AttachmentObserver { detail: String }, + #[error("the transition stamp no longer names the current Active attachment")] + StaleTransition, + #[error("StepEvent index {observed} does not match transition progress {expected}")] + StepIndexMismatch { expected: u64, observed: u64 }, + #[error("world progress step {observed} does not immediately follow completed step {previous}")] + NonMonotonicProgress { previous: u64, observed: u64 }, + #[error(transparent)] + InvalidProgress(#[from] crate::model::world::WorldProgressError), + #[error("the supervisor returned an invalid Live attachment: {detail}")] + AttachmentProtocol { detail: String }, + #[error("the host attachment transaction task stopped: {detail}")] + AttachmentTask { detail: String }, +} + +/// The simulator session closed, but a close stage left evidence. +#[derive(Debug, thiserror::Error)] +#[error("the simulator session did not close cleanly: {report}")] +pub struct SimulatorCloseError { + pub report: BusCloseReport, +} diff --git a/phoxal/src/simulator/host.rs b/phoxal/src/simulator/host.rs new file mode 100644 index 00000000..e67d125c --- /dev/null +++ b/phoxal/src/simulator/host.rs @@ -0,0 +1,368 @@ +//! Host-side Live attachment session ownership. + +use super::*; + +/// Inputs for one source-bound world host session against one execution. +#[derive(Clone, Debug)] +pub struct SimulationHostConnectOptions { + pub connect: String, + pub label: String, +} + +impl SimulationHostConnectOptions { + #[must_use] + pub fn new(connect: impl Into, label: impl Into) -> Self { + Self { + connect: connect.into(), + label: label.into(), + } + } +} + +/// One world-host transport bound to one execution by its own producer identity. +/// +/// This is deliberately distinct from [`SimulatorSession`]. +/// The host performs the source-bound supervisor attachment transaction, while +/// the per-Robot controller owns native device I/O and its Preparing lease. +pub struct SimulationHostSession { + attachment: tokio::sync::watch::Receiver>, + attachment_transitions: tokio::sync::broadcast::Sender, + attachment_fault: Arc>>, + attachment_task: Option>, + removal_acknowledgement: tokio::sync::Mutex>, + host_liveliness: Option, + attachment_liveliness: Arc>>, + attach: Querier, + end: Querier, + time_domain: TimeDomain, + robot: crate::model::Robot, + assets: crate::bundle::ParticipantAssets, + bus: BusHandle, + execution: ExecutionId, + owner: Option, +} + +impl std::fmt::Debug for SimulationHostSession { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SimulationHostSession") + .field("execution", &self.execution) + .field("producer", &self.bus.producer()) + .field("attachment", &*self.attachment.borrow()) + .finish_non_exhaustive() + } +} + +impl SimulationHostSession { + /// Join the sole execution at `connect` as the world host and complete the + /// frozen supervisor bootstrap before exposing attachment authority. + pub async fn connect(options: SimulationHostConnectOptions) -> Result { + let LiveBootstrap { + owner, + bus, + bootstrap, + robot, + assets, + } = open_live_bootstrap(options.connect, options.label).await?; + let execution = bootstrap.execution; + let attach = match Querier::new( + bus.clone(), + &crate::supervisor::api::topics() + .simulation() + .attach() + .client(), + DEFAULT_QUERY_TIMEOUT, + ) { + Ok(attach) => attach, + Err(error) => { + let _ = owner.close().await; + return Err(error.into()); + } + }; + let end = match Querier::new( + bus.clone(), + &crate::supervisor::api::topics().simulation().end().client(), + DEFAULT_QUERY_TIMEOUT, + ) { + Ok(end) => end, + Err(error) => { + let _ = owner.close().await; + return Err(error.into()); + } + }; + let host_liveliness = match owner + .declare_liveliness_key(&crate::supervisor::api::simulation::host_liveliness_key( + bus.producer(), + )) + .await + { + Ok(token) => token, + Err(error) => { + let _ = owner.close().await; + return Err(error.into()); + } + }; + let (attachment_tx, attachment) = tokio::sync::watch::channel(bootstrap.attachment); + let (attachment_transitions, _) = + tokio::sync::broadcast::channel(ATTACHMENT_TRANSITION_CAPACITY); + let task_transitions = attachment_transitions.clone(); + let attachment_fault = Arc::new(Mutex::new(None)); + let task_fault = Arc::clone(&attachment_fault); + let time_domain = bootstrap.time_domain; + let attachment_liveliness = Arc::new(tokio::sync::Mutex::new(None)); + let task_bus = bus.clone(); + let attachment_task = tokio::spawn(async move { + observe_attachment( + attachment_tx, + Some(task_transitions), + bootstrap.attachments, + bootstrap.time_domains, + time_domain, + task_fault, + task_bus, + ) + .await; + }); + Ok(Self { + attachment, + attachment_transitions, + attachment_fault, + attachment_task: Some(attachment_task), + removal_acknowledgement: tokio::sync::Mutex::new(None), + host_liveliness: Some(host_liveliness), + attachment_liveliness, + attach, + end, + time_domain, + robot, + assets, + bus, + execution, + owner: Some(owner), + }) + } + + #[must_use] + pub fn execution(&self) -> ExecutionId { + self.execution + } + + /// The producer identity that the supervisor source-binds as host. + #[must_use] + pub fn producer(&self) -> ProducerId { + self.bus.producer() + } + + /// The immutable robot model returned by the supervisor bootstrap. + #[must_use] + pub fn robot(&self) -> &crate::model::Robot { + &self.robot + } + + /// Lazy supervisor-backed access to the execution bundle's immutable + /// assets. + #[must_use] + pub fn assets(&self) -> &crate::bundle::ParticipantAssets { + &self.assets + } + + /// The execution domain that must remain unchanged for this session. + #[must_use] + pub const fn time_domain(&self) -> TimeDomain { + self.time_domain + } + + /// The newest complete supervisor attachment state. + pub async fn attachment(&self) -> Result, SimulatorError> { + self.check_attachment_observer()?; + Ok(*self.attachment.borrow()) + } + + /// Wait until the supervisor requests native removal for this bound host. + /// + /// The supervisor keeps its bus alive for a bounded grace after publishing + /// Removing. The adapter must park the controller, remove the native Robot, + /// release world membership, and then call [`Self::acknowledge_removal`]. + pub async fn wait_for_removing(&self) -> Result { + let mut attachment = self.attachment.clone(); + loop { + self.check_attachment_observer()?; + if let Some(current) = *attachment.borrow_and_update() + && current.phase == SimulationAttachmentPhase::Removing + { + if current.host != self.producer() { + return Err(SimulatorError::AttachmentProtocol { + detail: "Removing was bound to another world host producer".to_owned(), + }); + } + return Ok(current); + } + attachment + .changed() + .await + .map_err(|_| SimulatorError::AttachmentObserver { + detail: "the supervisor attachment authority closed before Removing".to_owned(), + })?; + } + } + + /// Acknowledge one Removing revision after native member cleanup is + /// complete. Repeating the acknowledgement for the same revision is + /// idempotent. + pub async fn acknowledge_removal(&self) -> Result { + let removing = self.wait_for_removing().await?; + let mut acknowledgement = self.removal_acknowledgement.lock().await; + if acknowledgement + .as_ref() + .is_some_and(|(revision, _)| *revision == removing.revision) + { + return Ok(removing); + } + let owner = self.owner.as_ref().ok_or(BusError::Closed)?; + let key = crate::supervisor::api::simulation::removal_liveliness_key( + removing.revision, + removing.host, + ); + let token = owner.declare_liveliness_key(&key).await?; + *acknowledgement = Some((removing.revision, token)); + Ok(removing) + } + + /// Start the source-bound attachment query and return only after observing + /// its ordered Preparing replacement. + /// + /// This split lets the world host publish truthful Preparing membership + /// before awaiting controller acknowledgement and the Active commit. + pub async fn begin_attach( + &self, + request: AttachRequest, + ) -> Result { + self.check_attachment_observer()?; + let mut transitions = self.attachment_transitions.subscribe(); + let owner = self.owner.as_ref().ok_or(BusError::Closed)?; + let transaction_key = crate::supervisor::api::simulation::transaction_liveliness_key( + request.world(), + self.producer(), + request.controller(), + ); + let transaction_liveliness = owner.declare_liveliness_key(&transaction_key).await?; + let attach = self.attach.clone(); + let mut response = tokio::spawn(async move { attach.query(request).await }); + loop { + tokio::select! { + biased; + transition = transitions.recv() => { + let transition = transition.map_err(|error| { + response.abort(); + SimulatorError::AttachmentObserver { + detail: format!("the ordered attachment transition feed failed: {error}"), + } + })?; + if transition.host != self.producer() + || transition.controller != request.controller() + || transition.world != request.world() + || transition.attached_at.world != request.progress() + { + continue; + } + if transition.phase != SimulationAttachmentPhase::Preparing { + response.abort(); + return Err(SimulatorError::AttachmentProtocol { + detail: "a new attachment reached a non-Preparing phase before the host observed Preparing".to_owned(), + }); + } + return Ok(SimulationAttachTransaction { + initial: transition, + request, + host: self.producer(), + time_domain: self.time_domain, + response: Some(AttachTransactionResponse::Pending(response)), + transaction_liveliness: Some(transaction_liveliness), + attachment_liveliness: Arc::clone(&self.attachment_liveliness), + end: self.end.clone(), + }); + } + joined = &mut response => { + let response = joined + .map_err(|error| SimulatorError::AttachmentTask { + detail: error.to_string(), + })??; + validate_attach_response( + response, + request, + self.producer(), + self.time_domain, + )?; + return Ok(SimulationAttachTransaction { + initial: response.attachment, + request, + host: self.producer(), + time_domain: self.time_domain, + response: Some(AttachTransactionResponse::Complete(response)), + transaction_liveliness: Some(transaction_liveliness), + attachment_liveliness: Arc::clone(&self.attachment_liveliness), + end: self.end.clone(), + }); + } + } + } + } + + /// Perform the complete source-bound Preparing-to-Active transaction. + pub async fn attach(&self, request: AttachRequest) -> Result { + self.begin_attach(request).await?.commit().await + } + + /// Enter Removing for this host's current attachment. + pub async fn end(&self, reason: SimulationEndReason) -> Result { + self.check_attachment_observer()?; + let response = self.end.query(EndRequest { reason }).await?; + if response.attachment.phase != SimulationAttachmentPhase::Removing + || response.attachment.host != self.producer() + { + return Err(SimulatorError::AttachmentProtocol { + detail: "end response was not Removing under this host producer".to_owned(), + }); + } + Ok(response) + } + + pub async fn close(mut self) -> Result<(), SimulatorCloseError> { + *self.removal_acknowledgement.lock().await = None; + *self.attachment_liveliness.lock().await = None; + self.host_liveliness.take(); + if let Some(task) = self.attachment_task.take() { + task.abort(); + let _ = task.await; + } + let Some(owner) = self.owner.take() else { + return Ok(()); + }; + let report = owner.close().await; + if report.is_clean() { + Ok(()) + } else { + Err(SimulatorCloseError { report }) + } + } + + fn check_attachment_observer(&self) -> Result<(), SimulatorError> { + let fault = self + .attachment_fault + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + match fault { + Some(detail) => Err(SimulatorError::AttachmentObserver { detail }), + None => Ok(()), + } + } +} + +impl Drop for SimulationHostSession { + fn drop(&mut self) { + if let Some(task) = &self.attachment_task { + task.abort(); + } + } +} diff --git a/phoxal/src/simulator/io.rs b/phoxal/src/simulator/io.rs new file mode 100644 index 00000000..2d1f1195 --- /dev/null +++ b/phoxal/src/simulator/io.rs @@ -0,0 +1,176 @@ +//! Typed Live simulator publication and setpoint reception. + +use super::*; + +/// A simulator sample publisher bound to the exact Active controller revision. +pub struct LiveSamplePublisher> { + pub(super) inner: SamplePublisher, + pub(super) bus: BusHandle, +} + +impl Clone for LiveSamplePublisher +where + E: RobotEndpoint + Endpoint, +{ + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + bus: self.bus.clone(), + } + } +} + +impl LiveSamplePublisher +where + E: RobotEndpoint + Endpoint, +{ + /// Publish one sample from `transition` only while its exact controller + /// and supervisor revision remain Active. + pub fn publish(&self, transition: &LiveTransitionStamp, body: E) -> Result<(), SimulatorError> { + let admitted = self.inner.publish_active_simulation( + self.bus.producer(), + transition.revision, + crate::bus::CaptureStamp::exact(transition.instant()), + body, + )?; + ensure_live_publication(admitted) + } +} + +/// A simulator state publisher that can emit only under the exact current +/// Active controller binding. +pub struct LiveStatePublisher> { + pub(super) inner: StatePublisher, + pub(super) bus: BusHandle, +} + +impl Clone for LiveStatePublisher +where + E: RobotEndpoint + Endpoint, +{ + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + bus: self.bus.clone(), + } + } +} + +impl LiveStatePublisher +where + E: RobotEndpoint + Endpoint, +{ + /// Publish state from `transition` only while its exact controller and + /// supervisor revision remain Active. + pub fn publish(&self, transition: &LiveTransitionStamp, body: E) -> Result<(), SimulatorError> { + let admitted = self.inner.publish_active_simulation( + self.bus.producer(), + transition.revision, + transition, + body, + )?; + ensure_live_publication(admitted) + } +} +pub struct LiveSetpointReceiver> { + pub(super) inner: SetpointReceiver, + pub(super) attachment: tokio::sync::watch::Receiver>, +} + +impl LiveSetpointReceiver +where + E: RobotEndpoint + Endpoint, +{ + /// Take the next buffered command that belongs to `transition`. + /// Commands from Preparing, Removing, a prior Active revision, or without + /// revision evidence are discarded and can never become live later. + pub fn try_recv_for(&self, transition: &LiveTransitionStamp) -> Option> { + self.try_recv_revision(transition.world, transition.revision) + } + + /// Take the next buffered command for a current pre-transition Active + /// boundary without inventing world progress. + pub fn try_recv_at(&self, boundary: &ActiveBoundaryStamp) -> Option> { + self.try_recv_revision(boundary.world, boundary.revision) + } + + fn try_recv_revision(&self, world: WorldInstanceId, revision: u64) -> Option> { + let active = self.attachment.borrow().is_some_and(|state| { + state.phase == SimulationAttachmentPhase::Active + && state.world == world + && state.revision == revision + }); + if !active { + self.flush(); + return None; + } + while let Some(observed) = self.inner.try_recv() { + if observed.metadata.attachment_revision == Some(revision) { + return Some(observed); + } + } + None + } + + /// Drain every currently buffered command for `transition` through the + /// capability's fixed-source lease. + /// + /// The lease remains the owner of source liveness, monotonic silence and + /// hold expiry, stale sequence rejection, and fail-closed selection. Feed + /// it [`ParticipantReadyEvents`] from [`SimulatorSession::participant_ready_events`], + /// call this immediately before a native transition, then select with + /// [`FixedSourceLease::live_host`] at the transition's host-monotonic + /// boundary. + pub fn drain_into( + &self, + transition: &LiveTransitionStamp, + lease: &mut FixedSourceLease, + ) -> usize { + let mut offered = 0; + while let Some(observed) = self.try_recv_for(transition) { + lease.offer( + observed.metadata.source.participant_source(), + observed.metadata.sequence, + observed.observed_at, + observed.body, + ); + offered += 1; + } + offered + } + + /// Drain commands for a pre-transition Active boundary through the typed + /// source lease. Select the result with + /// `lease.live_host(boundary.local_instant())` immediately before entering + /// the native transition. + pub fn drain_at( + &self, + boundary: &ActiveBoundaryStamp, + lease: &mut FixedSourceLease, + ) -> usize { + let mut offered = 0; + while let Some(observed) = self.try_recv_at(boundary) { + lease.offer( + observed.metadata.source.participant_source(), + observed.metadata.sequence, + observed.observed_at, + observed.body, + ); + offered += 1; + } + offered + } + + /// Discard every retained command, returning how many values were cleared. + pub fn flush(&self) -> usize { + let mut discarded = 0; + while self.inner.try_recv().is_some() { + discarded += 1; + } + discarded + } + + pub fn terminal(&self) -> Option { + self.inner.terminal() + } +} diff --git a/phoxal/src/simulator/live_contract_tests.rs b/phoxal/src/simulator/live_contract_tests.rs new file mode 100644 index 00000000..3107af44 --- /dev/null +++ b/phoxal/src/simulator/live_contract_tests.rs @@ -0,0 +1,338 @@ +use super::*; +use crate::bus::DeliveryFamily; +use crate::identity::TimelineId; +use crate::model::identity::CapabilityId; +use crate::model::world::WorldProgress; + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn supervisor_identity_loss_ends_observation_even_when_streams_stay_open() { + for controller in [true, false] { + let (owner, bus) = BusOwner::open(BusConfig::for_external( + ExecutionId::mint(), + None, + Vec::new(), + )) + .await + .unwrap(); + let identity = owner + .declare_liveliness_key(crate::supervisor::api::connect::PRESENCE_KEY) + .await + .unwrap(); + let attachments = crate::bus::StreamReceiver::new( + &bus, + &crate::supervisor::api::topics() + .simulation() + .attachment() + .client(), + ) + .await + .unwrap(); + let domains = crate::bus::StreamReceiver::new( + &bus, + &crate::supervisor::api::topics().time_domain().client(), + ) + .await + .unwrap(); + let (attachment, _current) = tokio::sync::watch::channel(None); + let transitions = (!controller).then(|| tokio::sync::broadcast::channel(8).0); + let fault = Arc::new(Mutex::new(None)); + bus.set_active_simulation_binding(Some((bus.producer(), 7))); + let observer = tokio::spawn(observe_attachment( + attachment, + transitions, + attachments, + domains, + TimeDomain { + revision: 1, + timeline: TimelineId::mint(), + mode: TimeMode::Monotonic, + }, + Arc::clone(&fault), + bus.clone(), + )); + tokio::task::yield_now().await; + drop(identity); + tokio::time::timeout(std::time::Duration::from_secs(5), observer) + .await + .expect("identity loss cannot wait for a retained stream") + .unwrap(); + assert!( + fault + .lock() + .unwrap() + .as_ref() + .unwrap() + .contains("supervisor identity") + ); + if controller { + assert!( + bus.active_simulation_delivery_metadata( + bus.producer(), + 7, + DeliveryFamily::Sample, + None, + ) + .unwrap() + .is_none() + ); + } + let _ = owner.close().await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn live_publishers_fail_closed_outside_the_exact_active_binding() { + let (owner, bus) = BusOwner::open(BusConfig::for_external( + ExecutionId::mint(), + None, + Vec::new(), + )) + .await + .expect("test simulator bus opens"); + let progress = WorldProgress::at(1, 12).expect("valid progress"); + let transition = LiveTransitionStamp { + instant: RobotInstant::new(TimelineId::mint(), 1), + world: WorldInstanceId::mint(), + revision: 7, + attached_at: LiveAttachmentBoundary { + world: WorldProgress::zero(12).expect("valid initial progress"), + execution: RobotInstant::new(TimelineId::mint(), 0), + }, + progress, + }; + + assert!( + bus.active_simulation_delivery_metadata( + bus.producer(), + transition.revision, + crate::bus::DeliveryFamily::Sample, + Some(crate::bus::TimeWindow::exact(transition.instant())), + ) + .expect("metadata check succeeds") + .is_none() + ); + bus.set_active_simulation_binding(Some((bus.producer(), 6))); + assert!( + bus.active_simulation_delivery_metadata( + bus.producer(), + transition.revision, + crate::bus::DeliveryFamily::Sample, + Some(crate::bus::TimeWindow::exact(transition.instant())), + ) + .expect("metadata check succeeds") + .is_none() + ); + bus.set_active_simulation_binding(Some((bus.producer(), 7))); + let metadata = bus + .active_simulation_delivery_metadata( + bus.producer(), + transition.revision, + crate::bus::DeliveryFamily::Sample, + Some(crate::bus::TimeWindow::exact(transition.instant())), + ) + .expect("metadata check succeeds") + .expect("the exact current Active binding admits publication"); + assert_eq!(metadata.attachment_revision, Some(transition.revision)); + bus.set_active_simulation_binding(None); + assert!( + bus.active_simulation_delivery_metadata( + bus.producer(), + transition.revision, + crate::bus::DeliveryFamily::Sample, + Some(crate::bus::TimeWindow::exact(transition.instant())), + ) + .expect("metadata check succeeds") + .is_none() + ); + + let _ = owner.close().await; +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn one_live_transition_admits_outputs_then_step_with_one_exact_instant() { + let execution = ExecutionId::mint(); + let (owner, bus) = BusOwner::open(BusConfig::for_external(execution, None, Vec::new())) + .await + .expect("test simulator bus opens"); + let state_topic = crate::api::topics().drive().state().owner(); + let state = LiveStatePublisher { + inner: StatePublisher::new(bus.clone(), &state_topic).expect("state publisher"), + bus: bus.clone(), + }; + let component = + crate::identity::ComponentInstanceId::new("accelerometer").expect("component id"); + let capability = CapabilityId::new("linear").expect("capability id"); + let sample_topic = crate::api::topics() + .component(&component) + .expect("component topic") + .accelerometer(&capability) + .expect("accelerometer topic") + .sample() + .owner(); + let sample = LiveSamplePublisher { + inner: SamplePublisher::new(bus.clone(), &sample_topic).expect("sample publisher"), + bus: bus.clone(), + }; + let step_topic = crate::simulation::api::topics().step().owner(); + let step = EventPublisher::new(bus.clone(), &step_topic).expect("step publisher"); + let pause = bus + .test_pause_outbound_drain() + .await + .expect("the one bus drain can be held before admission"); + let timeline = TimelineId::mint(); + let revision = 7; + let transition = LiveTransitionStamp { + instant: RobotInstant::new(timeline, 123), + world: WorldInstanceId::mint(), + revision, + attached_at: LiveAttachmentBoundary { + world: WorldProgress::zero(12).expect("initial world progress"), + execution: RobotInstant::new(timeline, 100), + }, + progress: WorldProgress::at(1, 12).expect("completed world transition"), + }; + bus.set_active_simulation_binding(Some((bus.producer(), revision))); + + state + .publish( + &transition, + crate::api::drive::State::Stopped { + target: crate::api::drive::Target::stopped(), + reason: crate::api::drive::StopReason::Fault, + }, + ) + .expect("state output is admitted without draining"); + sample + .publish( + &transition, + crate::api::component::accelerometer::Sample::try_new([1.0, 2.0, 3.0]) + .expect("finite sample"), + ) + .expect("sample output is admitted without draining"); + admit_step_event( + &step, + &bus, + &transition, + StepEvent { + index: transition.progress().completed_step(), + }, + ) + .expect("StepEvent is admitted without draining"); + + let mut queued = bus.test_queued_delivery_metadata(); + queued.sort_by_key(|(_, _, metadata)| metadata.sequence); + assert_eq!( + queued.len(), + 3, + "all publications use the one bus scheduler" + ); + assert_eq!( + queued + .iter() + .map(|(_, family, _)| *family) + .collect::>(), + vec![ + DeliveryFamily::State, + DeliveryFamily::Sample, + DeliveryFamily::Stream, + ] + ); + assert!(queued[0].0.ends_with(state_topic.key())); + assert!(queued[1].0.ends_with(sample_topic.key())); + assert!(queued[2].0.ends_with(step_topic.key())); + assert_eq!( + queued + .iter() + .map(|(_, _, metadata)| metadata.sequence) + .collect::>(), + vec![0, 1, 2], + "the StepEvent is admitted after every output in local producer order" + ); + for (_, _, metadata) in &queued { + assert_eq!(metadata.produced_exactly_at(), Some(transition.instant())); + assert_eq!(metadata.attachment_revision, Some(revision)); + } + + drop(pause); + let _ = owner + .close_until(tokio::time::Instant::now() + std::time::Duration::from_secs(10)) + .await; +} + +#[test] +fn a_transition_stamp_keeps_execution_and_world_time_separate() { + let timeline = TimelineId::mint(); + let world = WorldInstanceId::mint(); + let attached_at = LiveAttachmentBoundary { + world: WorldProgress::at(4, 12).unwrap(), + execution: RobotInstant::new(timeline, 90), + }; + let stamp = LiveTransitionStamp { + instant: RobotInstant::new(timeline, 100), + world, + revision: 7, + attached_at, + progress: WorldProgress::at(5, 12).unwrap(), + }; + assert_eq!(stamp.instant(), RobotInstant::new(timeline, 100)); + assert_eq!(stamp.world(), world); + assert_eq!(stamp.revision(), 7); + assert_eq!(stamp.attached_at(), attached_at); + assert_eq!(stamp.progress().completed_step(), 5); +} + +#[test] +fn an_active_boundary_carries_no_world_progress_or_step_authority() { + let timeline = TimelineId::mint(); + let world = WorldInstanceId::mint(); + let local = LocalInstant::from_boot_ns(100); + let attached_at = LiveAttachmentBoundary { + world: WorldProgress::at(4, 12).unwrap(), + execution: RobotInstant::new(timeline, 90), + }; + let boundary = ActiveBoundaryStamp { + local, + instant: RobotInstant::new(timeline, 100), + world, + revision: 7, + attached_at, + }; + assert_eq!(boundary.local_instant(), local); + assert_eq!(boundary.instant(), RobotInstant::new(timeline, 100)); + assert_eq!(boundary.world(), world); + assert_eq!(boundary.revision(), 7); + assert_eq!(boundary.attached_at(), attached_at); +} + +#[test] +fn transition_progress_must_advance_by_one_exact_quantum() { + let previous = WorldProgress::at(4, 12).expect("valid progress"); + validate_next_progress( + previous, + WorldProgress::at(5, 12).expect("the next exact quantum"), + ) + .expect("one exact transition is accepted"); + assert!(matches!( + validate_next_progress( + previous, + WorldProgress::at(6, 12).expect("valid but skipped progress") + ), + Err(SimulatorError::NonMonotonicProgress { + previous: 4, + observed: 6, + }) + )); + let inconsistent: WorldProgress = serde_json::from_value(serde_json::json!({ + "completed_step": 5, + "elapsed_ns": 65, + })) + .expect("the fields imply a positive quantum before session validation"); + assert!(matches!( + validate_next_progress(previous, inconsistent), + Err(SimulatorError::InvalidProgress( + crate::model::world::WorldProgressError::Inconsistent { .. } + )) + )); +} diff --git a/phoxal/src/simulator/mod.rs b/phoxal/src/simulator/mod.rs index 05d14ff2..0166c36a 100644 --- a/phoxal/src/simulator/mod.rs +++ b/phoxal/src/simulator/mod.rs @@ -1,466 +1,55 @@ -//! The external simulator host SDK. +//! Narrow SDK for one per-Robot Live simulator controller. //! -//! A simulator is not a robot participant. It owns a world, stands in for -//! several component-driver identities at once, and follows its own process -//! lifecycle - Webots decides when a step happens, not a Phoxal scheduler. So -//! there is no role attribute, no runner and no `SetupContext` here: there is -//! [`SimulatorSession`], and it is the whole of what the framework hands a -//! world adapter. -//! -//! ```text -//! SimulatorSession::connect one execution, one external bus session -//! .present(participant) stand in for one component driver -//! .sample_publisher(topic) typed component IO, owner side -//! .take_world_time() -> WorldTime, moved to the step thread -//! .close() drop presence, then close the transport -//! ``` -//! -//! # World time is a separate value on purpose -//! -//! Everything above is asynchronous and lives with the adapter's Tokio -//! runtime; the step loop is a synchronous thread that owns the world outright, -//! because every simulator call blocks and must come from the thread that -//! opened the devices. [`WorldTime`] is the part that belongs on that thread - -//! the timeline authority and the world-clock publisher - so it is taken once, -//! moved there, and cannot be taken twice. -//! -//! The order a step commits in is the contract, and [`WorldTime`] is shaped to -//! make it the easy path: the world advances, -//! [`completed_step`](WorldTime::completed_step) mints the one token for that -//! advance, every capability publishes with that token, and -//! [`publish_clock`](WorldTime::publish_clock) closes the step. A reader that -//! has seen a step's clock has already seen that step's outputs. -//! -//! # What it does not hand out -//! -//! No bus owner, no session construction, no timeline authority, no -//! unrestricted delegated presence. Those are how this module does its job, -//! not what it offers: an adapter that held them would be holding framework -//! transport ownership, and the typed handles could then promise nothing. +//! A controller joins one supervised execution, observes its source-bound +//! world attachment, stands in for simulated component drivers, and uses the +//! ordinary typed bus lanes for device IO. It never owns or replaces execution +//! time. Every Live transition is stamped from the execution's existing +//! monotonic timeline, and `simulation/step` is passive progress published +//! after that transition's outputs. + +mod attachment; +mod bootstrap; +mod controller; +mod error; +mod host; +mod io; +mod observation; +mod transition; use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; -use crate::bus::handle::publisher::WorldClockPublisher; -use crate::bus::handle::stamp::TimelineAuthority; use crate::bus::session::{BusConfig, BusOwner}; use crate::bus::{ - BusCloseReport, BusError, BusHandle, Endpoint, ParticipantReadyEvents, ParticipantReadyToken, - Publish, RobotEndpoint, Sample, SamplePublisher, Setpoint, SetpointReceiver, SourceLabel, - SourceLabelError, State, StatePublisher, Subscribe, Topic, WorldStepToken, + BusCloseReport, BusError, BusHandle, DEFAULT_QUERY_TIMEOUT, Endpoint, EventPublisher, + FixedSourceLease, KeyLivelinessToken, LocalInstant, Observed, ParticipantReadyEvents, + ParticipantReadyToken, Publish, Querier, QueryError, ReceiveTerminal, RobotEndpoint, + RobotInstant, Sample, SamplePublisher, Setpoint, SetpointReceiver, SourceLabel, + SourceLabelError, State, StatePublisher, Subscribe, Topic, }; -use crate::identity::{ExecutionId, ParticipantId, TimelineId}; -use crate::runtime::api::simulation::Clock; - -/// A failure while attaching a simulator to one execution, or while operating -/// the session it opened. -#[derive(Debug, thiserror::Error)] -pub enum SimulatorError { - /// No router answered at the configured endpoint. - #[error( - "no Phoxal execution is reachable at {connect}; start the supervisor before the simulation" - )] - NoExecution { connect: String }, - - /// More than one execution answered, so the endpoint did not identify one - /// world to simulate. - #[error( - "{count} Phoxal executions are reachable at {connect}, which must identify exactly one: {executions:?}" - )] - MultipleExecutions { - connect: String, - count: usize, - executions: Vec, - }, - - /// The diagnostic label could not be represented by the framework bus. - #[error(transparent)] - SourceLabel(#[from] SourceLabelError), - - /// The underlying transport failed. - #[error(transparent)] - Bus(#[from] BusError), - - /// [`SimulatorSession::take_world_time`] was called a second time. - #[error("this session's world time has already been taken")] - WorldTimeTaken, -} - -/// The simulator session closed, but a close stage left evidence. -/// -/// The session is gone either way; this is what the transport reported on the -/// way out, so an adapter can surface it rather than exit as if the world had -/// been put away cleanly. -#[derive(Debug, thiserror::Error)] -#[error("the simulator session did not close cleanly: {report}")] -pub struct SimulatorCloseError { - /// The transport's own account of the close. - pub report: BusCloseReport, -} - -/// Inputs for one simulator session against one execution. -#[derive(Clone, Debug)] -pub struct SimulatorConnectOptions { - /// The router endpoint to join. It must identify exactly one execution. - pub connect: String, - /// A bounded diagnostic label this simulator's own traffic carries. It - /// never affects routing, authority, or Ready admission - it only says - /// which external client produced a sample. - pub label: String, -} - -impl SimulatorConnectOptions { - #[must_use] - pub fn new(connect: impl Into, label: impl Into) -> Self { - Self { - connect: connect.into(), - label: label.into(), - } - } -} - -/// One simulator process attached to one execution. -/// -/// It owns the external bus session, the presence it stands in with, and - -/// until it is taken - the world's time. -/// -/// Field order is the teardown order, and it is load-bearing: Rust drops fields -/// in declaration order, so a session that is dropped without [`close`](Self::close) still -/// revokes its delegated Ready leases first, lets go of the world's time -/// second, and releases the transport last. [`close`](Self::close) walks the -/// same order explicitly and returns the transport's close evidence. -pub struct SimulatorSession { - /// One delegated Ready lease per component driver this process stands in - /// for, keyed so a repeated `present` is idempotent rather than a second - /// lease under the same identity. First to go: a reader must never see - /// the drivers present after the world that drove them is gone. - presence: BTreeMap, - /// The world's time, until the adapter takes it. Second to go. - world_time: Option, - bus: BusHandle, - execution: ExecutionId, - /// The transport. Last to go, so every lease and hand above has already - /// been released through it. - owner: Option, -} - -impl std::fmt::Debug for SimulatorSession { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("SimulatorSession") - .field("execution", &self.execution) - .field("presented", &self.presence.len()) - .field("world_time_taken", &self.world_time.is_none()) - .finish_non_exhaustive() - } -} - -impl SimulatorSession { - /// The executions reachable at `connect`. - /// - /// The execution id is never an argument to a simulator: a router's session - /// id *is* the execution, so asking the transport is the only answer that - /// cannot disagree with the run actually in progress. - /// - /// # Errors - /// - /// Returns [`SimulatorError::Bus`] when the endpoint cannot be probed. - pub async fn probe(connect: &str) -> Result, SimulatorError> { - Ok(BusOwner::probe_routers(connect).await?) - } - - /// Join the sole execution reachable at `options.connect`. - /// - /// # Errors - /// - /// Returns [`SimulatorError::NoExecution`] or - /// [`SimulatorError::MultipleExecutions`] when the endpoint does not name - /// exactly one execution, and [`SimulatorError::Bus`] when the session - /// cannot be opened. - pub async fn connect(options: SimulatorConnectOptions) -> Result { - let executions = Self::probe(&options.connect).await?; - let execution = match executions.as_slice() { - [only] => *only, - [] => { - return Err(SimulatorError::NoExecution { - connect: options.connect, - }); - } - many => { - let mut executions = many.to_vec(); - executions.sort_by_key(ToString::to_string); - return Err(SimulatorError::MultipleExecutions { - connect: options.connect, - count: executions.len(), - executions, - }); - } - }; - let label = SourceLabel::new(options.label)?; - Self::open( - BusConfig::for_external(execution, Some(label), vec![options.connect]), - execution, - ) - .await - } - - /// Open a session for a world with no router: an execution minted here, - /// reachable by nothing outside this process. - /// - /// This is the adapter's test seam. A simulator's step loop holds a - /// [`WorldTime`], and the only source of one is a session, so an adapter - /// that proves its own stepping and parking discipline against the real - /// transport needs a session that no supervisor is running. The framework's - /// own ordering and presence proofs run the same way. It is not a second - /// way to attach: there is no router, so nothing else can join. - /// - /// # Errors - /// - /// Returns [`SimulatorError`] when the label is not a valid source label or - /// the in-process transport cannot be opened. - pub async fn in_process(label: &str) -> Result { - let execution = ExecutionId::mint(); - Self::open( - BusConfig::for_external(execution, Some(SourceLabel::new(label)?), Vec::new()), - execution, - ) - .await - } - - async fn open(config: BusConfig, execution: ExecutionId) -> Result { - let (owner, bus) = BusOwner::open(config).await?; - let world_time = match WorldTime::open(&bus) { - Ok(world_time) => world_time, - Err(error) => { - let _ = owner.close().await; - return Err(error); - } - }; - Ok(Self { - owner: Some(owner), - bus, - execution, - presence: BTreeMap::new(), - world_time: Some(world_time), - }) - } - - /// The execution this simulator joined. - #[must_use] - pub fn execution(&self) -> ExecutionId { - self.execution - } - - /// Publish a component capability's measurements on the owner side. - /// - /// The topic comes from the robot api tree's owner side, because a - /// simulator *is* the owner of every capability it stands in for: - /// `api::topics().component(&instance)?.encoder(&capability)?.sample().owner()`. - /// - /// # Errors - /// - /// Returns [`SimulatorError::Bus`] when the publisher cannot be attached. - pub fn sample_publisher( - &self, - topic: Topic>, - ) -> Result, SimulatorError> - where - E: RobotEndpoint + Endpoint, - { - Ok(SamplePublisher::new(self.bus.clone(), &topic)?) - } - - /// Publish a component capability's current state on the owner side. - /// - /// # Errors - /// - /// Returns [`SimulatorError::Bus`] when the publisher cannot be attached. - pub fn state_publisher( - &self, - topic: Topic>, - ) -> Result, SimulatorError> - where - E: RobotEndpoint + Endpoint, - { - Ok(StatePublisher::new(self.bus.clone(), &topic)?) - } - - /// Receive the setpoints the graph sends a component capability this - /// simulator owns. - /// - /// Admission is deliberately not a parameter. The receiver keeps one - /// pending value per producer and the adapter offers that whole set to a - /// [`FixedSourceLease`](crate::bus::FixedSourceLease) it owns, so a rogue - /// producer cannot coalesce the authorised one away before the lease has - /// judged it. Folding the lease in here would decide for the adapter when - /// that judgement happens. - /// - /// # Errors - /// - /// Returns [`SimulatorError::Bus`] when the subscription cannot be - /// declared. - pub async fn setpoint_receiver( - &self, - topic: Topic>, - ) -> Result, SimulatorError> - where - E: RobotEndpoint + Endpoint, - { - Ok(SetpointReceiver::new(&self.bus, &topic).await?) - } - - /// Observe one participant's Ready leases, which is the evidence a - /// fixed-source admission decision stands on. - /// - /// # Errors - /// - /// Returns [`SimulatorError::Bus`] when the observer cannot be declared. - pub async fn participant_ready_events( - &self, - participant: &ParticipantId, - ) -> Result { - Ok(self.bus.participant_ready_events_for(participant).await?) - } - - /// Stand in for one component driver's presence until this session closes. - /// - /// A simulated robot must read as exactly as present as the same robot on - /// hardware, so the adapter declares one lease per component instance that - /// declares a `driver` block - the same set a launcher would start - /// processes for. Declaring the same identity twice is a no-op rather than - /// a second lease. - /// - /// Presence is a promise that the contracts are already served, so call it - /// after the capability handles are bound. - /// - /// # Errors - /// - /// Returns [`SimulatorError::Bus`] when the lease cannot be declared. - pub async fn present(&mut self, participant: &ParticipantId) -> Result<(), SimulatorError> { - if self.presence.contains_key(participant) { - return Ok(()); - } - let Some(owner) = self.owner.as_ref() else { - return Ok(()); - }; - let token = owner.declare_participant_ready_as(participant).await?; - self.presence.insert(participant.clone(), token); - Ok(()) - } - - /// Take this session's world time, once. - /// - /// The returned value is `Send` and is meant to move onto the simulator's - /// own step thread. A world has one hand, so a second call fails rather - /// than handing out a second one. - /// - /// # Errors - /// - /// Returns [`SimulatorError::WorldTimeTaken`] when it has already been - /// taken. - pub fn take_world_time(&mut self) -> Result { - self.world_time.take().ok_or(SimulatorError::WorldTimeTaken) - } - - /// Close deterministically: revoke the delegated Ready leases, drop the - /// world's time, then close the transport and return its evidence. - /// - /// The order is the point. Dropping presence while the wheels were still - /// turning would let a reader believe the drivers are already gone, so the - /// adapter parks its world, joins the thread that held the [`WorldTime`], - /// then calls this. A session that is dropped instead of closed tears down - /// in the same order (see the type's field order), but only `close` can - /// wait for the transport to drain and report what it saw. - /// - /// # Errors - /// - /// Returns [`SimulatorCloseError`] carrying the transport's - /// [`BusCloseReport`] when any close stage left evidence: a transport - /// failure while draining, a worker that did not exit cleanly, or a stage - /// that exceeded its deadline. The session is closed either way. - pub async fn close(mut self) -> Result<(), SimulatorCloseError> { - self.presence.clear(); - self.world_time = None; - let Some(owner) = self.owner.take() else { - return Ok(()); - }; - let report = owner.close().await; - if report.is_clean() { - Ok(()) - } else { - Err(SimulatorCloseError { report }) - } - } -} - -/// The world's own time: the timeline this process owns, and the clock hand it -/// closes each step with. -/// -/// Taken once from a [`SimulatorSession`] and moved to the thread that advances -/// the world. There is no way to make a second one, in this process or any -/// other reachable API: a world has one hand. -pub struct WorldTime { - authority: TimelineAuthority, - clock: WorldClockPublisher, -} - -impl std::fmt::Debug for WorldTime { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("WorldTime") - .field("timeline", &self.authority.timeline()) - .finish_non_exhaustive() - } -} - -impl WorldTime { - fn open(bus: &BusHandle) -> Result { - let authority = TimelineAuthority::mint(TimelineId::mint())?; - let clock = WorldClockPublisher::mint( - bus.clone(), - &crate::runtime::api::topics().simulation().clock().owner(), - )?; - Ok(Self { authority, clock }) - } - - /// Mint the one token for a completed world advance at `time_ns`. - /// - /// Every output of that advance is stamped with this token, and - /// [`publish_clock`](Self::publish_clock) closes the step with it. - pub fn completed_step(&mut self, time_ns: u64) -> WorldStepToken { - self.authority.completed_step(time_ns) - } +use crate::identity::{ExecutionId, ParticipantId, ProducerId}; +use crate::model::world::{LiveAttachmentBoundary, WorldInstanceId, WorldProgress}; +use crate::simulation::api::StepEvent; +use crate::supervisor::api::simulation::attach::{AttachRequest, AttachResponse}; +use crate::supervisor::api::simulation::end::{EndRequest, EndResponse}; +use crate::supervisor::api::simulation::{ + SimulationAttachmentPhase, SimulationAttachmentState, SimulationEndReason, +}; +use crate::supervisor::api::time_domain::{TimeDomain, TimeMode}; - /// Begin a new world history, which is what a rewind or a reset is. - /// - /// Robot instants on the previous timeline are not comparable with the new - /// ones, and every receiver treats the change as the discontinuity it is. - pub fn replace_timeline(&mut self) { - self.authority.replace_timeline(TimelineId::mint()); - } +pub use attachment::SimulationAttachTransaction; +use bootstrap::{LiveBootstrap, open_live_bootstrap}; +pub use controller::{SimulatorConnectOptions, SimulatorSession}; +pub use error::{SimulatorCloseError, SimulatorError}; +pub use host::{SimulationHostConnectOptions, SimulationHostSession}; +pub use io::{LiveSamplePublisher, LiveSetpointReceiver, LiveStatePublisher}; +pub use transition::{ActiveBoundaryStamp, LiveTransitionStamp}; - /// The timeline this world is currently on. - #[must_use] - pub fn timeline(&self) -> TimelineId { - self.authority.timeline() - } +use attachment::{AttachTransactionResponse, validate_attach_response}; +use observation::{install_active_controller_binding, observe_attachment}; +use transition::{admit_step_event, ensure_live_publication, validate_next_progress}; - /// Close a step by publishing the authoritative world clock for it. - /// - /// Publish every output of the step first: a reader that has seen the clock - /// has, by then, already seen everything that step produced. - /// - /// # Errors - /// - /// Returns [`SimulatorError::Bus`] when the clock cannot be admitted to the - /// outbound lane. - pub fn publish_clock( - &mut self, - step: &WorldStepToken, - clock: Clock, - ) -> Result<(), SimulatorError> { - Ok(self.clock.publish(step, clock)?) - } -} +const ATTACHMENT_TRANSITION_CAPACITY: usize = 32; #[cfg(test)] -mod world_session_tests; +mod live_contract_tests; diff --git a/phoxal/src/simulator/observation.rs b/phoxal/src/simulator/observation.rs new file mode 100644 index 00000000..9bb8af5a --- /dev/null +++ b/phoxal/src/simulator/observation.rs @@ -0,0 +1,112 @@ +//! Attachment observation and active-controller binding. + +use super::*; + +pub(super) async fn observe_attachment( + attachment: tokio::sync::watch::Sender>, + transitions: Option>, + attachments: crate::bus::StreamReceiver< + crate::supervisor::api::simulation::attachment::SimulationAttachmentStream, + >, + time_domains: crate::bus::StreamReceiver, + initial_domain: TimeDomain, + fault: Arc>>, + bus: BusHandle, +) { + let controller_bus = transitions.is_none().then_some(&bus); + let result: Result<(), String> = async { + // A retained stream does not close when its router disappears. Observe the + // execution-scoped supervisor identity separately, as ordinary sessions do. + let (lost_tx, mut lost) = tokio::sync::watch::channel(false); + let identity = bus + .observe_liveliness_key( + crate::supervisor::api::connect::PRESENCE_KEY, + move |status| { + if status == crate::bus::LivelinessStatus::Lost { + lost_tx.send_replace(true); + } + }, + ) + .await + .map_err(|error| error.to_string())?; + if identity.initial() == crate::bus::LivelinessStatus::Lost || *lost.borrow() { + return Err("the supervisor identity was lost".to_owned()); + } + let mut transport_check = tokio::time::interval(std::time::Duration::from_millis(250)); + transport_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + _ = transport_check.tick() => { + // Client reconnection can retain remote tokens after a hard router exit. + // Local transport identity must also remain present, independently of physics. + if !bus.execution_router_connected().await.map_err(|error| error.to_string())? { + return Err("the supervisor identity lost its execution router".to_owned()); + } + } + _ = lost.changed() => { + return Err("the supervisor identity was lost".to_owned()); + } + update = attachments.recv() => { + let update = update.map_err(|error| error.to_string())?; + let replacement = update.body.attachment; + let current = *attachment.borrow(); + match (replacement, current) { + (Some(replacement), Some(installed)) + if replacement.revision > installed.revision => + { + if let Some(bus) = &controller_bus { + install_active_controller_binding(bus, Some(replacement)); + } + attachment.send_replace(Some(replacement)); + if let Some(transitions) = &transitions { + let _ = transitions.send(replacement); + } + } + (Some(replacement), None) => { + if let Some(bus) = &controller_bus { + install_active_controller_binding(bus, Some(replacement)); + } + attachment.send_replace(Some(replacement)); + if let Some(transitions) = &transitions { + let _ = transitions.send(replacement); + } + } + // Absence is only the initial empty authority in Live + // v0. Removing remains retained terminal evidence. + (None, _) | (Some(_), Some(_)) => {} + } + } + update = time_domains.recv() => { + let update = update.map_err(|error| error.to_string())?.body.domain; + if update.revision > initial_domain.revision { + return Err(format!( + "the execution time domain changed from revision {} to {} during Live attachment", + initial_domain.revision, + update.revision, + )); + } + } + } + } + } + .await; + if let Err(detail) = result { + if let Some(bus) = &controller_bus { + bus.set_active_simulation_binding(None); + } + *fault + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(detail); + } +} + +pub(super) fn install_active_controller_binding( + bus: &BusHandle, + attachment: Option, +) { + let binding = attachment.and_then(|state| { + (state.phase == SimulationAttachmentPhase::Active) + .then_some((state.controller, state.revision)) + }); + bus.set_active_simulation_binding(binding); +} diff --git a/phoxal/src/simulator/transition.rs b/phoxal/src/simulator/transition.rs new file mode 100644 index 00000000..b232b22e --- /dev/null +++ b/phoxal/src/simulator/transition.rs @@ -0,0 +1,156 @@ +//! Live transition and active-boundary correlation. + +use super::*; + +/// One exact monotonic correlation shared by every output of a native transition. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LiveTransitionStamp { + pub(super) instant: RobotInstant, + pub(super) world: WorldInstanceId, + pub(super) revision: u64, + pub(super) attached_at: LiveAttachmentBoundary, + pub(super) progress: WorldProgress, +} + +/// One current Active attachment boundary for command selection immediately +/// before a native transition. +/// +/// This stamp intentionally carries no [`WorldProgress`] and does not +/// implement [`crate::bus::StepStamp`]. It can filter commands and anchor +/// monotonic lease selection, but it cannot publish simulator output or a +/// [`StepEvent`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ActiveBoundaryStamp { + pub(super) local: LocalInstant, + pub(super) instant: RobotInstant, + pub(super) world: WorldInstanceId, + pub(super) revision: u64, + pub(super) attached_at: LiveAttachmentBoundary, +} + +impl ActiveBoundaryStamp { + /// The execution's current monotonic robot instant. + #[must_use] + pub const fn instant(&self) -> RobotInstant { + self.instant + } + + /// The host-monotonic reading captured for lease selection at the same + /// boundary as [`Self::instant`]. + #[must_use] + pub const fn local_instant(&self) -> LocalInstant { + self.local + } + + #[must_use] + pub const fn world(&self) -> WorldInstanceId { + self.world + } + + #[must_use] + pub const fn revision(&self) -> u64 { + self.revision + } + + #[must_use] + pub const fn attached_at(&self) -> LiveAttachmentBoundary { + self.attached_at + } +} + +/// One source-bound host transaction after its ordered Preparing replacement +impl LiveTransitionStamp { + #[must_use] + pub const fn instant(&self) -> RobotInstant { + self.instant + } + + #[must_use] + pub const fn world(&self) -> WorldInstanceId { + self.world + } + + #[must_use] + pub const fn revision(&self) -> u64 { + self.revision + } + + /// The immutable progress-to-execution correlation captured when the + /// execution joined this world. + #[must_use] + pub const fn attached_at(&self) -> LiveAttachmentBoundary { + self.attached_at + } + + /// The validated world progress completed by this native transition. + #[must_use] + pub const fn progress(&self) -> WorldProgress { + self.progress + } +} + +impl crate::bus::handle::stamp::sealed::Sealed for LiveTransitionStamp {} + +impl crate::bus::StepStamp for LiveTransitionStamp { + fn instant(&self) -> RobotInstant { + self.instant + } +} + +/// A setpoint receiver that exposes only intent produced under the exact +pub(super) fn validate_next_progress( + previous: WorldProgress, + observed: WorldProgress, +) -> Result<(), SimulatorError> { + let expected = + previous + .completed_step() + .checked_add(1) + .ok_or(SimulatorError::NonMonotonicProgress { + previous: previous.completed_step(), + observed: observed.completed_step(), + })?; + if observed.completed_step() != expected || observed.elapsed_ns() <= previous.elapsed_ns() { + return Err(SimulatorError::NonMonotonicProgress { + previous: previous.completed_step(), + observed: observed.completed_step(), + }); + } + let time_step_ns = if previous.completed_step() == 0 { + observed + .elapsed_ns() + .checked_sub(previous.elapsed_ns()) + .ok_or(SimulatorError::NonMonotonicProgress { + previous: previous.completed_step(), + observed: observed.completed_step(), + })? + } else { + let completed = previous.completed_step(); + previous.elapsed_ns() / completed + }; + previous.validate(time_step_ns)?; + observed.validate(time_step_ns)?; + Ok(()) +} +pub(super) fn ensure_live_publication(admitted: bool) -> Result<(), SimulatorError> { + if admitted { + Ok(()) + } else { + Err(SimulatorError::StaleTransition) + } +} + +pub(super) fn admit_step_event( + publisher: &EventPublisher, + bus: &BusHandle, + transition: &LiveTransitionStamp, + event: StepEvent, +) -> Result<(), SimulatorError> { + let admitted = publisher.publish_active_simulation( + bus.producer(), + transition.revision, + transition, + event, + )?; + ensure_live_publication(admitted) +} diff --git a/phoxal/src/simulator/world_session_tests.rs b/phoxal/src/simulator/world_session_tests.rs deleted file mode 100644 index ab21e9cf..00000000 --- a/phoxal/src/simulator/world_session_tests.rs +++ /dev/null @@ -1,279 +0,0 @@ -//! What a simulator session promises, proven over the real in-process -//! transport rather than against a recording of intent. -//! -//! Every test is `#[serial]`: a process holds exactly one timeline authority, -//! so two sessions may not overlap, and each test drops its own before the -//! next one opens. - -use std::time::Duration; - -use serial_test::serial; - -use super::*; -use crate::api; -use crate::bus::{ - CaptureStamp, ParticipantReadyStatus, RobotInstant, SampleReceiver, SetpointPublisher, - StepStamp, StreamReceiver, -}; -use crate::identity::ComponentInstanceId; -use crate::model::identity::CapabilityId; - -const LABEL: &str = "simulator-test"; -const RECEIVE: Duration = Duration::from_secs(2); - -fn component() -> ComponentInstanceId { - ComponentInstanceId::new("left_drive").expect("a valid component instance") -} - -fn capability(id: &str) -> CapabilityId { - CapabilityId::new(id).expect("a valid capability id") -} - -fn driver() -> ParticipantId { - ParticipantId::new("left_drive").expect("a valid participant id") -} - -/// The world-step contract, end to end: one advance mints one token, every -/// output of that advance carries it, and the clock that closes the step -/// enqueues after all of them. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[serial] -async fn a_step_publishes_its_outputs_before_the_clock_that_closes_it() { - let mut session = SimulatorSession::in_process(LABEL) - .await - .expect("the in-process simulator session opens"); - let encoder = session - .sample_publisher( - api::topics() - .component(&component()) - .expect("a concrete component segment") - .encoder(&capability("encoder")) - .expect("a concrete capability segment") - .sample() - .owner(), - ) - .expect("the encoder publisher attaches"); - let samples = SampleReceiver::::new( - &session.bus, - &api::topics() - .component(&component()) - .expect("a concrete component segment") - .encoder(&capability("encoder")) - .expect("a concrete capability segment") - .sample() - .client(), - ) - .await - .expect("the encoder subscriber attaches"); - let clocks = StreamReceiver::::new( - &session.bus, - &crate::runtime::api::topics().simulation().clock().client(), - ) - .await - .expect("the clock subscriber attaches"); - - let mut world = session.take_world_time().expect("world time is available"); - let timeline = world.timeline(); - - let step = world.completed_step(20_000_000); - encoder - .publish( - CaptureStamp::exact(step.instant()), - api::component::encoder::Sample::try_new(1.0, 0.5).expect("a finite sample"), - ) - .expect("the sample is admitted"); - world - .publish_clock(&step, Clock { step: 1 }) - .expect("the clock is admitted"); - - let sample = tokio::time::timeout(RECEIVE, samples.recv()) - .await - .expect("the encoder sample arrives") - .expect("the encoder sample decodes"); - let tick = tokio::time::timeout(RECEIVE, clocks.recv()) - .await - .expect("the clock arrives") - .expect("the clock decodes"); - - let expected = RobotInstant::new(timeline, 20_000_000); - assert_eq!(sample.metadata.produced_exactly_at(), Some(expected)); - assert_eq!(tick.metadata.produced_exactly_at(), Some(expected)); - assert_eq!(tick.body.step, 1); - assert!( - sample.metadata.sequence < tick.metadata.sequence, - "every output of a completed step must enqueue before the clock that closes it" - ); - - drop(world); - session - .close() - .await - .expect("the simulator session closes cleanly"); -} - -/// The graph's setpoints reach the capability this simulator owns, decoded as -/// the endpoint's own body. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[serial] -async fn typed_component_setpoints_reach_the_capability_the_simulator_owns() { - let session = SimulatorSession::in_process(LABEL) - .await - .expect("the in-process simulator session opens"); - let commands = session - .setpoint_receiver( - api::topics() - .component(&component()) - .expect("a concrete component segment") - .motor(&capability("motor")) - .expect("a concrete capability segment") - .command() - .owner(), - ) - .await - .expect("the motor receiver attaches"); - let drive = SetpointPublisher::::new( - session.bus.clone(), - &api::topics() - .component(&component()) - .expect("a concrete component segment") - .motor(&capability("motor")) - .expect("a concrete capability segment") - .command() - .client(), - ) - .expect("the drive publisher attaches"); - - drive - .send(api::component::motor::Command::Velocity(0.25)) - .expect("the command is admitted"); - - let observed = tokio::time::timeout(RECEIVE, async { - loop { - if let Some(observed) = commands.try_recv() { - return observed; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the command arrives"); - assert_eq!( - observed.body, - api::component::motor::Command::Velocity(0.25) - ); - - session - .close() - .await - .expect("the simulator session closes cleanly"); -} - -/// Delegated presence is what makes a simulated robot read as exactly as -/// present as the same robot on hardware: the lease appears under the driver's -/// own participant id, and it goes when the session does. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[serial] -async fn delegated_presence_appears_under_the_driver_identity_and_leaves_with_the_session() { - let mut session = SimulatorSession::in_process(LABEL) - .await - .expect("the in-process simulator session opens"); - let events = session - .participant_ready_events(&driver()) - .await - .expect("the ready observer attaches"); - - session.present(&driver()).await.expect("presence declared"); - session - .present(&driver()) - .await - .expect("a repeated presence is a no-op"); - - let ready = next_status(&events).await; - assert_eq!(ready.0, driver()); - assert_eq!(ready.1, ParticipantReadyStatus::Ready); - - session - .close() - .await - .expect("the simulator session closes cleanly"); - - let lost = next_status(&events).await; - assert_eq!(lost.0, driver()); - assert_eq!(lost.1, ParticipantReadyStatus::Lost); -} - -/// A rewind is a new world history, not a jump backwards inside the old one. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[serial] -async fn replacing_the_timeline_starts_a_new_world_history() { - let mut session = SimulatorSession::in_process(LABEL) - .await - .expect("the in-process simulator session opens"); - let mut world = session.take_world_time().expect("world time is available"); - - let before = world.timeline(); - let first = world.completed_step(20_000_000); - world.replace_timeline(); - let after = world.timeline(); - let second = world.completed_step(20_000_000); - - assert_ne!(before, after, "a rewind mints a new timeline"); - assert_ne!( - first.instant(), - second.instant(), - "the same tick on a new timeline is a different instant" - ); - assert_eq!(second.instant(), RobotInstant::new(after, 20_000_000)); - - drop(world); - session - .close() - .await - .expect("the simulator session closes cleanly"); -} - -/// A world has one hand. Taking it twice is a failure, not a second one, and -/// the session that has handed it out still closes deterministically. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[serial] -async fn world_time_is_taken_exactly_once_and_close_stays_deterministic() { - let mut session = SimulatorSession::in_process(LABEL) - .await - .expect("the in-process simulator session opens"); - let world = session.take_world_time().expect("world time is available"); - assert!(matches!( - session.take_world_time(), - Err(SimulatorError::WorldTimeTaken) - )); - - drop(world); - session - .close() - .await - .expect("the simulator session closes cleanly"); - - // The authority is a per-process singleton, so a session that opens after - // a clean close proves the previous one released everything it held. - let next = SimulatorSession::in_process(LABEL) - .await - .expect("a closed session releases the world authority"); - next.close() - .await - .expect("the second session closes cleanly"); -} - -/// The next Ready change for the observed participant. -async fn next_status( - events: &ParticipantReadyEvents, -) -> (ParticipantId, crate::bus::ParticipantReadyStatus) { - tokio::time::timeout(RECEIVE, async { - loop { - if let Some(event) = events.try_recv() { - return (event.participant().clone(), event.status); - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("a ready change arrives") -} diff --git a/phoxal/src/supervisor/api/bundle.rs b/phoxal/src/supervisor/api/bundle.rs index 9528d348..f599d54b 100644 --- a/phoxal/src/supervisor/api/bundle.rs +++ b/phoxal/src/supervisor/api/bundle.rs @@ -1,9 +1,9 @@ //! Read access to the runtime bundle the supervisor is running. //! //! The supervisor is the only process that knows where the bundle lives, so a -//! client asks it for a path rather than reaching into a filesystem it does not -//! own. What paths resolve, and which of them are refused, is the supervisor's -//! decision; this module owns only the request and the three answers. +//! client asks it for a bounded range rather than reaching into a filesystem it +//! does not own. What paths resolve, and which of them are refused, is the +//! supervisor's decision; this module owns only the request and answers. crate::endpoints! { get: Query; @@ -13,7 +13,11 @@ crate::endpoints! { phoxal_macros::DescribeWire, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, )] pub struct GetRequest { - pub path: String, + /// The normalized bundle-relative target. + pub path: crate::bundle::BundlePath, + /// The first byte requested. The caller advances it by the returned byte + /// count until the supervisor marks the final chunk. + pub offset: u64, } /// A missing entry and a path the supervisor refuses to resolve are distinct @@ -22,7 +26,14 @@ pub struct GetRequest { phoxal_macros::DescribeWire, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, )] pub enum GetResponse { - Found { bytes: Vec }, + /// One supervisor-sized range. A non-final range always makes progress. + Chunk { bytes: Vec, eof: bool }, + /// No regular bundle entry exists at the valid requested path. Missing, + /// The syntactically valid path cannot resolve to an admissible regular + /// file under the canonical bundle root. InvalidPath, + /// The target exists and is admissible, but the supervisor does not expose + /// it to a bundle reader. + Refused, } diff --git a/phoxal/src/supervisor/api/info.rs b/phoxal/src/supervisor/api/info.rs index 96464095..ac7b4340 100644 --- a/phoxal/src/supervisor/api/info.rs +++ b/phoxal/src/supervisor/api/info.rs @@ -1,7 +1,7 @@ //! What robot this supervisor is running. //! -//! The answer is the bundle's own `manifest.json`, handed back exactly as it -//! is on disk: one schema-tagged [`crate::model::manifest::ManifestDocument`]. +//! The response carries the bundle's own `manifest.json`, handed back exactly +//! as it is on disk: one schema-tagged [`crate::model::manifest::ManifestDocument`]. //! A client that needs the robot identity, the mounted components or a //! runtime's configuration therefore reads the same document every participant //! reads, instead of a second projection of it that could disagree. @@ -12,16 +12,27 @@ //! document. crate::endpoints! { - self: Query; + self: Query; } /// Ask which robot this supervisor is running. There is nothing to select: a /// supervisor is handed one bundle root and never reopens it. #[derive( - phoxal_macros::DescribeWire, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, + phoxal_macros::DescribeWire, Clone, Debug, serde::Serialize, serde::Deserialize, )] #[serde(deny_unknown_fields)] pub struct InfoRequest {} -/// The bundle's manifest document, byte-identical to `manifest.json`. -pub type InfoReply = crate::model::manifest::ManifestDocument; +/// The static execution description the supervisor opened. +/// +/// Dynamic process and time state deliberately stay on their own contracts, so +/// every attachment reads one immutable model before it starts role-specific +/// initialization. +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, serde::Serialize, serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct InfoResponse { + /// The bundle manifest, exactly as the supervisor parsed it at startup. + pub manifest: crate::model::manifest::ManifestDocument, +} diff --git a/phoxal/src/supervisor/api/mod.rs b/phoxal/src/supervisor/api/mod.rs index 13d666bb..29e274fa 100644 --- a/phoxal/src/supervisor/api/mod.rs +++ b/phoxal/src/supervisor/api/mod.rs @@ -17,7 +17,9 @@ crate::nodes! { info; logs; snapshot; + simulation; telemetry; + time_domain; } /// The supervisor's execution projection. diff --git a/phoxal/src/supervisor/api/simulation/attach.rs b/phoxal/src/supervisor/api/simulation/attach.rs new file mode 100644 index 00000000..730834d8 --- /dev/null +++ b/phoxal/src/supervisor/api/simulation/attach.rs @@ -0,0 +1,97 @@ +//! Begin one source-bound Live attachment transaction. + +crate::endpoints! { + self: Query; +} + +use super::{SimulationAttachmentState, WorldInstanceId, WorldProgress}; +use crate::identity::ProducerId; +use crate::supervisor::api::time_domain::TimeDomain; + +/// The host proposal for one already prepared per-Robot controller. +#[derive( + phoxal_macros::DescribeWire, Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct AttachRequest { + world: WorldInstanceId, + controller: ProducerId, + progress: WorldProgress, +} + +impl AttachRequest { + /// Build a host-attributed request only after validating the boundary + /// against the session's immutable physics quantum. + /// + /// # Errors + /// + /// Returns [`crate::model::world::WorldProgressError`] when the completed + /// step and elapsed duration do not describe the same world boundary. + pub fn validated( + world: WorldInstanceId, + controller: ProducerId, + progress: WorldProgress, + time_step_ns: u64, + ) -> Result { + progress.validate(time_step_ns)?; + Ok(Self { + world, + controller, + progress, + }) + } + + /// The independently hosted world this execution will join. + #[must_use] + pub const fn world(self) -> WorldInstanceId { + self.world + } + + /// The exact external producer delegated to simulate this Robot. + #[must_use] + pub const fn controller(self) -> ProducerId { + self.controller + } + + /// The validated world boundary captured by the host. + #[must_use] + pub const fn progress(self) -> WorldProgress { + self.progress + } +} + +/// The committed Active binding and unchanged monotonic execution domain. +#[derive( + phoxal_macros::DescribeWire, Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct AttachResponse { + pub attachment: SimulationAttachmentState, + pub time_domain: TimeDomain, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn producer() -> ProducerId { + ProducerId::try_from((1_u128 << 124) | 7).expect("a canonical producer") + } + + #[test] + fn malformed_progress_cannot_be_decoded_into_an_attach_request() { + let request = serde_json::from_value::(serde_json::json!({ + "world": WorldInstanceId::mint(), + "controller": producer(), + "progress": { + "completed_step": 3, + "elapsed_ns": 35, + } + })); + + assert!(matches!( + request, + Err(error) if error.to_string().contains("positive integral physics quantum") + )); + } +} diff --git a/phoxal/src/supervisor/api/simulation/attachment.rs b/phoxal/src/supervisor/api/simulation/attachment.rs new file mode 100644 index 00000000..99704964 --- /dev/null +++ b/phoxal/src/supervisor/api/simulation/attachment.rs @@ -0,0 +1,34 @@ +//! Ordered attachment state plus the race-closing current query. + +crate::endpoints! { + self: Stream; + current: Query; +} + +use super::SimulationAttachmentState; + +/// One complete replacement of the execution's attachment state. +#[derive( + phoxal_macros::DescribeWire, Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct SimulationAttachmentStream { + /// The current attachment, or `None` after a completed removal. + pub attachment: Option, +} + +/// Ask for the current attachment after subscribing to the ordered stream. +#[derive( + phoxal_macros::DescribeWire, Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct CurrentRequest {} + +/// The current complete attachment state. +#[derive( + phoxal_macros::DescribeWire, Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct CurrentResponse { + pub attachment: Option, +} diff --git a/phoxal/src/supervisor/api/simulation/end.rs b/phoxal/src/supervisor/api/simulation/end.rs new file mode 100644 index 00000000..21b62279 --- /dev/null +++ b/phoxal/src/supervisor/api/simulation/end.rs @@ -0,0 +1,25 @@ +//! End the attachment from its source-bound world host. + +crate::endpoints! { + self: Query; +} + +use super::{SimulationAttachmentState, SimulationEndReason}; + +/// One typed terminal outcome reported by the bound host. +#[derive( + phoxal_macros::DescribeWire, Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct EndRequest { + pub reason: SimulationEndReason, +} + +/// The Removing state accepted from the bound host. +#[derive( + phoxal_macros::DescribeWire, Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct EndResponse { + pub attachment: SimulationAttachmentState, +} diff --git a/phoxal/src/supervisor/api/simulation/mod.rs b/phoxal/src/supervisor/api/simulation/mod.rs new file mode 100644 index 00000000..32460807 --- /dev/null +++ b/phoxal/src/supervisor/api/simulation/mod.rs @@ -0,0 +1,139 @@ +//! Supervisor-owned Live simulation attachment state and control. +//! +//! Attachment is execution-local state. It correlates one independently +//! progressing world with the execution's unchanged monotonic time domain and +//! binds both the host transaction and the controller producer that may emit +//! simulator data. + +crate::nodes! { + attachment; + attach; + end; +} + +pub use crate::model::world::{LiveAttachmentBoundary, WorldInstanceId, WorldProgress}; + +use crate::identity::ProducerId; + +/// The phase of the serialized attachment transaction. +#[derive( + phoxal_macros::DescribeWire, + Clone, + Copy, + Debug, + Eq, + PartialEq, + serde::Serialize, + serde::Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum SimulationAttachmentPhase { + /// The supervisor has bound the proposed sources, but simulator traffic is + /// not yet admissible. + Preparing, + /// The controller may publish outputs and receive revision-bound commands. + Active, + /// The attachment is being removed and no simulator traffic is admissible. + Removing, +} + +/// The complete execution-local binding to one Live world. +#[derive( + phoxal_macros::DescribeWire, + Clone, + Copy, + Debug, + Eq, + PartialEq, + serde::Serialize, + serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct SimulationAttachmentState { + /// Strictly increasing attachment-state revision within this execution. + pub revision: u64, + /// The independently hosted world session. + pub world: WorldInstanceId, + /// The exact external producer that owns this transaction. + pub host: ProducerId, + /// The exact per-Robot controller producer admitted for simulator data. + pub controller: ProducerId, + /// The current serialized transaction phase. + pub phase: SimulationAttachmentPhase, + /// The immutable world-progress to monotonic-execution correlation captured + /// at attachment. + pub attached_at: LiveAttachmentBoundary, +} + +impl SimulationAttachmentState { + /// The revision that setpoint metadata must carry while this attachment is + /// active. Preparing and Removing deliberately admit no revision. + #[must_use] + pub const fn active_revision(self) -> Option { + match self.phase { + SimulationAttachmentPhase::Active => Some(self.revision), + SimulationAttachmentPhase::Preparing | SimulationAttachmentPhase::Removing => None, + } + } +} + +/// Why a world host ended one execution's simulation attachment. +#[derive( + phoxal_macros::DescribeWire, + Clone, + Copy, + Debug, + Eq, + PartialEq, + serde::Serialize, + serde::Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum SimulationEndReason { + WorldStopped, + HostLost, + SimulatorLost, + WorldControllerLost, + ControllerLost, + MutationFailed, + RemovalFailed, + UnsupportedNativeMode, + InvalidProgress, + ProtocolViolation, +} + +#[allow( + dead_code, + reason = "attachment lease keys are consumed only by simulator and supervisor profiles" +)] +pub(crate) fn preparation_liveliness_key(revision: u64, controller: ProducerId) -> String { + format!("simulation/attachment/prepared/{revision}/{controller}") +} + +#[allow( + dead_code, + reason = "attachment lease keys are consumed only by simulator and supervisor profiles" +)] +pub(crate) fn host_liveliness_key(host: ProducerId) -> String { + format!("simulation/attachment/host/{host}") +} + +#[allow( + dead_code, + reason = "attachment lease keys are consumed only by simulator and supervisor profiles" +)] +pub(crate) fn transaction_liveliness_key( + world: WorldInstanceId, + host: ProducerId, + controller: ProducerId, +) -> String { + format!("simulation/attachment/transaction/{world}/{host}/{controller}") +} + +#[allow( + dead_code, + reason = "attachment lease keys are consumed only by simulator and supervisor profiles" +)] +pub(crate) fn removal_liveliness_key(revision: u64, host: ProducerId) -> String { + format!("simulation/attachment/removed/{revision}/{host}") +} diff --git a/phoxal/src/supervisor/api/time_domain.rs b/phoxal/src/supervisor/api/time_domain.rs new file mode 100644 index 00000000..8203be0c --- /dev/null +++ b/phoxal/src/supervisor/api/time_domain.rs @@ -0,0 +1,81 @@ +//! The supervisor-owned execution time domain. +//! +//! A stream carries ordered replacements and `current` closes the subscribe +//! race. `revision` orders supervisor state updates, while `timeline` is an +//! opaque identity for one history and has no ordering relation to another. + +crate::endpoints! { + self: Stream; + current: Query; +} + +use crate::identity::TimelineId; + +/// The cadence source an execution currently authorizes for services and the +/// brain. +#[derive( + phoxal_macros::DescribeWire, + Clone, + Copy, + Debug, + Eq, + PartialEq, + serde::Serialize, + serde::Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum TimeMode { + /// Services and the brain schedule from the host-local monotonic clock. + Monotonic, + /// Services and the brain advance from an external logical-time source. + /// Live does not select this dormant mode. + Simulated, +} + +/// The supervisor's complete current scheduling authority. +#[derive( + phoxal_macros::DescribeWire, + Clone, + Copy, + Debug, + Eq, + PartialEq, + serde::Serialize, + serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct TimeDomain { + /// Strictly increasing supervisor state revision within one execution. + pub revision: u64, + /// The opaque history newly active at this revision. + pub timeline: TimelineId, + /// The scheduling source the active history uses. + pub mode: TimeMode, +} + +/// Ask for the current complete domain after subscribing to its update stream. +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct CurrentRequest {} + +/// The complete value returned by [`CurrentRequest`]. +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct CurrentResponse { + /// The current supervisor-owned scheduling authority. + pub domain: TimeDomain, +} + +/// A complete replacement published on the ordered domain stream. +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct TimeDomainStream { + /// The replacement scheduling authority. + pub domain: TimeDomain, +} diff --git a/phoxal/src/supervisor/host/mod.rs b/phoxal/src/supervisor/host/mod.rs index 4bd67857..59e56263 100644 --- a/phoxal/src/supervisor/host/mod.rs +++ b/phoxal/src/supervisor/host/mod.rs @@ -84,7 +84,7 @@ pub async fn run(requested_root: &Path) -> Result<()> { "phoxal-supervisor starting" ); - let state = ExecutionState::new(Presence::for_robot(runtime.robot())); + let state = ExecutionState::new(Presence::for_robot(runtime.robot()))?; let shutdown = CancellationToken::new(); // Installed before the router is opened, so a signal arriving mid-startup diff --git a/phoxal/src/supervisor/host/presence.rs b/phoxal/src/supervisor/host/presence.rs index e9df115e..41fbe605 100644 --- a/phoxal/src/supervisor/host/presence.rs +++ b/phoxal/src/supervisor/host/presence.rs @@ -152,6 +152,26 @@ impl Presence { .map(|(participant, row)| row.project(participant)) .collect() } + + /// Whether one controller has exclusively replaced every declared driver + /// while every service and the brain retain their own Ready producer. + pub(crate) fn admits_live_controller(&self, controller: ProducerId) -> bool { + self.rows.values().all(|row| match row.kind { + ParticipantKind::Driver => row.producers.as_slice() == [controller], + ParticipantKind::Brain | ParticipantKind::Service => { + !row.producers.is_empty() + && row.producers.iter().all(|producer| *producer != controller) + } + }) + } + + /// Whether any expected runtime still retains a Ready lease from this + /// concrete producer. + pub(crate) fn contains_producer(&self, producer: ProducerId) -> bool { + self.rows + .values() + .any(|row| row.producers.contains(&producer)) + } } /// The one mandatory runtime every robot has: its composition root, staged as @@ -181,9 +201,10 @@ mod tests { .component_type("motor", |motor| motor.motor("spin", "axle")) .component_with("left", "motor", |mounted| { mounted.driver( - crate::model::connection::Connection::Can( - crate::model::connection::Can { bus: 0, node_id: 1 }, - ), + crate::model::connection::Connection::Can(crate::model::connection::Can { + bus: 0, + node_id: 1, + }), None, ) }) @@ -274,6 +295,21 @@ mod tests { assert_eq!(row.state, ProcessState::Present); } + #[test] + fn live_admission_requires_one_controller_for_drivers_and_ready_non_drivers() { + let mut presence = presence(); + let controller = producer(9); + presence.record(&participant("brain"), producer(1), true); + presence.record(&participant("drive"), producer(2), true); + assert!(!presence.admits_live_controller(controller)); + + presence.record(&participant("left"), controller, true); + assert!(presence.admits_live_controller(controller)); + + presence.record(&participant("left"), producer(8), true); + assert!(!presence.admits_live_controller(controller)); + } + /// A participant the manifest never mentions is free to run; it simply has /// no row, and it can neither complete nor degrade the expected graph. #[test] diff --git a/phoxal/src/supervisor/host/serve/bootstrap.rs b/phoxal/src/supervisor/host/serve/bootstrap.rs new file mode 100644 index 00000000..8ade1d90 --- /dev/null +++ b/phoxal/src/supervisor/host/serve/bootstrap.rs @@ -0,0 +1,51 @@ +use super::*; + +/// The frozen attachment bootstrap. +/// +/// It answers with this supervisor's framework train and nothing else, and it is +/// declared alongside every other endpoint so a client that disagrees learns +/// that from the first thing it asks rather than from a decode failure. The +/// robot this supervisor runs is not here: a client asks `supervisor/info` for +/// it once the two trains have agreed, which keeps this document exactly what +/// every framework line can decode. +pub(super) async fn serve_connect(bus: BusHandle) -> Result<()> { + let server = declare(&bus, &supervisor::topics().connect().owner()).await?; + loop { + let incoming = server.recv().await?; + let ConnectRequest::V0 {} = match decode(&incoming).await? { + Some(request) => request, + None => continue, + }; + reply(&incoming, &bus, &connect_reply()).await?; + } +} +pub(super) fn connect_reply() -> ConnectReply { + ConnectReply::V0 { + framework: FrameworkVersion::CURRENT, + } +} + +/// Which robot this supervisor is running. +/// +/// The answer is the manifest document the supervisor opened, so a client +/// reads exactly what every participant of this execution reads instead of a +/// projection that could disagree with it. The supervisor holds one bundle for +/// the life of the process, so the reply never changes. +pub(super) async fn serve_info(bus: BusHandle, manifest: ManifestDocument) -> Result<()> { + let server = declare(&bus, &supervisor::topics().info().owner()).await?; + loop { + let incoming = server.recv().await?; + let supervisor::info::InfoRequest {} = match decode(&incoming).await? { + Some(request) => request, + None => continue, + }; + reply( + &incoming, + &bus, + &supervisor::info::InfoResponse { + manifest: manifest.clone(), + }, + ) + .await?; + } +} diff --git a/phoxal/src/supervisor/host/serve/bundle.rs b/phoxal/src/supervisor/host/serve/bundle.rs new file mode 100644 index 00000000..9ce3b362 --- /dev/null +++ b/phoxal/src/supervisor/host/serve/bundle.rs @@ -0,0 +1,173 @@ +use super::*; + +pub(super) async fn serve_bundle(bus: BusHandle, root: PathBuf) -> Result<()> { + let server = declare(&bus, &supervisor::topics().bundle().get().owner()).await?; + loop { + let incoming = server.recv().await?; + let request: supervisor::bundle::GetRequest = match decode(&incoming).await? { + Some(request) => request, + None => continue, + }; + let entry_root = root.clone(); + let response = tokio::task::spawn_blocking(move || bundle_entry(&entry_root, &request)) + .await + .context("the supervisor bundle reader worker stopped")?; + reply(&incoming, &bus, &response).await?; + } +} + +/// Resolve one requested path against the bundle root. +/// +/// The path has already passed the wire `BundlePath` parser, but a normalized +/// spelling can still escape through a symlink. Both sides are canonicalized, +/// and only regular files under the canonical root are eligible. The static +/// model and staged executables have dedicated contracts, so only immutable +/// assets are exposed through this reader. +pub(super) fn bundle_entry( + root: &Path, + request: &supervisor::bundle::GetRequest, +) -> supervisor::bundle::GetResponse { + let canonical_root = match root.canonicalize() { + Ok(root) => root, + Err(_) => return supervisor::bundle::GetResponse::Refused, + }; + let candidate = canonical_root.join(request.path.as_str().split('/').collect::()); + let resolved = match candidate.canonicalize() { + Ok(path) => path, + Err(error) => { + return classify_bundle_candidate_error(&canonical_root, request, &error); + } + }; + if !resolved.starts_with(&canonical_root) { + return supervisor::bundle::GetResponse::InvalidPath; + } + if !resolved.is_file() { + return supervisor::bundle::GetResponse::InvalidPath; + } + if !is_served_bundle_asset(&request.path) { + return supervisor::bundle::GetResponse::Refused; + } + read_chunk(&resolved, request.offset) +} + +/// Classify failure to resolve the requested entry without treating an existing +/// dangling path as if the bundle did not contain it. +fn classify_bundle_candidate_error( + root: &Path, + request: &supervisor::bundle::GetRequest, + error: &std::io::Error, +) -> supervisor::bundle::GetResponse { + match error.kind() { + std::io::ErrorKind::NotFound => match requested_path_status(root, request) { + Ok(RequestedPathStatus::Invalid) => supervisor::bundle::GetResponse::InvalidPath, + Ok(RequestedPathStatus::Missing) => supervisor::bundle::GetResponse::Missing, + Err(_) => supervisor::bundle::GetResponse::Refused, + }, + std::io::ErrorKind::NotADirectory => supervisor::bundle::GetResponse::InvalidPath, + _ if is_symlink_loop(error) => supervisor::bundle::GetResponse::InvalidPath, + _ => supervisor::bundle::GetResponse::Refused, + } +} + +/// How a requested path failed to resolve below an otherwise canonical root. +enum RequestedPathStatus { + /// A normal component is absent from the bundle. + Missing, + /// An existing link cannot produce an admissible path under the bundle root. + Invalid, +} + +/// Inspect every existing component to distinguish absence from a broken or +/// escaping symlink before a later component produces `NotFound`. +fn requested_path_status( + root: &Path, + request: &supervisor::bundle::GetRequest, +) -> std::io::Result { + let mut candidate = root.to_path_buf(); + for segment in request.path.as_str().split('/') { + candidate.push(segment); + match std::fs::symlink_metadata(&candidate) { + Ok(metadata) if metadata.file_type().is_symlink() => match candidate.canonicalize() { + Ok(resolved) if resolved.starts_with(root) => {} + Ok(_) => return Ok(RequestedPathStatus::Invalid), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) || is_symlink_loop(&error) => + { + return Ok(RequestedPathStatus::Invalid); + } + Err(error) => return Err(error), + }, + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(RequestedPathStatus::Missing); + } + Err(error) => return Err(error), + } + } + Ok(RequestedPathStatus::Invalid) +} + +/// `ErrorKind::FilesystemLoop` is still unstable, so Unix loop evidence stays +/// at the portable OS-error boundary until that standard-library variant is +/// available. +fn is_symlink_loop(error: &std::io::Error) -> bool { + #[cfg(unix)] + { + error.raw_os_error() == Some(libc::ELOOP) + } + #[cfg(not(unix))] + { + let _ = error; + false + } +} + +/// Preserve the distinction between an absent entry, an invalid resolved path, +/// and a bundle entry the supervisor could not serve. +pub(super) fn classify_bundle_path_error( + error: &std::io::Error, +) -> supervisor::bundle::GetResponse { + match error.kind() { + std::io::ErrorKind::NotFound => supervisor::bundle::GetResponse::Missing, + std::io::ErrorKind::NotADirectory => supervisor::bundle::GetResponse::InvalidPath, + _ => supervisor::bundle::GetResponse::Refused, + } +} + +fn is_served_bundle_asset(path: &BundlePath) -> bool { + path.as_str().starts_with("assets/") +} + +fn read_chunk(path: &Path, offset: u64) -> supervisor::bundle::GetResponse { + let mut file = match std::fs::File::open(path) { + Ok(file) => file, + Err(error) => return classify_bundle_path_error(&error), + }; + let length = match file.metadata().map(|metadata| metadata.len()) { + Ok(length) => length, + Err(_) => return supervisor::bundle::GetResponse::Refused, + }; + if offset >= length { + return supervisor::bundle::GetResponse::Chunk { + bytes: Vec::new(), + eof: true, + }; + } + if file.seek(SeekFrom::Start(offset)).is_err() { + return supervisor::bundle::GetResponse::Refused; + } + let remaining = usize::try_from(length.saturating_sub(offset)).unwrap_or(usize::MAX); + let mut bytes = vec![0; remaining.min(MAX_BUNDLE_CHUNK_BYTES)]; + let read = match file.read(&mut bytes) { + Ok(read) => read, + Err(_) => return supervisor::bundle::GetResponse::Refused, + }; + bytes.truncate(read); + supervisor::bundle::GetResponse::Chunk { + eof: read == 0 || offset.saturating_add(read as u64) >= length, + bytes, + } +} diff --git a/phoxal/src/supervisor/host/serve/commands.rs b/phoxal/src/supervisor/host/serve/commands.rs new file mode 100644 index 00000000..85f02218 --- /dev/null +++ b/phoxal/src/supervisor/host/serve/commands.rs @@ -0,0 +1,63 @@ +use super::*; + +pub(super) async fn serve_commands(bus: BusHandle, state: ExecutionState) -> Result<()> { + let server = declare(&bus, &supervisor::topics().command().owner()).await?; + loop { + let incoming = server.recv().await?; + let request: supervisor::command::Request = match decode(&incoming).await? { + Some(request) => request, + None => continue, + }; + let supervisor::command::Request::V0 { command: request } = request; + let (outcome, action) = command(&state, request); + // Acceptance reaches the client before the host is asked to go down; + // reversing these turns an accepted reboot into an ambiguous + // no-responder failure at the caller. + reply(&incoming, &bus, &supervisor::command::Reply::V0 { outcome }).await?; + action.request().await; + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum HostAction { + Reboot, + Poweroff, +} + +impl HostAction { + async fn request(self) { + let name = match self { + Self::Reboot => "reboot", + Self::Poweroff => "power-off", + }; + let result = tokio::task::spawn_blocking(move || match self { + Self::Reboot => system_shutdown::reboot(), + Self::Poweroff => system_shutdown::shutdown(), + }) + .await; + match result { + Ok(Ok(())) => {} + Ok(Err(error)) => tracing::error!(action = name, %error, "host action failed"), + Err(error) => tracing::error!(action = name, %error, "host action task failed"), + } + } +} + +/// Accept one host request, and say which execution revision it was accepted +/// at. +/// +/// The revision is evidence, not a fence: whether cycling this machine's power +/// is safe is the operator's judgment about the machine, and how many times a +/// Ready lease has moved since they last looked says nothing about it. +pub(super) fn command(state: &ExecutionState, command: Command) -> (CommandOutcome, HostAction) { + let action = match command { + Command::Reboot => HostAction::Reboot, + Command::Poweroff => HostAction::Poweroff, + }; + ( + CommandOutcome::Accepted { + at_revision: state.snapshot().revision, + }, + action, + ) +} diff --git a/phoxal/src/supervisor/host/serve/endpoint_contract_tests.rs b/phoxal/src/supervisor/host/serve/endpoint_contract_tests.rs index ad210f8b..8c64c9bc 100644 --- a/phoxal/src/supervisor/host/serve/endpoint_contract_tests.rs +++ b/phoxal/src/supervisor/host/serve/endpoint_contract_tests.rs @@ -8,11 +8,12 @@ use std::fmt::Debug; use std::fs; +use crate::bundle::BundlePath; use crate::bus::{ - Codec, Endpoint, EndpointKind, EndpointSemantics, MessagePack, Publish, QueryEndpoint, - ServeQuery, Topic, + BusConfig, BusOwner, Codec, DEFAULT_QUERY_TIMEOUT, Endpoint, EndpointKind, EndpointSemantics, + MessagePack, Publish, Querier, QueryEndpoint, QueryError, ServeQuery, SourceLabel, Topic, }; -use crate::identity::{ParticipantId, ProducerId}; +use crate::identity::{ExecutionId, ParticipantId, ProducerId}; use crate::model::builder::RobotBuilder; use crate::model::manifest::ManifestDocument; use crate::runtime::api as runtime; @@ -21,7 +22,10 @@ use crate::supervisor::api::command::{Command, CommandOutcome}; use crate::supervisor::api::connect::{ConnectReply, ConnectRequest}; use crate::version::FrameworkVersion; -use super::{HostAction, bundle_entry, command, connect_reply}; +use super::{ + HostAction, MAX_BUNDLE_CHUNK_BYTES, bundle_entry, classify_bundle_path_error, command, + connect_reply, finish_clean_simulation_removal, serve_simulation_end, +}; use crate::supervisor::host::presence::Presence; use crate::supervisor::host::state::ExecutionState; @@ -69,6 +73,19 @@ fn the_supervisor_boundary_is_pinned_to_its_rendered_keys() { snapshot, ); + let domain = state.time_domain(); + assert_stream_round_trip( + &api.time_domain().owner(), + "supervisor/time_domain", + supervisor::time_domain::TimeDomainStream { domain }, + ); + assert_query_round_trip( + &api.time_domain().current().owner(), + "supervisor/time_domain/current", + supervisor::time_domain::CurrentRequest {}, + supervisor::time_domain::CurrentResponse { domain }, + ); + assert_query_round_trip( &api.logs().snapshot().owner(), "supervisor/logs/snapshot", @@ -120,73 +137,132 @@ fn the_supervisor_boundary_is_pinned_to_its_rendered_keys() { &api.bundle().get().owner(), "supervisor/bundle/get", supervisor::bundle::GetRequest { - path: "assets/map.bin".to_owned(), + path: bundle_path("assets/map.bin"), + offset: 0, }, - supervisor::bundle::GetResponse::Found { + supervisor::bundle::GetResponse::Chunk { bytes: vec![1, 2, 3], + eof: true, }, ); } -/// The identity endpoint answers with the bundle's own manifest document, so a -/// client decodes the same document every participant of this execution reads. +/// The identity endpoint wraps the immutable manifest and nothing dynamic, so +/// every participant decodes the same static document. #[test] -fn the_info_reply_is_the_manifest_document_itself() { - fn reply_is_the_manifest_document( - reply: ::Response, - ) -> ManifestDocument { - reply - } - +fn the_info_reply_contains_the_manifest_document() { let manifest = ManifestDocument::new( RobotBuilder::new("rover") .service("drive", None) .build() .expect("fixture robot"), ); - let encoded = MessagePack::encode(&manifest).expect("the manifest encodes"); + let reply = supervisor::info::InfoResponse { manifest }; + let encoded = MessagePack::encode(&reply).expect("the execution info encodes"); let decoded = MessagePack::decode::<::Response>(&encoded) .expect("the manifest decodes"); - let decoded = reply_is_the_manifest_document(decoded); - assert_eq!(decoded.robot().id().as_str(), "rover"); + assert_eq!(decoded.manifest.robot().id().as_str(), "rover"); assert_eq!( MessagePack::encode(&decoded).expect("the decoded manifest encodes"), encoded, - "the reply is the document, not a projection of it" + "the reply preserves the static execution document" ); } #[test] -fn bundle_entry_serves_only_plain_relative_files() { +fn bundle_entry_serves_bounded_asset_ranges() { let root = tempfile::tempdir().expect("temporary bundle root"); fs::write(root.path().join("manifest.json"), b"manifest").expect("manifest fixture"); fs::create_dir(root.path().join("assets")).expect("asset directory"); fs::write(root.path().join("assets/map.bin"), b"map").expect("asset fixture"); assert_eq!( - bundle_entry(root.path(), "manifest.json"), - supervisor::bundle::GetResponse::Found { - bytes: b"manifest".to_vec(), - } + bundle_entry(root.path(), &request("manifest.json", 0)), + supervisor::bundle::GetResponse::Refused ); assert_eq!( - bundle_entry(root.path(), "assets/map.bin"), - supervisor::bundle::GetResponse::Found { + bundle_entry(root.path(), &request("assets/map.bin", 0)), + supervisor::bundle::GetResponse::Chunk { bytes: b"map".to_vec(), + eof: true, } ); assert_eq!( - bundle_entry(root.path(), "assets/missing.bin"), + bundle_entry(root.path(), &request("assets/missing.bin", 0)), supervisor::bundle::GetResponse::Missing ); - for refused in ["", "../outside", "/etc/passwd", "assets/../manifest.json"] { - assert_eq!( - bundle_entry(root.path(), refused), - supervisor::bundle::GetResponse::InvalidPath, - "{refused:?}" - ); - } + assert_eq!( + bundle_entry(root.path(), &request("assets/map.bin", 3)), + supervisor::bundle::GetResponse::Chunk { + bytes: Vec::new(), + eof: true, + } + ); +} + +/// The supervisor, rather than each caller, sets the largest reply. A caller +/// advances its requested offset by the bytes it received and therefore never +/// needs the size as a protocol field. +#[test] +fn bundle_entry_splits_a_large_asset_at_the_fixed_supervisor_bound() { + let root = tempfile::tempdir().expect("temporary bundle root"); + fs::create_dir(root.path().join("assets")).expect("asset directory"); + let bytes = vec![9_u8; MAX_BUNDLE_CHUNK_BYTES + 1]; + fs::write(root.path().join("assets/large.bin"), bytes).expect("large asset fixture"); + + let first = bundle_entry(root.path(), &request("assets/large.bin", 0)); + assert!(matches!( + first, + supervisor::bundle::GetResponse::Chunk { + ref bytes, + eof: false, + } if bytes.len() == MAX_BUNDLE_CHUNK_BYTES + )); + assert_eq!( + bundle_entry( + root.path(), + &request("assets/large.bin", MAX_BUNDLE_CHUNK_BYTES as u64) + ), + supervisor::bundle::GetResponse::Chunk { + bytes: vec![9], + eof: true, + } + ); +} + +/// A decoded request can name an existing directory or fail to resolve through +/// an existing non-directory. Neither is a missing asset, and an unreadable +/// entry must not be reported as absent. +#[test] +fn bundle_entry_classifies_invalid_and_unservable_paths_without_hiding_them_as_missing() { + let root = tempfile::tempdir().expect("temporary bundle root"); + fs::create_dir(root.path().join("assets")).expect("asset directory"); + fs::create_dir(root.path().join("assets/maps")).expect("nested asset directory"); + fs::write(root.path().join("assets/map.bin"), b"map").expect("asset fixture"); + + assert_eq!( + bundle_entry(root.path(), &request("assets/maps", 0)), + supervisor::bundle::GetResponse::InvalidPath + ); + assert_eq!( + bundle_entry(root.path(), &request("assets/map.bin/child", 0)), + supervisor::bundle::GetResponse::InvalidPath + ); + assert_eq!( + classify_bundle_path_error(&std::io::Error::from(std::io::ErrorKind::PermissionDenied)), + supervisor::bundle::GetResponse::Refused + ); + assert_eq!( + classify_bundle_path_error(&std::io::Error::from(std::io::ErrorKind::NotFound)), + supervisor::bundle::GetResponse::Missing + ); + + let unavailable_root = root.path().join("unavailable"); + assert_eq!( + bundle_entry(&unavailable_root, &request("assets/map.bin", 0)), + supervisor::bundle::GetResponse::Refused + ); } /// A path can spell nothing but plain names and still leave the bundle, if @@ -209,21 +285,71 @@ fn bundle_entry_refuses_an_entry_that_resolves_outside_the_bundle() { root.path().join("elsewhere"), ) .expect("a symlinked directory out of the bundle"); + fs::create_dir(root.path().join("assets")).expect("asset directory"); + std::os::unix::fs::symlink( + root.path().join("assets/missing.bin"), + root.path().join("assets/dangling.bin"), + ) + .expect("a dangling asset symlink"); + std::os::unix::fs::symlink( + root.path().join("assets/missing-directory"), + root.path().join("assets/dangling-directory"), + ) + .expect("a dangling asset directory symlink"); + std::os::unix::fs::symlink( + outside.path().join("elsewhere"), + root.path().join("assets/outside-directory"), + ) + .expect("an outside asset directory symlink"); + std::os::unix::fs::symlink( + root.path().join("assets/loop-b"), + root.path().join("assets/loop-a"), + ) + .expect("the first symlink loop entry"); + std::os::unix::fs::symlink( + root.path().join("assets/loop-a"), + root.path().join("assets/loop-b"), + ) + .expect("the second symlink loop entry"); for refused in ["escape", "elsewhere/secret"] { assert_eq!( - bundle_entry(root.path(), refused), + bundle_entry(root.path(), &request(refused, 0)), supervisor::bundle::GetResponse::InvalidPath, "{refused:?}" ); } // The bundle's own entries are unaffected by the check. assert_eq!( - bundle_entry(root.path(), "manifest.json"), - supervisor::bundle::GetResponse::Found { - bytes: b"manifest".to_vec(), - } + bundle_entry(root.path(), &request("manifest.json", 0)), + supervisor::bundle::GetResponse::Refused + ); + assert_eq!( + bundle_entry(root.path(), &request("assets/dangling.bin", 0)), + supervisor::bundle::GetResponse::InvalidPath ); + for invalid in [ + "assets/dangling-directory/child.bin", + "assets/outside-directory/missing.bin", + "assets/loop-a", + ] { + assert_eq!( + bundle_entry(root.path(), &request(invalid, 0)), + supervisor::bundle::GetResponse::InvalidPath, + "{invalid:?}" + ); + } +} + +fn bundle_path(path: &str) -> BundlePath { + BundlePath::new(path).expect("a canonical test bundle path") +} + +fn request(path: &str, offset: u64) -> supervisor::bundle::GetRequest { + supervisor::bundle::GetRequest { + path: bundle_path(path), + offset, + } } /// Both host actions are accepted as asked, and the acceptance names the @@ -247,10 +373,169 @@ fn host_actions_are_accepted_at_the_current_revision() { } } +/// Intentional supervisor shutdown keeps the bus alive until the native host +/// acknowledges cleanup and the delegated controller has withdrawn. Neither +/// signal alone is enough to report a clean member removal. +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn clean_shutdown_waits_for_host_acknowledgement_and_controller_withdrawal() { + let execution = ExecutionId::mint(); + let (owner, bus) = BusOwner::open(BusConfig::for_external( + execution, + Some(SourceLabel::new("removal-test").expect("bounded label")), + Vec::new(), + )) + .await + .expect("test bus opens"); + let host = bus.producer(); + let controller = + ProducerId::try_from((1_u128 << 124) | 33).expect("a canonical controller producer"); + let robot = RobotBuilder::new("rover") + .component_type("motor", |motor| motor.motor("spin", "axle")) + .component_with("left", "motor", |mounted| { + mounted.driver( + crate::model::connection::Connection::Can(crate::model::connection::Can { + bus: 0, + node_id: 1, + }), + None, + ) + }) + .build() + .expect("fixture robot"); + let state = ExecutionState::new(Presence::for_robot(&robot)).expect("fresh execution state"); + let brain = ParticipantId::new("brain").expect("brain id"); + let driver = ParticipantId::new("left").expect("driver id"); + state.record_presence( + &brain, + ProducerId::try_from((1_u128 << 124) | 32).expect("brain producer"), + true, + ); + state.record_presence(&driver, controller, true); + let request = supervisor::simulation::attach::AttachRequest::validated( + crate::model::world::WorldInstanceId::mint(), + controller, + crate::model::world::WorldProgress::at(4, 12).expect("valid progress"), + 12, + ) + .expect("validated attachment request"); + let (preparing, _) = state + .prepare_attachment(host, request) + .expect("attachment prepares"); + state + .activate_attachment(host, preparing.revision) + .expect("attachment activates"); + + let cleanup_bus = bus.clone(); + let cleanup_state = state.clone(); + let mut cleanup = + tokio::spawn( + async move { finish_clean_simulation_removal(&cleanup_bus, &cleanup_state).await }, + ); + let removing = loop { + if let Some(attachment) = state.attachment() + && attachment.phase == supervisor::simulation::SimulationAttachmentPhase::Removing + { + break attachment; + } + tokio::task::yield_now().await; + }; + let acknowledgement = owner + .declare_liveliness_key(&supervisor::simulation::removal_liveliness_key( + removing.revision, + host, + )) + .await + .expect("host cleanup acknowledgement"); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), &mut cleanup) + .await + .is_err(), + "host acknowledgement alone cannot hide a live controller" + ); + + state.record_presence(&driver, controller, false); + tokio::time::timeout(std::time::Duration::from_secs(2), cleanup) + .await + .expect("clean removal completes within the test bound") + .expect("cleanup task joins") + .expect("both cleanup facts are accepted"); + drop(acknowledgement); + owner.close().await; +} + +/// A host-reported terminal outcome replies on the locked end contract before +/// it requests the same orderly supervisor shutdown used by a robot stop. +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_typed_simulation_end_requests_supervisor_shutdown_after_replying() { + let execution = ExecutionId::mint(); + let (owner, bus) = BusOwner::open(BusConfig::for_external( + execution, + Some(SourceLabel::new("end-test").expect("bounded label")), + Vec::new(), + )) + .await + .expect("test bus opens"); + let host = bus.producer(); + let controller = ProducerId::try_from((1_u128 << 124) | 43).expect("controller producer"); + let (state, _) = present_state(); + let request = supervisor::simulation::attach::AttachRequest::validated( + crate::model::world::WorldInstanceId::mint(), + controller, + crate::model::world::WorldProgress::at(2, 12).expect("valid progress"), + 12, + ) + .expect("validated attachment request"); + let (preparing, _) = state + .prepare_attachment(host, request) + .expect("attachment prepares"); + state + .activate_attachment(host, preparing.revision) + .expect("attachment activates"); + + let shutdown = tokio_util::sync::CancellationToken::new(); + let server_bus = bus.clone(); + let server_state = state.clone(); + let server_shutdown = shutdown.clone(); + let server = tokio::spawn(async move { + serve_simulation_end(server_bus, server_state, server_shutdown).await + }); + let end = Querier::new( + bus.clone(), + &supervisor::topics().simulation().end().client(), + DEFAULT_QUERY_TIMEOUT, + ) + .expect("end querier"); + let response = loop { + match end + .query(supervisor::simulation::end::EndRequest { + reason: supervisor::simulation::SimulationEndReason::WorldStopped, + }) + .await + { + Ok(response) => break response, + Err(QueryError::Unavailable) => tokio::task::yield_now().await, + Err(error) => panic!("end query failed: {error}"), + } + }; + assert_eq!( + response.attachment.phase, + supervisor::simulation::SimulationAttachmentPhase::Removing + ); + tokio::time::timeout(std::time::Duration::from_secs(1), shutdown.cancelled()) + .await + .expect("end response requests supervisor shutdown"); + server.abort(); + let _ = server.await; + owner.close().await; +} + /// One expected runtime, present under a known producer. fn present_state() -> (ExecutionState, ParticipantId) { let robot = RobotBuilder::new("rover").build().expect("fixture robot"); - let state = ExecutionState::new(Presence::for_robot(&robot)); + let state = ExecutionState::new(Presence::for_robot(&robot)) + .expect("a fresh execution state accepts its initial time domain"); let participant = ParticipantId::new("brain").expect("fixture participant"); state.record_presence( &participant, diff --git a/phoxal/src/supervisor/host/serve/mod.rs b/phoxal/src/supervisor/host/serve/mod.rs index 0df48d59..038e9e8e 100644 --- a/phoxal/src/supervisor/host/serve/mod.rs +++ b/phoxal/src/supervisor/host/serve/mod.rs @@ -5,13 +5,14 @@ //! is the whole of what the two binaries negotiate. Every other endpoint below //! assumes that comparison already agreed, so none of them carries a version. -use std::path::{Component, Path, PathBuf}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::time::Duration; -use anyhow::{Context, Result, bail}; -use crate::bundle::RuntimeBundle; +use crate::bundle::{BundlePath, RuntimeBundle}; use crate::bus::{ - BusHandle, Codec, IncomingQuery, MessagePack, QueryEndpoint, QueryFailure, ServeQuery, - ServerQueryable, StreamPublisher, Topic, + BusHandle, Codec, IncomingQuery, LivelinessStatus, MessagePack, QueryEndpoint, QueryFailure, + ServeQuery, ServerQueryable, StreamPublisher, Topic, }; use crate::model::manifest::ManifestDocument; use crate::supervisor::api as supervisor; @@ -19,13 +20,53 @@ use crate::supervisor::api::command::{Command, CommandOutcome}; use crate::supervisor::api::connect::{ConnectReply, ConnectRequest}; use crate::supervisor::api::execution::SnapshotDocument; use crate::version::FrameworkVersion; +use anyhow::{Context, Result, bail}; use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; use super::state::ExecutionState; +mod bootstrap; +mod bundle; +mod commands; mod logs; +mod simulation_attachment; +mod snapshots; mod telemetry; +mod transport; + +use bootstrap::{serve_connect, serve_info}; +use bundle::serve_bundle; +use commands::serve_commands; +use simulation_attachment::{ + finish_clean_simulation_removal, serve_attach, serve_attachment_liveness, serve_attachments, + serve_current_attachment, serve_simulation_end, +}; +use snapshots::{ + serve_current, serve_current_time_domain, serve_snapshots, serve_time_domains, +}; +use transport::{declare, decode, reply}; + +#[cfg(test)] +use bootstrap::connect_reply; +#[cfg(test)] +use bundle::{bundle_entry, classify_bundle_path_error}; +#[cfg(test)] +use commands::{HostAction, command}; + +/// One reply stays small enough that a malformed or very large asset cannot +/// turn a supervisor query into an unbounded allocation. +const MAX_BUNDLE_CHUNK_BYTES: usize = 64 * 1024; + +/// One intentional supervisor shutdown keeps the execution bus alive long +/// enough for the bound host to remove the native member and for the delegated +/// controller's Ready leases to disappear. +const SIMULATION_REMOVAL_GRACE: Duration = Duration::from_secs(10); + +/// Preparing must resolve early enough for the public 5 s attach query to +/// receive an explicit reply rather than timing out while the supervisor can +/// still activate later. +const SIMULATION_PREPARATION_GRACE: Duration = Duration::from_secs(4); pub(crate) async fn serve( bus: BusHandle, @@ -38,15 +79,33 @@ pub(crate) async fn serve( tasks.spawn(serve_info(bus.clone(), bundle.manifest().clone())); tasks.spawn(serve_snapshots(bus.clone(), state.clone())); tasks.spawn(serve_current(bus.clone(), state.clone())); + tasks.spawn(serve_time_domains(bus.clone(), state.clone())); + tasks.spawn(serve_current_time_domain(bus.clone(), state.clone())); + tasks.spawn(serve_attachments(bus.clone(), state.clone())); + tasks.spawn(serve_current_attachment(bus.clone(), state.clone())); + tasks.spawn(serve_attach( + bus.clone(), + state.clone(), + shutdown.clone(), + )); + tasks.spawn(serve_attachment_liveness( + bus.clone(), + state.clone(), + shutdown.clone(), + )); + tasks.spawn(serve_simulation_end( + bus.clone(), + state.clone(), + shutdown.clone(), + )); tasks.spawn(serve_bundle(bus.clone(), bundle.root().to_path_buf())); tasks.spawn(serve_commands(bus.clone(), state.clone())); tasks.spawn(logs::run(bus.clone())); - tasks.spawn(telemetry::run(bus)); + tasks.spawn(telemetry::run(bus.clone())); - tokio::select! { + let outcome = tokio::select! { () = shutdown.cancelled() => { - tasks.shutdown().await; - Ok(()) + finish_clean_simulation_removal(&bus, &state).await } joined = tasks.join_next() => { match joined { @@ -56,233 +115,9 @@ pub(crate) async fn serve( None => bail!("all supervisor endpoint tasks ended before shutdown"), } } - } -} - -/// The frozen attachment bootstrap. -/// -/// It answers with this supervisor's framework train and nothing else, and it is -/// declared alongside every other endpoint so a client that disagrees learns -/// that from the first thing it asks rather than from a decode failure. The -/// robot this supervisor runs is not here: a client asks `supervisor/info` for -/// it once the two trains have agreed, which keeps this document exactly what -/// every framework line can decode. -async fn serve_connect(bus: BusHandle) -> Result<()> { - let server = declare(&bus, &supervisor::topics().connect().owner()).await?; - loop { - let incoming = server.recv().await?; - let ConnectRequest::V0 {} = match decode(&incoming).await? { - Some(request) => request, - None => continue, - }; - reply(&incoming, &bus, &connect_reply()).await?; - } -} - -fn connect_reply() -> ConnectReply { - ConnectReply::V0 { - framework: FrameworkVersion::CURRENT, - } -} - -/// Which robot this supervisor is running. -/// -/// The answer is the manifest document the supervisor opened, so a client -/// reads exactly what every participant of this execution reads instead of a -/// projection that could disagree with it. The supervisor holds one bundle for -/// the life of the process, so the reply never changes. -async fn serve_info(bus: BusHandle, manifest: ManifestDocument) -> Result<()> { - let server = declare(&bus, &supervisor::topics().info().owner()).await?; - loop { - let incoming = server.recv().await?; - let supervisor::info::InfoRequest {} = match decode(&incoming).await? { - Some(request) => request, - None => continue, - }; - reply(&incoming, &bus, &manifest).await?; - } -} - -async fn serve_snapshots(bus: BusHandle, state: ExecutionState) -> Result<()> { - let publisher = StreamPublisher::new(bus, &supervisor::topics().snapshot().owner())?; - let mut snapshots = state.subscribe(); - publisher.send(SnapshotDocument::V0(snapshots.borrow_and_update().clone()))?; - loop { - snapshots - .changed() - .await - .context("the supervisor snapshot authority closed")?; - publisher.send(SnapshotDocument::V0(snapshots.borrow_and_update().clone()))?; - } -} - -async fn serve_current(bus: BusHandle, state: ExecutionState) -> Result<()> { - let server = declare(&bus, &supervisor::topics().snapshot().current().owner()).await?; - loop { - let incoming = server.recv().await?; - let _: supervisor::snapshot::CurrentRequest = match decode(&incoming).await? { - Some(request) => request, - None => continue, - }; - reply(&incoming, &bus, &SnapshotDocument::V0(state.snapshot())).await?; - } -} - -/// Read access to the bundle this supervisor is running. -/// -/// The supervisor is the only process that knows where the bundle lives, so a -/// client asks it for a path instead of reaching into a filesystem it does not -/// own. -async fn serve_bundle(bus: BusHandle, root: PathBuf) -> Result<()> { - let server = declare(&bus, &supervisor::topics().bundle().get().owner()).await?; - loop { - let incoming = server.recv().await?; - let request: supervisor::bundle::GetRequest = match decode(&incoming).await? { - Some(request) => request, - None => continue, - }; - reply(&incoming, &bus, &bundle_entry(&root, &request.path)).await?; - } -} - -/// Resolve one requested path against the bundle root. -/// -/// An empty path, an absolute path, and any component that is not a plain name -/// are refused outright: they are requests this endpoint never answers, which -/// is a different answer than an entry the bundle does not have. -/// -/// Passing that spelling check is not enough to be inside the bundle, because -/// a symlink under the root can point anywhere on the host. Both sides are -/// therefore canonicalized and compared: what leaves this process is a file -/// whose real location is under the bundle's real root, and a path that -/// resolves outside it is refused rather than reported missing, because the -/// entry exists and the supervisor is declining to serve it. -fn bundle_entry(root: &Path, requested: &str) -> supervisor::bundle::GetResponse { - let path = Path::new(requested); - let refusable = requested.is_empty() - || path.is_absolute() - || !path - .components() - .all(|component| matches!(component, Component::Normal(_))); - if refusable { - return supervisor::bundle::GetResponse::InvalidPath; - } - let Ok(canonical_root) = root.canonicalize() else { - return supervisor::bundle::GetResponse::Missing; - }; - let Ok(resolved) = canonical_root.join(path).canonicalize() else { - return supervisor::bundle::GetResponse::Missing; - }; - if !resolved.starts_with(&canonical_root) { - return supervisor::bundle::GetResponse::InvalidPath; - } - if !resolved.is_file() { - return supervisor::bundle::GetResponse::Missing; - } - std::fs::read(&resolved).map_or(supervisor::bundle::GetResponse::Missing, |bytes| { - supervisor::bundle::GetResponse::Found { bytes } - }) -} - -/// The two host actions, and nothing about the robot graph. -/// -/// The supervisor started no runtime, so it stops none: `phoxal stop` signals -/// the processes the session that launched them recorded, and a client attached -/// to an execution it did not start has nothing here to stop it with. -async fn serve_commands(bus: BusHandle, state: ExecutionState) -> Result<()> { - let server = declare(&bus, &supervisor::topics().command().owner()).await?; - loop { - let incoming = server.recv().await?; - let request: supervisor::command::Request = match decode(&incoming).await? { - Some(request) => request, - None => continue, - }; - let supervisor::command::Request::V0 { command: request } = request; - let (outcome, action) = command(&state, request); - // Acceptance reaches the client before the host is asked to go down; - // reversing these turns an accepted reboot into an ambiguous - // no-responder failure at the caller. - reply(&incoming, &bus, &supervisor::command::Reply::V0 { outcome }).await?; - action.request().await; - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum HostAction { - Reboot, - Poweroff, -} - -impl HostAction { - async fn request(self) { - let name = match self { - Self::Reboot => "reboot", - Self::Poweroff => "power-off", - }; - let result = tokio::task::spawn_blocking(move || match self { - Self::Reboot => system_shutdown::reboot(), - Self::Poweroff => system_shutdown::shutdown(), - }) - .await; - match result { - Ok(Ok(())) => {} - Ok(Err(error)) => tracing::error!(action = name, %error, "host action failed"), - Err(error) => tracing::error!(action = name, %error, "host action task failed"), - } - } -} - -/// Accept one host request, and say which execution revision it was accepted -/// at. -/// -/// The revision is evidence, not a fence: whether cycling this machine's power -/// is safe is the operator's judgment about the machine, and how many times a -/// Ready lease has moved since they last looked says nothing about it. -fn command(state: &ExecutionState, command: Command) -> (CommandOutcome, HostAction) { - let action = match command { - Command::Reboot => HostAction::Reboot, - Command::Poweroff => HostAction::Poweroff, }; - ( - CommandOutcome::Accepted { - at_revision: state.snapshot().revision, - }, - action, - ) -} - -/// Declare the queryable for one supervisor-owned query endpoint. -/// -/// The owner-side topic is the only way in, so the key a server binds is the -/// one the api tree rendered for that endpoint and nothing a caller spelled. -async fn declare( - bus: &BusHandle, - topic: &Topic>, -) -> Result { - Ok(bus.declare_server(topic.key()).await?) -} - -async fn decode(incoming: &IncomingQuery) -> Result> { - match MessagePack::decode(&incoming.request_bytes()?) { - Ok(request) => Ok(Some(request)), - Err(error) => { - incoming - .reply_err(&QueryFailure::invalid_argument(error.to_string())) - .await?; - Ok(None) - } - } -} - -async fn reply( - incoming: &IncomingQuery, - bus: &BusHandle, - response: &T, -) -> Result<()> { - incoming - .reply(bus, MessagePack::encode(response)?) - .await - .map_err(Into::into) + tasks.shutdown().await; + outcome } #[cfg(test)] diff --git a/phoxal/src/supervisor/host/serve/simulation_attachment.rs b/phoxal/src/supervisor/host/serve/simulation_attachment.rs new file mode 100644 index 00000000..8860d7ac --- /dev/null +++ b/phoxal/src/supervisor/host/serve/simulation_attachment.rs @@ -0,0 +1,512 @@ +use super::*; + +pub(super) async fn serve_attachments(bus: BusHandle, state: ExecutionState) -> Result<()> { + let publisher = + StreamPublisher::new(bus, &supervisor::topics().simulation().attachment().owner())?; + let mut attachments = state + .take_attachment_updates() + .context("the supervisor attachment authority is already being served")?; + while let Some(attachment) = attachments.recv().await { + publisher + .send(supervisor::simulation::attachment::SimulationAttachmentStream { attachment })?; + } + bail!("the supervisor attachment authority closed") +} + +pub(super) async fn serve_current_attachment(bus: BusHandle, state: ExecutionState) -> Result<()> { + let server = declare( + &bus, + &supervisor::topics() + .simulation() + .attachment() + .current() + .owner(), + ) + .await?; + loop { + let incoming = server.recv().await?; + let _: supervisor::simulation::attachment::CurrentRequest = match decode(&incoming).await? { + Some(request) => request, + None => continue, + }; + reply( + &incoming, + &bus, + &supervisor::simulation::attachment::CurrentResponse { + attachment: state.attachment(), + }, + ) + .await?; + } +} + +/// Serialize a Live attachment, holding the query until the exact controller +/// acknowledges the Preparing revision through its execution-scoped lease. +pub(super) async fn serve_attach( + bus: BusHandle, + state: ExecutionState, + shutdown: CancellationToken, +) -> Result<()> { + let server = declare(&bus, &supervisor::topics().simulation().attach().owner()).await?; + loop { + let incoming = server.recv().await?; + let request: supervisor::simulation::attach::AttachRequest = match decode(&incoming).await? + { + Some(request) => request, + None => continue, + }; + let host = incoming.request_metadata()?.source.producer(); + let (preparing, time_domain) = match state.prepare_attachment(host, request) { + Ok(attached) => attached, + Err(error) => { + incoming + .reply_err(&QueryFailure::invalid_argument(error.to_string())) + .await?; + continue; + } + }; + let (attachment, time_domain) = match preparing.phase { + supervisor::simulation::SimulationAttachmentPhase::Active => (preparing, time_domain), + supervisor::simulation::SimulationAttachmentPhase::Preparing => { + if let Err(error) = wait_for_prepared_controller( + &bus, + preparing, + &shutdown, + ) + .await + { + match state.abort_preparing_attachment(host, preparing.revision) { + Ok(removing) => { + tracing::warn!( + revision = removing.revision, + world = %removing.world, + host = %removing.host, + controller = %removing.controller, + error = %error, + "simulation attachment preparation was rolled back" + ); + } + Err(super::super::state::AttachmentStateError::NotPreparing) => {} + Err(state_error) => return Err(state_error.into()), + } + incoming + .reply_err(&QueryFailure::unavailable(error.to_string())) + .await?; + shutdown.cancel(); + continue; + } + match state.activate_attachment(host, preparing.revision) { + Ok(active) => active, + Err(error) => { + match state.abort_preparing_attachment(host, preparing.revision) { + Ok(removing) => { + tracing::warn!( + revision = removing.revision, + controller = %removing.controller, + error = %error, + "simulation attachment failed its final Active admission recheck" + ); + } + Err(super::super::state::AttachmentStateError::NotPreparing) => {} + Err(state_error) => return Err(state_error.into()), + } + incoming + .reply_err(&QueryFailure::unavailable(error.to_string())) + .await?; + shutdown.cancel(); + continue; + } + } + } + supervisor::simulation::SimulationAttachmentPhase::Removing => { + incoming + .reply_err(&QueryFailure::unavailable( + "the existing simulation attachment is being removed", + )) + .await?; + continue; + } + }; + reply( + &incoming, + &bus, + &supervisor::simulation::attach::AttachResponse { + attachment, + time_domain, + }, + ) + .await?; + } +} + +async fn wait_for_prepared_controller( + bus: &BusHandle, + attachment: supervisor::simulation::SimulationAttachmentState, + shutdown: &CancellationToken, +) -> std::result::Result<(), PreparationWaitError> { + tokio::time::timeout( + SIMULATION_PREPARATION_GRACE, + wait_for_prepared_controller_inner(bus, attachment, shutdown), + ) + .await + .map_err(|_| PreparationWaitError::TimedOut)? +} + +async fn wait_for_prepared_controller_inner( + bus: &BusHandle, + attachment: supervisor::simulation::SimulationAttachmentState, + shutdown: &CancellationToken, +) -> std::result::Result<(), PreparationWaitError> { + let prepared_key = supervisor::simulation::preparation_liveliness_key( + attachment.revision, + attachment.controller, + ); + let host_key = supervisor::simulation::host_liveliness_key(attachment.host); + let transaction_key = supervisor::simulation::transaction_liveliness_key( + attachment.world, + attachment.host, + attachment.controller, + ); + let (prepared_observer, mut prepared) = observe_status(bus, &prepared_key).await?; + let (host_observer, mut host) = observe_status(bus, &host_key).await?; + let (transaction_observer, mut transaction) = observe_status(bus, &transaction_key).await?; + let mut prepared_alive = latest_status(&prepared_observer, &prepared) == LivelinessStatus::Alive; + let mut host_alive = latest_status(&host_observer, &host) == LivelinessStatus::Alive; + let mut transaction_alive = + latest_status(&transaction_observer, &transaction) == LivelinessStatus::Alive; + let mut prepared_was_alive = prepared_alive; + let mut host_was_alive = host_alive; + let mut transaction_was_alive = transaction_alive; + + loop { + if prepared_alive && host_alive && transaction_alive { + return Ok(()); + } + tokio::select! { + biased; + changed = transaction.changed() => { + changed.map_err(|_| PreparationWaitError::ObserverClosed)?; + transaction_alive = *transaction.borrow_and_update() == Some(LivelinessStatus::Alive); + if transaction_was_alive && !transaction_alive { + return Err(PreparationWaitError::TransactionAbandoned); + } + transaction_was_alive |= transaction_alive; + } + changed = host.changed() => { + changed.map_err(|_| PreparationWaitError::ObserverClosed)?; + host_alive = *host.borrow_and_update() == Some(LivelinessStatus::Alive); + if host_was_alive && !host_alive { + return Err(PreparationWaitError::HostLost); + } + host_was_alive |= host_alive; + } + changed = prepared.changed() => { + changed.map_err(|_| PreparationWaitError::ObserverClosed)?; + prepared_alive = *prepared.borrow_and_update() == Some(LivelinessStatus::Alive); + if prepared_was_alive && !prepared_alive { + return Err(PreparationWaitError::ControllerLost); + } + prepared_was_alive |= prepared_alive; + } + () = shutdown.cancelled() => return Err(PreparationWaitError::Cancelled), + } + } +} + +async fn observe_status( + bus: &BusHandle, + key: &str, +) -> std::result::Result< + ( + crate::bus::KeyLivelinessObserver, + tokio::sync::watch::Receiver>, + ), + PreparationWaitError, +> { + let (status_tx, status_rx) = tokio::sync::watch::channel(None); + let observer = bus + .observe_liveliness_key(key, move |status| { + status_tx.send_replace(Some(status)); + }) + .await + .map_err(|error| PreparationWaitError::Observer(error.to_string()))?; + Ok((observer, status_rx)) +} + +fn latest_status( + observer: &crate::bus::KeyLivelinessObserver, + status: &tokio::sync::watch::Receiver>, +) -> LivelinessStatus { + (*status.borrow()).unwrap_or_else(|| observer.initial()) +} + +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +enum PreparationWaitError { + #[error("simulation attachment preparation exceeded its 4 s deadline")] + TimedOut, + #[error("the world host disappeared during simulation attachment preparation")] + HostLost, + #[error("the controller withdrew its preparation acknowledgement")] + ControllerLost, + #[error("the world host abandoned the simulation attachment transaction")] + TransactionAbandoned, + #[error("the execution began shutting down during simulation attachment preparation")] + Cancelled, + #[error("a simulation attachment liveliness observer closed")] + ObserverClosed, + #[error("failed to establish a simulation attachment liveliness observer: {0}")] + Observer(String), +} + +pub(super) async fn serve_attachment_liveness( + bus: BusHandle, + state: ExecutionState, + shutdown: CancellationToken, +) -> Result<()> { + let mut attachments = state.subscribe_attachment(); + loop { + let Some(active) = *attachments.borrow_and_update() else { + attachments + .changed() + .await + .context("the supervisor attachment authority closed")?; + continue; + }; + if active.phase != supervisor::simulation::SimulationAttachmentPhase::Active { + attachments + .changed() + .await + .context("the supervisor attachment authority closed")?; + continue; + } + let Some(reason) = monitor_active_attachment( + &bus, + &state, + &mut attachments, + active, + ) + .await? + else { + continue; + }; + match state.fail_active_attachment(active.revision, reason) { + Ok(removing) => { + tracing::error!( + ?reason, + revision = removing.revision, + world = %removing.world, + host = %removing.host, + controller = %removing.controller, + "Active simulation attachment lost a bound authority" + ); + shutdown.cancel(); + std::future::pending::<()>().await; + } + Err(super::super::state::AttachmentStateError::NotActiveRevision) => continue, + Err(error) => return Err(error.into()), + } + } +} + +async fn monitor_active_attachment( + bus: &BusHandle, + state: &ExecutionState, + attachments: &mut tokio::sync::watch::Receiver< + Option, + >, + active: supervisor::simulation::SimulationAttachmentState, +) -> Result> { + let host_key = supervisor::simulation::host_liveliness_key(active.host); + let (status_tx, mut host_status) = tokio::sync::watch::channel(None); + let host_observer = bus + .observe_liveliness_key(&host_key, move |status| { + status_tx.send_replace(Some(status)); + }) + .await?; + let transaction_key = supervisor::simulation::transaction_liveliness_key( + active.world, + active.host, + active.controller, + ); + let (transaction_tx, mut transaction_status) = tokio::sync::watch::channel(None); + let transaction_observer = bus + .observe_liveliness_key(&transaction_key, move |status| { + transaction_tx.send_replace(Some(status)); + }) + .await?; + if state.attachment() != Some(active) { + return Ok(None); + } + if latest_status(&host_observer, &host_status) != LivelinessStatus::Alive { + return Ok(Some(supervisor::simulation::SimulationEndReason::HostLost)); + } + if latest_status(&transaction_observer, &transaction_status) != LivelinessStatus::Alive { + return Ok(Some( + supervisor::simulation::SimulationEndReason::ProtocolViolation, + )); + } + if !state.controller_is_exclusive(active.controller) { + return Ok(Some( + supervisor::simulation::SimulationEndReason::ControllerLost, + )); + } + let mut snapshots = state.subscribe(); + loop { + tokio::select! { + biased; + changed = attachments.changed() => { + changed.context("the supervisor attachment authority closed")?; + return Ok(None); + } + changed = host_status.changed() => { + changed.context("the world-host liveness observer closed")?; + if *host_status.borrow_and_update() == Some(LivelinessStatus::Lost) { + return Ok(Some(supervisor::simulation::SimulationEndReason::HostLost)); + } + } + changed = transaction_status.changed() => { + changed.context("the attachment transaction liveness observer closed")?; + if *transaction_status.borrow_and_update() == Some(LivelinessStatus::Lost) { + return Ok(Some( + supervisor::simulation::SimulationEndReason::ProtocolViolation, + )); + } + } + changed = snapshots.changed() => { + changed.context("the supervisor presence authority closed")?; + let _ = snapshots.borrow_and_update(); + if !state.controller_is_exclusive(active.controller) { + return Ok(Some( + supervisor::simulation::SimulationEndReason::ControllerLost, + )); + } + } + } + } +} + +pub(super) async fn serve_simulation_end( + bus: BusHandle, + state: ExecutionState, + shutdown: CancellationToken, +) -> Result<()> { + let server = declare(&bus, &supervisor::topics().simulation().end().owner()).await?; + loop { + let incoming = server.recv().await?; + let request: supervisor::simulation::end::EndRequest = match decode(&incoming).await? { + Some(request) => request, + None => continue, + }; + let host = incoming.request_metadata()?.source.producer(); + let attachment = match state.remove_attachment(host) { + Ok(attachment) => attachment, + Err(error) => { + incoming + .reply_err(&QueryFailure::invalid_argument(error.to_string())) + .await?; + continue; + } + }; + reply( + &incoming, + &bus, + &supervisor::simulation::end::EndResponse { attachment }, + ) + .await?; + tracing::info!( + reason = ?request.reason, + revision = attachment.revision, + %host, + "the world host ended this simulation attachment" + ); + // A host-reported terminal attachment outcome ends this fresh Live + // execution. The reply above is sent first, then the ordinary shutdown + // path retains the bus for the same bounded removal acknowledgement as + // a robot-initiated stop. + shutdown.cancel(); + } +} + +/// Publish Removing and retain the control plane until both sides of native +/// cleanup are observable. The host acknowledges only after it has removed the +/// native member; controller Ready loss independently proves that the delegated +/// process no longer presents this execution's drivers. +pub(super) async fn finish_clean_simulation_removal(bus: &BusHandle, state: &ExecutionState) -> Result<()> { + let Some(removing) = state.begin_shutdown_attachment()? else { + return Ok(()); + }; + let key = supervisor::simulation::removal_liveliness_key(removing.revision, removing.host); + let (status_tx, mut status_rx) = tokio::sync::watch::channel(LivelinessStatus::Lost); + let observer = bus + .observe_liveliness_key(&key, move |status| { + status_tx.send_replace(status); + }) + .await?; + let mut host_acknowledged = observer.initial() == LivelinessStatus::Alive; + let mut snapshots = state.subscribe(); + let deadline = tokio::time::Instant::now() + SIMULATION_REMOVAL_GRACE; + + loop { + let controller_withdrawn = !state.producer_is_present(removing.controller); + if host_acknowledged && controller_withdrawn { + tracing::info!( + revision = removing.revision, + world = %removing.world, + host = %removing.host, + controller = %removing.controller, + "clean simulation removal was acknowledged" + ); + return match state.attachment_failure() { + Some(reason) => Err(ActiveAttachmentFailure::Clean { reason }.into()), + None => Ok(()), + }; + } + + tokio::select! { + () = tokio::time::sleep_until(deadline) => { + if let Some(reason) = state.attachment_failure() { + return Err(ActiveAttachmentFailure::Cleanup { + reason, + cleanup: format!( + "removal revision {} exceeded {:?}: host_acknowledged={}, controller_withdrawn={}", + removing.revision, + SIMULATION_REMOVAL_GRACE, + host_acknowledged, + controller_withdrawn, + ), + } + .into()); + } + bail!( + "simulation removal revision {} exceeded {:?}: host_acknowledged={}, controller_withdrawn={}", + removing.revision, + SIMULATION_REMOVAL_GRACE, + host_acknowledged, + controller_withdrawn, + ); + } + changed = status_rx.changed(), if !host_acknowledged => { + changed.context("the host removal acknowledgement observer closed")?; + host_acknowledged = + *status_rx.borrow_and_update() == LivelinessStatus::Alive; + } + changed = snapshots.changed(), if !controller_withdrawn => { + changed.context("the supervisor presence authority closed during removal")?; + let _ = snapshots.borrow_and_update(); + } + } + } +} + +#[derive(Debug, thiserror::Error)] +enum ActiveAttachmentFailure { + #[error("Active simulation attachment failed with {reason:?}")] + Clean { + reason: supervisor::simulation::SimulationEndReason, + }, + #[error("Active simulation attachment failed with {reason:?}; {cleanup}")] + Cleanup { + reason: supervisor::simulation::SimulationEndReason, + cleanup: String, + }, +} diff --git a/phoxal/src/supervisor/host/serve/snapshots.rs b/phoxal/src/supervisor/host/serve/snapshots.rs new file mode 100644 index 00000000..bfb72e86 --- /dev/null +++ b/phoxal/src/supervisor/host/serve/snapshots.rs @@ -0,0 +1,59 @@ +use super::*; + +pub(super) async fn serve_snapshots(bus: BusHandle, state: ExecutionState) -> Result<()> { + let publisher = StreamPublisher::new(bus, &supervisor::topics().snapshot().owner())?; + let mut snapshots = state.subscribe(); + publisher.send(SnapshotDocument::V0(snapshots.borrow_and_update().clone()))?; + loop { + snapshots + .changed() + .await + .context("the supervisor snapshot authority closed")?; + publisher.send(SnapshotDocument::V0(snapshots.borrow_and_update().clone()))?; + } +} + +pub(super) async fn serve_current(bus: BusHandle, state: ExecutionState) -> Result<()> { + let server = declare(&bus, &supervisor::topics().snapshot().current().owner()).await?; + loop { + let incoming = server.recv().await?; + let _: supervisor::snapshot::CurrentRequest = match decode(&incoming).await? { + Some(request) => request, + None => continue, + }; + reply(&incoming, &bus, &SnapshotDocument::V0(state.snapshot())).await?; + } +} + +/// Publish every complete scheduling-authority replacement in order. +pub(super) async fn serve_time_domains(bus: BusHandle, state: ExecutionState) -> Result<()> { + let publisher = StreamPublisher::new(bus, &supervisor::topics().time_domain().owner())?; + let mut domains = state + .take_time_domain_updates() + .context("the supervisor time-domain authority is already being served")?; + while let Some(domain) = domains.recv().await { + publisher.send(supervisor::time_domain::TimeDomainStream { domain })?; + } + bail!("the supervisor time-domain authority closed") +} + +/// Answer the current domain after a client subscribed to its replacement +/// stream, closing the ordinary subscribe/query race. +pub(super) async fn serve_current_time_domain(bus: BusHandle, state: ExecutionState) -> Result<()> { + let server = declare(&bus, &supervisor::topics().time_domain().current().owner()).await?; + loop { + let incoming = server.recv().await?; + let _: supervisor::time_domain::CurrentRequest = match decode(&incoming).await? { + Some(request) => request, + None => continue, + }; + reply( + &incoming, + &bus, + &supervisor::time_domain::CurrentResponse { + domain: state.time_domain(), + }, + ) + .await?; + } +} diff --git a/phoxal/src/supervisor/host/serve/transport.rs b/phoxal/src/supervisor/host/serve/transport.rs new file mode 100644 index 00000000..a21f41d0 --- /dev/null +++ b/phoxal/src/supervisor/host/serve/transport.rs @@ -0,0 +1,30 @@ +use super::*; + +pub(super) async fn declare( + bus: &BusHandle, + topic: &Topic>, +) -> Result { + Ok(bus.declare_server(topic.key()).await?) +} +pub(super) async fn decode(incoming: &IncomingQuery) -> Result> { + match MessagePack::decode(&incoming.request_bytes()?) { + Ok(request) => Ok(Some(request)), + Err(error) => { + incoming + .reply_err(&QueryFailure::invalid_argument(error.to_string())) + .await?; + Ok(None) + } + } +} + +pub(super) async fn reply( + incoming: &IncomingQuery, + bus: &BusHandle, + response: &T, +) -> Result<()> { + incoming + .reply(bus, MessagePack::encode(response)?) + .await + .map_err(Into::into) +} diff --git a/phoxal/src/supervisor/host/state.rs b/phoxal/src/supervisor/host/state.rs index 4fbf2a01..3c1951fe 100644 --- a/phoxal/src/supervisor/host/state.rs +++ b/phoxal/src/supervisor/host/state.rs @@ -12,26 +12,45 @@ use std::sync::{Arc, Mutex, MutexGuard}; -use crate::identity::{ParticipantId, ProducerId}; +use crate::bus::{LocalInstant, RobotInstant}; +use crate::identity::{ParticipantId, ProducerId, TimelineId}; use crate::supervisor::api::execution::Snapshot; -use tokio::sync::watch; +use crate::supervisor::api::simulation::attach::AttachRequest; +use crate::supervisor::api::simulation::{ + SimulationAttachmentPhase, SimulationAttachmentState, SimulationEndReason, +}; +use crate::supervisor::api::time_domain::{TimeDomain, TimeMode}; +use tokio::sync::{mpsc, watch}; use super::presence::Presence; +mod attachment; +pub(crate) use attachment::AttachmentStateError; + /// Shared handle to one execution's published state. Cloning shares it. #[derive(Clone)] pub(crate) struct ExecutionState { inner: Arc, } - struct Inner { data: Mutex, published: watch::Sender, + time_domain: watch::Sender, + time_domain_updates: mpsc::Sender, + time_domain_receiver: Mutex>>, + attachment_updates: mpsc::Sender>, + attachment_receiver: Mutex>>>, + attachment_current: watch::Sender>, } struct Data { revision: u64, presence: Presence, + stopping: bool, + time_domain: TimeDomain, + attachment_revision: u64, + attachment: Option, + attachment_failure: Option, } impl Data { @@ -47,18 +66,47 @@ impl Data { impl ExecutionState { /// Start an execution from its expected runtime set, before any of them /// has been seen. - pub(crate) fn new(presence: Presence) -> Self { + pub(crate) fn new(presence: Presence) -> Result { let data = Data { revision: 0, presence, + stopping: false, + time_domain: TimeDomain { + revision: 0, + timeline: TimelineId::mint(), + mode: TimeMode::Monotonic, + }, + attachment_revision: 0, + attachment: None, + attachment_failure: None, }; let (published, _) = watch::channel(data.project()); - Self { + let (time_domain, _) = watch::channel(data.time_domain); + // A watch channel deliberately coalesces values, which is right for a + // current query but wrong for history replacement. The served stream + // must expose every replacement in revision order, so it owns this + // bounded queue for the lifetime of the execution. + let (time_domain_updates, receiver) = mpsc::channel(32); + time_domain_updates + .try_send(data.time_domain) + .map_err(|error| match error { + mpsc::error::TrySendError::Full(_) => TimeDomainReplacementError::StreamFull, + mpsc::error::TrySendError::Closed(_) => TimeDomainReplacementError::StreamClosed, + })?; + let (attachment_updates, attachment_receiver) = mpsc::channel(32); + let (attachment_current, _) = watch::channel(None); + Ok(Self { inner: Arc::new(Inner { data: Mutex::new(data), published, + time_domain, + time_domain_updates, + time_domain_receiver: Mutex::new(Some(receiver)), + attachment_updates, + attachment_receiver: Mutex::new(Some(attachment_receiver)), + attachment_current, }), - } + }) } /// The most recently published snapshot. This is what the `current` query @@ -72,6 +120,79 @@ impl ExecutionState { self.inner.published.subscribe() } + /// The supervisor's current execution time authority. + pub(crate) fn time_domain(&self) -> TimeDomain { + *self.inner.time_domain.borrow() + } + + /// Whether the delegated controller still owns any expected Ready row. + pub(crate) fn producer_is_present(&self, producer: ProducerId) -> bool { + self.lock().presence.contains_producer(producer) + } + + /// Whether this producer still exclusively owns every delegated driver + /// while all non-driver runtime roles remain Ready. + pub(crate) fn controller_is_exclusive(&self, controller: ProducerId) -> bool { + self.lock().presence.admits_live_controller(controller) + } + + /// Take the one ordered stream of time-domain replacements. + /// + /// An execution has exactly one serving endpoint for this authority. + /// Taking the receiver rather than cloning a broadcast receiver gives that + /// endpoint an explicit backpressure limit instead of silently dropping + /// revisions when a client or transport is slow. + pub(crate) fn take_time_domain_updates( + &self, + ) -> Result, TimeDomainReplacementError> { + self.inner + .time_domain_receiver + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .ok_or(TimeDomainReplacementError::StreamAlreadyTaken) + } + + /// Replace the current execution history with one freshly minted timeline. + /// + /// The caller owns lifecycle admission before invoking this method. This + /// state owner only makes the replacement indivisible and publishes it in + /// the order clients reconcile by `revision`. + #[allow( + dead_code, + reason = "the production simulation lifecycle calls this authority in the combined campaign" + )] + pub(crate) fn replace_time_domain( + &self, + mode: TimeMode, + ) -> Result { + let mut data = self.lock(); + // Reserve capacity while holding the revision lock. This makes a full + // stream a visible lifecycle fault rather than a state change whose + // publication was lost, and preserves queue order with revision order. + let permit = self + .inner + .time_domain_updates + .try_reserve() + .map_err(|error| match error { + mpsc::error::TrySendError::Full(()) => TimeDomainReplacementError::StreamFull, + mpsc::error::TrySendError::Closed(()) => TimeDomainReplacementError::StreamClosed, + })?; + let domain = TimeDomain { + revision: data + .time_domain + .revision + .checked_add(1) + .ok_or(TimeDomainReplacementError::RevisionExhausted)?, + timeline: TimelineId::mint(), + mode, + }; + data.time_domain = domain; + self.inner.time_domain.send_replace(domain); + permit.send(domain); + Ok(domain) + } + /// Apply one participant Ready lease change. pub(crate) fn record_presence( &self, @@ -104,6 +225,23 @@ impl ExecutionState { } } +/// A time-domain transition could not be published exactly once. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub(crate) enum TimeDomainReplacementError { + /// The host attempted to serve the ordered authority more than once. + #[error("the execution time-domain stream is already being served")] + StreamAlreadyTaken, + /// An admitted transition would overflow the bounded publication queue. + #[error("the execution time-domain stream is saturated")] + StreamFull, + /// The serving endpoint stopped before a new transition was admitted. + #[error("the execution time-domain stream is unavailable")] + StreamClosed, + /// The execution has published the largest representable domain revision. + #[error("the execution time-domain revision is exhausted")] + RevisionExhausted, +} + #[cfg(test)] mod tests { use crate::bus::{Codec, MessagePack}; @@ -118,6 +256,7 @@ mod tests { .build() .expect("a valid robot"); ExecutionState::new(Presence::for_robot(&robot)) + .expect("a fresh execution state accepts its initial time domain") } fn producer(seed: u128) -> ProducerId { @@ -135,6 +274,9 @@ mod tests { .iter() .all(|process| process.state == ProcessState::Absent) ); + let domain = state().time_domain(); + assert_eq!(domain.revision, 0); + assert_eq!(domain.mode, TimeMode::Monotonic); } /// A Ready lease reaches the published snapshot at a higher revision, and @@ -173,4 +315,285 @@ mod tests { } assert!(seen.windows(2).all(|pair| pair[1] > pair[0]), "{seen:?}"); } + + #[tokio::test] + async fn every_time_domain_replacement_has_a_fresh_timeline_and_revision() { + let state = state(); + let initial = state.time_domain(); + let mut domains = state + .take_time_domain_updates() + .expect("the one serving stream is available"); + assert_eq!(domains.recv().await, Some(initial)); + let replacement = state + .replace_time_domain(TimeMode::Simulated) + .expect("the serving stream has capacity"); + + assert_eq!(domains.recv().await, Some(replacement)); + assert!(replacement.revision > initial.revision); + assert_ne!(replacement.timeline, initial.timeline); + assert_eq!(replacement.mode, TimeMode::Simulated); + } + + #[tokio::test] + async fn time_domain_replacements_never_coalesce() { + let state = state(); + let mut domains = state + .take_time_domain_updates() + .expect("the one serving stream is available"); + let initial = domains.recv().await.expect("the initial domain"); + let simulated = state + .replace_time_domain(TimeMode::Simulated) + .expect("the serving stream has capacity"); + let monotonic = state + .replace_time_domain(TimeMode::Monotonic) + .expect("the serving stream has capacity"); + + assert_eq!(domains.recv().await, Some(simulated)); + assert_eq!(domains.recv().await, Some(monotonic)); + assert_eq!( + [initial.revision, simulated.revision, monotonic.revision], + [0, 1, 2] + ); + assert_ne!(initial.timeline, simulated.timeline); + assert_ne!(simulated.timeline, monotonic.timeline); + } + + #[tokio::test] + async fn live_attachment_is_source_bound_ordered_and_preserves_the_domain() { + let state = state(); + let before = state.time_domain(); + let mut updates = state + .take_attachment_updates() + .expect("the attachment stream is available once"); + let host = producer(21); + let controller = producer(22); + state.record_presence( + &ParticipantId::new("brain").expect("valid brain id"), + producer(19), + true, + ); + state.record_presence( + &ParticipantId::new("drive").expect("valid service id"), + producer(20), + true, + ); + let request = AttachRequest::validated( + crate::model::world::WorldInstanceId::mint(), + controller, + crate::model::world::WorldProgress::at(4, 12).expect("valid progress"), + 12, + ) + .expect("the host validated its progress boundary"); + + let (preparing, preparing_domain) = state + .prepare_attachment(host, request) + .expect("a monotonic execution accepts preparation"); + assert_eq!(preparing.phase, SimulationAttachmentPhase::Preparing); + assert_eq!(preparing.revision, 1); + assert_eq!(preparing.host, host); + assert_eq!(preparing.controller, controller); + assert_eq!(preparing.attached_at.world, request.progress()); + assert_eq!(preparing_domain, before); + assert_eq!(updates.recv().await, Some(Some(preparing))); + + assert_eq!( + state + .activate_attachment(producer(23), preparing.revision) + .expect_err("another producer cannot commit the attachment"), + AttachmentStateError::WrongHost { + expected: host, + observed: producer(23), + } + ); + let brain = ParticipantId::new("brain").expect("valid brain id"); + state.record_presence(&brain, producer(19), false); + assert_eq!( + state + .activate_attachment(host, preparing.revision) + .expect_err("controller exclusivity is rechecked at commit"), + AttachmentStateError::ControllerNotReady { controller } + ); + state.record_presence(&brain, producer(19), true); + let (active, active_domain) = state + .activate_attachment(host, preparing.revision) + .expect("the source-bound host commits preparation"); + assert_eq!(active.phase, SimulationAttachmentPhase::Active); + assert_eq!(active.revision, 2); + assert_eq!(active_domain, before); + assert_eq!(state.time_domain(), before); + assert_eq!(updates.recv().await, Some(Some(active))); + + let (retry, retry_domain) = state + .prepare_attachment(host, request) + .expect("an identical lost-reply retry is idempotent"); + assert_eq!(retry, active); + assert_eq!(retry_domain, before); + assert!(updates.try_recv().is_err(), "a retry publishes no phase"); + + assert_eq!( + state + .remove_attachment(producer(24)) + .expect_err("another producer cannot remove the attachment"), + AttachmentStateError::WrongHost { + expected: host, + observed: producer(24), + } + ); + let removing = state + .remove_attachment(host) + .expect("the bound host enters Removing"); + assert_eq!(removing.phase, SimulationAttachmentPhase::Removing); + assert_eq!(removing.revision, 3); + assert_eq!(updates.recv().await, Some(Some(removing))); + assert_eq!(state.time_domain(), before); + } + + #[test] + fn aborting_preparing_prevents_a_delayed_active_commit() { + let state = state(); + let host = producer(41); + let controller = producer(42); + state.record_presence( + &ParticipantId::new("brain").expect("valid brain id"), + producer(39), + true, + ); + state.record_presence( + &ParticipantId::new("drive").expect("valid service id"), + producer(40), + true, + ); + let request = AttachRequest::validated( + crate::model::world::WorldInstanceId::mint(), + controller, + crate::model::world::WorldProgress::at(1, 12).expect("valid progress"), + 12, + ) + .expect("valid attachment request"); + let (preparing, _) = state + .prepare_attachment(host, request) + .expect("preparation starts"); + + let removing = state + .abort_preparing_attachment(host, preparing.revision) + .expect("the exact Preparing revision rolls back"); + assert_eq!(removing.phase, SimulationAttachmentPhase::Removing); + assert_eq!( + state + .activate_attachment(host, preparing.revision) + .expect_err("a delayed acknowledgement cannot commit after rollback"), + AttachmentStateError::NotPreparing + ); + } + + #[test] + fn active_authority_loss_records_a_typed_reason_before_removing() { + let state = state(); + let host = producer(51); + let controller = producer(52); + state.record_presence( + &ParticipantId::new("brain").expect("valid brain id"), + producer(49), + true, + ); + state.record_presence( + &ParticipantId::new("drive").expect("valid service id"), + producer(50), + true, + ); + let request = AttachRequest::validated( + crate::model::world::WorldInstanceId::mint(), + controller, + crate::model::world::WorldProgress::at(1, 12).expect("valid progress"), + 12, + ) + .expect("valid attachment request"); + let (preparing, _) = state + .prepare_attachment(host, request) + .expect("preparation starts"); + let (active, _) = state + .activate_attachment(host, preparing.revision) + .expect("preparation commits"); + + let removing = state + .fail_active_attachment(active.revision, SimulationEndReason::HostLost) + .expect("the exact Active revision converges to Removing"); + assert_eq!(removing.phase, SimulationAttachmentPhase::Removing); + assert_eq!(state.attachment_failure(), Some(SimulationEndReason::HostLost)); + assert_eq!( + state + .fail_active_attachment(active.revision, SimulationEndReason::ControllerLost) + .expect_err("a stale liveness callback cannot rewrite terminal evidence"), + AttachmentStateError::NotActiveRevision + ); + assert_eq!(state.attachment_failure(), Some(SimulationEndReason::HostLost)); + } + + #[tokio::test] + async fn shutdown_publishes_removing_once_and_refuses_new_attachment_work() { + let state = state(); + let mut updates = state + .take_attachment_updates() + .expect("the attachment stream is available once"); + let host = producer(31); + let controller = producer(32); + state.record_presence( + &ParticipantId::new("brain").expect("valid brain id"), + producer(29), + true, + ); + state.record_presence( + &ParticipantId::new("drive").expect("valid service id"), + producer(30), + true, + ); + let request = AttachRequest::validated( + crate::model::world::WorldInstanceId::mint(), + controller, + crate::model::world::WorldProgress::at(3, 12).expect("valid progress"), + 12, + ) + .expect("the host validated its progress boundary"); + let (preparing, _) = state + .prepare_attachment(host, request) + .expect("preparation starts before shutdown"); + assert_eq!(updates.recv().await, Some(Some(preparing))); + + let removing = state + .begin_shutdown_attachment() + .expect("shutdown publishes terminal attachment evidence") + .expect("the attachment exists"); + assert_eq!(removing.phase, SimulationAttachmentPhase::Removing); + assert!(removing.revision > preparing.revision); + assert_eq!(updates.recv().await, Some(Some(removing))); + assert_eq!( + state.begin_shutdown_attachment().unwrap(), + Some(removing), + "repeated shutdown does not mint another revision" + ); + assert!(updates.try_recv().is_err()); + assert_eq!( + state + .prepare_attachment(host, request) + .expect_err("a stopping execution refuses attachment work"), + AttachmentStateError::Stopping + ); + } + + #[test] + fn a_exhausted_time_domain_revision_refuses_the_replacement() { + let state = state(); + { + let mut data = state.lock(); + data.time_domain.revision = u64::MAX; + } + + assert_eq!( + state + .replace_time_domain(TimeMode::Simulated) + .expect_err("an exhausted revision cannot identify a newer timeline"), + TimeDomainReplacementError::RevisionExhausted + ); + assert_eq!(state.lock().time_domain.revision, u64::MAX); + } } diff --git a/phoxal/src/supervisor/host/state/attachment.rs b/phoxal/src/supervisor/host/state/attachment.rs new file mode 100644 index 00000000..aad43208 --- /dev/null +++ b/phoxal/src/supervisor/host/state/attachment.rs @@ -0,0 +1,296 @@ +//! Serialized Live attachment transitions under the execution publication lock. + +use super::*; + +impl ExecutionState { + /// The current source-bound Live attachment, if any. + pub(crate) fn attachment(&self) -> Option { + self.lock().attachment + } + + /// Observe current attachment phase for internal liveness enforcement. + pub(crate) fn subscribe_attachment( + &self, + ) -> watch::Receiver> { + self.inner.attachment_current.subscribe() + } + + /// Take the one ordered stream of complete attachment replacements. + pub(crate) fn take_attachment_updates( + &self, + ) -> Result>, AttachmentStateError> { + self.inner + .attachment_receiver + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .ok_or(AttachmentStateError::StreamAlreadyTaken) + } + + /// Bind a proposed world and controller in Preparing without changing the + /// execution time domain. + pub(crate) fn prepare_attachment( + &self, + host: ProducerId, + request: AttachRequest, + ) -> Result<(SimulationAttachmentState, TimeDomain), AttachmentStateError> { + let mut data = self.lock(); + if data.stopping { + return Err(AttachmentStateError::Stopping); + } + if let Some(current) = data.attachment { + if current.host == host + && current.world == request.world() + && current.controller == request.controller() + && current.attached_at.world == request.progress() + && current.phase != SimulationAttachmentPhase::Removing + { + if current.phase == SimulationAttachmentPhase::Active + && !data.presence.admits_live_controller(current.controller) + { + return Err(AttachmentStateError::ControllerNotReady { + controller: current.controller, + }); + } + return Ok((current, data.time_domain)); + } + return Err(AttachmentStateError::AlreadyAttached { + world: current.world, + host: current.host, + controller: current.controller, + }); + } + if data.time_domain.mode != TimeMode::Monotonic { + return Err(AttachmentStateError::NonMonotonic); + } + if !data.presence.admits_live_controller(request.controller()) { + return Err(AttachmentStateError::ControllerNotReady { + controller: request.controller(), + }); + } + let Some(now) = LocalInstant::try_now() else { + return Err(AttachmentStateError::ClockUnavailable); + }; + let permit = reserve_attachment(&self.inner.attachment_updates)?; + let revision = data + .attachment_revision + .checked_add(1) + .ok_or(AttachmentStateError::RevisionExhausted)?; + let attachment = SimulationAttachmentState { + revision, + world: request.world(), + host, + controller: request.controller(), + phase: SimulationAttachmentPhase::Preparing, + attached_at: crate::model::world::LiveAttachmentBoundary { + world: request.progress(), + execution: RobotInstant::new(data.time_domain.timeline, now.boot_ns()), + }, + }; + data.attachment_revision = revision; + data.attachment = Some(attachment); + self.inner.attachment_current.send_replace(Some(attachment)); + permit.send(Some(attachment)); + Ok((attachment, data.time_domain)) + } + + /// Commit one Preparing transaction after its bound controller has + /// acknowledged the revision. + pub(crate) fn activate_attachment( + &self, + host: ProducerId, + preparing_revision: u64, + ) -> Result<(SimulationAttachmentState, TimeDomain), AttachmentStateError> { + let mut data = self.lock(); + let current = data.attachment.ok_or(AttachmentStateError::NotAttached)?; + if current.host != host { + return Err(AttachmentStateError::WrongHost { + expected: current.host, + observed: host, + }); + } + if current.phase == SimulationAttachmentPhase::Active { + return Ok((current, data.time_domain)); + } + if current.phase != SimulationAttachmentPhase::Preparing + || current.revision != preparing_revision + { + return Err(AttachmentStateError::NotPreparing); + } + if !data.presence.admits_live_controller(current.controller) { + return Err(AttachmentStateError::ControllerNotReady { + controller: current.controller, + }); + } + let permit = reserve_attachment(&self.inner.attachment_updates)?; + let revision = data + .attachment_revision + .checked_add(1) + .ok_or(AttachmentStateError::RevisionExhausted)?; + let active = SimulationAttachmentState { + revision, + phase: SimulationAttachmentPhase::Active, + ..current + }; + data.attachment_revision = revision; + data.attachment = Some(active); + self.inner.attachment_current.send_replace(Some(active)); + permit.send(Some(active)); + Ok((active, data.time_domain)) + } + + /// Enter Removing from the bound host. The execution retains the terminal + /// state until its ordinary supervisor shutdown completes. + pub(crate) fn remove_attachment( + &self, + host: ProducerId, + ) -> Result { + let mut data = self.lock(); + let current = data.attachment.ok_or(AttachmentStateError::NotAttached)?; + if current.host != host { + return Err(AttachmentStateError::WrongHost { + expected: current.host, + observed: host, + }); + } + if current.phase == SimulationAttachmentPhase::Removing { + return Ok(current); + } + transition_to_removing(&mut data, &self.inner, current) + } + + /// Abort exactly one still-Preparing transaction. A delayed waiter cannot + /// use this to remove a later revision. + pub(crate) fn abort_preparing_attachment( + &self, + host: ProducerId, + preparing_revision: u64, + ) -> Result { + let mut data = self.lock(); + let current = data.attachment.ok_or(AttachmentStateError::NotAttached)?; + if current.host != host { + return Err(AttachmentStateError::WrongHost { + expected: current.host, + observed: host, + }); + } + if current.phase != SimulationAttachmentPhase::Preparing + || current.revision != preparing_revision + { + return Err(AttachmentStateError::NotPreparing); + } + transition_to_removing(&mut data, &self.inner, current) + } + + /// Converge an exact Active revision to Removing after a typed liveness + /// failure. This is supervisor-owned rather than host-attributed. + pub(crate) fn fail_active_attachment( + &self, + active_revision: u64, + reason: SimulationEndReason, + ) -> Result { + let mut data = self.lock(); + let current = data.attachment.ok_or(AttachmentStateError::NotAttached)?; + if current.phase != SimulationAttachmentPhase::Active + || current.revision != active_revision + { + return Err(AttachmentStateError::NotActiveRevision); + } + data.stopping = true; + data.attachment_failure = Some(reason); + transition_to_removing(&mut data, &self.inner, current) + } + + pub(crate) fn attachment_failure(&self) -> Option { + self.lock().attachment_failure + } + + /// Refuse new attachment work and publish Removing before an intentional + /// supervisor shutdown tears down the transport. + pub(crate) fn begin_shutdown_attachment( + &self, + ) -> Result, AttachmentStateError> { + let mut data = self.lock(); + data.stopping = true; + let Some(current) = data.attachment else { + return Ok(None); + }; + if current.phase == SimulationAttachmentPhase::Removing { + return Ok(Some(current)); + } + transition_to_removing(&mut data, &self.inner, current).map(Some) + } + +} +fn reserve_attachment( + sender: &mpsc::Sender>, +) -> Result>, AttachmentStateError> { + sender.try_reserve().map_err(|error| match error { + mpsc::error::TrySendError::Full(()) => AttachmentStateError::StreamFull, + mpsc::error::TrySendError::Closed(()) => AttachmentStateError::StreamClosed, + }) +} +fn transition_to_removing( + data: &mut Data, + inner: &Inner, + current: SimulationAttachmentState, +) -> Result { + let permit = reserve_attachment(&inner.attachment_updates)?; + let revision = data + .attachment_revision + .checked_add(1) + .ok_or(AttachmentStateError::RevisionExhausted)?; + let removing = SimulationAttachmentState { + revision, + phase: SimulationAttachmentPhase::Removing, + ..current + }; + data.attachment_revision = revision; + data.attachment = Some(removing); + inner.attachment_current.send_replace(Some(removing)); + permit.send(Some(removing)); + Ok(removing) +} + +/// An attachment transition could not be admitted without losing serialized +/// state or violating source ownership. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub(crate) enum AttachmentStateError { + #[error("the execution attachment stream is already being served")] + StreamAlreadyTaken, + #[error("the execution attachment stream is saturated")] + StreamFull, + #[error("the execution attachment stream is unavailable")] + StreamClosed, + #[error("the execution attachment revision is exhausted")] + RevisionExhausted, + #[error("Live attachment requires the unchanged monotonic execution time domain")] + NonMonotonic, + #[error( + "controller {controller} does not exclusively hold every delegated driver Ready lease while all non-drivers are Ready" + )] + ControllerNotReady { controller: ProducerId }, + #[error("the host monotonic clock is unavailable")] + ClockUnavailable, + #[error("this execution has no simulation attachment")] + NotAttached, + #[error("the execution is stopping and refuses new simulation attachment work")] + Stopping, + #[error("the simulation attachment is not in the expected Preparing revision")] + NotPreparing, + #[error("the simulation attachment is not in the expected Active revision")] + NotActiveRevision, + #[error( + "execution is already attached to world {world} by host {host} and controller {controller}" + )] + AlreadyAttached { + world: crate::model::world::WorldInstanceId, + host: ProducerId, + controller: ProducerId, + }, + #[error("attachment is bound to host {expected}, not request source {observed}")] + WrongHost { + expected: ProducerId, + observed: ProducerId, + }, +} diff --git a/phoxal/src/world/api/mod.rs b/phoxal/src/world/api/mod.rs new file mode 100644 index 00000000..810403cc --- /dev/null +++ b/phoxal/src/world/api/mod.rs @@ -0,0 +1,51 @@ +//! The backend-neutral `world` wire family. + +crate::nodes! { + family World; + + session; +} + +/// The world family's endpoint and persisted-document compatibility surface. +#[doc(hidden)] +pub mod __compat { + use crate::__compat::surface::{ContractRecord, ContractSurface}; + + /// The canonical rendering of the complete world-owned contract surface. + #[must_use] + pub fn contract_surface() -> String { + let mut records = Vec::new(); + contract_records(&mut records); + ContractSurface::new(records).canonical_json() + } + + /// Every endpoint and persisted document owned by the world family. + pub(crate) fn contract_records(out: &mut Vec) { + super::contract_records(out); + super::session::document::__compat::contract_records(out); + } + + #[cfg(test)] + mod tests { + use super::contract_surface; + + #[test] + fn the_surface_contains_all_session_documents_and_endpoints() { + let rendered = contract_surface(); + serde_json::from_str::(&rendered).expect("the surface is JSON"); + assert_eq!(contract_surface(), rendered); + for expected in [ + r#""tag":"phoxal/local-world-registration/v0""#, + r#""tag":"phoxal/world-checkpoint/v0""#, + r#""tag":"phoxal/world-member-terminal/v0""#, + r#""tag":"phoxal/world-terminal-summary/v0""#, + r#""path":"world/session/state""#, + ] { + assert!( + rendered.contains(expected), + "{expected} missing: {rendered}" + ); + } + } + } +} diff --git a/phoxal/src/world/api/session/connect.rs b/phoxal/src/world/api/session/connect.rs new file mode 100644 index 00000000..5c944cc0 --- /dev/null +++ b/phoxal/src/world/api/session/connect.rs @@ -0,0 +1,48 @@ +//! Frozen host bootstrap and idempotent fresh-execution attachment. + +crate::endpoints! { + self: Query; +} + +use super::state::WorldSessionState; +use super::{SpawnId, WorldDigest, WorldId, WorldInstanceId}; +use crate::identity::ExecutionId; +use crate::version::FrameworkVersion; + +/// The immutable facts a registry lookup verifies before trusting a live host. +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct WorldSessionBootstrap { + pub instance: WorldInstanceId, + pub framework: FrameworkVersion, + pub world: WorldId, + pub digest: WorldDigest, +} + +/// One request on the local session's frozen entry point. +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum WorldSessionConnectRequest { + Bootstrap { framework: FrameworkVersion }, + Attach { + framework: FrameworkVersion, + instance: WorldInstanceId, + execution: ExecutionId, + supervisor_endpoint: String, + spawn: Option, + }, +} + +/// A bootstrap observation or the complete state after idempotent admission. +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum WorldSessionConnectResponse { + Bootstrap { bootstrap: WorldSessionBootstrap }, + Attached { state: WorldSessionState }, +} diff --git a/phoxal/src/world/api/session/control.rs b/phoxal/src/world/api/session/control.rs new file mode 100644 index 00000000..0ba31c35 --- /dev/null +++ b/phoxal/src/world/api/session/control.rs @@ -0,0 +1,52 @@ +//! Explicit idempotent world motion and stop requests. + +crate::endpoints! { + self: Query; +} + +use super::WorldInstanceId; +use super::state::WorldSessionState; + +#[derive( + phoxal_macros::DescribeWire, + Clone, + Copy, + Debug, + Eq, + PartialEq, + serde::Serialize, + serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct WorldSessionControlRequest { + /// The world instance the operation must target before it is dispatched. + pub instance: WorldInstanceId, + /// The idempotent world motion operation to apply. + pub operation: WorldControl, +} + +/// One idempotent world motion operation. +#[derive( + phoxal_macros::DescribeWire, + Clone, + Copy, + Debug, + Eq, + PartialEq, + serde::Serialize, + serde::Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum WorldControl { + Pause, + Resume, + Stop, +} + +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct WorldSessionControlResponse { + pub state: WorldSessionState, +} diff --git a/phoxal/src/world/api/session/diagnostics.rs b/phoxal/src/world/api/session/diagnostics.rs new file mode 100644 index 00000000..b1e2abaa --- /dev/null +++ b/phoxal/src/world/api/session/diagnostics.rs @@ -0,0 +1,183 @@ +//! Bounded operational evidence that never controls pacing or scheduling. + +use super::WorldInstanceId; + +crate::endpoints! { + self: Stream; + current: Query; +} + +/// One positive completed running window. +#[derive( + phoxal_macros::DescribeWire, + Clone, + Copy, + Debug, + Eq, + PartialEq, + serde::Serialize, + serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct ObservedWorldPacing { + pub world_elapsed_ns: u64, + pub host_elapsed_ns: u64, + pub completed_transitions: u64, +} + +impl ObservedWorldPacing { + #[must_use] + pub const fn is_valid(self) -> bool { + self.world_elapsed_ns > 0 && self.host_elapsed_ns > 0 && self.completed_transitions > 0 + } +} + +#[derive( + phoxal_macros::DescribeWire, + Clone, + Copy, + Debug, + Eq, + PartialEq, + serde::Serialize, + serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct WorldSessionDiagnostics { + pub revision: u64, + pub pacing: Option, + pub last_transition_age_ns: Option, +} + +impl WorldSessionDiagnostics { + /// Validate the bounded diagnostic window. + /// + /// # Errors + /// + /// Returns [`WorldSessionDiagnosticsError`] when a present pacing window + /// contains no elapsed world time, host time, or completed transition. + pub fn validate(self) -> Result<(), WorldSessionDiagnosticsError> { + if self.pacing.is_some_and(ObservedWorldPacing::is_valid) || self.pacing.is_none() { + Ok(()) + } else { + Err(WorldSessionDiagnosticsError) + } + } +} + +/// A diagnostics value whose pacing window cannot represent an observation. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +#[error("a world pacing window must contain positive world time, host time, and transitions")] +pub struct WorldSessionDiagnosticsError; + +#[derive( + phoxal_macros::DescribeWire, + Clone, + Copy, + Debug, + Eq, + PartialEq, + serde::Serialize, + serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct WorldSessionDiagnosticsStream { + pub diagnostics: WorldSessionDiagnostics, +} + +#[derive( + phoxal_macros::DescribeWire, + Clone, + Copy, + Debug, + Eq, + PartialEq, + serde::Serialize, + serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct WorldSessionDiagnosticsCurrentRequest { + pub instance: WorldInstanceId, +} + +/// Identity binding for a long-lived diagnostics subscription. +#[derive( + phoxal_macros::DescribeWire, Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct WorldSessionDiagnosticsSubscriptionRequest { + pub instance: WorldInstanceId, +} + +#[derive( + phoxal_macros::DescribeWire, + Clone, + Copy, + Debug, + Eq, + PartialEq, + serde::Serialize, + serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct WorldSessionDiagnosticsCurrentResponse { + pub diagnostics: WorldSessionDiagnostics, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pacing_windows_are_absent_or_strictly_positive() { + assert!( + WorldSessionDiagnostics { + revision: 0, + pacing: None, + last_transition_age_ns: None, + } + .validate() + .is_ok() + ); + assert!( + WorldSessionDiagnostics { + revision: 1, + pacing: Some(ObservedWorldPacing { + world_elapsed_ns: 1, + host_elapsed_ns: 1, + completed_transitions: 1, + }), + last_transition_age_ns: Some(0), + } + .validate() + .is_ok() + ); + for invalid in [ + ObservedWorldPacing { + world_elapsed_ns: 0, + host_elapsed_ns: 1, + completed_transitions: 1, + }, + ObservedWorldPacing { + world_elapsed_ns: 1, + host_elapsed_ns: 0, + completed_transitions: 1, + }, + ObservedWorldPacing { + world_elapsed_ns: 1, + host_elapsed_ns: 1, + completed_transitions: 0, + }, + ] { + assert!( + WorldSessionDiagnostics { + revision: 2, + pacing: Some(invalid), + last_transition_age_ns: Some(0), + } + .validate() + .is_err() + ); + } + } +} diff --git a/phoxal/src/world/api/session/document.rs b/phoxal/src/world/api/session/document.rs new file mode 100644 index 00000000..4b81ee4b --- /dev/null +++ b/phoxal/src/world/api/session/document.rs @@ -0,0 +1,1014 @@ +//! Durable local world-session documents shared across adapter and client ownership. +//! +//! This module owns only the versioned serialized records and their pure +//! structural validation. Filesystem layout and permissions, registration +//! leases, process liveness, orphan recovery, cleanup execution, and retention +//! policy remain responsibilities of the concrete host and local client. + +use std::collections::BTreeSet; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use super::state::WorldSessionState; +use super::{WorldMember, WorldMemberTerminal}; +use crate::identity::{ExecutionId, ProducerId}; +use crate::model::identity::WorldId; +use crate::model::world::{WorldDigest, WorldInstanceId, WorldProgress, WorldProvenance}; +use crate::supervisor::api::simulation::SimulationEndReason; +use crate::version::FrameworkVersion; + +/// Schema of one immutable live local-world locator. +pub const LOCAL_WORLD_REGISTRATION_SCHEMA: &str = "phoxal/local-world-registration/v0"; + +/// Schema of one durable world checkpoint. +pub const WORLD_CHECKPOINT_SCHEMA: &str = "phoxal/world-checkpoint/v0"; + +/// Schema of one complete terminal world summary. +pub const WORLD_TERMINAL_SUMMARY_SCHEMA: &str = "phoxal/world-terminal-summary/v0"; + +/// Schema of one terminal member record. +pub const WORLD_MEMBER_TERMINAL_SCHEMA: &str = "phoxal/world-member-terminal/v0"; + +/// A structurally invalid durable world-session document. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[error("{detail}")] +pub struct WorldSessionDocumentError { + detail: String, +} + +impl WorldSessionDocumentError { + fn new(detail: impl Into) -> Self { + Self { + detail: detail.into(), + } + } +} + +/// An operating-system process identified across PID reuse. +#[derive( + phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, +)] +#[serde(deny_unknown_fields)] +pub struct ProcessIdentity { + /// Process identifier assigned by the operating system. + pub pid: u32, + /// Process birth time reported as Unix seconds. + pub started_at_unix_s: u64, +} + +impl ProcessIdentity { + /// Validate the process identity without consulting the process table. + /// + /// # Errors + /// + /// Returns an error when the operating system process identifier is zero. + pub fn validate(self) -> Result<(), WorldSessionDocumentError> { + if self.pid == 0 { + return Err(WorldSessionDocumentError::new( + "process identity PID must be positive", + )); + } + Ok(()) + } +} + +/// Exact native process-tree ownership retained for orphan convergence. +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Eq, PartialEq, Serialize, +)] +#[serde(deny_unknown_fields)] +pub struct NativeProcessIdentity { + /// Direct native process identity. + pub process: ProcessIdentity, + /// Canonical executable used to validate the process before signalling it. + pub executable: PathBuf, + /// Owned Unix process group, when the platform supplies that primitive. + pub process_group: Option, +} + +impl NativeProcessIdentity { + /// Validate identity fields without inspecting or signalling a process. + /// + /// # Errors + /// + /// Returns an error for a zero PID, an empty executable, or a zero process + /// group. Filesystem canonicality and platform ownership remain local checks. + pub fn validate(&self) -> Result<(), WorldSessionDocumentError> { + self.process.validate()?; + if self.executable.as_os_str().is_empty() { + return Err(WorldSessionDocumentError::new( + "native process executable is empty", + )); + } + if self.process_group == Some(0) { + return Err(WorldSessionDocumentError::new( + "native process group must be positive", + )); + } + Ok(()) + } +} + +/// Immutable world identity carried by a local registration. +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Eq, PartialEq, Serialize, +)] +#[serde(deny_unknown_fields)] +pub struct RegisteredWorld { + /// Compiled world identity. + pub id: WorldId, + /// Digest of the canonical world bundle. + pub digest: WorldDigest, +} + +/// Immutable locator written while one local world host holds its lease. +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Eq, PartialEq, Serialize, +)] +#[serde(deny_unknown_fields)] +pub struct LocalWorldRegistration { + /// Exact document schema. + pub schema: String, + /// Hosted world-session identity. + pub instance: WorldInstanceId, + /// Loopback endpoint of the typed world-session API. + pub endpoint: String, + /// Host process identity. + pub process: ProcessIdentity, + /// Framework train served by the host. + pub framework: FrameworkVersion, + /// Immutable compiled world identity. + pub world: RegisteredWorld, + /// Instance-relative lease filename. + pub lease: String, +} + +impl LocalWorldRegistration { + /// Validate fields that do not require the lease file or process table. + /// + /// # Errors + /// + /// Returns an error for an unsupported schema, a mismatched instance, an + /// empty endpoint, or an invalid process identity. + pub fn validate_structure( + &self, + expected_instance: WorldInstanceId, + ) -> Result<(), WorldSessionDocumentError> { + require_schema( + "local world registration", + &self.schema, + LOCAL_WORLD_REGISTRATION_SCHEMA, + )?; + if self.instance != expected_instance { + return Err(WorldSessionDocumentError::new(format!( + "registration {expected_instance} claims world instance {}", + self.instance + ))); + } + if self.endpoint.is_empty() { + return Err(WorldSessionDocumentError::new( + "world registration endpoint is empty", + )); + } + self.process.validate() + } +} + +/// Last durable typed world state and process ownership written by the host. +#[derive(phoxal_macros::DescribeWire, Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct WorldCheckpoint { + /// Exact document schema. + pub schema: String, + /// Host process identity. + pub process: ProcessIdentity, + /// Separately grouped native process tree, once launched. + pub native_process: Option, + /// Last complete public world state. + pub state: WorldSessionState, + /// Host wall-clock write time as Unix milliseconds. + pub updated_at_unix_ms: u64, +} + +impl WorldCheckpoint { + /// Validate the checkpoint against its immutable live registration. + /// + /// # Errors + /// + /// Returns an error when either document is structurally inconsistent. + /// Process liveness, executable canonicality, and process-group ownership + /// require local platform checks and are deliberately outside this method. + pub fn validate_structure( + &self, + registration: &LocalWorldRegistration, + ) -> Result<(), WorldSessionDocumentError> { + require_schema( + "world checkpoint", + &self.schema, + WORLD_CHECKPOINT_SCHEMA, + )?; + if self.process != registration.process { + return Err(WorldSessionDocumentError::new(format!( + "world checkpoint process identity disagrees with registration for {}", + registration.instance + ))); + } + validate_timestamp_after_process( + self.updated_at_unix_ms, + self.process, + "world checkpoint predates registered host process birth", + )?; + if self.state.instance != registration.instance { + return Err(WorldSessionDocumentError::new(format!( + "world checkpoint instance {} disagrees with registration {}", + self.state.instance, registration.instance + ))); + } + if self.state.provenance.framework != registration.framework + || self.state.provenance.world != registration.world.id + || self.state.provenance.digest != registration.world.digest + { + return Err(WorldSessionDocumentError::new(format!( + "world checkpoint provenance disagrees with registration for {}", + registration.instance + ))); + } + self.state.validate().map_err(|source| { + WorldSessionDocumentError::new(format!("invalid checkpoint world state: {source}")) + })?; + if let Some(native) = &self.native_process { + native.validate()?; + validate_timestamp_after_process( + self.updated_at_unix_ms, + native.process, + "world checkpoint predates its recorded native process birth", + )?; + } + Ok(()) + } +} + +/// One member-terminal artifact indexed by a world summary. +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Eq, PartialEq, Serialize, +)] +#[serde(deny_unknown_fields)] +pub struct WorldMemberEvidenceIndex { + /// Execution whose terminal evidence is indexed. + pub execution: ExecutionId, + /// Session-relative path to the member record. + pub path: String, +} + +/// Persisted terminal evidence for one former member. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct WorldMemberEvidence { + /// Exact document schema. + pub schema: String, + /// Generic member-terminal payload flattened into the document root. + #[serde(flatten)] + pub terminal: WorldMemberTerminal, +} + +// The derive deliberately rejects `serde(flatten)`. This record's serializer +// writes the schema field followed by the exact fields of WorldMemberTerminal. +impl crate::__compat::wire::DescribeWire for WorldMemberEvidence { + fn wire_schema() -> crate::__compat::wire::WireSchema { + use crate::__compat::wire::{WireField, WireSchema}; + + WireSchema::structure([ + WireField::required("schema", String::wire_schema()), + WireField::required("execution", ExecutionId::wire_schema()), + WireField::required("robot", crate::identity::RobotId::wire_schema()), + WireField::required("controller", ProducerId::wire_schema()), + WireField::required("spawn", crate::model::identity::SpawnId::wire_schema()), + WireField::required( + "reason", + super::WorldMemberEndReason::wire_schema(), + ), + WireField::required("last_progress", WorldProgress::wire_schema()), + WireField::required("cleanup", super::WorldMemberCleanup::wire_schema()), + WireField::required("evidence_paths", Vec::::wire_schema()), + ]) + } +} + +impl WorldMemberEvidence { + /// Validate the record wrapper against the execution named by its filename. + /// + /// # Errors + /// + /// Returns an error for an unsupported schema or mismatched execution. + pub fn validate_structure( + &self, + expected_execution: ExecutionId, + ) -> Result<(), WorldSessionDocumentError> { + require_schema( + "member evidence", + &self.schema, + WORLD_MEMBER_TERMINAL_SCHEMA, + )?; + if self.terminal.execution != expected_execution { + return Err(WorldSessionDocumentError::new(format!( + "member evidence for {expected_execution} contains execution {}", + self.terminal.execution + ))); + } + Ok(()) + } +} + +/// Whether one world stopped orderly or failed. +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Eq, PartialEq, Serialize, +)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum TerminalOutcome { + /// Orderly world termination. + Stopped { + /// Typed stop reason. + reason: SimulationEndReason, + }, + /// Failed world termination. + Failed { + /// Typed failure reason. + reason: SimulationEndReason, + /// Human-readable evidence for the specific occurrence. + detail: String, + }, +} + +impl TerminalOutcome { + /// Stable display category used by local clients. + #[must_use] + pub const fn kind(&self) -> &'static str { + match self { + Self::Stopped { .. } => "stopped", + Self::Failed { .. } => "failed", + } + } + + /// Typed reason carried by either outcome. + #[must_use] + pub const fn reason(&self) -> SimulationEndReason { + match self { + Self::Stopped { reason } | Self::Failed { reason, .. } => *reason, + } + } + + /// Occurrence detail for a failed outcome. + #[must_use] + pub fn detail(&self) -> Option<&str> { + match self { + Self::Stopped { .. } => None, + Self::Failed { detail, .. } => Some(detail), + } + } + + /// Validate the relationship between outcome kind and end reason. + /// + /// # Errors + /// + /// Returns an error unless `WorldStopped` is carried exclusively by the + /// orderly stopped outcome. + pub fn validate(&self) -> Result<(), WorldSessionDocumentError> { + match self { + Self::Stopped { + reason: SimulationEndReason::WorldStopped, + } => Ok(()), + Self::Stopped { reason } => Err(WorldSessionDocumentError::new(format!( + "stopped terminal outcome cannot carry failure reason {reason:?}" + ))), + Self::Failed { + reason: SimulationEndReason::WorldStopped, + .. + } => Err(WorldSessionDocumentError::new( + "failed terminal outcome cannot carry WorldStopped", + )), + Self::Failed { detail, .. } if detail.is_empty() => Err( + WorldSessionDocumentError::new("failed terminal outcome requires nonempty detail"), + ), + Self::Failed { .. } => Ok(()), + } + } +} + +/// Process or producer attributed as the terminal failure source. +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Eq, PartialEq, Serialize, +)] +#[serde(deny_unknown_fields)] +pub struct TerminalFailure { + /// Exact process identity, when process attribution is available. + pub process: Option, + /// Exact producer identity, when producer attribution is available. + pub producer: Option, +} + +impl TerminalFailure { + /// Validate any process attribution without consulting the process table. + /// + /// # Errors + /// + /// Returns an error when the attributed process has a zero PID. + pub fn validate(&self) -> Result<(), WorldSessionDocumentError> { + if let Some(process) = self.process { + process.validate()?; + } + Ok(()) + } +} + +/// Whether terminal cleanup removed every owned resource. +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Eq, PartialEq, Serialize, +)] +#[serde(deny_unknown_fields)] +pub struct TerminalCleanup { + /// True only when cleanup converged without known residue. + pub complete: bool, + /// Cleanup failure detail when convergence was incomplete. + pub detail: Option, +} + +impl TerminalCleanup { + /// Validate that cleanup success and detail do not contradict each other. + /// + /// # Errors + /// + /// Returns an error when complete cleanup carries failure detail or + /// incomplete cleanup does not carry nonempty detail. + pub fn validate(&self) -> Result<(), WorldSessionDocumentError> { + match (self.complete, self.detail.as_deref()) { + (true, None) => Ok(()), + (true, Some(_)) => Err(WorldSessionDocumentError::new( + "complete terminal cleanup cannot carry failure detail", + )), + (false, Some(detail)) if !detail.is_empty() => Ok(()), + (false, None | Some(_)) => Err(WorldSessionDocumentError::new( + "incomplete terminal cleanup requires nonempty failure detail", + )), + } + } +} + +/// Bounded evidence-retention outcome for one terminal session. +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Eq, PartialEq, Serialize, +)] +#[serde(deny_unknown_fields)] +pub struct TerminalRetention { + /// Total byte limit configured for retained logs. + pub log_byte_limit: u64, + /// Session-relative evidence files truncated at their bounds. + pub truncated: Vec, +} + +impl TerminalRetention { + /// Validate the bounded-retention accounting. + /// + /// # Errors + /// + /// Returns an error for a zero byte limit or duplicate truncated paths. + pub fn validate(&self) -> Result<(), WorldSessionDocumentError> { + if self.log_byte_limit == 0 { + return Err(WorldSessionDocumentError::new( + "terminal retention log byte limit must be positive", + )); + } + let mut truncated = BTreeSet::new(); + for path in &self.truncated { + if !truncated.insert(path) { + return Err(WorldSessionDocumentError::new(format!( + "terminal retention lists truncated path `{path}` more than once" + ))); + } + } + Ok(()) + } +} + +/// Complete terminal projection written only after world cleanup converges. +#[derive(phoxal_macros::DescribeWire, Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct WorldTerminalSummary { + /// Exact document schema. + pub schema: String, + /// Terminal world-session identity. + pub instance: WorldInstanceId, + /// Immutable world provenance. + pub provenance: WorldProvenance, + /// Typed terminal outcome. + pub outcome: TerminalOutcome, + /// Last authoritative world progress. + pub progress: WorldProgress, + /// Last complete public member projection before cleanup. + pub members: Vec, + /// Indexed per-member terminal artifacts. + pub member_evidence: Vec, + /// Best available failure attribution. + pub failing: TerminalFailure, + /// Session-relative world evidence paths. + pub evidence: Vec, + /// Cleanup convergence result. + pub cleanup: TerminalCleanup, + /// Bounded-retention result. + pub retention: TerminalRetention, + /// Terminal write time as Unix milliseconds. + pub ended_at_unix_ms: u64, +} + +impl WorldTerminalSummary { + /// Validate relationships contained entirely in the terminal document. + /// + /// # Errors + /// + /// Returns an error for an unsupported schema, a mismatched instance, + /// invalid progress, duplicate member evidence, or a member-evidence path + /// that disagrees with its execution. Filesystem path safety remains local. + pub fn validate_structure( + &self, + expected_instance: WorldInstanceId, + ) -> Result<(), WorldSessionDocumentError> { + require_schema( + "terminal world summary", + &self.schema, + WORLD_TERMINAL_SUMMARY_SCHEMA, + )?; + if self.instance != expected_instance { + return Err(WorldSessionDocumentError::new(format!( + "terminal summary for {expected_instance} contains instance {}", + self.instance + ))); + } + self.progress + .validate(self.provenance.time_step_ns) + .map_err(|source| { + WorldSessionDocumentError::new(format!( + "terminal world progress disagrees with retained provenance: {source}" + )) + })?; + for member in &self.members { + member + .attached_at + .world + .validate(self.provenance.time_step_ns) + .map_err(|source| { + WorldSessionDocumentError::new(format!( + "terminal member {} attachment disagrees with retained provenance: {source}", + member.execution + )) + })?; + if member.attached_at.world.completed_step() > self.progress.completed_step() + || member.attached_at.world.elapsed_ns() > self.progress.elapsed_ns() + { + return Err(WorldSessionDocumentError::new(format!( + "terminal member {} attachment cannot be ahead of retained progress", + member.execution + ))); + } + } + if self + .members + .windows(2) + .any(|pair| pair[0].execution.to_string() >= pair[1].execution.to_string()) + { + return Err(WorldSessionDocumentError::new( + "terminal world members must be unique and ordered by ExecutionId", + )); + } + let mut indexed_members = BTreeSet::new(); + for member in &self.member_evidence { + let expected_path = format!("members/{}.json", member.execution); + if member.path != expected_path { + return Err(WorldSessionDocumentError::new(format!( + "member evidence path `{}` disagrees with execution {}", + member.path, member.execution + ))); + } + if !indexed_members.insert(member.execution.to_string()) { + return Err(WorldSessionDocumentError::new(format!( + "member evidence indexes execution {} more than once", + member.execution + ))); + } + } + self.outcome.validate()?; + self.failing.validate()?; + self.cleanup.validate()?; + self.retention.validate()?; + Ok(()) + } +} + +fn require_schema( + document: &str, + actual: &str, + expected: &'static str, +) -> Result<(), WorldSessionDocumentError> { + if actual != expected { + return Err(WorldSessionDocumentError::new(format!( + "unsupported {document} schema `{actual}`" + ))); + } + Ok(()) +} + +fn validate_timestamp_after_process( + timestamp_unix_ms: u64, + process: ProcessIdentity, + message: &'static str, +) -> Result<(), WorldSessionDocumentError> { + let process_unix_ms = process + .started_at_unix_s + .checked_mul(1_000) + .ok_or_else(|| WorldSessionDocumentError::new("process birth time overflows milliseconds"))?; + if timestamp_unix_ms < process_unix_ms { + return Err(WorldSessionDocumentError::new(message)); + } + Ok(()) +} + +/// Compatibility records for the four schema-tagged persisted documents. +#[doc(hidden)] +pub mod __compat { + use super::{ + LOCAL_WORLD_REGISTRATION_SCHEMA, LocalWorldRegistration, WORLD_CHECKPOINT_SCHEMA, + WORLD_MEMBER_TERMINAL_SCHEMA, WORLD_TERMINAL_SUMMARY_SCHEMA, WorldCheckpoint, + WorldMemberEvidence, WorldTerminalSummary, + }; + use crate::__compat::surface::ContractRecord; + use crate::__compat::wire::DescribeWire; + + pub(crate) fn contract_records(out: &mut Vec) { + out.extend([ + ContractRecord::document( + "LocalWorldRegistration", + LOCAL_WORLD_REGISTRATION_SCHEMA, + LocalWorldRegistration::wire_schema(), + ), + ContractRecord::document( + "WorldCheckpoint", + WORLD_CHECKPOINT_SCHEMA, + WorldCheckpoint::wire_schema(), + ), + ContractRecord::document( + "WorldMemberEvidence", + WORLD_MEMBER_TERMINAL_SCHEMA, + WorldMemberEvidence::wire_schema(), + ), + ContractRecord::document( + "WorldTerminalSummary", + WORLD_TERMINAL_SUMMARY_SCHEMA, + WorldTerminalSummary::wire_schema(), + ), + ]); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::__compat::wire::DescribeWire; + use crate::bus::RobotInstant; + use crate::identity::TimelineId; + use crate::model::identity::{RobotId, SpawnId}; + use crate::model::world::LiveAttachmentBoundary; + use crate::world::api::session::{ + WorldLifecycle, WorldMemberCleanup, WorldMemberEndReason, WorldMemberPhase, + }; + + fn execution() -> ExecutionId { + ExecutionId::parse("10000000000000000000000000000001") + .expect("canonical execution") + } + + fn instance() -> WorldInstanceId { + WorldInstanceId::parse("20000000000000000000000000000002") + .expect("canonical world instance") + } + + fn registration() -> LocalWorldRegistration { + LocalWorldRegistration { + schema: LOCAL_WORLD_REGISTRATION_SCHEMA.to_owned(), + instance: instance(), + endpoint: "tcp://127.0.0.1:7000".to_owned(), + process: ProcessIdentity { + pid: 42, + started_at_unix_s: 99, + }, + framework: FrameworkVersion::CURRENT, + world: RegisteredWorld { + id: WorldId::new("warehouse").expect("world id"), + digest: WorldDigest::parse(&"aa".repeat(32)).expect("world digest"), + }, + lease: format!("{}.lease", instance()), + } + } + + fn provenance() -> WorldProvenance { + WorldProvenance { + world: WorldId::new("warehouse").expect("world id"), + digest: WorldDigest::parse(&"aa".repeat(32)).expect("world digest"), + random_seed: 7, + framework: FrameworkVersion::CURRENT, + adapter: "webots".to_owned(), + adapter_version: "0.68.0".to_owned(), + simulator_version: "R2025a".to_owned(), + platform: "test".to_owned(), + time_step_ns: 12_000_000, + } + } + + fn member(execution: &str, producer: u128, attached_step: u64) -> WorldMember { + WorldMember { + execution: ExecutionId::parse(execution).expect("canonical execution"), + robot: RobotId::new("rover").expect("robot id"), + controller: ProducerId::try_from(producer).expect("producer"), + phase: WorldMemberPhase::Active, + attached_at: LiveAttachmentBoundary { + world: WorldProgress::at(attached_step, 12_000_000) + .expect("attachment progress"), + execution: RobotInstant::new( + TimelineId::from_raw(1).expect("timeline"), + attached_step, + ), + }, + spawn: SpawnId::new("bay").expect("spawn"), + initial_pose: serde_json::from_value(serde_json::json!({ + "xyz": [0.0, 0.0, 0.0], + "rpy": [0.0, 0.0, 0.0] + })) + .expect("pose"), + } + } + + fn summary(members: Vec, completed_step: u64) -> WorldTerminalSummary { + WorldTerminalSummary { + schema: WORLD_TERMINAL_SUMMARY_SCHEMA.to_owned(), + instance: instance(), + provenance: provenance(), + outcome: TerminalOutcome::Stopped { + reason: SimulationEndReason::WorldStopped, + }, + progress: WorldProgress::at(completed_step, 12_000_000).expect("world progress"), + members, + member_evidence: Vec::new(), + failing: TerminalFailure { + process: None, + producer: None, + }, + evidence: vec!["host.log".to_owned(), "webots.log".to_owned()], + cleanup: TerminalCleanup { + complete: true, + detail: None, + }, + retention: TerminalRetention { + log_byte_limit: 1024, + truncated: Vec::new(), + }, + ended_at_unix_ms: 100_000, + } + } + + #[test] + fn registration_and_process_identity_keep_the_exact_v0_shape() { + let registration = registration(); + registration + .validate_structure(instance()) + .expect("registration validates"); + let value = serde_json::to_value(®istration).expect("registration encodes"); + assert_eq!( + LocalWorldRegistration::wire_schema().conforms(&value), + Ok(()) + ); + assert_eq!(value["schema"], LOCAL_WORLD_REGISTRATION_SCHEMA); + assert_eq!(value["process"]["started_at_unix_s"], 99); + assert!(value.get("controller_endpoint").is_none()); + assert_eq!( + serde_json::from_value::(value) + .expect("registration decodes"), + registration + ); + } + + #[test] + fn registration_validation_rejects_a_zero_process_and_wrong_instance() { + let mut registration = registration(); + registration.process.pid = 0; + assert_eq!( + registration + .validate_structure(instance()) + .expect_err("zero PID is rejected") + .to_string(), + "process identity PID must be positive" + ); + registration.process.pid = 42; + let other = WorldInstanceId::parse("30000000000000000000000000000003") + .expect("other world instance"); + assert!( + registration + .validate_structure(other) + .expect_err("wrong instance is rejected") + .to_string() + .contains("claims world instance") + ); + } + + #[test] + fn checkpoint_round_trips_and_validates_against_registration() { + let registration = registration(); + let checkpoint = WorldCheckpoint { + schema: WORLD_CHECKPOINT_SCHEMA.to_owned(), + process: registration.process, + native_process: Some(NativeProcessIdentity { + process: ProcessIdentity { + pid: 43, + started_at_unix_s: 99, + }, + executable: PathBuf::from("/Webots"), + process_group: Some(43), + }), + state: WorldSessionState { + revision: 0, + instance: instance(), + provenance: WorldProvenance { + framework: registration.framework, + world: registration.world.id.clone(), + digest: registration.world.digest, + ..provenance() + }, + lifecycle: WorldLifecycle::Starting, + progress: WorldProgress::zero(12_000_000).expect("zero progress"), + members: Vec::new(), + }, + updated_at_unix_ms: 100_000, + }; + checkpoint + .validate_structure(®istration) + .expect("checkpoint validates"); + let value = serde_json::to_value(&checkpoint).expect("checkpoint encodes"); + assert_eq!(WorldCheckpoint::wire_schema().conforms(&value), Ok(())); + assert_eq!(value["schema"], WORLD_CHECKPOINT_SCHEMA); + assert_eq!( + serde_json::from_value::(value).expect("checkpoint decodes"), + checkpoint + ); + } + + #[test] + fn member_evidence_flattens_and_round_trips_the_generic_terminal_payload() { + let member = WorldMemberEvidence { + schema: WORLD_MEMBER_TERMINAL_SCHEMA.to_owned(), + terminal: WorldMemberTerminal { + execution: execution(), + robot: RobotId::new("rover").expect("robot id"), + controller: ProducerId::try_from( + 0x3000_0000_0000_0000_0000_0000_0000_0003, + ) + .expect("producer"), + spawn: SpawnId::new("bay").expect("spawn"), + reason: WorldMemberEndReason::Stopped, + last_progress: WorldProgress::zero(12_000_000).expect("progress"), + cleanup: WorldMemberCleanup::Complete, + evidence_paths: vec![format!("members/{}.actuation.json", execution())], + }, + }; + member + .validate_structure(execution()) + .expect("member evidence validates"); + let value = serde_json::to_value(&member).expect("member evidence encodes"); + assert_eq!(WorldMemberEvidence::wire_schema().conforms(&value), Ok(())); + assert_eq!(value["schema"], WORLD_MEMBER_TERMINAL_SCHEMA); + assert_eq!(value["execution"], execution().to_string()); + assert!(value.get("terminal").is_none()); + assert_eq!( + serde_json::from_value::(value) + .expect("member evidence decodes"), + member + ); + } + + #[test] + fn terminal_outcome_helpers_preserve_the_tagged_shape() { + let outcome = TerminalOutcome::Failed { + reason: SimulationEndReason::HostLost, + detail: "host disappeared".to_owned(), + }; + assert_eq!(outcome.kind(), "failed"); + assert_eq!(outcome.reason(), SimulationEndReason::HostLost); + assert_eq!(outcome.detail(), Some("host disappeared")); + assert_eq!( + serde_json::to_value(outcome).expect("outcome encodes"), + serde_json::json!({ + "kind": "failed", + "reason": "host_lost", + "detail": "host disappeared" + }) + ); + } + + #[test] + fn terminal_outcome_cleanup_and_retention_reject_contradictions() { + assert!( + TerminalOutcome::Stopped { + reason: SimulationEndReason::HostLost, + } + .validate() + .is_err() + ); + assert!( + TerminalOutcome::Failed { + reason: SimulationEndReason::WorldStopped, + detail: "contradiction".to_owned(), + } + .validate() + .is_err() + ); + assert!( + TerminalOutcome::Failed { + reason: SimulationEndReason::HostLost, + detail: String::new(), + } + .validate() + .is_err() + ); + assert!( + TerminalCleanup { + complete: true, + detail: Some("not complete".to_owned()), + } + .validate() + .is_err() + ); + assert!( + TerminalCleanup { + complete: false, + detail: Some(String::new()), + } + .validate() + .is_err() + ); + assert!( + TerminalRetention { + log_byte_limit: 0, + truncated: Vec::new(), + } + .validate() + .is_err() + ); + assert!( + TerminalRetention { + log_byte_limit: 1, + truncated: vec!["host.log".to_owned(), "host.log".to_owned()], + } + .validate() + .is_err() + ); + } + + #[test] + fn terminal_summary_requires_ordered_members_with_past_attachment_boundaries() { + let first = member( + "10000000000000000000000000000001", + 0x3000_0000_0000_0000_0000_0000_0000_0003, + 1, + ); + let second = member( + "20000000000000000000000000000002", + 0x4000_0000_0000_0000_0000_0000_0000_0004, + 2, + ); + + let valid = summary(vec![first.clone(), second.clone()], 2); + valid + .validate_structure(instance()) + .expect("ordered terminal summary validates"); + let value = serde_json::to_value(&valid).expect("terminal summary encodes"); + assert_eq!(WorldTerminalSummary::wire_schema().conforms(&value), Ok(())); + assert_eq!(value["schema"], WORLD_TERMINAL_SUMMARY_SCHEMA); + assert_eq!( + serde_json::from_value::(value) + .expect("terminal summary decodes"), + valid + ); + + assert!( + summary(vec![second.clone(), first.clone()], 2) + .validate_structure(instance()) + .is_err() + ); + assert!( + summary(vec![first.clone(), first], 2) + .validate_structure(instance()) + .is_err() + ); + assert!( + summary(vec![second], 1) + .validate_structure(instance()) + .is_err() + ); + } +} diff --git a/phoxal/src/world/api/session/mod.rs b/phoxal/src/world/api/session/mod.rs new file mode 100644 index 00000000..78feb2fb --- /dev/null +++ b/phoxal/src/world/api/session/mod.rs @@ -0,0 +1,135 @@ +//! Complete world-session state, diagnostics, and explicit operations. + +crate::nodes! { + state; + diagnostics; + control; + connect; +} + +/// Durable backend-neutral documents shared by a local world host and client. +pub mod document; + +pub use crate::model::identity::{SpawnId, WorldId}; +pub use crate::model::structure::Pose; +pub use crate::model::world::{ + LiveAttachmentBoundary, WorldDigest, WorldInstanceId, WorldProgress, WorldProvenance, +}; + +use crate::identity::{ExecutionId, ProducerId, RobotId}; +use crate::supervisor::api::simulation::SimulationEndReason; + +/// Whether a Ready Live world is paused or requesting native REAL_TIME motion. +#[derive( + phoxal_macros::DescribeWire, + Clone, + Copy, + Debug, + Eq, + PartialEq, + serde::Serialize, + serde::Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum WorldMotion { + Paused, + Running, +} + +/// One non-contradictory world-session lifecycle. +#[derive( + phoxal_macros::DescribeWire, + Clone, + Copy, + Debug, + Eq, + PartialEq, + serde::Serialize, + serde::Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum WorldLifecycle { + Starting, + Ready { motion: WorldMotion }, + Stopping, + Failed { reason: SimulationEndReason }, +} + +/// The current attachment phase of one robot member. +#[derive( + phoxal_macros::DescribeWire, + Clone, + Copy, + Debug, + Eq, + PartialEq, + serde::Serialize, + serde::Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum WorldMemberPhase { + Preparing, + Active, + Removing, +} + +/// One current robot member, keyed and ordered by execution identity. +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct WorldMember { + pub execution: ExecutionId, + pub robot: RobotId, + pub controller: ProducerId, + pub phase: WorldMemberPhase, + pub attached_at: LiveAttachmentBoundary, + /// The resolved authored spawn, including automatic single-spawn selection. + pub spawn: SpawnId, + pub initial_pose: Pose, +} + +/// Why one member left a world that may remain live for other robots. +#[derive( + phoxal_macros::DescribeWire, + Clone, + Copy, + Debug, + Eq, + PartialEq, + serde::Serialize, + serde::Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum WorldMemberEndReason { + Stopped, + SupervisorLost, + ControllerFault, + AttachmentFailed, +} + +/// Whether member cleanup completed without residue. +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum WorldMemberCleanup { + Complete, + Incomplete { detail: String }, +} + +/// Persistable terminal evidence for one former member. +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct WorldMemberTerminal { + pub execution: ExecutionId, + pub robot: RobotId, + pub controller: ProducerId, + pub spawn: SpawnId, + pub reason: WorldMemberEndReason, + pub last_progress: WorldProgress, + pub cleanup: WorldMemberCleanup, + pub evidence_paths: Vec, +} diff --git a/phoxal/src/world/api/session/state.rs b/phoxal/src/world/api/session/state.rs new file mode 100644 index 00000000..1974ec71 --- /dev/null +++ b/phoxal/src/world/api/session/state.rs @@ -0,0 +1,97 @@ +//! Ordered complete world-session projections and a race-closing current query. + +crate::endpoints! { + self: Stream; + current: Query; +} + +use super::{WorldLifecycle, WorldMember, WorldInstanceId, WorldProgress, WorldProvenance}; + +/// The complete authoritative projection of one world session. +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct WorldSessionState { + pub revision: u64, + pub instance: WorldInstanceId, + pub provenance: WorldProvenance, + pub lifecycle: WorldLifecycle, + pub progress: WorldProgress, + /// Current members in strictly increasing `ExecutionId` text order. + pub members: Vec, +} + +impl WorldSessionState { + /// Validate ordering and world progress against immutable provenance. + pub fn validate(&self) -> Result<(), WorldSessionStateError> { + self.progress + .validate(self.provenance.time_step_ns) + .map_err(WorldSessionStateError::Progress)?; + for member in &self.members { + member + .attached_at + .world + .validate(self.provenance.time_step_ns) + .map_err(WorldSessionStateError::AttachmentProgress)?; + if member.attached_at.world.completed_step() > self.progress.completed_step() + || member.attached_at.world.elapsed_ns() > self.progress.elapsed_ns() + { + return Err(WorldSessionStateError::AttachmentAfterCurrent); + } + } + if self + .members + .windows(2) + .any(|pair| pair[0].execution.to_string() >= pair[1].execution.to_string()) + { + return Err(WorldSessionStateError::MemberOrder); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum WorldSessionStateError { + #[error(transparent)] + Progress(crate::model::world::WorldProgressError), + #[error("member attachment progress is invalid: {0}")] + AttachmentProgress(crate::model::world::WorldProgressError), + #[error("member attachment progress cannot be ahead of current world progress")] + AttachmentAfterCurrent, + #[error("world members must be unique and ordered by ExecutionId")] + MemberOrder, +} + +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct WorldSessionStateStream { + pub state: WorldSessionState, +} + +#[derive( + phoxal_macros::DescribeWire, Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct WorldSessionStateCurrentRequest { + pub instance: WorldInstanceId, +} + +/// Identity binding for a long-lived state subscription. +#[derive( + phoxal_macros::DescribeWire, Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct WorldSessionStateSubscriptionRequest { + pub instance: WorldInstanceId, +} + +#[derive( + phoxal_macros::DescribeWire, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[serde(deny_unknown_fields)] +pub struct WorldSessionStateCurrentResponse { + pub state: WorldSessionState, +} diff --git a/phoxal/src/world/local.rs b/phoxal/src/world/local.rs new file mode 100644 index 00000000..c37a7c5b --- /dev/null +++ b/phoxal/src/world/local.rs @@ -0,0 +1,89 @@ +//! Bounded loopback MessagePack transport for the backend-neutral world API. +//! +//! This is deliberately separate from the execution bus. A world host owns no +//! `ExecutionId`; its registry record contains this loopback endpoint and the +//! frozen bootstrap below establishes the one `WorldInstanceId` it serves. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; +use std::{future::Future, pin::Pin}; + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{broadcast, mpsc, oneshot}; +use tokio::task::{JoinHandle, JoinSet}; + +use crate::bus::QueryEndpoint; +use crate::identity::ExecutionId; +use crate::model::identity::SpawnId; +use crate::version::FrameworkVersion; +use crate::world::api::session::WorldMemberPhase; +use crate::world::api::session::connect::{ + WorldSessionBootstrap, WorldSessionConnectRequest, WorldSessionConnectResponse, +}; +use crate::world::api::session::control::{ + WorldControl, WorldSessionControlRequest, WorldSessionControlResponse, +}; +use crate::world::api::session::diagnostics::{ + WorldSessionDiagnostics, WorldSessionDiagnosticsCurrentRequest, + WorldSessionDiagnosticsCurrentResponse, WorldSessionDiagnosticsStream, + WorldSessionDiagnosticsSubscriptionRequest, +}; +use crate::world::api::session::state::{ + WorldSessionState, WorldSessionStateCurrentRequest, WorldSessionStateCurrentResponse, + WorldSessionStateStream, WorldSessionStateSubscriptionRequest, +}; + +const MAX_FRAME_BYTES: usize = 1024 * 1024; +const MAX_CONNECTIONS: usize = 64; +const CLIENT_STREAM_CAPACITY: usize = 32; +#[cfg(not(test))] +const CONNECT_TIMEOUT: Duration = Duration::from_secs(2); +#[cfg(test)] +const CONNECT_TIMEOUT: Duration = Duration::from_millis(500); +#[cfg(not(test))] +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(1); +#[cfg(test)] +const HANDSHAKE_TIMEOUT: Duration = Duration::from_millis(250); +#[cfg(not(test))] +const FRAME_IO_TIMEOUT: Duration = Duration::from_secs(2); +#[cfg(test)] +const FRAME_IO_TIMEOUT: Duration = Duration::from_millis(500); +#[cfg(not(test))] +const HOST_OPERATION_TIMEOUT: Duration = Duration::from_secs(45); +#[cfg(test)] +const HOST_OPERATION_TIMEOUT: Duration = Duration::from_millis(500); +#[cfg(not(test))] +const CLIENT_OPERATION_TIMEOUT: Duration = Duration::from_secs(50); +#[cfg(test)] +const CLIENT_OPERATION_TIMEOUT: Duration = Duration::from_millis(750); + +const STATE_PATH: &str = "world/session/state"; +const STATE_CURRENT_PATH: &str = "world/session/state/current"; +const DIAGNOSTICS_PATH: &str = "world/session/diagnostics"; +const DIAGNOSTICS_CURRENT_PATH: &str = "world/session/diagnostics/current"; +const CONTROL_PATH: &str = "world/session/control"; +const CONNECT_PATH: &str = "world/session/connect"; + +mod client; +mod error; +mod framing; +mod server; +mod subscription; + +pub use client::WorldSessionClient; +pub use error::WorldSessionWireError; +pub use server::{WorldSessionHandler, WorldSessionOperation, WorldSessionServer}; +pub use subscription::{WorldDiagnosticsSubscription, WorldStateSubscription}; + +use framing::{ + WireRequest, decode_body, open_subscription, parse_endpoint, read_frame, request, send_error, + send_gap, send_timeout, send_value, with_timeout, +}; +use subscription::{WireSubscription, validate_state_against}; + +#[cfg(test)] +mod local_session_contract_tests; diff --git a/phoxal/src/world/local/client.rs b/phoxal/src/world/local/client.rs new file mode 100644 index 00000000..68382559 --- /dev/null +++ b/phoxal/src/world/local/client.rs @@ -0,0 +1,141 @@ +use super::*; + +/// A verified client of one local world host. +#[derive(Clone, Debug)] +pub struct WorldSessionClient { + endpoint: SocketAddr, + bootstrap: WorldSessionBootstrap, +} + +impl WorldSessionClient { + /// Verify the frozen host bootstrap before trusting the registered endpoint. + pub async fn connect(endpoint: &str) -> Result { + let endpoint = parse_endpoint(endpoint)?; + let response: WorldSessionConnectResponse = request( + endpoint, + &WorldSessionConnectRequest::Bootstrap { + framework: FrameworkVersion::CURRENT, + }, + ) + .await?; + let WorldSessionConnectResponse::Bootstrap { bootstrap } = response else { + return Err(WorldSessionWireError::Protocol( + "world host returned an attachment response to bootstrap".to_owned(), + )); + }; + if !bootstrap + .framework + .is_compatible_with(FrameworkVersion::CURRENT) + { + return Err(WorldSessionWireError::IncompatibleFramework { + local: FrameworkVersion::CURRENT, + remote: bootstrap.framework, + }); + } + Ok(Self { + endpoint, + bootstrap, + }) + } + + #[must_use] + pub fn bootstrap(&self) -> &WorldSessionBootstrap { + &self.bootstrap + } + + pub async fn current_state(&self) -> Result { + let response: WorldSessionStateCurrentResponse = request( + self.endpoint, + &WorldSessionStateCurrentRequest { + instance: self.bootstrap.instance, + }, + ) + .await?; + validate_state_against(&self.bootstrap, &response.state)?; + Ok(response.state) + } + + pub async fn state_subscription( + &self, + ) -> Result { + let updates = open_subscription( + self.endpoint, + &WorldSessionStateSubscriptionRequest { + instance: self.bootstrap.instance, + }, + ) + .await?; + let current = self.current_state().await?; + WorldStateSubscription::reconcile(self.bootstrap.clone(), current, updates) + } + + pub async fn current_diagnostics( + &self, + ) -> Result { + let response: WorldSessionDiagnosticsCurrentResponse = request( + self.endpoint, + &WorldSessionDiagnosticsCurrentRequest { + instance: self.bootstrap.instance, + }, + ) + .await?; + response.diagnostics.validate()?; + Ok(response.diagnostics) + } + + pub async fn diagnostics_subscription( + &self, + ) -> Result { + let updates = open_subscription( + self.endpoint, + &WorldSessionDiagnosticsSubscriptionRequest { + instance: self.bootstrap.instance, + }, + ) + .await?; + let current = self.current_diagnostics().await?; + WorldDiagnosticsSubscription::reconcile(current, updates) + } + + pub async fn control( + &self, + operation: WorldControl, + ) -> Result { + let response: WorldSessionControlResponse = request( + self.endpoint, + &WorldSessionControlRequest { + instance: self.bootstrap.instance, + operation, + }, + ) + .await?; + validate_state_against(&self.bootstrap, &response.state)?; + Ok(response.state) + } + + pub async fn attach( + &self, + execution: ExecutionId, + supervisor_endpoint: impl Into, + spawn: Option, + ) -> Result { + let response: WorldSessionConnectResponse = request( + self.endpoint, + &WorldSessionConnectRequest::Attach { + framework: FrameworkVersion::CURRENT, + instance: self.bootstrap.instance, + execution, + supervisor_endpoint: supervisor_endpoint.into(), + spawn, + }, + ) + .await?; + let WorldSessionConnectResponse::Attached { state } = response else { + return Err(WorldSessionWireError::Protocol( + "world host returned a bootstrap response to attachment".to_owned(), + )); + }; + validate_state_against(&self.bootstrap, &state)?; + Ok(state) + } +} diff --git a/phoxal/src/world/local/error.rs b/phoxal/src/world/local/error.rs new file mode 100644 index 00000000..fb222b28 --- /dev/null +++ b/phoxal/src/world/local/error.rs @@ -0,0 +1,38 @@ +use super::*; + +#[derive(Debug, thiserror::Error)] +pub enum WorldSessionWireError { + #[error("world-session I/O failed: {0}")] + Io(#[from] std::io::Error), + #[error("world-session encoding failed: {0}")] + Encode(#[from] rmp_serde::encode::Error), + #[error("world-session decoding failed: {0}")] + Decode(#[from] rmp_serde::decode::Error), + #[error("world-session frame is {bytes} bytes, exceeding the {maximum}-byte bound")] + FrameTooLarge { bytes: usize, maximum: usize }, + #[error("invalid loopback world-session endpoint '{endpoint}'")] + InvalidEndpoint { endpoint: String }, + #[error("remote framework {remote} is incompatible with local framework {local}")] + IncompatibleFramework { + local: FrameworkVersion, + remote: FrameworkVersion, + }, + #[error("world host refused the operation: {0}")] + Refused(String), + #[error("invalid world-session state: {0}")] + State(#[from] crate::world::api::session::state::WorldSessionStateError), + #[error("invalid world-session diagnostics: {0}")] + Diagnostics(#[from] crate::world::api::session::diagnostics::WorldSessionDiagnosticsError), + #[error("world-session {operation} timed out after {timeout_ms} ms")] + Timeout { operation: String, timeout_ms: u64 }, + #[error("world-session protocol failed: {0}")] + Protocol(String), + #[error("world-session state contradicts frozen bootstrap field '{field}'")] + BootstrapMismatch { field: &'static str }, + #[error("the world-session stream closed")] + Closed, + #[error( + "the world-session {stream} stream lost {skipped} replacement(s); query current and resubscribe" + )] + StreamGap { stream: &'static str, skipped: u64 }, +} diff --git a/phoxal/src/world/local/framing.rs b/phoxal/src/world/local/framing.rs new file mode 100644 index 00000000..c957dfaf --- /dev/null +++ b/phoxal/src/world/local/framing.rs @@ -0,0 +1,303 @@ +use super::*; + +pub(super) trait LocalQueryEndpoint: QueryEndpoint { + const PATH: &'static str; +} + +impl LocalQueryEndpoint for WorldSessionConnectRequest { + const PATH: &'static str = CONNECT_PATH; +} + +impl LocalQueryEndpoint for WorldSessionStateCurrentRequest { + const PATH: &'static str = STATE_CURRENT_PATH; +} + +impl LocalQueryEndpoint for WorldSessionDiagnosticsCurrentRequest { + const PATH: &'static str = DIAGNOSTICS_CURRENT_PATH; +} + +impl LocalQueryEndpoint for WorldSessionControlRequest { + const PATH: &'static str = CONTROL_PATH; +} + +pub(super) trait LocalSubscriptionEndpoint: Serialize { + type Stream: DeserializeOwned + Send + 'static; + const PATH: &'static str; +} + +impl LocalSubscriptionEndpoint for WorldSessionStateSubscriptionRequest { + type Stream = WorldSessionStateStream; + const PATH: &'static str = STATE_PATH; +} + +impl LocalSubscriptionEndpoint for WorldSessionDiagnosticsSubscriptionRequest { + type Stream = WorldSessionDiagnosticsStream; + const PATH: &'static str = DIAGNOSTICS_PATH; +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct WireRequest { + pub(super) path: String, + pub(super) body: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum WireReply { + Value { body: Vec }, + Error { message: String }, + Timeout { operation: String, timeout_ms: u64 }, + Gap { stream: String, skipped: u64 }, +} + +pub(super) async fn with_timeout( + operation: &'static str, + timeout: Duration, + future: F, +) -> Result +where + F: Future>, +{ + tokio::time::timeout(timeout, future) + .await + .map_err(|_| WorldSessionWireError::Timeout { + operation: operation.to_owned(), + timeout_ms: timeout_millis(timeout), + })? +} + +fn timeout_millis(timeout: Duration) -> u64 { + u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX) +} + +pub(super) async fn request( + endpoint: SocketAddr, + request: &E, +) -> Result { + let mut stream = with_timeout("connect", CONNECT_TIMEOUT, async move { + Ok(TcpStream::connect(endpoint).await?) + }) + .await?; + with_timeout( + "request write", + FRAME_IO_TIMEOUT, + write_frame( + &mut stream, + &WireRequest { + path: E::PATH.to_owned(), + body: rmp_serde::to_vec_named(request)?, + }, + ), + ) + .await?; + let reply = with_timeout( + "request response", + CLIENT_OPERATION_TIMEOUT, + read_frame(&mut stream), + ) + .await?; + decode_reply(reply) +} + +pub(super) async fn open_subscription( + endpoint: SocketAddr, + request: &E, +) -> Result, WorldSessionWireError> { + let mut stream = with_timeout("connect", CONNECT_TIMEOUT, async move { + Ok(TcpStream::connect(endpoint).await?) + }) + .await?; + with_timeout( + "subscription request write", + FRAME_IO_TIMEOUT, + write_frame( + &mut stream, + &WireRequest { + path: E::PATH.to_owned(), + body: rmp_serde::to_vec_named(request)?, + }, + ), + ) + .await?; + let reply = with_timeout( + "subscription handshake", + FRAME_IO_TIMEOUT, + read_frame::<_, WireReply>(&mut stream), + ) + .await?; + let initial = decode_reply(reply)?; + let (sender, receiver) = mpsc::channel(CLIENT_STREAM_CAPACITY); + sender.try_send(Ok(initial)).map_err(|_| { + WorldSessionWireError::Protocol("subscription bootstrap queue closed".to_owned()) + })?; + let task = tokio::spawn(async move { + loop { + let value = match read_frame::<_, WireReply>(&mut stream).await { + Ok(reply) => decode_reply(reply), + Err(error) => Err(error), + }; + let terminal = value.is_err(); + if sender.send(value).await.is_err() || terminal { + return; + } + } + }); + Ok(WireSubscription { receiver, task }) +} + +pub(super) async fn send_value( + stream: &mut W, + value: &T, +) -> Result<(), WorldSessionWireError> { + with_timeout( + "response write", + FRAME_IO_TIMEOUT, + write_frame( + stream, + &WireReply::Value { + body: rmp_serde::to_vec_named(value)?, + }, + ), + ) + .await +} + +pub(super) async fn send_error( + stream: &mut W, + message: String, +) -> Result<(), WorldSessionWireError> { + with_timeout( + "error response write", + FRAME_IO_TIMEOUT, + write_frame(stream, &WireReply::Error { message }), + ) + .await +} + +pub(super) async fn send_timeout( + stream: &mut W, + operation: &'static str, + timeout: Duration, +) -> Result<(), WorldSessionWireError> { + with_timeout( + "timeout response write", + FRAME_IO_TIMEOUT, + write_frame( + stream, + &WireReply::Timeout { + operation: operation.to_owned(), + timeout_ms: timeout_millis(timeout), + }, + ), + ) + .await +} + +pub(super) async fn send_gap( + stream: &mut W, + stream_name: &'static str, + skipped: u64, +) -> Result<(), WorldSessionWireError> { + with_timeout( + "stream gap response write", + FRAME_IO_TIMEOUT, + write_frame( + stream, + &WireReply::Gap { + stream: stream_name.to_owned(), + skipped, + }, + ), + ) + .await +} + +fn decode_reply(reply: WireReply) -> Result { + match reply { + WireReply::Value { body } => Ok(rmp_serde::from_slice(&body)?), + WireReply::Error { message } => Err(WorldSessionWireError::Refused(message)), + WireReply::Timeout { + operation, + timeout_ms, + } => Err(WorldSessionWireError::Timeout { + operation, + timeout_ms, + }), + WireReply::Gap { stream, skipped } => { + let stream = match stream.as_str() { + "state" => "state", + "diagnostics" => "diagnostics", + _ => { + return Err(WorldSessionWireError::Protocol(format!( + "world host reported a gap for unknown stream '{stream}'" + ))); + } + }; + Err(WorldSessionWireError::StreamGap { stream, skipped }) + } + } +} + +pub(super) fn decode_body(body: &[u8]) -> Result { + Ok(rmp_serde::from_slice(body)?) +} + +pub(super) async fn write_frame( + writer: &mut W, + value: &T, +) -> Result<(), WorldSessionWireError> { + let body = rmp_serde::to_vec_named(value)?; + if body.len() > MAX_FRAME_BYTES { + return Err(WorldSessionWireError::FrameTooLarge { + bytes: body.len(), + maximum: MAX_FRAME_BYTES, + }); + } + let length = u32::try_from(body.len()).map_err(|_| WorldSessionWireError::FrameTooLarge { + bytes: body.len(), + maximum: MAX_FRAME_BYTES, + })?; + writer.write_all(&length.to_be_bytes()).await?; + writer.write_all(&body).await?; + writer.flush().await?; + Ok(()) +} + +pub(super) async fn read_frame( + reader: &mut R, +) -> Result { + let mut length = [0_u8; 4]; + reader.read_exact(&mut length).await?; + let length = u32::from_be_bytes(length) as usize; + if length > MAX_FRAME_BYTES { + return Err(WorldSessionWireError::FrameTooLarge { + bytes: length, + maximum: MAX_FRAME_BYTES, + }); + } + let mut body = vec![0_u8; length]; + reader.read_exact(&mut body).await?; + Ok(rmp_serde::from_slice(&body)?) +} + +pub(super) fn parse_endpoint(endpoint: &str) -> Result { + let address = + endpoint + .strip_prefix("tcp://") + .ok_or_else(|| WorldSessionWireError::InvalidEndpoint { + endpoint: endpoint.to_owned(), + })?; + let address = + address + .parse::() + .map_err(|_| WorldSessionWireError::InvalidEndpoint { + endpoint: endpoint.to_owned(), + })?; + if !address.ip().is_loopback() { + return Err(WorldSessionWireError::InvalidEndpoint { + endpoint: endpoint.to_owned(), + }); + } + Ok(address) +} diff --git a/phoxal/src/world/local/local_session_contract_tests.rs b/phoxal/src/world/local/local_session_contract_tests.rs new file mode 100644 index 00000000..21892254 --- /dev/null +++ b/phoxal/src/world/local/local_session_contract_tests.rs @@ -0,0 +1,519 @@ +use super::*; +use crate::model::identity::WorldId; +use crate::model::world::{WorldDigest, WorldInstanceId, WorldProgress, WorldProvenance}; +use crate::world::api::session::diagnostics::ObservedWorldPacing; +use crate::world::api::session::{WorldLifecycle, WorldMotion}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +fn compatible_patch_other_than_current() -> FrameworkVersion { + let current = FrameworkVersion::CURRENT; + let patch = if current.patch() == u16::MAX { + current.patch() - 1 + } else { + current.patch() + 1 + }; + FrameworkVersion::new(current.major(), current.minor(), patch) +} + +struct TestHandler { + bootstrap: WorldSessionBootstrap, + state: std::sync::Mutex, + states: broadcast::Sender, + diagnostics: std::sync::Mutex, + diagnostic_updates: broadcast::Sender, + race_state_subscription: AtomicBool, + race_diagnostics_subscription: AtomicBool, + hang_attach: bool, + control_calls: AtomicUsize, + attach_calls: AtomicUsize, +} + +impl TestHandler { + fn new() -> Self { + let instance = WorldInstanceId::mint(); + let world = WorldId::new("warehouse").expect("a valid world id"); + let digest = WorldDigest::parse(&"00".repeat(32)).expect("a canonical digest"); + let bootstrap = WorldSessionBootstrap { + instance, + framework: FrameworkVersion::CURRENT, + world: world.clone(), + digest, + }; + let state = WorldSessionState { + revision: 0, + instance, + provenance: WorldProvenance { + world, + digest, + random_seed: 0, + framework: FrameworkVersion::CURRENT, + adapter: "test".to_owned(), + adapter_version: "1".to_owned(), + simulator_version: "1".to_owned(), + platform: "test".to_owned(), + time_step_ns: 12, + }, + lifecycle: WorldLifecycle::Ready { + motion: WorldMotion::Paused, + }, + progress: WorldProgress::zero(12).expect("valid zero progress"), + members: Vec::new(), + }; + let (states, _) = broadcast::channel(8); + let diagnostics = WorldSessionDiagnostics { + revision: 0, + pacing: None, + last_transition_age_ns: None, + }; + let (diagnostic_updates, _) = broadcast::channel(8); + Self { + bootstrap, + state: std::sync::Mutex::new(state), + states, + diagnostics: std::sync::Mutex::new(diagnostics), + diagnostic_updates, + race_state_subscription: AtomicBool::new(false), + race_diagnostics_subscription: AtomicBool::new(false), + hang_attach: false, + control_calls: AtomicUsize::new(0), + attach_calls: AtomicUsize::new(0), + } + } + + fn with_subscription_races(mut self) -> Self { + self.race_state_subscription = AtomicBool::new(true); + self.race_diagnostics_subscription = AtomicBool::new(true); + self + } + + fn with_hanging_attach(mut self) -> Self { + self.hang_attach = true; + self + } + + fn replace_motion(&self, motion: WorldMotion) -> WorldSessionState { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.lifecycle != (WorldLifecycle::Ready { motion }) { + state.revision += 1; + state.lifecycle = WorldLifecycle::Ready { motion }; + let _ = self.states.send(state.clone()); + } + state.clone() + } + + fn replace_diagnostics(&self, pacing: Option) -> WorldSessionDiagnostics { + let mut diagnostics = self + .diagnostics + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + diagnostics.revision += 1; + diagnostics.pacing = pacing; + diagnostics.last_transition_age_ns = Some(diagnostics.revision); + let _ = self.diagnostic_updates.send(*diagnostics); + *diagnostics + } +} + +impl WorldSessionHandler for TestHandler { + fn bootstrap(&self) -> WorldSessionBootstrap { + self.bootstrap.clone() + } + + fn state(&self) -> WorldSessionState { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + fn subscribe_state(&self) -> broadcast::Receiver { + let updates = self.states.subscribe(); + if self.race_state_subscription.swap(false, Ordering::AcqRel) { + self.replace_motion(WorldMotion::Running); + } + updates + } + + fn diagnostics(&self) -> WorldSessionDiagnostics { + *self + .diagnostics + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn subscribe_diagnostics(&self) -> broadcast::Receiver { + let updates = self.diagnostic_updates.subscribe(); + if self + .race_diagnostics_subscription + .swap(false, Ordering::AcqRel) + { + self.replace_diagnostics(None); + } + updates + } + + fn control(&self, request: WorldControl) -> WorldSessionOperation<'_, WorldSessionState> { + Box::pin(async move { + self.control_calls.fetch_add(1, Ordering::AcqRel); + Ok(match request { + WorldControl::Pause => self.replace_motion(WorldMotion::Paused), + WorldControl::Resume => self.replace_motion(WorldMotion::Running), + WorldControl::Stop => { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.lifecycle != WorldLifecycle::Stopping { + state.revision += 1; + state.lifecycle = WorldLifecycle::Stopping; + let _ = self.states.send(state.clone()); + } + state.clone() + } + }) + }) + } + + fn attach( + &self, + _execution: ExecutionId, + _supervisor_endpoint: String, + _spawn: Option, + ) -> WorldSessionOperation<'_, WorldSessionState> { + Box::pin(async move { + self.attach_calls.fetch_add(1, Ordering::AcqRel); + if self.hang_attach { + std::future::pending::<()>().await; + } + Ok(self.state()) + }) + } +} + +#[tokio::test] +async fn loopback_client_reconciles_and_drives_idempotent_operations() { + let handler = Arc::new(TestHandler::new()); + let server = WorldSessionServer::bind(Arc::clone(&handler)) + .await + .expect("the loopback server binds"); + let client = WorldSessionClient::connect(server.endpoint()) + .await + .expect("the client verifies bootstrap"); + assert_eq!(client.bootstrap(), &handler.bootstrap); + + let mut states = client + .state_subscription() + .await + .expect("subscribe-first state reconciliation succeeds"); + assert_eq!(states.current().revision, 0); + let running = client + .control(WorldControl::Resume) + .await + .expect("resume is accepted"); + assert_eq!(running.revision, 1); + assert_eq!( + states.recv().await.expect("the replacement is delivered"), + &running + ); + let retry = client + .control(WorldControl::Resume) + .await + .expect("resume retry is idempotent"); + assert_eq!(retry.revision, running.revision); + + let attached = client + .attach(ExecutionId::mint(), "tcp/localhost:7447", None) + .await + .expect("the async host operation returns one complete state"); + assert_eq!(attached.revision, running.revision); + assert_eq!( + client + .current_diagnostics() + .await + .expect("diagnostics current is available") + .revision, + 0 + ); + + drop(states); + server.close().await.expect("the server closes cleanly"); +} + +#[tokio::test] +async fn client_rejects_state_that_contradicts_frozen_bootstrap() { + let handler = Arc::new(TestHandler::new()); + let server = WorldSessionServer::bind(Arc::clone(&handler)) + .await + .expect("the loopback server binds"); + let client = WorldSessionClient::connect(server.endpoint()) + .await + .expect("the client verifies bootstrap"); + handler + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .instance = WorldInstanceId::mint(); + + assert!(matches!( + client.current_state().await, + Err(WorldSessionWireError::BootstrapMismatch { field: "instance" }) + )); + server.close().await.expect("the server closes cleanly"); +} + +#[tokio::test] +async fn attachment_preserves_the_frozen_instance_and_exact_framework_patch() { + let handler = Arc::new(TestHandler::new()); + let server = WorldSessionServer::bind(Arc::clone(&handler)) + .await + .expect("the loopback server binds"); + let client = WorldSessionClient::connect(server.endpoint()) + .await + .expect("the client verifies bootstrap"); + let original_instance = handler.bootstrap.instance; + { + let mut state = handler + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.instance = WorldInstanceId::mint(); + } + assert!(matches!( + client + .attach(ExecutionId::mint(), "tcp/localhost:7447", None) + .await, + Err(WorldSessionWireError::BootstrapMismatch { field: "instance" }) + )); + + let other_patch = compatible_patch_other_than_current(); + assert!(other_patch.is_compatible_with(FrameworkVersion::CURRENT)); + { + let mut state = handler + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.instance = original_instance; + state.provenance.framework = other_patch; + } + assert!(matches!( + client + .attach(ExecutionId::mint(), "tcp/localhost:7447", None) + .await, + Err(WorldSessionWireError::BootstrapMismatch { field: "framework" }) + )); + + server.close().await.expect("the server closes cleanly"); +} + +#[tokio::test] +async fn stale_client_cannot_mutate_a_reused_endpoint() { + let first = Arc::new(TestHandler::new()); + let first_server = WorldSessionServer::bind(Arc::clone(&first)) + .await + .expect("the first loopback server binds"); + let client = WorldSessionClient::connect(first_server.endpoint()) + .await + .expect("the client captures the first bootstrap"); + let endpoint = parse_endpoint(first_server.endpoint()).expect("the endpoint parses"); + first_server + .close() + .await + .expect("the first server releases its endpoint"); + + let replacement = Arc::new(TestHandler::new()); + let replacement_server = WorldSessionServer::bind_at(endpoint, Arc::clone(&replacement)) + .await + .expect("the replacement server reuses the endpoint"); + + assert!(matches!( + client.control(WorldControl::Stop).await, + Err(WorldSessionWireError::Refused(message)) if message.contains("targets instance") + )); + assert_eq!(replacement.control_calls.load(Ordering::Acquire), 0); + assert!(matches!( + client + .attach(ExecutionId::mint(), "tcp/localhost:7447", None) + .await, + Err(WorldSessionWireError::Refused(message)) if message.contains("targets instance") + )); + assert_eq!(replacement.attach_calls.load(Ordering::Acquire), 0); + + replacement_server + .close() + .await + .expect("the replacement server closes cleanly"); +} + +#[tokio::test] +async fn streams_discard_subscribe_current_duplicates_and_remain_live() { + let handler = Arc::new(TestHandler::new().with_subscription_races()); + let server = WorldSessionServer::bind(Arc::clone(&handler)) + .await + .expect("the loopback server binds"); + let client = WorldSessionClient::connect(server.endpoint()) + .await + .expect("the client verifies bootstrap"); + + let mut states = client + .state_subscription() + .await + .expect("the raced state snapshot reconciles"); + assert_eq!(states.current().revision, 1); + let paused = handler.replace_motion(WorldMotion::Paused); + assert_eq!( + states.recv().await.expect("the state stream remains live"), + &paused + ); + + let mut diagnostics = client + .diagnostics_subscription() + .await + .expect("the raced diagnostics snapshot reconciles"); + assert_eq!(diagnostics.current().revision, 1); + let next = handler.replace_diagnostics(Some(ObservedWorldPacing { + world_elapsed_ns: 12, + host_elapsed_ns: 20, + completed_transitions: 1, + })); + assert_eq!( + diagnostics + .recv() + .await + .expect("the diagnostics stream remains live"), + next + ); + + server.close().await.expect("the server closes cleanly"); +} + +#[tokio::test] +async fn invalid_pacing_is_rejected_from_current_and_streamed_diagnostics() { + let handler = Arc::new(TestHandler::new()); + let server = WorldSessionServer::bind(Arc::clone(&handler)) + .await + .expect("the loopback server binds"); + let client = WorldSessionClient::connect(server.endpoint()) + .await + .expect("the client verifies bootstrap"); + + handler.replace_diagnostics(Some(ObservedWorldPacing { + world_elapsed_ns: 0, + host_elapsed_ns: 1, + completed_transitions: 1, + })); + assert!(matches!( + client.current_diagnostics().await, + Err(WorldSessionWireError::Diagnostics(_)) + )); + + handler.replace_diagnostics(None); + let mut diagnostics = client + .diagnostics_subscription() + .await + .expect("valid diagnostics subscribe"); + handler.replace_diagnostics(Some(ObservedWorldPacing { + world_elapsed_ns: 1, + host_elapsed_ns: 0, + completed_transitions: 1, + })); + assert!(matches!( + diagnostics.recv().await, + Err(WorldSessionWireError::Diagnostics(_)) + )); + + server.close().await.expect("the server closes cleanly"); +} + +#[tokio::test] +async fn client_and_host_operations_have_typed_deadlines() { + let silent_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .expect("the silent listener binds"); + let silent_address = silent_listener + .local_addr() + .expect("an address is assigned"); + let silent = tokio::spawn(async move { + let (_stream, _) = silent_listener.accept().await.expect("a client connects"); + std::future::pending::<()>().await; + }); + let error = WorldSessionClient::connect(&format!("tcp://{silent_address}")) + .await + .expect_err("a silent listener must time out"); + assert!(matches!( + error, + WorldSessionWireError::Timeout { ref operation, .. } + if operation == "request response" + )); + silent.abort(); + + let handler = Arc::new(TestHandler::new().with_hanging_attach()); + let server = WorldSessionServer::bind(Arc::clone(&handler)) + .await + .expect("the loopback server binds"); + let client = WorldSessionClient::connect(server.endpoint()) + .await + .expect("the client verifies bootstrap"); + let error = client + .attach(ExecutionId::mint(), "tcp/localhost:7447", None) + .await + .expect_err("a hung host operation must time out"); + assert!(matches!( + error, + WorldSessionWireError::Timeout { ref operation, .. } + if operation == "host attachment" + )); + server.close().await.expect("the server closes cleanly"); +} + +#[tokio::test] +async fn idle_handshakes_release_the_bounded_connection_permits() { + let handler = Arc::new(TestHandler::new()); + let server = WorldSessionServer::bind(Arc::clone(&handler)) + .await + .expect("the loopback server binds"); + let endpoint = parse_endpoint(server.endpoint()).expect("the endpoint parses"); + let mut idle = Vec::with_capacity(MAX_CONNECTIONS); + for _ in 0..MAX_CONNECTIONS { + idle.push( + TcpStream::connect(endpoint) + .await + .expect("an idle client connects"), + ); + } + tokio::time::sleep(HANDSHAKE_TIMEOUT + Duration::from_millis(100)).await; + + WorldSessionClient::connect(server.endpoint()) + .await + .expect("expired handshakes release permits for a valid client"); + drop(idle); + server.close().await.expect("the server closes cleanly"); +} + +#[tokio::test] +async fn idle_subscription_disconnects_release_connection_permits() { + let handler = Arc::new(TestHandler::new()); + let server = WorldSessionServer::bind(Arc::clone(&handler)) + .await + .expect("the loopback server binds"); + let client = WorldSessionClient::connect(server.endpoint()) + .await + .expect("the client verifies bootstrap"); + + for _ in 0..MAX_CONNECTIONS * 2 { + let subscription = client + .state_subscription() + .await + .expect("an idle state subscription opens"); + drop(subscription); + tokio::task::yield_now().await; + } + + tokio::time::timeout(Duration::from_secs(2), client.control(WorldControl::Resume)) + .await + .expect("idle subscriptions release their server permits") + .expect("a fresh control request succeeds"); + server.close().await.expect("the server closes cleanly"); +} diff --git a/phoxal/src/world/local/server.rs b/phoxal/src/world/local/server.rs new file mode 100644 index 00000000..ee109342 --- /dev/null +++ b/phoxal/src/world/local/server.rs @@ -0,0 +1,415 @@ +use super::*; + +/// One host operation whose completion is driven asynchronously by the host. +/// +/// Attachment may perform supervisor queries, controller readiness +/// coordination, native simulator mutation, and rollback. Keeping that work +/// asynchronous prevents a client connection from blocking the Tokio worker +/// that serves the local session endpoint. +pub type WorldSessionOperation<'a, T> = + Pin> + Send + 'a>>; + +/// Host-owned state and operation hooks served by [`WorldSessionServer`]. +/// +/// Implementations must make `state` and `subscribe_state` one serialized +/// authority, and likewise for diagnostics. The server subscribes before it +/// reads current, then filters buffered revisions, closing both races. +pub trait WorldSessionHandler: Send + Sync + 'static { + fn bootstrap(&self) -> WorldSessionBootstrap; + fn state(&self) -> WorldSessionState; + fn subscribe_state(&self) -> broadcast::Receiver; + fn diagnostics(&self) -> WorldSessionDiagnostics; + fn subscribe_diagnostics(&self) -> broadcast::Receiver; + fn control(&self, operation: WorldControl) -> WorldSessionOperation<'_, WorldSessionState>; + fn attach( + &self, + execution: ExecutionId, + supervisor_endpoint: String, + spawn: Option, + ) -> WorldSessionOperation<'_, WorldSessionState>; +} + +/// The unique listener for one local world session. +pub struct WorldSessionServer { + endpoint: String, + shutdown: Option>, + task: Option>>, +} + +impl WorldSessionServer { + /// Bind a private loopback port and start serving one host authority. + pub async fn bind( + handler: Arc, + ) -> Result { + let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?; + Self::from_listener(listener, handler).await + } + + #[cfg(test)] + pub(super) async fn bind_at( + address: SocketAddr, + handler: Arc, + ) -> Result { + let listener = TcpListener::bind(address).await?; + Self::from_listener(listener, handler).await + } + + async fn from_listener( + listener: TcpListener, + handler: Arc, + ) -> Result { + let address = listener.local_addr()?; + let endpoint = format!("tcp://{address}"); + let (shutdown, stop) = oneshot::channel(); + let task = tokio::spawn(serve(listener, handler, stop)); + Ok(Self { + endpoint, + shutdown: Some(shutdown), + task: Some(task), + }) + } + + #[must_use] + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + pub async fn close(mut self) -> Result<(), WorldSessionWireError> { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + let Some(task) = self.task.take() else { + return Ok(()); + }; + task.await + .map_err(|error| WorldSessionWireError::Protocol(error.to_string()))? + } +} + +impl Drop for WorldSessionServer { + fn drop(&mut self) { + if let Some(task) = &self.task { + task.abort(); + } + } +} + +async fn serve( + listener: TcpListener, + handler: Arc, + mut shutdown: oneshot::Receiver<()>, +) -> Result<(), WorldSessionWireError> { + let permits = Arc::new(tokio::sync::Semaphore::new(MAX_CONNECTIONS)); + let mut connections = JoinSet::new(); + loop { + tokio::select! { + _ = &mut shutdown => { + connections.shutdown().await; + return Ok(()); + } + accepted = listener.accept() => { + let (stream, address) = accepted?; + if !address.ip().is_loopback() { + continue; + } + let Ok(permit) = Arc::clone(&permits).try_acquire_owned() else { + continue; + }; + let handler = Arc::clone(&handler); + connections.spawn(async move { + let _permit = permit; + if let Err(error) = serve_connection(stream, handler).await { + tracing::warn!(target: "phoxal.world.session", %error, "world-session client ended with an error"); + } + }); + } + Some(joined) = connections.join_next(), if !connections.is_empty() => { + if let Err(error) = joined { + tracing::warn!(target: "phoxal.world.session", %error, "world-session connection task failed"); + } + } + } + } +} + +async fn serve_connection( + mut stream: TcpStream, + handler: Arc, +) -> Result<(), WorldSessionWireError> { + let request: WireRequest = with_timeout( + "server handshake", + HANDSHAKE_TIMEOUT, + read_frame(&mut stream), + ) + .await?; + match request.path.as_str() { + STATE_PATH => { + let request = decode_body::(&request.body)?; + if let Err(error) = validate_instance(handler.as_ref(), request.instance) { + return send_error(&mut stream, error.to_string()).await; + } + serve_state_stream(&mut stream, handler).await + } + STATE_CURRENT_PATH => { + let request = decode_body::(&request.body)?; + if let Err(error) = validate_instance(handler.as_ref(), request.instance) { + return send_error(&mut stream, error.to_string()).await; + } + send_value( + &mut stream, + &WorldSessionStateCurrentResponse { + state: handler.state(), + }, + ) + .await + } + DIAGNOSTICS_PATH => { + let request = decode_body::(&request.body)?; + if let Err(error) = validate_instance(handler.as_ref(), request.instance) { + return send_error(&mut stream, error.to_string()).await; + } + serve_diagnostics_stream(&mut stream, handler).await + } + DIAGNOSTICS_CURRENT_PATH => { + let request = decode_body::(&request.body)?; + if let Err(error) = validate_instance(handler.as_ref(), request.instance) { + return send_error(&mut stream, error.to_string()).await; + } + send_value( + &mut stream, + &WorldSessionDiagnosticsCurrentResponse { + diagnostics: handler.diagnostics(), + }, + ) + .await + } + CONTROL_PATH => { + let control = decode_body::(&request.body)?; + if let Err(error) = validate_instance(handler.as_ref(), control.instance) { + return send_error(&mut stream, error.to_string()).await; + } + match tokio::time::timeout(HOST_OPERATION_TIMEOUT, handler.control(control.operation)) + .await + { + Ok(Ok(state)) => { + send_value(&mut stream, &WorldSessionControlResponse { state }).await + } + Ok(Err(message)) => send_error(&mut stream, message).await, + Err(_) => send_timeout(&mut stream, "host control", HOST_OPERATION_TIMEOUT).await, + } + } + CONNECT_PATH => serve_connect(&mut stream, handler, &request.body).await, + _ => { + send_error( + &mut stream, + format!("unknown world-session path '{}'", request.path), + ) + .await + } + } +} + +async fn serve_connect( + stream: &mut TcpStream, + handler: Arc, + body: &[u8], +) -> Result<(), WorldSessionWireError> { + match decode_body::(body)? { + WorldSessionConnectRequest::Bootstrap { .. } => { + send_value( + stream, + &WorldSessionConnectResponse::Bootstrap { + bootstrap: handler.bootstrap(), + }, + ) + .await + } + WorldSessionConnectRequest::Attach { + framework, + instance, + execution, + supervisor_endpoint, + spawn, + } => { + if !framework.is_compatible_with(FrameworkVersion::CURRENT) { + return send_error( + stream, + format!( + "framework {framework} is incompatible with host {}", + FrameworkVersion::CURRENT + ), + ) + .await; + } + if let Err(error) = validate_instance(handler.as_ref(), instance) { + return send_error(stream, error.to_string()).await; + } + match tokio::time::timeout( + HOST_OPERATION_TIMEOUT, + handler.attach(execution, supervisor_endpoint, spawn), + ) + .await + { + Ok(Ok(state)) => { + send_value(stream, &WorldSessionConnectResponse::Attached { state }).await + } + Ok(Err(message)) => send_error(stream, message).await, + Err(_) => send_timeout(stream, "host attachment", HOST_OPERATION_TIMEOUT).await, + } + } + } +} + +fn validate_instance( + handler: &H, + requested: crate::model::world::WorldInstanceId, +) -> Result<(), WorldSessionWireError> { + let actual = handler.bootstrap().instance; + if requested == actual { + Ok(()) + } else { + Err(WorldSessionWireError::Protocol(format!( + "world-session request targets instance {requested}, but this endpoint serves {actual}" + ))) + } +} + +async fn serve_state_stream( + stream: &mut TcpStream, + handler: Arc, +) -> Result<(), WorldSessionWireError> { + let (mut peer, mut output) = stream.split(); + let mut updates = handler.subscribe_state(); + let current = handler.state(); + let mut revision = current.revision; + send_value(&mut output, &WorldSessionStateStream { state: current }).await?; + loop { + match updates.try_recv() { + Ok(state) if state.revision > revision => { + revision = state.revision; + send_value(&mut output, &WorldSessionStateStream { state }).await?; + } + Ok(_) => continue, + Err(broadcast::error::TryRecvError::Empty) => break, + Err(broadcast::error::TryRecvError::Lagged(skipped)) => { + send_gap(&mut output, "state", skipped).await?; + return Ok(()); + } + Err(broadcast::error::TryRecvError::Closed) => return Ok(()), + } + } + serve_subscription( + &mut peer, + &mut output, + updates, + revision, + "state", + |state| WorldSessionStateStream { state }, + ) + .await +} + +async fn serve_diagnostics_stream( + stream: &mut TcpStream, + handler: Arc, +) -> Result<(), WorldSessionWireError> { + let (mut peer, mut output) = stream.split(); + let mut updates = handler.subscribe_diagnostics(); + let current = handler.diagnostics(); + let mut revision = current.revision; + send_value( + &mut output, + &WorldSessionDiagnosticsStream { + diagnostics: current, + }, + ) + .await?; + loop { + match updates.try_recv() { + Ok(diagnostics) if diagnostics.revision > revision => { + revision = diagnostics.revision; + send_value(&mut output, &WorldSessionDiagnosticsStream { diagnostics }).await?; + } + Ok(_) => continue, + Err(broadcast::error::TryRecvError::Empty) => break, + Err(broadcast::error::TryRecvError::Lagged(skipped)) => { + send_gap(&mut output, "diagnostics", skipped).await?; + return Ok(()); + } + Err(broadcast::error::TryRecvError::Closed) => return Ok(()), + } + } + serve_subscription( + &mut peer, + &mut output, + updates, + revision, + "diagnostics", + |diagnostics| WorldSessionDiagnosticsStream { diagnostics }, + ) + .await +} + +async fn serve_subscription( + peer: &mut tokio::net::tcp::ReadHalf<'_>, + output: &mut tokio::net::tcp::WriteHalf<'_>, + mut updates: broadcast::Receiver, + mut revision: u64, + stream_name: &'static str, + wrap: impl Fn(T) -> U, +) -> Result<(), WorldSessionWireError> +where + T: Clone + Revisioned, + U: Serialize, +{ + let mut peer_byte = [0_u8; 1]; + loop { + tokio::select! { + update = updates.recv() => match update { + Ok(update) if update.revision() > revision => { + revision = update.revision(); + send_value(output, &wrap(update)).await?; + } + Ok(update) => { + send_error( + output, + format!( + "world {stream_name} revision {} did not increase beyond {revision}", + update.revision() + ), + ) + .await?; + return Ok(()); + } + Err(broadcast::error::RecvError::Lagged(skipped)) => { + send_gap(output, stream_name, skipped).await?; + return Ok(()); + } + Err(broadcast::error::RecvError::Closed) => return Ok(()), + }, + result = peer.read(&mut peer_byte) => match result { + Ok(0) => return Ok(()), + Ok(_) => return Err(WorldSessionWireError::Protocol( + "world-session subscription client sent unexpected data".to_owned(), + )), + Err(error) => return Err(error.into()), + }, + } + } +} + +trait Revisioned { + fn revision(&self) -> u64; +} + +impl Revisioned for WorldSessionState { + fn revision(&self) -> u64 { + self.revision + } +} + +impl Revisioned for WorldSessionDiagnostics { + fn revision(&self) -> u64 { + self.revision + } +} diff --git a/phoxal/src/world/local/subscription.rs b/phoxal/src/world/local/subscription.rs new file mode 100644 index 00000000..93b0a7a4 --- /dev/null +++ b/phoxal/src/world/local/subscription.rs @@ -0,0 +1,279 @@ +use super::*; + +/// A gap-free current state plus every strictly newer complete replacement. +pub struct WorldStateSubscription { + bootstrap: WorldSessionBootstrap, + current: WorldSessionState, + updates: WireSubscription, + last_stream_revision: u64, + last_stream_progress: crate::model::world::WorldProgress, +} + +impl WorldStateSubscription { + pub(super) fn reconcile( + bootstrap: WorldSessionBootstrap, + mut current: WorldSessionState, + mut updates: WireSubscription, + ) -> Result { + let mut last_stream_revision = None; + let mut last_stream_progress = None; + while let Some(update) = updates.try_recv()? { + validate_state_against(&bootstrap, &update.state)?; + validate_stream_revision("state", &mut last_stream_revision, update.state.revision)?; + validate_stream_progress(&mut last_stream_progress, update.state.progress)?; + if update.state.revision > current.revision { + validate_progress_not_before(current.progress, update.state.progress)?; + current = update.state; + } + } + let last_stream_revision = last_stream_revision.ok_or_else(|| { + WorldSessionWireError::Protocol( + "world state subscription did not begin with a snapshot".to_owned(), + ) + })?; + let last_stream_progress = last_stream_progress.ok_or_else(|| { + WorldSessionWireError::Protocol( + "world state subscription did not begin with progress".to_owned(), + ) + })?; + Ok(Self { + bootstrap, + current, + updates, + last_stream_revision, + last_stream_progress, + }) + } + + #[must_use] + pub fn current(&self) -> &WorldSessionState { + &self.current + } + + pub fn try_recv(&mut self) -> Result, WorldSessionWireError> { + let Some(update) = self.updates.try_recv()? else { + return Ok(None); + }; + validate_state_against(&self.bootstrap, &update.state)?; + validate_stream_revision( + "state", + &mut Some(self.last_stream_revision), + update.state.revision, + )?; + validate_progress_not_before(self.last_stream_progress, update.state.progress)?; + self.last_stream_revision = update.state.revision; + self.last_stream_progress = update.state.progress; + if update.state.revision <= self.current.revision { + return Ok(None); + } + validate_progress_not_before(self.current.progress, update.state.progress)?; + self.current = update.state; + Ok(Some(&self.current)) + } + + pub async fn recv(&mut self) -> Result<&WorldSessionState, WorldSessionWireError> { + loop { + let update = self.updates.recv().await?; + validate_state_against(&self.bootstrap, &update.state)?; + validate_stream_revision( + "state", + &mut Some(self.last_stream_revision), + update.state.revision, + )?; + validate_progress_not_before(self.last_stream_progress, update.state.progress)?; + self.last_stream_revision = update.state.revision; + self.last_stream_progress = update.state.progress; + if update.state.revision > self.current.revision { + validate_progress_not_before(self.current.progress, update.state.progress)?; + self.current = update.state; + return Ok(&self.current); + } + } + } + + pub async fn wait_for_member_active( + &mut self, + execution: ExecutionId, + ) -> Result<&WorldSessionState, WorldSessionWireError> { + loop { + if self.current.members.iter().any(|member| { + member.execution == execution && member.phase == WorldMemberPhase::Active + }) { + return Ok(&self.current); + } + self.recv().await?; + } + } +} + +/// A gap-free current diagnostics value plus strictly newer replacements. +pub struct WorldDiagnosticsSubscription { + current: WorldSessionDiagnostics, + updates: WireSubscription, + last_stream_revision: u64, +} + +impl WorldDiagnosticsSubscription { + pub(super) fn reconcile( + mut current: WorldSessionDiagnostics, + mut updates: WireSubscription, + ) -> Result { + current.validate()?; + let mut last_stream_revision = None; + while let Some(update) = updates.try_recv()? { + update.diagnostics.validate()?; + validate_stream_revision( + "diagnostics", + &mut last_stream_revision, + update.diagnostics.revision, + )?; + if update.diagnostics.revision > current.revision { + current = update.diagnostics; + } + } + let last_stream_revision = last_stream_revision.ok_or_else(|| { + WorldSessionWireError::Protocol( + "world diagnostics subscription did not begin with a snapshot".to_owned(), + ) + })?; + Ok(Self { + current, + updates, + last_stream_revision, + }) + } + + #[must_use] + pub const fn current(&self) -> WorldSessionDiagnostics { + self.current + } + + pub fn try_recv(&mut self) -> Result, WorldSessionWireError> { + let Some(update) = self.updates.try_recv()? else { + return Ok(None); + }; + update.diagnostics.validate()?; + validate_stream_revision( + "diagnostics", + &mut Some(self.last_stream_revision), + update.diagnostics.revision, + )?; + self.last_stream_revision = update.diagnostics.revision; + if update.diagnostics.revision <= self.current.revision { + return Ok(None); + } + self.current = update.diagnostics; + Ok(Some(self.current)) + } + + pub async fn recv(&mut self) -> Result { + loop { + let update = self.updates.recv().await?; + update.diagnostics.validate()?; + validate_stream_revision( + "diagnostics", + &mut Some(self.last_stream_revision), + update.diagnostics.revision, + )?; + self.last_stream_revision = update.diagnostics.revision; + if update.diagnostics.revision > self.current.revision { + self.current = update.diagnostics; + return Ok(self.current); + } + } + } +} + +pub(super) struct WireSubscription { + pub(super) receiver: mpsc::Receiver>, + pub(super) task: JoinHandle<()>, +} + +impl WireSubscription { + fn try_recv(&mut self) -> Result, WorldSessionWireError> { + match self.receiver.try_recv() { + Ok(Ok(value)) => Ok(Some(value)), + Ok(Err(error)) => Err(error), + Err(mpsc::error::TryRecvError::Empty) => Ok(None), + Err(mpsc::error::TryRecvError::Disconnected) => Err(WorldSessionWireError::Closed), + } + } + + async fn recv(&mut self) -> Result { + self.receiver + .recv() + .await + .ok_or(WorldSessionWireError::Closed)? + } +} + +impl Drop for WireSubscription { + fn drop(&mut self) { + self.task.abort(); + } +} + +fn validate_stream_revision( + stream: &'static str, + previous: &mut Option, + revision: u64, +) -> Result<(), WorldSessionWireError> { + if let Some(previous) = *previous + && revision <= previous + { + return Err(WorldSessionWireError::Protocol(format!( + "world {stream} revision {revision} did not increase beyond {previous}" + ))); + } + *previous = Some(revision); + Ok(()) +} + +pub(super) fn validate_state_against( + bootstrap: &WorldSessionBootstrap, + state: &WorldSessionState, +) -> Result<(), WorldSessionWireError> { + state.validate()?; + if state.instance != bootstrap.instance { + return Err(WorldSessionWireError::BootstrapMismatch { field: "instance" }); + } + if state.provenance.world != bootstrap.world { + return Err(WorldSessionWireError::BootstrapMismatch { field: "world" }); + } + if state.provenance.digest != bootstrap.digest { + return Err(WorldSessionWireError::BootstrapMismatch { field: "digest" }); + } + if state.provenance.framework != bootstrap.framework { + return Err(WorldSessionWireError::BootstrapMismatch { field: "framework" }); + } + Ok(()) +} + +fn validate_stream_progress( + previous: &mut Option, + progress: crate::model::world::WorldProgress, +) -> Result<(), WorldSessionWireError> { + if let Some(previous) = *previous { + validate_progress_not_before(previous, progress)?; + } + *previous = Some(progress); + Ok(()) +} + +fn validate_progress_not_before( + previous: crate::model::world::WorldProgress, + observed: crate::model::world::WorldProgress, +) -> Result<(), WorldSessionWireError> { + if observed.completed_step() < previous.completed_step() + || observed.elapsed_ns() < previous.elapsed_ns() + { + return Err(WorldSessionWireError::Protocol(format!( + "world progress regressed from step {} at {} ns to step {} at {} ns", + previous.completed_step(), + previous.elapsed_ns(), + observed.completed_step(), + observed.elapsed_ns(), + ))); + } + Ok(()) +} diff --git a/phoxal/src/world/mod.rs b/phoxal/src/world/mod.rs new file mode 100644 index 00000000..2b3f1c84 --- /dev/null +++ b/phoxal/src/world/mod.rs @@ -0,0 +1,10 @@ +//! Backend-neutral world-session contracts and bounded loopback transport. + +pub mod api; + +mod local; + +pub use local::{ + WorldDiagnosticsSubscription, WorldSessionClient, WorldSessionHandler, WorldSessionOperation, + WorldSessionServer, WorldSessionWireError, WorldStateSubscription, +}; diff --git a/phoxal/tests/api.rs b/phoxal/tests/api.rs index 634b6b9c..f32fcd6d 100644 --- a/phoxal/tests/api.rs +++ b/phoxal/tests/api.rs @@ -1,11 +1,13 @@ -//! Black-box coverage of the three contract families and the dynamic tree that +//! Black-box coverage of the five contract families and the dynamic tree that //! declares them. #![allow(clippy::expect_used, clippy::unwrap_used)] use phoxal::api as robot; use phoxal::runtime::api as runtime; +use phoxal::simulation::api as simulation; use phoxal::supervisor::api as supervisor; +use phoxal::world::api as world; #[path = "api/behavior.rs"] mod behavior; diff --git a/phoxal/tests/api/contract_surface.rs b/phoxal/tests/api/contract_surface.rs index d939a4a2..314b2ac0 100644 --- a/phoxal/tests/api/contract_surface.rs +++ b/phoxal/tests/api/contract_surface.rs @@ -163,4 +163,14 @@ fn an_endpoint_record_carries_the_payload_types_own_schema() { ) .expect("a declared schema renders as JSON"); assert_eq!(by_path["supervisor/connect"]["request"], request); + + // World control is bound to the bootstrap instance in the request itself. + // Keeping this exact record in the checked surface prevents a future + // endpoint refactor from silently accepting unbound control operations. + let control_request: Value = serde_json::from_str( + &::wire_schema() + .canonical_json(), + ) + .expect("a declared schema renders as JSON"); + assert_eq!(by_path["world/session/control"]["request"], control_request); } diff --git a/phoxal/tests/api/templates.rs b/phoxal/tests/api/templates.rs index 5567f984..88e46f60 100644 --- a/phoxal/tests/api/templates.rs +++ b/phoxal/tests/api/templates.rs @@ -1,5 +1,4 @@ -//! The compatibility templates the same declarations emit, and the proof that -//! they are the `0.65` wire surface unchanged. +//! The compatibility templates the current declarations emit. //! //! A template is what a concrete key looks like with its dynamic segments left //! as `{variable}`. Both come out of the same `nodes!`/`endpoints!` structure - @@ -10,7 +9,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde_json::Value; -/// The dynamic variables the three families declare, spelled exactly as their +/// The dynamic variables the five families declare, spelled exactly as their /// `nodes!` declarations bind them. const DECLARED_VARIABLES: [&str; 3] = ["capability", "instance", "joint"]; @@ -39,7 +38,7 @@ fn template(record: &Value) -> &str { /// One endpoint, one key. Two endpoints sharing a template would be two /// contracts a receiver's per-key subscription could not tell apart. #[test] -fn every_endpoint_template_is_unique_across_the_three_families() { +fn every_endpoint_template_is_unique_across_the_five_families() { let declared = endpoint_records(); let unique = declared.iter().map(template).collect::>(); assert_eq!( @@ -58,7 +57,7 @@ fn every_template_is_rooted_at_its_own_family() { .as_str() .expect("an endpoint record names its family"); assert!( - ["robot", "runtime", "supervisor"].contains(&family), + ["robot", "runtime", "simulation", "supervisor", "world"].contains(&family), "{family} is not a declared family" ); let path = template(&record); @@ -102,8 +101,7 @@ fn every_placeholder_is_a_declared_variable_bound_exactly_once() { } /// The kind and the delivery lane on a record are the ones the endpoint's -/// semantic fixes, including the world clock, whose distinct authority keeps -/// the ordinary event wire kind. +/// semantic fixes, including passive world progress as an ordinary event. #[test] fn a_records_kind_and_lane_are_the_ones_its_semantic_fixes() { let by_path = endpoint_records() @@ -126,117 +124,18 @@ fn a_records_kind_and_lane_are_the_ones_its_semantic_fixes() { "stream", ), ("runtime/logs", "stream", "stream"), - // The world clock: a distinct Rust authority, the same wire kind it has - // always had. - ("runtime/simulation/clock", "event", "stream"), + ("simulation/step", "event", "stream"), ("supervisor/connect", "query", "query"), + ("supervisor/simulation/attachment", "stream", "stream"), + ("supervisor/simulation/attach", "query", "query"), ("supervisor/snapshot", "stream", "stream"), ("supervisor/snapshot/current", "query", "query"), + ("world/session/state", "stream", "stream"), + ("world/session/state/current", "query", "query"), + ("world/session/control", "query", "query"), ] { let record = &by_path[path]; assert_eq!(record["kind"], kind, "{path}"); assert_eq!(record["delivery"], delivery, "{path}"); } } - -/// The `0.65` process/wire surface, unchanged. -/// -/// The baseline is the five contract surfaces the `0.65` train published, one -/// document per retired library carrier. `0.66` carries them all in one crate, -/// so the proof is that the union of those five documents, put back into the -/// canonical order this crate renders in, is byte-for-byte what this crate now -/// renders. -/// -/// Ignored by default and pointed at a directory rather than a checked-in -/// fixture: a published train is the baseline, and this workspace keeps no -/// snapshot fixtures. Run it with -/// `PHOXAL_COMPAT_BASELINE_DIR= cargo test -p phoxal --test api -- --ignored`. -#[test] -#[ignore = "reads a published-baseline directory named by PHOXAL_COMPAT_BASELINE_DIR"] -fn the_aggregate_is_the_0_65_surface_byte_for_byte() { - let directory = std::env::var("PHOXAL_COMPAT_BASELINE_DIR") - .expect("PHOXAL_COMPAT_BASELINE_DIR names the published baseline directory"); - let mut baseline = Vec::new(); - for carrier in [ - "phoxal", - "phoxal-bundle", - "phoxal-bus", - "phoxal-protocol", - "phoxal-runtime-contract", - ] { - let path = std::path::Path::new(&directory).join(format!("{carrier}.json")); - let document = std::fs::read_to_string(&path) - .unwrap_or_else(|error| panic!("{} is unreadable: {error}", path.display())); - baseline.extend(split_records(&document)); - } - baseline.sort_by_key(|record| sort_key(record)); - - let expected = format!("{{\"records\":[{}]}}", baseline.join(",")); - let rendered = phoxal::__compat::contract_surface(); - assert_eq!( - split_records(&rendered).len(), - baseline.len(), - "the aggregate holds a different number of records than the baseline union" - ); - assert_eq!(rendered, expected); -} - -/// Split one canonical surface document into its record substrings, exactly as -/// written. -/// -/// The records are compared as the bytes they were published as, so they are -/// never re-rendered on the way through: a records array is scanned with string -/// and bracket depth awareness and cut at the top-level commas. -fn split_records(document: &str) -> Vec { - const OPEN: &str = "{\"records\":["; - let body = document - .strip_prefix(OPEN) - .and_then(|rest| rest.strip_suffix("]}")) - .expect("a canonical surface document opens with its records array"); - let mut records = Vec::new(); - let mut depth = 0_usize; - let mut in_string = false; - let mut escaped = false; - let mut start = 0_usize; - for (index, character) in body.char_indices() { - if in_string { - if escaped { - escaped = false; - } else if character == '\\' { - escaped = true; - } else if character == '"' { - in_string = false; - } - continue; - } - match character { - '"' => in_string = true, - '{' | '[' => depth += 1, - '}' | ']' => depth -= 1, - ',' if depth == 0 => { - records.push(body[start..index].to_owned()); - start = index + 1; - } - _ => {} - } - } - if !body.is_empty() { - records.push(body[start..].to_owned()); - } - records -} - -/// The key one record sorts on, mirroring `ContractRecord::sort_key`: the -/// record's own kind first, then the fields that identify one record within it. -fn sort_key(record: &str) -> (String, String, String) { - let value: Value = serde_json::from_str(record).expect("a record is JSON"); - let text = |field: &str| value[field].as_str().unwrap_or_default().to_owned(); - match value["record"].as_str().expect("a record names its kind") { - "endpoint" => ("endpoint".to_owned(), text("family"), text("path")), - "document" => ("document".to_owned(), text("name"), text("tag")), - "envelope" => ("envelope".to_owned(), text("name"), String::new()), - "identifier" => ("identifier".to_owned(), text("name"), String::new()), - "launch" => ("launch".to_owned(), String::new(), String::new()), - other => panic!("unknown record kind {other:?}"), - } -} diff --git a/phoxal/tests/api/tree.rs b/phoxal/tests/api/tree.rs index e4f4dacd..e1b1d715 100644 --- a/phoxal/tests/api/tree.rs +++ b/phoxal/tests/api/tree.rs @@ -7,7 +7,7 @@ use phoxal::identity::ComponentInstanceId; use phoxal::model::identity::{CapabilityId, JointId}; -use crate::{robot as api, runtime, supervisor}; +use crate::{robot as api, runtime, simulation, supervisor, world}; fn component(value: &str) -> ComponentInstanceId { ComponentInstanceId::new(value).expect("a canonical component instance") @@ -244,8 +244,8 @@ fn a_self_node_is_the_endpoint_and_named_leaves_sit_beside_it() { "runtime/telemetry" ); assert_eq!( - runtime::topics().simulation().clock().key(), - "runtime/simulation/clock" + simulation::topics().step().client().key(), + "simulation/step" ); assert_eq!( @@ -288,6 +288,54 @@ fn a_self_node_is_the_endpoint_and_named_leaves_sit_beside_it() { supervisor::topics().telemetry().follow().key(), "supervisor/telemetry/follow" ); + assert_eq!( + supervisor::topics() + .simulation() + .attachment() + .client() + .key(), + "supervisor/simulation/attachment" + ); + assert_eq!( + supervisor::topics() + .simulation() + .attachment() + .current() + .key(), + "supervisor/simulation/attachment/current" + ); + assert_eq!( + supervisor::topics().simulation().attach().client().key(), + "supervisor/simulation/attach" + ); + assert_eq!( + supervisor::topics().simulation().end().client().key(), + "supervisor/simulation/end" + ); + assert_eq!( + world::topics().session().state().client().key(), + "world/session/state" + ); + assert_eq!( + world::topics().session().state().current().key(), + "world/session/state/current" + ); + assert_eq!( + world::topics().session().diagnostics().client().key(), + "world/session/diagnostics" + ); + assert_eq!( + world::topics().session().diagnostics().current().key(), + "world/session/diagnostics/current" + ); + assert_eq!( + world::topics().session().control().client().key(), + "world/session/control" + ); + assert_eq!( + world::topics().session().connect().client().key(), + "world/session/connect" + ); } /// There is one path tree and the side is chosen at the endpoint, so an owner diff --git a/phoxal/tests/bus_bindings.rs b/phoxal/tests/bus_bindings.rs index 69422173..5b1a2d3e 100644 --- a/phoxal/tests/bus_bindings.rs +++ b/phoxal/tests/bus_bindings.rs @@ -8,8 +8,8 @@ use phoxal::api; use phoxal::bus::{ - BusMetadata, CodecId, ParticipantSourceIdentity, ProducerId, RobotInstant, SourceAttribution, - TimeWindow, TimelineId, + BusMetadata, CodecId, DeliveryMetadata, ParticipantSourceIdentity, ProducerId, RobotInstant, + SourceAttribution, TimeWindow, TimelineId, }; use phoxal::supervisor::api as supervisor; @@ -53,4 +53,10 @@ fn bus_metadata_for_a_real_endpoint_round_trips() { let decoded = BusMetadata::decode(&timeless.encode().unwrap()).unwrap(); assert_eq!(decoded, timeless); assert_eq!(decoded.produced_exactly_at(), None); + + let delivery = DeliveryMetadata::new(timeless, Some(3)); + assert_eq!( + DeliveryMetadata::decode(&delivery.encode().unwrap()).unwrap(), + delivery + ); } diff --git a/phoxal/tests/golden/world.schema.json b/phoxal/tests/golden/world.schema.json new file mode 100644 index 00000000..67e09993 --- /dev/null +++ b/phoxal/tests/golden/world.schema.json @@ -0,0 +1,277 @@ +{ + "$defs": { + "Asset": { + "additionalProperties": false, + "properties": { + "collision": { + "anyOf": [ + { + "$ref": "#/$defs/Geometry" + }, + { + "type": "null" + } + ] + }, + "geometry": { + "$ref": "#/$defs/Geometry" + } + }, + "required": [ + "geometry" + ], + "type": "object" + }, + "EntityDeclaration": { + "additionalProperties": false, + "properties": { + "asset": { + "type": "string" + }, + "instances": { + "items": { + "$ref": "#/$defs/EntityInstance" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "asset", + "instances" + ], + "type": "object" + }, + "EntityInstance": { + "additionalProperties": false, + "properties": { + "pose": { + "$ref": "#/$defs/Pose" + } + }, + "required": [ + "pose" + ], + "type": "object" + }, + "Geometry": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "box", + "type": "string" + }, + "size": { + "items": { + "format": "double", + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + } + }, + "required": [ + "kind", + "size" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "cylinder", + "type": "string" + }, + "length": { + "format": "double", + "type": "number" + }, + "radius": { + "format": "double", + "type": "number" + } + }, + "required": [ + "kind", + "radius", + "length" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "capsule", + "type": "string" + }, + "length": { + "format": "double", + "type": "number" + }, + "radius": { + "format": "double", + "type": "number" + } + }, + "required": [ + "kind", + "radius", + "length" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "sphere", + "type": "string" + }, + "radius": { + "format": "double", + "type": "number" + } + }, + "required": [ + "kind", + "radius" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "mesh", + "type": "string" + }, + "path": { + "type": "string" + }, + "scale": { + "items": { + "format": "double", + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, + "Pose": { + "additionalProperties": false, + "properties": { + "rpy": { + "items": { + "format": "double", + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "xyz": { + "items": { + "format": "double", + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + } + }, + "required": [ + "xyz", + "rpy" + ], + "type": "object" + }, + "World": { + "additionalProperties": false, + "properties": { + "entities": { + "additionalProperties": { + "$ref": "#/$defs/EntityDeclaration" + }, + "default": {}, + "type": "object" + }, + "gravity_mps2": { + "items": { + "format": "double", + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "id": { + "type": "string" + }, + "spawn_points": { + "additionalProperties": { + "$ref": "#/$defs/Pose" + }, + "default": {}, + "type": "object" + }, + "time_step_ms": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "id", + "time_step_ms", + "gravity_mps2" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Editor schema for an authored Phoxal world.yaml document.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "assets": { + "additionalProperties": { + "$ref": "#/$defs/Asset" + }, + "type": "object" + }, + "schema": { + "const": "phoxal/world/v0", + "type": "string" + }, + "world": { + "$ref": "#/$defs/World" + } + }, + "required": [ + "schema", + "assets", + "world" + ], + "type": "object" + } + ], + "title": "Phoxal world manifest (phoxal/world/v0)" +} diff --git a/phoxal/tests/model_boundary.rs b/phoxal/tests/model_boundary.rs index 733373f2..222bfc6c 100644 --- a/phoxal/tests/model_boundary.rs +++ b/phoxal/tests/model_boundary.rs @@ -15,8 +15,8 @@ fn one_manifest_serves_runtime_and_simulation_consumers() -> anyhow::Result<()> .component("front_left_drive") .ok_or_else(|| anyhow::anyhow!("the fixture mounts a driven component"))?; assert_eq!(drive.instance().component_type().as_str(), "drive_motor"); - // The driver block is kept in every mode: simulation is a launch decision, - // never a bundle fact. + // The driver block is kept in every mode: physical and simulated execution + // consume the same bundle declaration. assert!(drive.instance().driver().is_some()); assert!( drive diff --git a/phoxal/tests/runtime_serialized_smoke.rs b/phoxal/tests/runtime_serialized_smoke.rs index 2ce00361..1cf0af34 100644 --- a/phoxal/tests/runtime_serialized_smoke.rs +++ b/phoxal/tests/runtime_serialized_smoke.rs @@ -52,8 +52,9 @@ impl SerializedSmoke { _request: supervisor::bundle::GetRequest, state: &mut SmokeState, ) -> QueryResult { - Ok(supervisor::bundle::GetResponse::Found { + Ok(supervisor::bundle::GetResponse::Chunk { bytes: (state.steps as u64).to_le_bytes().to_vec(), + eof: true, }) } } @@ -98,7 +99,8 @@ async fn a_pending_query_reply_does_not_hold_serialized_steps() { async move { querier .query(supervisor::bundle::GetRequest { - path: "smoke".to_string(), + path: phoxal::bundle::BundlePath::new("smoke").expect("valid smoke path"), + offset: 0, }) .await } @@ -113,7 +115,7 @@ async fn a_pending_query_reply_does_not_hold_serialized_steps() { .await .expect("query task should not panic") .expect("the serialized query should answer"); - let supervisor::bundle::GetResponse::Found { bytes } = response else { + let supervisor::bundle::GetResponse::Chunk { bytes, eof: true } = response else { panic!("smoke query returned the wrong response variant"); }; let observed_steps = diff --git a/phoxal/tests/trybuild.rs b/phoxal/tests/trybuild.rs index 45e41751..72681df5 100644 --- a/phoxal/tests/trybuild.rs +++ b/phoxal/tests/trybuild.rs @@ -21,11 +21,9 @@ fn trybuild_ui() { #[test] fn trybuild_ui_with_test_harness_enabled() {} -// The cases a *host* profile is the subject of. A participant cannot name the -// runtime family at all, which `fail/participant_cannot_reach_the_world_clock` -// pins; the guarantee below is the one that still has to hold for a consumer -// that *can* name it - the world clock is a sibling semantic of `State`, never -// a subtype of it, so no ordinary state publisher can mint a world step. +// The cases where a *host* profile is the subject. A participant cannot name +// host families at all; these cases pin type separation for consumers that can +// name those families. #[cfg(feature = "session")] #[test] fn trybuild_host_ui() { diff --git a/phoxal/tests/trybuild/fail/participant_cannot_reach_the_host_families.rs b/phoxal/tests/trybuild/fail/participant_cannot_reach_the_host_families.rs index fbf7c38d..374c861a 100644 --- a/phoxal/tests/trybuild/fail/participant_cannot_reach_the_host_families.rs +++ b/phoxal/tests/trybuild/fail/participant_cannot_reach_the_host_families.rs @@ -1,12 +1,11 @@ // A participant authors against the robot family and sees no host domain. The -// runtime family is what a process says about itself - the runner emits it, and -// the world clock inside it belongs to whoever owns the world - and the -// supervisor family is the control plane an attached application speaks. Both -// are published by other profiles, so a participant cannot name either, let -// alone publish on one. -use phoxal::runtime::api::simulation::Clock; +// simulation family carries progress published by an attached world, and the +// supervisor family is the control plane an attached application speaks. +// Those families are published by other profiles, so a participant cannot name +// either, let alone publish on one. +use phoxal::simulation::api::StepEvent; use phoxal::supervisor::api::execution::Snapshot; fn main() { - let _ = std::mem::size_of::<(Clock, Snapshot)>(); + let _ = std::mem::size_of::<(StepEvent, Snapshot)>(); } diff --git a/phoxal/tests/trybuild/fail/participant_cannot_reach_the_host_families.stderr b/phoxal/tests/trybuild/fail/participant_cannot_reach_the_host_families.stderr index 7ef8e47c..92b7a31a 100644 --- a/phoxal/tests/trybuild/fail/participant_cannot_reach_the_host_families.stderr +++ b/phoxal/tests/trybuild/fail/participant_cannot_reach_the_host_families.stderr @@ -1,21 +1,21 @@ -error[E0603]: module `runtime` is private - --> tests/trybuild/fail/participant_cannot_reach_the_host_families.rs:7:13 +error[E0603]: module `simulation` is private + --> tests/trybuild/fail/participant_cannot_reach_the_host_families.rs:6:13 | -7 | use phoxal::runtime::api::simulation::Clock; - | ^^^^^^^ ---------- module `simulation` is not publicly re-exported +6 | use phoxal::simulation::api::StepEvent; + | ^^^^^^^^^^ --- module `api` is not publicly re-exported | | | private module | -note: the module `runtime` is defined here +note: the module `simulation` is defined here --> src/lib.rs | - | mod runtime; - | ^^^^^^^^^^^ + | mod simulation; + | ^^^^^^^^^^^^^^ error[E0603]: module `supervisor` is private - --> tests/trybuild/fail/participant_cannot_reach_the_host_families.rs:8:13 + --> tests/trybuild/fail/participant_cannot_reach_the_host_families.rs:7:13 | -8 | use phoxal::supervisor::api::execution::Snapshot; +7 | use phoxal::supervisor::api::execution::Snapshot; | ^^^^^^^^^^ --------- module `execution` is not publicly re-exported | | | private module diff --git a/phoxal/tests/trybuild/fail/state_cannot_be_received_as_stream.stderr b/phoxal/tests/trybuild/fail/state_cannot_be_received_as_stream.stderr index 3b4e51c2..b12de412 100644 --- a/phoxal/tests/trybuild/fail/state_cannot_be_received_as_stream.stderr +++ b/phoxal/tests/trybuild/fail/state_cannot_be_received_as_stream.stderr @@ -17,9 +17,6 @@ help: the following other types implement trait `StreamDelivered` ... | impl StreamDelivered for Stream {} | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `phoxal::bus::Stream` -... - | impl StreamDelivered for WorldClock {} - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `WorldClock` note: required by a bound in `phoxal::SetupContext::::stream_receiver` --> src/participant/context.rs | diff --git a/phoxal/tests/trybuild/host_fail/attach_request_fields_are_private.rs b/phoxal/tests/trybuild/host_fail/attach_request_fields_are_private.rs new file mode 100644 index 00000000..b66c234e --- /dev/null +++ b/phoxal/tests/trybuild/host_fail/attach_request_fields_are_private.rs @@ -0,0 +1,11 @@ +use phoxal::identity::ProducerId; +use phoxal::model::world::{WorldInstanceId, WorldProgress}; +use phoxal::supervisor::api::simulation::attach::AttachRequest; + +fn main() { + let _ = AttachRequest { + world: WorldInstanceId::mint(), + controller: ProducerId::parse("10000000000000000000000000000001").unwrap(), + progress: WorldProgress::at(1, 12).unwrap(), + }; +} diff --git a/phoxal/tests/trybuild/host_fail/attach_request_fields_are_private.stderr b/phoxal/tests/trybuild/host_fail/attach_request_fields_are_private.stderr new file mode 100644 index 00000000..afb481b4 --- /dev/null +++ b/phoxal/tests/trybuild/host_fail/attach_request_fields_are_private.stderr @@ -0,0 +1,11 @@ +error[E0451]: fields `world`, `controller` and `progress` of struct `AttachRequest` are private + --> tests/trybuild/host_fail/attach_request_fields_are_private.rs:7:9 + | +6 | let _ = AttachRequest { + | ------------- in this type +7 | world: WorldInstanceId::mint(), + | ^^^^^ private field +8 | controller: ProducerId::parse("10000000000000000000000000000001").unwrap(), + | ^^^^^^^^^^ private field +9 | progress: WorldProgress::at(1, 12).unwrap(), + | ^^^^^^^^ private field diff --git a/phoxal/tests/trybuild/host_fail/state_publisher_rejects_the_world_clock.rs b/phoxal/tests/trybuild/host_fail/state_publisher_rejects_the_world_clock.rs deleted file mode 100644 index 51023069..00000000 --- a/phoxal/tests/trybuild/host_fail/state_publisher_rejects_the_world_clock.rs +++ /dev/null @@ -1,30 +0,0 @@ -// The world clock is a sibling semantic of `State`, never a subtype of it, so -// the ordinary state publisher every participant has cannot mint a world step. -// Both halves of the refusal are pinned: the participant builder (which also -// refuses the runtime family) and the handle itself, where the semantic is the -// only bound in play. -use phoxal::prelude::*; -use phoxal::runtime::api as runtime; - -#[phoxal::service(id = "world-clock-minter")] -struct WorldClockMinter; - -impl Participant for WorldClockMinter { - async fn setup( - &self, - ctx: &mut SetupContext, - _config: Self::Config, - ) -> Result<(Self::State, Self::Api)> { - let _publisher = ctx.state_publisher(runtime::topics().simulation().clock().owner())?; - Ok(((), ())) - } -} - -fn state_publisher_over_the_clock(bus: phoxal::bus::BusHandle) { - let _ = phoxal::bus::StatePublisher::new( - bus, - &runtime::topics().simulation().clock().owner(), - ); -} - -fn main() {} diff --git a/phoxal/tests/trybuild/host_fail/state_publisher_rejects_the_world_clock.stderr b/phoxal/tests/trybuild/host_fail/state_publisher_rejects_the_world_clock.stderr deleted file mode 100644 index b0c512ab..00000000 --- a/phoxal/tests/trybuild/host_fail/state_publisher_rejects_the_world_clock.stderr +++ /dev/null @@ -1,86 +0,0 @@ -error[E0271]: type mismatch resolving `::Semantics == State` - --> tests/trybuild/host_fail/state_publisher_rejects_the_world_clock.rs:18:46 - | -18 | let _publisher = ctx.state_publisher(runtime::topics().simulation().clock().owner())?; - | --------------- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `State`, found `WorldClock` - | | - | required by a bound introduced by this call - | -note: required by a bound in `phoxal::SetupContext::::state_publisher` - --> src/participant/context.rs - | - | pub fn state_publisher>( - | ^^^^^^^^^^^^^^^^^ required by this bound in `SetupContext::::state_publisher` - -error[E0271]: type mismatch resolving `::Family == Robot` - --> tests/trybuild/host_fail/state_publisher_rejects_the_world_clock.rs:18:46 - | -18 | let _publisher = ctx.state_publisher(runtime::topics().simulation().clock().owner())?; - | --------------- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Robot`, found `Runtime` - | | - | required by a bound introduced by this call - | - = note: required for `Clock` to implement `RobotEndpoint` -note: required by a bound in `phoxal::SetupContext::::state_publisher` - --> src/participant/context.rs - | - | pub fn state_publisher>( - | ^^^^^^^^^^^^^ required by this bound in `SetupContext::::state_publisher` - -error[E0271]: type mismatch resolving `::Semantics == State` - --> tests/trybuild/host_fail/state_publisher_rejects_the_world_clock.rs:18:26 - | -18 | let _publisher = ctx.state_publisher(runtime::topics().simulation().clock().owner())?; - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `State`, found `WorldClock` - | -note: required by a bound in `StatePublisher` - --> src/bus/handle/publisher.rs - | - | pub struct StatePublisher>(Outbox); - | ^^^^^^^^^^^^^^^^^ required by this bound in `StatePublisher` - -error[E0271]: type mismatch resolving `::Semantics == State` - --> tests/trybuild/host_fail/state_publisher_rejects_the_world_clock.rs:18:26 - | -18 | let _publisher = ctx.state_publisher(runtime::topics().simulation().clock().owner())?; - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `State`, found `WorldClock` - | -note: required by a bound in `StatePublisher` - --> src/bus/handle/publisher.rs - | - | pub struct StatePublisher>(Outbox); - | ^^^^^^^^^^^^^^^^^ required by this bound in `StatePublisher` - -error[E0271]: type mismatch resolving `::Semantics == State` - --> tests/trybuild/host_fail/state_publisher_rejects_the_world_clock.rs:26:9 - | -24 | let _ = phoxal::bus::StatePublisher::new( - | -------------------------------- required by a bound introduced by this call -25 | bus, -26 | &runtime::topics().simulation().clock().owner(), - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `State`, found `WorldClock` - | -note: required by a bound in `StatePublisher::::new` - --> src/bus/handle/publisher.rs - | - | impl> StatePublisher { - | ^^^^^^^^^^^^^^^^^ required by this bound in `StatePublisher::::new` - | #[doc(hidden)] - | pub fn new(bus: BusHandle, topic: &Topic>) -> Result { - | --- required by a bound in this associated function - -error[E0271]: type mismatch resolving `::Semantics == State` - --> tests/trybuild/host_fail/state_publisher_rejects_the_world_clock.rs:24:13 - | -24 | let _ = phoxal::bus::StatePublisher::new( - | _____________^ -25 | | bus, -26 | | &runtime::topics().simulation().clock().owner(), -27 | | ); - | |_____^ expected `State`, found `WorldClock` - | -note: required by a bound in `StatePublisher` - --> src/bus/handle/publisher.rs - | - | pub struct StatePublisher>(Outbox); - | ^^^^^^^^^^^^^^^^^ required by this bound in `StatePublisher` diff --git a/release-plz.toml b/release-plz.toml index e5b19b46..466dc2b8 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -19,9 +19,10 @@ pr_name = "chore(release): release v{{ version }}" # patch because nobody wrote `!`. It is the Rust-API half of the promise; the # wire half is `cargo xtask compatibility check-release`. # -# No per-package override is needed: release-plz only runs the check on packages -# that contain a library, and every executable published to the `phoxal` -# registry is bin-only, so each is skipped on its own. +# Registry executables are bin-only and skipped on their own. The Webots +# controller contract is its own exact-train adapter library. It has no +# crates.io baseline, so its package carries one narrow `semver_check = false` +# override below. # # It runs with default features, and release-plz exposes no way to pass a # feature selection through to cargo-semver-checks. Since `0.66` most of this @@ -73,6 +74,40 @@ changelog_update = false git_tag_enable = false git_release_enable = false +[[package]] +name = "phoxal-simulator-webots-host" +version_group = "framework-train" +publish = false +changelog_update = false +git_tag_enable = false +git_release_enable = false +semver_check = false + +[[package]] +name = "phoxal-simulator-webots-shared" +version_group = "framework-train" +publish = false +changelog_update = false +git_tag_enable = false +git_release_enable = false +semver_check = false + +[[package]] +name = "phoxal-simulator-webots-world-controller" +version_group = "framework-train" +publish = false +changelog_update = false +git_tag_enable = false +git_release_enable = false + +[[package]] +name = "phoxal-simulator-webots-robot-controller" +version_group = "framework-train" +publish = false +changelog_update = false +git_tag_enable = false +git_release_enable = false + [[package]] name = "phoxal-component-bno085" version_group = "framework-train" diff --git a/services/drive/src/drive.rs b/services/drive/src/drive.rs index 3714afa5..cb7b50a7 100644 --- a/services/drive/src/drive.rs +++ b/services/drive/src/drive.rs @@ -26,7 +26,7 @@ use phoxal::api; use phoxal::model::Robot; use phoxal::model::component::capability::MotorCommand; use phoxal::model::identity::CapabilityRef; -use phoxal::model::robot::{BodyTwist, DifferentialDrive, KinematicConfig, MotionLimits}; +use phoxal::model::kinematics::{BodyTwist, DifferentialDrive, KinematicConfig, MotionLimits}; use phoxal::prelude::*; /// How long `drive` tolerates silence from the accepted target producer. This diff --git a/services/motion/src/arbitration.rs b/services/motion/src/arbitration.rs index e58def1e..d45a56a9 100644 --- a/services/motion/src/arbitration.rs +++ b/services/motion/src/arbitration.rs @@ -4,7 +4,7 @@ use std::time::Duration; use phoxal::api; use phoxal::bus::{LocalInstant, RobotInstant, Timed}; -use phoxal::model::robot::MotionLimits; +use phoxal::model::kinematics::MotionLimits; /// Manual teleoperation is a **leased** command: the operator must keep proving /// presence in human time, so silence is measured on the host clock and an diff --git a/services/motion/src/motion.rs b/services/motion/src/motion.rs index cd287c60..98ce146c 100644 --- a/services/motion/src/motion.rs +++ b/services/motion/src/motion.rs @@ -31,7 +31,7 @@ use anyhow::{Result, bail}; use phoxal::api; use phoxal::model::component::capability::Capability; use phoxal::model::identity::CapabilityRef; -use phoxal::model::robot::MotionLimits; +use phoxal::model::kinematics::MotionLimits; use phoxal::prelude::*; use crate::arbitration::{ diff --git a/services/odometry/src/odometry.rs b/services/odometry/src/odometry.rs index 63bc2b1e..2472c331 100644 --- a/services/odometry/src/odometry.rs +++ b/services/odometry/src/odometry.rs @@ -14,7 +14,7 @@ use phoxal::api; use phoxal::geometry::normalize_angle; use phoxal::model::Robot; use phoxal::model::identity::CapabilityRef; -use phoxal::model::robot::{DifferentialDrive, DifferentialWheelSpeeds, KinematicConfig}; +use phoxal::model::kinematics::{DifferentialDrive, DifferentialWheelSpeeds, KinematicConfig}; use phoxal::prelude::*; /// One encoder binding resolved from the robot model. diff --git a/simulators/webots/README.md b/simulators/webots/README.md new file mode 100644 index 00000000..90220965 --- /dev/null +++ b/simulators/webots/README.md @@ -0,0 +1,66 @@ +# Webots adapter + +This directory contains the official Webots adapter packages for the Phoxal framework train. +The adapter supports exactly Webots R2025a. +It discovers Webots through `WEBOTS_HOME` or the platform default and refuses every other observed version. + +The Webots dependency and native scene behavior stay in these packages. +They do not enter the universal `phoxal` library or the backend-neutral CLI. + +## Process roles + +- `phoxal-simulator-webots-host` owns one long-lived world session, the generated native project, the Webots process tree, local registration, retained evidence, and serialized robot attachment. +- `phoxal-simulator-webots-world-controller` is the single Webots supervisor controller for the shared native world. +- `phoxal-simulator-webots-robot-controller` is the one controller for all simulated devices of one attached robot execution. + +The host accepts one canonical compiled `WorldBundle` through `--world-bundle `. +The CLI supplies owner-only registry and evidence directories plus a bounded combined log budget through `PHOXAL_SIMULATION_REGISTRY_DIR`, `PHOXAL_SIMULATION_EVIDENCE_DIR`, and `PHOXAL_SIMULATION_LOG_BYTE_LIMIT`. + +The two Webots controllers are staged beside the generated world and use fixed arguments. +The world controller accepts only `--host-connect `. +The robot controller accepts only `--connect --host-connect `. +The host-controller connection is a private loopback coordination channel for native mutation, readiness, progress, and shutdown. +It is not a public simulation protocol or a world-step barrier. + +## Native lifecycle + +The generated world uses synchronized controllers and a deterministic seed of `0`. +The world controller enters `PAUSE` before the first native step and reports readiness to the host. +Running requests native Webots `REAL_TIME` mode. +Observed `RUN` or `FAST` modes are unsupported and fail the whole world session. +R2025a starts dynamically imported controllers only from its running event loop. +During attachment bootstrap, the world controller temporarily enables `REAL_TIME` using only zero-duration control exchanges, waits for native controller readiness, and restores `PAUSE` before completing the import transaction. +No positive native step is issued in that phase; the world controller verifies that physics time is unchanged throughout and fails the world if it advances. + +Each attached robot keeps its independent supervisor, execution bus, monotonic execution time, and timeline. +The per-robot controller snapshots admissible commands at a completed native boundary, advances with the shared world, publishes typed simulator outputs, and publishes `StepEvent` last with the same monotonic capture instant. +Neither service scheduling nor native world progress waits for a participant acknowledgement. + +## Geometry and collision + +The host consumes only the compiled bundle and never reopens authoring paths. +Static primitives and self-contained GLB assets are rendered into an R2025a world. +Robot structure and capability facts are compiled into one authoritative native plan before any scene mutation. +Existing URDF robot meshes also accept triangle OBJ files with finite coordinates and normals, plus bundled MTL files containing diffuse `Kd` colors. +OBJ material references must stay beneath the mesh directory; missing materials, textures, other material inputs, non-triangle geometry, and unsupported statements fail before mutation. +Both formats become native indexed geometry, preserving their authored coordinates without native resource loading. +Generated Robot source is limited to 16 MiB and checked before the host pauses or mutates the world. + +Collision GLB validation is intentionally conservative and fail-closed. +The accepted subset contains plain triangle primitives with `POSITION` and optional `NORMAL` or `TEXCOORD_0` attributes. +Morph targets, active material extensions, skins, animations, sparse accessors, non-triangle primitives, coincident collision vertices, and collision scenes above 100,000 total triangles are refused. +Only semantically inactive `KHR_materials_clearcoat` is accepted as an extension. +Scene nesting is limited to 256 nodes, and composed transforms and scaled collision vertices must remain finite. +Visuals support base color, metallic and roughness factors, emissive factors, double-sided triangles, and embedded PNG or JPEG base-color textures with supported samplers. +Unsupported material inputs fail before scene mutation instead of being silently discarded. +A detailed visual outside this subset needs an explicit supported primitive or low-complexity GLB collision override in the authored model. + +## Development + +Webots controller packages dynamically link the R2025a controller SDK. +They are unsupported on musl and Linux aarch64 in this release. +Run the workspace checks with the SDK available, for example: + +```sh +WEBOTS_HOME=/Applications/Webots.app cargo test --workspace --all-targets +``` diff --git a/simulators/webots/host/Cargo.toml b/simulators/webots/host/Cargo.toml new file mode 100644 index 00000000..c44f65f1 --- /dev/null +++ b/simulators/webots/host/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "phoxal-simulator-webots-host" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = ["phoxal"] +description = "Phoxal Webots world-session host and native generation." +documentation.workspace = true +homepage.workspace = true +repository.workspace = true + +[[bin]] +name = "phoxal-simulator-webots-host" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true } +image = { workspace = true } +libc = { workspace = true } +nalgebra = { workspace = true } +phoxal = { workspace = true, features = ["session", "simulator"] } +phoxal-simulator-webots-shared = { path = "../shared", version = "=0.67.1", registry = "phoxal" } +rmp-serde = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +sysinfo = { workspace = true } +tempfile = { workspace = true } +tobj = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } +tokio-util = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +webots-proto-ast = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/simulators/webots/host/src/application.rs b/simulators/webots/host/src/application.rs new file mode 100644 index 00000000..769809c9 --- /dev/null +++ b/simulators/webots/host/src/application.rs @@ -0,0 +1,321 @@ +use super::*; +use crate::shutdown::{await_world_controller_stop, failing_identity, terminal_outcome}; + +struct HostApplication { + bundle: WorldBundle, + instance: WorldInstanceId, + registry_root: PathBuf, + runtime: Arc, + session: Arc, + attachments: Arc, + native: Arc, + process: registration::ProcessIdentity, + public: Option, + registration: Option, +} + +pub(super) async fn run(args: Args, log_byte_limit: u64, host_log: BoundedStderr) -> Result<()> { + let bundle = WorldBundle::open(&args.world_bundle).with_context(|| { + format!( + "failed to open world bundle {}", + args.world_bundle.display() + ) + })?; + let instance = WorldInstanceId::mint(); + let evidence_root = required_path(EVIDENCE_DIRECTORY_ENV)?; + let registry_root = required_path(REGISTRY_DIRECTORY_ENV)?; + let evidence = Arc::new(EvidenceSession::create( + &evidence_root, + instance, + &bundle, + log_byte_limit, + )?); + let process = current_process_identity()?; + let installation = WebotsInstallation::discover()?; + let native = Arc::new(HostServer::bind()?); + let executable_directory = std::env::current_exe()? + .parent() + .context("host executable has no containing directory")? + .to_path_buf(); + let controllers = ControllerExecutables { + world: executable_directory.join(WORLD_CONTROLLER_PACKAGE), + robot: executable_directory.join(ROBOT_CONTROLLER_PACKAGE), + }; + let staging = tempfile::Builder::new() + .prefix("phoxal-webots-") + .tempdir() + .context("failed to create the native Webots staging directory")?; + let project_root = staging.path().join("project"); + let project = stage_project(&bundle, &project_root, native.endpoint(), &controllers)?; + let attachments = Arc::new(WebotsAttachments::new( + instance, + bundle.world().clone(), + project_root, + Arc::clone(&native), + Arc::clone(&evidence), + )); + let runtime = Arc::new( + WorldRuntime::new( + instance, + &bundle, + installation.version(), + Arc::clone(&native), + Arc::clone(&evidence), + process, + ) + .map_err(anyhow::Error::msg)?, + ); + let webots_limit = log_byte_limit.saturating_sub(log_byte_limit / 2).max(1); + let mut webots = WebotsProcess::launch( + &installation, + project.world(), + &evidence.webots_log(), + webots_limit, + false, + )?; + evidence.set_native_process(webots.identity()?); + runtime.refresh_checkpoint().map_err(anyhow::Error::msg)?; + let session = Arc::new(WebotsWorldSession::new( + Arc::clone(&runtime), + Arc::clone(&attachments), + )); + + let mut application = HostApplication { + bundle, + instance, + registry_root, + runtime: Arc::clone(&runtime), + session, + attachments: Arc::clone(&attachments), + native: Arc::clone(&native), + process, + public: None, + registration: None, + }; + let live_result = application.serve_live(&mut webots).await; + let state_before_cleanup = runtime.snapshot(); + let native_before_cleanup = native.snapshot(); + let members_before_cleanup = state_before_cleanup.members.clone(); + let terminal_reason = match state_before_cleanup.lifecycle { + WorldLifecycle::Failed { reason } => Some(reason), + WorldLifecycle::Starting | WorldLifecycle::Ready { .. } | WorldLifecycle::Stopping => None, + }; + let end_reason = match state_before_cleanup.lifecycle { + WorldLifecycle::Failed { reason } => reason, + _ => SimulationEndReason::WorldStopped, + }; + let member_cleanup_detail = attachments + .end_all(&runtime, end_reason) + .await + .err() + .map(|error| format!("{error:#}")); + native.stop_world(); + let controller_cleanup_detail = await_world_controller_stop(&native, &mut webots) + .await + .err() + .map(|error| format!("{error:#}")); + let stopped = webots.stop().await; + let (webots_log, native_cleanup_detail) = match stopped { + Ok(outcome) => (outcome, None), + Err(error) => ( + LogCaptureOutcome { + bytes: 0, + truncated: true, + }, + Some(format!("{error:#}")), + ), + }; + let public_cleanup_detail = match application.public.take() { + Some(public) => public + .close() + .await + .err() + .map(|error| format!("failed to close public world-session endpoint: {error}")), + None => None, + }; + let evidence_writer_detail = runtime + .finish_evidence_writer() + .err() + .map(|error| format!("failed to flush checkpoint evidence: {error}")); + let cleanup_detail = [ + member_cleanup_detail.clone(), + controller_cleanup_detail.clone(), + native_cleanup_detail.clone(), + public_cleanup_detail.clone(), + evidence_writer_detail.clone(), + ] + .into_iter() + .flatten() + .reduce(|left, right| format!("{left}; {right}")); + let cleanup_failure_reason = cleanup_detail.as_ref().map(|_| { + if member_cleanup_detail.is_some() { + SimulationEndReason::RemovalFailed + } else if controller_cleanup_detail.is_some() { + SimulationEndReason::WorldControllerLost + } else if native_cleanup_detail.is_some() { + SimulationEndReason::SimulatorLost + } else { + SimulationEndReason::ProtocolViolation + } + }); + let failure_reason = terminal_reason.or(cleanup_failure_reason); + let failing = failing_identity( + failure_reason, + native_before_cleanup.lifecycle(), + &members_before_cleanup, + evidence.native_process().as_ref(), + ); + let outcome = terminal_outcome( + live_result.as_ref().err().map(|error| format!("{error:#}")), + failure_reason, + cleanup_detail.clone(), + ); + let mut truncated = Vec::new(); + if host_log.truncated() { + truncated.push("host.log".to_owned()); + } + if webots_log.truncated { + truncated.push("webots.log".to_owned()); + } + evidence.write_summary(&world_terminal_summary( + instance, + state_before_cleanup.provenance, + outcome, + runtime.snapshot().progress, + members_before_cleanup, + evidence.member_evidence()?, + failing, + TerminalCleanup { + complete: cleanup_detail.is_none(), + detail: cleanup_detail.clone(), + }, + TerminalRetention { + log_byte_limit, + truncated, + }, + ))?; + // Keep the owner-held lease discoverable until every native/member authority has converged + // and terminal evidence is atomically durable. Registration is removed last. + drop(application.registration.take()); + match (live_result, cleanup_detail) { + (Ok(()), None) => Ok(()), + (Ok(()), Some(detail)) => bail!("terminal cleanup failed: {detail}"), + (Err(error), _) => Err(error), + } +} + +impl HostApplication { + async fn serve_live(&mut self, webots: &mut WebotsProcess) -> Result<()> { + // The CLI rolls back a transaction-owned host with SIGTERM. Install its + // handler before publishing readiness so rollback uses the same cleanup + // path and retained evidence as an explicit world stop. + let mut terminate = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .context("failed to observe world-host termination")?; + let deadline = tokio::time::Instant::now() + BOOTSTRAP_TIMEOUT; + loop { + if let Some(status) = webots.exited()? { + return fail_for_webots_exit( + &self.runtime, + format!("Webots exited during bootstrap with {status}"), + ); + } + self.native.enforce_liveness(); + let snapshot = self.native.snapshot(); + match snapshot.lifecycle() { + NativeWorldLifecycle::Ready { .. } => break, + NativeWorldLifecycle::Failed(reason) => { + self.runtime + .reconcile_latest_native() + .map_err(anyhow::Error::msg)?; + bail!("native Webots bootstrap failed: {reason:?}"); + } + NativeWorldLifecycle::Starting | NativeWorldLifecycle::Stopping => {} + } + if tokio::time::Instant::now() >= deadline { + self.runtime + .fail(SimulationEndReason::WorldControllerLost) + .map_err(anyhow::Error::msg)?; + bail!("Webots world controller did not become ready within {BOOTSTRAP_TIMEOUT:?}"); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + self.runtime.mark_ready().map_err(anyhow::Error::msg)?; + let public = WorldSessionServer::bind(Arc::clone(&self.session)) + .await + .context("failed to bind the public world-session endpoint")?; + let registration = RegistrationGuard::create( + &self.registry_root, + self.instance, + public.endpoint().to_owned(), + &self.bundle, + self.process, + )?; + let endpoint = public.endpoint().to_owned(); + self.public = Some(public); + self.registration = Some(registration); + println!("{}", self.instance); + std::io::stdout() + .flush() + .context("failed to publish the world ready line")?; + tracing::info!( + instance = %self.instance, + world = %self.bundle.world().id(), + digest = %self.bundle.digest(), + endpoint, + "native Webots world is ready and paused" + ); + + let mut interval = tokio::time::interval(RECONCILE_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + signal = tokio::signal::ctrl_c() => { + signal.context("failed to wait for the stop signal")?; + self.runtime.mark_stopping().map_err(anyhow::Error::msg)?; + } + _ = terminate.recv() => { + self.runtime.mark_stopping().map_err(anyhow::Error::msg)?; + } + _ = interval.tick() => {} + } + if let Some(status) = webots.exited()? { + return fail_for_webots_exit( + &self.runtime, + format!("Webots exited unexpectedly with {status}"), + ); + } + self.native.enforce_liveness(); + let snapshot = self + .runtime + .reconcile_latest_native() + .map_err(anyhow::Error::msg)?; + match self.runtime.snapshot().lifecycle { + WorldLifecycle::Stopping => break, + WorldLifecycle::Failed { reason } => { + bail!( + "native world failed: {reason:?}; {:?}", + snapshot.lifecycle() + ); + } + WorldLifecycle::Starting | WorldLifecycle::Ready { .. } => {} + } + self.attachments.reconcile_removals(&self.runtime).await?; + } + Ok(()) + } +} + +fn fail_for_webots_exit(runtime: &WorldRuntime, detail: String) -> Result<()> { + if let Err(error) = runtime.fail(SimulationEndReason::SimulatorLost) { + bail!("{detail}; failed to publish SimulatorLost: {error}"); + } + bail!("{detail}") +} + +fn required_path(name: &str) -> Result { + std::env::var_os(name) + .map(PathBuf::from) + .with_context(|| format!("required environment variable {name} is missing")) +} diff --git a/simulators/webots/host/src/assets.rs b/simulators/webots/host/src/assets.rs new file mode 100644 index 00000000..1e8fff09 --- /dev/null +++ b/simulators/webots/host/src/assets.rs @@ -0,0 +1,99 @@ +//! Owned staging for one imported Robot's assets and decoded textures. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, ensure}; +use phoxal::identity::ExecutionId; +use phoxal::model::asset::AssetId; + +use crate::generation::stage_decoded_images; +use crate::glb::DecodedMesh; + +#[derive(Clone)] +pub struct StagedRobotAssets { + execution: ExecutionId, + asset_root: PathBuf, + texture_root: PathBuf, +} + +impl StagedRobotAssets { + #[must_use] + pub fn new(project_root: &Path, execution: ExecutionId) -> Self { + Self { + execution, + asset_root: project_root + .join("assets") + .join("robots") + .join(execution.to_string()), + texture_root: project_root + .join(".phoxal") + .join("textures") + .join("robots") + .join(execution.to_string()), + } + } + + pub async fn stage(&self, assets: &BTreeMap>) -> Result<()> { + let result = self.stage_inner(assets).await; + match result { + Ok(()) => Ok(()), + Err(error) => match self.cleanup().await { + Ok(()) => Err(error), + Err(cleanup) => { + Err(error.context(format!("staged asset cleanup was incomplete: {cleanup:#}"))) + } + }, + } + } + + async fn stage_inner(&self, assets: &BTreeMap>) -> Result<()> { + tokio::fs::create_dir_all(&self.asset_root) + .await + .with_context(|| format!("failed to create {}", self.asset_root.display()))?; + for (id, bytes) in assets { + let target = self.asset_root.join(id.as_str()); + ensure!( + target.starts_with(&self.asset_root), + "asset {id} escapes Robot staging" + ); + if let Some(parent) = target.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(&target, bytes) + .await + .with_context(|| format!("failed to stage Robot asset {}", target.display()))?; + if Path::new(id.as_str()) + .extension() + .and_then(std::ffi::OsStr::to_str) + == Some("glb") + { + let decoded = DecodedMesh::decode(bytes) + .with_context(|| format!("failed to decode staged Robot GLB {id}"))?; + stage_decoded_images(&self.texture_root, id.as_str(), &decoded)?; + } + } + Ok(()) + } + + /// Attempt both independent roots before returning an aggregate failure. + pub async fn cleanup(&self) -> Result<()> { + let mut failures = Vec::new(); + for root in [&self.asset_root, &self.texture_root] { + match tokio::fs::remove_dir_all(root).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => failures.push(format!("{}: {error}", root.display())), + } + } + if failures.is_empty() { + Ok(()) + } else { + anyhow::bail!( + "failed to remove staged Robot assets for {}: {}", + self.execution, + failures.join("; ") + ) + } + } +} diff --git a/simulators/webots/host/src/attachment/attachment_transaction_tests.rs b/simulators/webots/host/src/attachment/attachment_transaction_tests.rs new file mode 100644 index 00000000..a3ba8845 --- /dev/null +++ b/simulators/webots/host/src/attachment/attachment_transaction_tests.rs @@ -0,0 +1,173 @@ +use super::transaction::AttachmentTransactionPhase; +use super::*; +use phoxal::bus::RobotInstant; +use phoxal::identity::{ProducerId, TimelineId}; +use phoxal::model::identity::RobotId; +use phoxal::model::world::{LiveAttachmentBoundary, WorldProgress}; +use std::sync::atomic::{AtomicBool, Ordering}; + +fn pose(x: f64) -> phoxal::model::structure::Pose { + serde_json::from_value(serde_json::json!({ + "xyz": [x, 0.0, 0.0], + "rpy": [0.0, 0.0, 0.0] + })) + .expect("pose") +} + +fn world(spawns: &[(&str, f64)]) -> World { + let spawn_points = spawns + .iter() + .map(|(id, x)| { + ( + (*id).to_owned(), + serde_json::to_value(pose(*x)).expect("pose JSON"), + ) + }) + .collect::>(); + serde_json::from_value(serde_json::json!({ + "id": "test-world", + "time_step_ns": 12_000_000, + "gravity_mps2": [0.0, 0.0, -9.81], + "spawn_points": spawn_points, + "entities": [] + })) + .expect("world") +} + +fn member(execution: ExecutionId, spawn: SpawnId) -> WorldMember { + WorldMember { + execution, + robot: RobotId::new("robot").expect("robot id"), + controller: ProducerId::try_from(0x3000_0000_0000_0000_0000_0000_0000_0003) + .expect("producer"), + phase: WorldMemberPhase::Active, + attached_at: LiveAttachmentBoundary { + world: WorldProgress::zero(12_000_000).expect("progress"), + execution: RobotInstant::new(TimelineId::from_raw(1).expect("timeline"), 0), + }, + spawn, + initial_pose: pose(0.0), + } +} + +#[test] +fn omitted_spawn_requires_exactly_one_authored_point() { + let one = world(&[("only", 2.0)]); + let (spawn, resolved) = resolve_spawn(&one, None).expect("sole spawn resolves"); + assert_eq!(spawn.as_str(), "only"); + assert_eq!(resolved.xyz(), [2.0, 0.0, 0.0]); + assert!(resolve_spawn(&world(&[]), None).is_err()); + assert!(resolve_spawn(&world(&[("first", 0.0), ("second", 1.0)]), None).is_err()); +} + +#[test] +fn duplicate_spawn_and_conflicting_idempotent_retries_fail_before_mutation() { + let first = + ExecutionId::try_from(0x1000_0000_0000_0000_0000_0000_0000_0001).expect("execution"); + let second = + ExecutionId::try_from(0x2000_0000_0000_0000_0000_0000_0000_0002).expect("execution"); + let spawn = SpawnId::new("west-bay").expect("spawn"); + let other = SpawnId::new("east-bay").expect("spawn"); + let members = vec![member(first, spawn.clone())]; + assert!(ensure_attach_slot(&members, second, &spawn).is_err()); + ensure_attach_slot(&members, second, &other) + .expect("a second member may reserve the distinct authored spawn"); + assert!(ensure_attach_slot(&members, first, &SpawnId::new("other").expect("spawn")).is_err()); + + ensure_idempotent_request(first, &spawn, "tcp://one", &spawn, "tcp://one") + .expect("exact retry"); + assert!( + ensure_idempotent_request( + first, + &spawn, + "tcp://one", + &SpawnId::new("other").expect("spawn"), + "tcp://one" + ) + .is_err() + ); + assert!(ensure_idempotent_request(first, &spawn, "tcp://one", &spawn, "tcp://two").is_err()); +} + +#[tokio::test] +async fn dropping_a_request_at_an_await_cancels_its_owned_worker_cleanup() { + let cancellation = OperationCancellation::new(); + let worker_cancellation = cancellation.clone(); + let cleaned = Arc::new(AtomicBool::new(false)); + let worker_cleaned = Arc::clone(&cleaned); + let worker = tokio::spawn(async move { + loop { + if worker_cancellation.check().is_err() { + worker_cleaned.store(true, Ordering::Release); + return; + } + tokio::time::sleep(Duration::from_millis(1)).await; + } + }); + let (started_tx, started_rx) = oneshot::channel(); + let request = tokio::spawn(async move { + let _cancel_on_drop = CancelOnDrop::new(cancellation); + let _ = started_tx.send(()); + std::future::pending::<()>().await; + }); + started_rx.await.expect("request reached its await point"); + request.abort(); + worker.await.expect("owned cleanup worker converged"); + assert!(cleaned.load(Ordering::Acquire)); +} + +#[tokio::test] +async fn shutdown_closes_admission_before_worker_drain() { + let mut workers = AttachmentWorkers::new(); + let admitted = OperationCancellation::child(&workers.shutdown); + let worker_cancellation = admitted.clone(); + workers.tasks.spawn(async move { + worker_cancellation.0.cancelled().await; + }); + + let mut tasks = workers.close_admission(); + + assert!(admitted.check().is_err()); + assert!( + OperationCancellation::child(&workers.shutdown) + .check() + .is_err() + ); + assert!(tasks.join_next().await.expect("worker result").is_ok()); +} + +#[tokio::test] +async fn repeated_shutdown_never_reopens_attachment_admission() { + let mut workers = AttachmentWorkers::new(); + let first = workers.close_admission(); + assert!(workers.shutdown.is_cancelled()); + let mut second = workers.close_admission(); + assert!(first.is_empty()); + assert!(second.join_next().await.is_none()); + assert!(workers.shutdown.is_cancelled()); +} + +#[tokio::test] +async fn a_panicked_attachment_worker_is_reported_without_losing_other_workers() { + let mut workers = AttachmentWorkers::new(); + workers + .tasks + .spawn(async { panic!("deterministic worker failure") }); + workers.tasks.spawn(async {}); + tokio::task::yield_now().await; + + let error = workers.reap_finished().expect_err("panic is retained"); + assert!(error.to_string().contains("attachment worker failed")); + assert!( + workers.tasks.is_empty(), + "all completed workers were reaped" + ); +} + +#[test] +fn failed_import_attempt_still_owns_idempotent_native_removal() { + let phase = AttachmentTransactionPhase::NativeImportAttempted; + let native_result = Result::<(), &'static str>::Err("partial native import"); + assert!(native_result.is_err()); + assert_eq!(phase.controller_ready(), Some(false)); +} diff --git a/simulators/webots/host/src/attachment/mod.rs b/simulators/webots/host/src/attachment/mod.rs new file mode 100644 index 00000000..39a09339 --- /dev/null +++ b/simulators/webots/host/src/attachment/mod.rs @@ -0,0 +1,92 @@ +//! Serialized robot admission, native import, and supervisor commit. + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result, ensure}; +use phoxal::identity::ExecutionId; +use phoxal::model::identity::SpawnId; +use phoxal::model::world::{World, WorldInstanceId}; +use phoxal::simulator::{SimulationHostConnectOptions, SimulationHostSession}; +use phoxal::supervisor::api::simulation::SimulationEndReason; +use phoxal::supervisor::api::simulation::attach::AttachRequest; +use phoxal::world::api::session::WorldMember; +use phoxal::world::api::session::WorldMemberPhase; +use phoxal::world::api::session::state::WorldSessionState; +use phoxal::world::api::session::{WorldMemberCleanup, WorldMemberEndReason, WorldMemberTerminal}; +use tokio::sync::Mutex; +use tokio::sync::oneshot; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; + +use crate::assets::StagedRobotAssets; +use crate::evidence::{EvidenceSession, world_member_evidence}; +use crate::plan::{lower_robot_plan, required_assets}; +use crate::robot_generation::{render_robot, robot_definition}; +use crate::runtime::WorldRuntime; +use crate::server::HostServer; +use crate::state::{NativeRobotFailure, NativeWorldLifecycle}; +use phoxal_simulator_webots_shared::protocol::validate_robot_import; + +const CONTROLLER_READY_TIMEOUT: Duration = Duration::from_secs(30); + +mod preparation; +mod removal; +mod transaction; +mod workers; + +use preparation::{ + PreparedRobot, ensure_attach_slot, ensure_idempotent_request, prepare_robot, resolve_spawn, + wait_for_active_ack, wait_for_controller, +}; +use workers::{AttachmentWorkers, CancelOnDrop, OperationCancellation}; + +/// Concrete attachment authority retained by one world session. +#[derive(Clone)] +pub struct WebotsAttachments { + pub(super) instance: WorldInstanceId, + pub(super) world: World, + pub(super) project_root: PathBuf, + pub(super) native: Arc, + pub(super) evidence: Arc, + pub(super) sessions: Arc>>, + pub(super) workers: Arc>, +} + +pub(super) struct AttachedSession { + #[allow( + dead_code, + reason = "retaining the host session retains source-bound liveness" + )] + pub(super) host: SimulationHostSession, + pub(super) definition: String, + pub(super) member: WorldMember, + pub(super) supervisor_endpoint: String, + pub(super) assets: StagedRobotAssets, +} + +impl WebotsAttachments { + #[must_use] + pub fn new( + instance: WorldInstanceId, + world: World, + project_root: PathBuf, + native: Arc, + evidence: Arc, + ) -> Self { + Self { + instance, + world, + project_root, + native, + evidence, + sessions: Arc::new(Mutex::new(BTreeMap::new())), + workers: Arc::new(Mutex::new(AttachmentWorkers::new())), + } + } +} + +#[cfg(test)] +mod attachment_transaction_tests; diff --git a/simulators/webots/host/src/attachment/preparation.rs b/simulators/webots/host/src/attachment/preparation.rs new file mode 100644 index 00000000..74d3fa39 --- /dev/null +++ b/simulators/webots/host/src/attachment/preparation.rs @@ -0,0 +1,232 @@ +use super::*; + +pub(super) struct PreparedRobot { + pub(super) host: SimulationHostSession, + pub(super) assets: StagedRobotAssets, + pub(super) plan: phoxal_simulator_webots_shared::plan::RobotSimulationPlan, + pub(super) definition: String, + pub(super) source: String, +} + +pub(super) async fn prepare_robot( + service: &WebotsAttachments, + host: SimulationHostSession, + execution: ExecutionId, + supervisor_endpoint: &str, + initial_pose: phoxal::model::structure::Pose, + cancellation: &OperationCancellation, +) -> Result { + let staged = StagedRobotAssets::new(&service.project_root, execution); + let preparation = async { + cancellation.check()?; + ensure!( + host.execution() == execution, + "session endpoint resolved execution {}, expected {execution}", + host.execution() + ); + let full_plan = required_assets(host.robot())?; + let mut assets = BTreeMap::new(); + for id in full_plan.required_assets() { + assets.insert( + id.clone(), + host.assets() + .read(id) + .await + .with_context(|| format!("failed to preflight asset {id}"))?, + ); + cancellation.check()?; + } + let materials = assets.iter().try_fold( + std::collections::BTreeSet::new(), + |mut dependencies, (id, bytes)| { + dependencies.extend(crate::obj::material_dependencies(id, bytes)?); + Ok::<_, anyhow::Error>(dependencies) + }, + )?; + for id in materials { + if let std::collections::btree_map::Entry::Vacant(entry) = assets.entry(id) { + let bytes = host.assets().read(entry.key()).await.with_context(|| { + format!("failed to preflight mesh material {}", entry.key()) + })?; + entry.insert(bytes); + cancellation.check()?; + } + } + let mut collision_assets = host + .robot() + .structure() + .links() + .flat_map(|link| link.collisions()) + .filter_map(|collision| collision.geometry().asset_id().cloned()) + .collect::>(); + for component in host.robot().components() { + collision_assets.extend( + component + .component_type() + .structure() + .links() + .flat_map(|link| link.collisions()) + .filter_map(|collision| collision.geometry().asset_id().cloned()), + ); + } + for collision in collision_assets { + crate::obj::decode(&collision, &assets)? + .validate_collision() + .with_context(|| { + format!("Robot collision asset {collision} exceeds the accepted Webots subset") + })?; + } + let step_ms = i32::try_from(service.world.time_step_ns() / 1_000_000) + .context("world time step does not fit Webots milliseconds")?; + let plan = lower_robot_plan(host.robot(), &full_plan, step_ms, |id| { + assets + .get(id) + .cloned() + .ok_or_else(|| format!("asset {id} was not prefetched")) + })?; + staged.stage(&assets).await?; + cancellation.check()?; + let definition = robot_definition(execution); + let source = render_robot( + host.robot(), + &plan, + &assets, + execution, + initial_pose, + supervisor_endpoint, + service.native.endpoint(), + ) + .context("failed to render the admitted native Robot")?; + validate_robot_import(&definition, &source) + .context("generated Robot exceeds the native import budget")?; + let _: webots_proto_ast::Proto = source + .parse() + .context("generated native Robot did not parse as R2025a VRML")?; + cancellation.check()?; + Ok::<_, anyhow::Error>((plan, definition, source)) + } + .await; + match preparation { + Ok((plan, definition, source)) => Ok(PreparedRobot { + host, + assets: staged, + plan, + definition, + source, + }), + Err(error) => { + let cleanup = staged.cleanup().await; + let close = host.close().await; + if cleanup.is_err() || close.is_err() { + Err(error.context(format!( + "preparation cleanup: {cleanup:?}; host close: {close:?}" + ))) + } else { + Err(error) + } + } + } +} + +pub(super) fn ensure_idempotent_request( + execution: ExecutionId, + existing_spawn: &SpawnId, + existing_endpoint: &str, + requested_spawn: &SpawnId, + requested_endpoint: &str, +) -> Result<()> { + ensure!( + existing_spawn == requested_spawn, + "idempotent execution {execution} retry changed its resolved spawn" + ); + ensure!( + existing_endpoint == requested_endpoint, + "idempotent execution {execution} retry changed its supervisor endpoint" + ); + Ok(()) +} + +pub(super) fn ensure_attach_slot( + members: &[WorldMember], + execution: ExecutionId, + spawn: &SpawnId, +) -> Result<()> { + ensure!( + !members.iter().any(|member| member.execution == execution), + "world state already contains execution {execution} without a retained host session" + ); + ensure!( + !members.iter().any(|member| &member.spawn == spawn), + "spawn point '{spawn}' is already occupied" + ); + Ok(()) +} + +pub(super) fn resolve_spawn( + world: &World, + requested: Option, +) -> Result<(SpawnId, phoxal::model::structure::Pose)> { + let spawns = world.spawn_points().collect::>(); + match requested { + Some(requested) => spawns + .into_iter() + .find(|(id, _)| **id == requested) + .map(|(id, pose)| (id.clone(), pose)) + .with_context(|| format!("world has no spawn point '{requested}'")), + None => { + let [(id, pose)] = spawns.as_slice() else { + anyhow::bail!( + "spawn may be omitted only when the world has exactly one authored spawn point" + ); + }; + Ok(((*id).clone(), *pose)) + } + } +} + +pub(super) async fn wait_for_controller( + native: &HostServer, + execution: ExecutionId, + cancellation: &OperationCancellation, +) -> Result { + let deadline = tokio::time::Instant::now() + CONTROLLER_READY_TIMEOUT; + loop { + cancellation.check()?; + if let Some(controller) = native.robot_controller(execution) { + return Ok(controller); + } + if let NativeWorldLifecycle::Failed(failure) = native.snapshot().lifecycle() { + anyhow::bail!("native world failed while Robot started: {failure:?}"); + } + if tokio::time::Instant::now() >= deadline { + anyhow::bail!( + "Robot controller did not become ready within {CONTROLLER_READY_TIMEOUT:?}" + ); + } + tokio::time::sleep(Duration::from_millis(5)).await; + } +} + +pub(super) async fn wait_for_active_ack( + native: &HostServer, + execution: ExecutionId, + revision: u64, + cancellation: &OperationCancellation, +) -> Result<()> { + let deadline = tokio::time::Instant::now() + CONTROLLER_READY_TIMEOUT; + loop { + cancellation.check()?; + if native.robot_active_revision(execution) == Some(revision) { + return Ok(()); + } + if let NativeWorldLifecycle::Failed(failure) = native.snapshot().lifecycle() { + anyhow::bail!("native world failed before Robot acknowledged Active: {failure:?}"); + } + if tokio::time::Instant::now() >= deadline { + anyhow::bail!( + "Robot controller did not acknowledge Active revision {revision} within {CONTROLLER_READY_TIMEOUT:?}" + ); + } + tokio::time::sleep(Duration::from_millis(5)).await; + } +} diff --git a/simulators/webots/host/src/attachment/removal.rs b/simulators/webots/host/src/attachment/removal.rs new file mode 100644 index 00000000..514cb964 --- /dev/null +++ b/simulators/webots/host/src/attachment/removal.rs @@ -0,0 +1,291 @@ +use super::*; + +impl WebotsAttachments { + /// Reconcile one supervisor-initiated Removing transition without blocking other sessions. + pub async fn reconcile_removals(&self, runtime: &WorldRuntime) -> Result<()> { + let candidate = { + // Attachment owns this mutex through bounded native mutations. The host's health + // loop must remain free to classify shared-process loss during that transaction. + let Ok(sessions) = self.sessions.try_lock() else { + return Ok(()); + }; + let mut candidate = None; + for (key, session) in sessions.iter() { + if let Some(failure) = self.native.robot_failure(session.member.execution) { + candidate = Some(match failure { + NativeRobotFailure::Controller(_) => ( + key.clone(), + WorldMemberEndReason::ControllerFault, + Some(SimulationEndReason::ControllerLost), + true, + ), + NativeRobotFailure::SupervisorLost => ( + key.clone(), + WorldMemberEndReason::SupervisorLost, + None, + false, + ), + }); + break; + } + match session.host.attachment().await { + Ok(Some(attachment)) + if attachment.phase + == phoxal::supervisor::api::simulation::SimulationAttachmentPhase::Removing => + { + candidate = Some(( + key.clone(), + WorldMemberEndReason::Stopped, + None, + true, + )); + break; + } + Ok(None) | Err(_) => { + candidate = Some(( + key.clone(), + WorldMemberEndReason::SupervisorLost, + None, + false, + )); + break; + } + Ok(Some(_)) => {} + } + } + candidate + }; + let Some((key, reason, request_end, acknowledge)) = candidate else { + return Ok(()); + }; + let session = self + .sessions + .lock() + .await + .remove(&key) + .context("member cleanup candidate disappeared")?; + self.finish_removal(runtime, session, reason, request_end, acknowledge) + .await + } + + /// End every retained execution before the native world process exits. + pub async fn end_all(&self, runtime: &WorldRuntime, reason: SimulationEndReason) -> Result<()> { + let mut failures = self + .cancel_and_join_workers() + .await + .err() + .map(|error| vec![format!("attachment worker cleanup failed: {error:#}")]) + .unwrap_or_default(); + let member_reason = match reason { + SimulationEndReason::WorldStopped => WorldMemberEndReason::Stopped, + SimulationEndReason::ControllerLost => WorldMemberEndReason::ControllerFault, + _ => WorldMemberEndReason::AttachmentFailed, + }; + loop { + let session = { + let mut sessions = self.sessions.lock().await; + let Some(key) = sessions.keys().next().cloned() else { + break; + }; + sessions.remove(&key) + }; + let Some(session) = session else { + continue; + }; + if let Err(error) = self + .finish_removal(runtime, session, member_reason, Some(reason), true) + .await + { + failures.push(format!("{error:#}")); + } + } + if failures.is_empty() { + Ok(()) + } else { + anyhow::bail!("member cleanup failed: {}", failures.join("; ")) + } + } + + async fn finish_removal( + &self, + runtime: &WorldRuntime, + session: AttachedSession, + reason: WorldMemberEndReason, + request_end: Option, + acknowledge: bool, + ) -> Result<()> { + let _operation = runtime.lock_operation().await; + let was_running = matches!( + runtime.snapshot().lifecycle, + phoxal::world::api::session::WorldLifecycle::Ready { + motion: phoxal::world::api::session::WorldMotion::Running + } + ); + let mut cleanup_failures = Vec::new(); + let mut isolation_failure = runtime + .pause_native_for_operation() + .await + .err() + .map(|error| format!("native pause failed: {error}")); + if let Err(error) = runtime.mark_member_removing(session.member.execution) { + isolation_failure = Some(format!("failed to publish Removing: {error}")); + } + if let Some(failure) = isolation_failure { + if let Err(error) = runtime.fail(SimulationEndReason::RemovalFailed) { + cleanup_failures.push(format!("failed to publish fatal removal state: {error}")); + } + cleanup_failures.push(failure); + if let Err(error) = session.host.end(SimulationEndReason::RemovalFailed).await { + cleanup_failures.push(format!("supervisor failure end failed: {error}")); + } + if let Err(error) = session.host.close().await { + cleanup_failures.push(format!("host session close failed: {error}")); + } + let (actuation, dropped_actuation) = self + .native + .take_actuation_evidence(session.member.execution); + let actuation_path = self.evidence.write_actuation( + session.member.execution, + actuation, + dropped_actuation, + )?; + self.evidence + .write_member(&world_member_evidence(WorldMemberTerminal { + execution: session.member.execution, + robot: session.member.robot, + controller: session.member.controller, + spawn: session.member.spawn, + reason, + last_progress: runtime.snapshot().progress, + cleanup: WorldMemberCleanup::Incomplete { + detail: cleanup_failures.join("; "), + }, + evidence_paths: vec![actuation_path], + }))?; + anyhow::bail!(cleanup_failures.join("; ")); + } + if let Some(end_reason) = request_end + && let Err(error) = session.host.end(end_reason).await + { + cleanup_failures.push(format!("supervisor end failed: {error}")); + } + + self.native.retire_robot(session.member.execution); + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + while !self.native.robot_is_parked(session.member.execution) + && tokio::time::Instant::now() < deadline + && !matches!( + self.native.snapshot().lifecycle(), + NativeWorldLifecycle::Failed(_) + ) + { + tokio::time::sleep(Duration::from_millis(5)).await; + } + let parked = self.native.robot_is_parked(session.member.execution); + if !parked { + cleanup_failures.push("Robot controller did not confirm parked".to_owned()); + if let Err(error) = runtime.fail(SimulationEndReason::RemovalFailed) { + cleanup_failures.push(format!("failed to publish failed isolation: {error}")); + } + } + let mut removed = false; + if parked + && !matches!( + self.native.snapshot().lifecycle(), + NativeWorldLifecycle::Failed(_) + ) + { + let removal = tokio::task::spawn_blocking({ + let native = Arc::clone(&self.native); + let definition = session.definition.clone(); + move || native.remove_robot(definition) + }) + .await; + match removal { + Ok(Ok(())) => removed = true, + Ok(Err(error)) => { + cleanup_failures.push(format!("native removal failed: {error:#}")) + } + Err(error) => { + cleanup_failures.push(format!("native removal worker failed: {error}")) + } + } + } else if parked { + cleanup_failures.push("native world failed before Robot removal".to_owned()); + } + let progress = runtime.snapshot().progress; + if removed { + self.native.release_robot(session.member.execution); + if let Err(error) = session.assets.cleanup().await { + cleanup_failures.push(format!("staged asset cleanup failed: {error:#}")); + } + if let Err(error) = runtime.complete_member_removal(session.member.execution) { + cleanup_failures.push(format!("failed to publish member removal: {error}")); + } + } + + if acknowledge + && cleanup_failures.is_empty() + && let Err(error) = session.host.acknowledge_removal().await + { + cleanup_failures.push(format!( + "supervisor removal acknowledgement failed: {error}" + )); + } + if let Err(error) = session.host.close().await { + cleanup_failures.push(format!("host session close failed: {error}")); + } + if !cleanup_failures.is_empty() + && let Err(error) = runtime.fail(SimulationEndReason::RemovalFailed) + { + cleanup_failures.push(format!("failed to publish removal failure: {error}")); + } + if was_running + && cleanup_failures.is_empty() + && !matches!( + runtime.snapshot().lifecycle, + phoxal::world::api::session::WorldLifecycle::Stopping + | phoxal::world::api::session::WorldLifecycle::Failed { .. } + ) + && let Err(error) = runtime.restore_native_after_operation(true).await + { + cleanup_failures.push(format!("native resume failed: {error}")); + if let Err(publish) = runtime.fail(SimulationEndReason::RemovalFailed) { + cleanup_failures.push(format!( + "failed to publish fatal removal resume state: {publish}" + )); + } + } + let cleanup = if cleanup_failures.is_empty() { + WorldMemberCleanup::Complete + } else { + WorldMemberCleanup::Incomplete { + detail: cleanup_failures.join("; "), + } + }; + let (actuation, dropped_actuation) = self + .native + .take_actuation_evidence(session.member.execution); + let actuation_path = self.evidence.write_actuation( + session.member.execution, + actuation, + dropped_actuation, + )?; + self.evidence + .write_member(&world_member_evidence(WorldMemberTerminal { + execution: session.member.execution, + robot: session.member.robot, + controller: session.member.controller, + spawn: session.member.spawn, + reason, + last_progress: progress, + cleanup, + evidence_paths: vec![actuation_path], + }))?; + if cleanup_failures.is_empty() { + Ok(()) + } else { + anyhow::bail!(cleanup_failures.join("; ")) + } + } +} diff --git a/simulators/webots/host/src/attachment/transaction.rs b/simulators/webots/host/src/attachment/transaction.rs new file mode 100644 index 00000000..57507faa --- /dev/null +++ b/simulators/webots/host/src/attachment/transaction.rs @@ -0,0 +1,429 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(super) enum AttachmentTransactionPhase { + Prepared, + NativeImportAttempted, + NativeControllerReady, + SupervisorPreparing, + MemberPreparing, + Active, +} + +impl AttachmentTransactionPhase { + fn import_attempted(self) -> bool { + self >= Self::NativeImportAttempted + } + + pub(super) fn controller_ready(self) -> Option { + self.import_attempted() + .then_some(self >= Self::NativeControllerReady) + } + + fn supervisor_started(self) -> bool { + self >= Self::SupervisorPreparing + } + + fn member_published(self) -> bool { + self >= Self::MemberPreparing + } +} + +impl WebotsAttachments { + pub(super) async fn attach_inner( + &self, + runtime: &WorldRuntime, + execution: ExecutionId, + supervisor_endpoint: String, + requested_spawn: Option, + cancellation: &OperationCancellation, + ) -> Result { + let _operation = runtime.lock_operation().await; + cancellation.check()?; + ensure!( + matches!( + runtime.snapshot().lifecycle, + phoxal::world::api::session::WorldLifecycle::Ready { .. } + ), + "world attachment requires a Ready world" + ); + let mut sessions = self.sessions.lock().await; + cancellation.check()?; + let (spawn, initial_pose) = resolve_spawn(&self.world, requested_spawn)?; + if let Some(existing) = sessions.get(&execution.to_string()) { + ensure_idempotent_request( + execution, + &existing.member.spawn, + &existing.supervisor_endpoint, + &spawn, + &supervisor_endpoint, + )?; + return Ok(runtime.snapshot()); + } + ensure_attach_slot(&runtime.snapshot().members, execution, &spawn)?; + let host = SimulationHostSession::connect(SimulationHostConnectOptions::new( + &supervisor_endpoint, + format!("webots-world-host-{}", self.instance), + )) + .await + .context("failed to join the fresh execution as its world host")?; + let PreparedRobot { + host, + assets: staged_assets, + plan, + definition, + source, + } = prepare_robot( + self, + host, + execution, + &supervisor_endpoint, + initial_pose, + cancellation, + ) + .await?; + + let was_running = matches!( + runtime.snapshot().lifecycle, + phoxal::world::api::session::WorldLifecycle::Ready { + motion: phoxal::world::api::session::WorldMotion::Running + } + ); + if let Err(error) = runtime.pause_native_for_operation().await { + let cleanup = staged_assets.cleanup().await; + let close = host.close().await; + let publish = runtime.fail(SimulationEndReason::MutationFailed); + return Err(anyhow::anyhow!(error)) + .context("failed to pause before native Robot import") + .with_context(|| { + format!( + "fatal world publication: {publish:?}; staged asset cleanup: {cleanup:?}; host close: {close:?}" + ) + }); + } + let mut phase = AttachmentTransactionPhase::Prepared; + let operation = async { + cancellation.check()?; + self.native + .reserve_robot(execution, plan) + .map_err(|error| anyhow::anyhow!("failed to reserve Robot plan: {error:?}"))?; + // Once the mutation request can reach Webots, its outcome is conservative: even an + // error may follow a partial scene import and therefore owns rollback removal. + phase = AttachmentTransactionPhase::NativeImportAttempted; + let native_import = tokio::task::spawn_blocking({ + let native = Arc::clone(&self.native); + let definition = definition.clone(); + move || native.import_robot(execution, definition, source) + }) + .await + .context("native import worker failed")?; + native_import.context("native Robot import failed")?; + cancellation.check()?; + + let controller = wait_for_controller(&self.native, execution, cancellation).await?; + phase = AttachmentTransactionPhase::NativeControllerReady; + let boundary = runtime.snapshot().progress; + let request = AttachRequest::validated( + self.instance, + controller, + boundary, + self.world.time_step_ns(), + )?; + let transaction = host + .begin_attach(request) + .await + .context("failed to begin the supervisor attachment transaction")?; + phase = AttachmentTransactionPhase::SupervisorPreparing; + cancellation.check()?; + let preparing = transaction.initial(); + let member = WorldMember { + execution, + robot: host.robot().id().clone(), + controller, + phase: match preparing.phase { + phoxal::supervisor::api::simulation::SimulationAttachmentPhase::Preparing => { + WorldMemberPhase::Preparing + } + phoxal::supervisor::api::simulation::SimulationAttachmentPhase::Active => { + WorldMemberPhase::Preparing + } + phoxal::supervisor::api::simulation::SimulationAttachmentPhase::Removing => { + anyhow::bail!("attachment entered Removing before its Active commit") + } + }, + attached_at: preparing.attached_at, + spawn: spawn.clone(), + initial_pose, + }; + runtime.prepare_member(member).map_err(anyhow::Error::msg)?; + phase = AttachmentTransactionPhase::MemberPreparing; + cancellation.check()?; + let response = transaction + .commit() + .await + .context("supervisor attachment transaction failed")?; + cancellation.check()?; + wait_for_active_ack( + &self.native, + execution, + response.attachment.revision, + cancellation, + ) + .await?; + let member = WorldMember { + execution, + robot: host.robot().id().clone(), + controller, + phase: WorldMemberPhase::Active, + attached_at: response.attachment.attached_at, + spawn: spawn.clone(), + initial_pose, + }; + let state = runtime + .activate_member(member) + .map_err(anyhow::Error::msg)?; + phase = AttachmentTransactionPhase::Active; + cancellation.check()?; + Ok::<_, anyhow::Error>(state) + } + .await; + + let failure = match operation { + Ok(state) => { + if let Err(error) = cancellation.check() { + error + } else if let Err(error) = runtime.restore_native_after_operation(was_running).await + { + let publish = runtime.fail(SimulationEndReason::MutationFailed); + anyhow::Error::msg(error) + .context("failed to restore native motion after Robot attachment") + .context(format!("fatal world publication: {publish:?}")) + } else if let Err(error) = cancellation.check() { + error + } else { + let member = state + .members + .iter() + .find(|member| member.execution == execution) + .cloned() + .context("committed world member disappeared")?; + sessions.insert( + execution.to_string(), + AttachedSession { + host, + definition, + member, + supervisor_endpoint, + assets: staged_assets, + }, + ); + return Ok(runtime.snapshot()); + } + } + Err(error) => error, + }; + + let failed_member = runtime + .snapshot() + .members + .iter() + .find(|member| member.execution == execution) + .cloned(); + let mut cleanup_failures = Vec::new(); + let isolated = match runtime.pause_native_for_operation().await { + Ok(_) => true, + Err(error) => { + cleanup_failures.push(format!( + "failed to isolate native world for attachment rollback: {error}" + )); + if let Err(publish) = runtime.fail(SimulationEndReason::RemovalFailed) { + cleanup_failures + .push(format!("failed to publish fatal rollback state: {publish}")); + } + false + } + }; + let mut removing = false; + if phase.supervisor_started() { + match host.end(SimulationEndReason::MutationFailed).await { + Ok(_) => removing = true, + Err(error) => { + cleanup_failures.push(format!("supervisor rollback end failed: {error}")) + } + } + } + if phase.member_published() + && let Err(error) = runtime.complete_member_removal(execution) + { + cleanup_failures.push(format!("failed to publish attachment rollback: {error}")); + } + if let Some(controller_ready) = phase.controller_ready().filter(|_| isolated) { + if let Err(error) = + rollback_import(&self.native, execution, &definition, controller_ready).await + { + cleanup_failures.push(format!("native rollback failed: {error:#}")); + } + } else if !phase.import_attempted() { + self.native.release_robot(execution); + } else { + cleanup_failures.push( + "native Robot retained because rollback isolation was not confirmed".to_owned(), + ); + } + if let Err(error) = staged_assets.cleanup().await { + cleanup_failures.push(format!("staged asset rollback failed: {error:#}")); + } + if removing + && cleanup_failures.is_empty() + && let Err(error) = host.acknowledge_removal().await + { + cleanup_failures.push(format!( + "supervisor rollback acknowledgement failed: {error}" + )); + } + if let Err(error) = host.close().await { + cleanup_failures.push(format!("host rollback close failed: {error}")); + } + if !cleanup_failures.is_empty() + && !matches!( + runtime.snapshot().lifecycle, + phoxal::world::api::session::WorldLifecycle::Failed { .. } + ) + && let Err(error) = runtime.fail(SimulationEndReason::MutationFailed) + { + cleanup_failures.push(format!("failed to publish fatal rollback state: {error}")); + } + if was_running + && isolated + && cleanup_failures.is_empty() + && !matches!( + runtime.snapshot().lifecycle, + phoxal::world::api::session::WorldLifecycle::Failed { .. } + | phoxal::world::api::session::WorldLifecycle::Stopping + ) + && let Err(error) = runtime.restore_native_after_operation(true).await + { + cleanup_failures.push(format!("native rollback resume failed: {error}")); + if let Err(publish) = runtime.fail(SimulationEndReason::MutationFailed) { + cleanup_failures.push(format!( + "failed to publish fatal rollback resume state: {publish}" + )); + } + } + if let Some(member) = failed_member { + let cleanup = if cleanup_failures.is_empty() { + WorldMemberCleanup::Complete + } else { + WorldMemberCleanup::Incomplete { + detail: cleanup_failures.join("; "), + } + }; + let (actuation, dropped_actuation) = + self.native.take_actuation_evidence(member.execution); + let actuation_path = + self.evidence + .write_actuation(member.execution, actuation, dropped_actuation)?; + self.evidence + .write_member(&world_member_evidence(WorldMemberTerminal { + execution: member.execution, + robot: member.robot, + controller: member.controller, + spawn: member.spawn, + reason: WorldMemberEndReason::AttachmentFailed, + last_progress: runtime.snapshot().progress, + cleanup, + evidence_paths: vec![actuation_path], + }))?; + } + if cleanup_failures.is_empty() { + Err(failure) + } else { + Err(failure.context(format!( + "attachment rollback was incomplete: {}", + cleanup_failures.join("; ") + ))) + } + } +} + +impl WebotsAttachments { + pub fn attach<'a>( + &'a self, + runtime: &'a WorldRuntime, + execution: ExecutionId, + supervisor_endpoint: String, + spawn: Option, + ) -> phoxal::world::WorldSessionOperation<'a, WorldSessionState> { + Box::pin(async move { + let (result_tx, result_rx) = oneshot::channel(); + let owned = self.clone(); + let runtime = runtime.clone(); + let cancellation = { + let mut workers = self.workers.lock().await; + if let Err(error) = workers.reap_finished() { + return Err(format!("{error:#}")); + } + if workers.shutdown.is_cancelled() { + return Err("world attachment admission is closed".to_owned()); + } + let cancellation = OperationCancellation::child(&workers.shutdown); + let worker_cancellation = cancellation.clone(); + workers.tasks.spawn(async move { + let result = owned + .attach_inner( + &runtime, + execution, + supervisor_endpoint, + spawn, + &worker_cancellation, + ) + .await + .map_err(|error| format!("{error:#}")); + let _ = result_tx.send(result); + }); + cancellation + }; + let mut cancel_on_drop = CancelOnDrop::new(cancellation); + let result = result_rx + .await + .map_err(|_| "owned world attachment worker exited without a result".to_owned())?; + cancel_on_drop.disarm(); + result + }) + } +} + +pub(super) async fn rollback_import( + native: &Arc, + execution: ExecutionId, + definition: &str, + controller_ready: bool, +) -> Result<()> { + if controller_ready { + native.retire_robot(execution); + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + while !native.robot_is_parked(execution) + && tokio::time::Instant::now() < deadline + && !matches!( + native.snapshot().lifecycle(), + NativeWorldLifecycle::Failed(_) + ) + { + tokio::time::sleep(Duration::from_millis(5)).await; + } + ensure!( + native.robot_is_parked(execution), + "Robot controller did not confirm parked during rollback" + ); + } + let result = tokio::task::spawn_blocking({ + let native = Arc::clone(native); + let definition = definition.to_owned(); + move || native.rollback_robot(definition) + }) + .await + .context("native rollback worker failed")?; + result.context("native Robot rollback failed")?; + native.release_robot(execution); + Ok(()) +} diff --git a/simulators/webots/host/src/attachment/workers.rs b/simulators/webots/host/src/attachment/workers.rs new file mode 100644 index 00000000..f1526369 --- /dev/null +++ b/simulators/webots/host/src/attachment/workers.rs @@ -0,0 +1,109 @@ +use super::*; + +#[derive(Clone)] +pub(super) struct OperationCancellation(pub(super) CancellationToken); + +pub(crate) struct AttachmentWorkers { + pub(super) shutdown: CancellationToken, + pub(super) tasks: JoinSet<()>, +} + +impl AttachmentWorkers { + pub(super) fn new() -> Self { + Self { + shutdown: CancellationToken::new(), + tasks: JoinSet::new(), + } + } + + pub(super) fn reap_finished(&mut self) -> Result<()> { + let mut failures = Vec::new(); + while let Some(result) = self.tasks.try_join_next() { + if let Err(error) = result { + failures.push(error.to_string()); + } + } + if failures.is_empty() { + Ok(()) + } else { + anyhow::bail!("attachment worker failed: {}", failures.join("; ")) + } + } + + pub(super) fn close_admission(&mut self) -> JoinSet<()> { + self.shutdown.cancel(); + std::mem::take(&mut self.tasks) + } +} + +impl OperationCancellation { + pub(super) fn child(parent: &CancellationToken) -> Self { + Self(parent.child_token()) + } + + #[cfg(test)] + pub(super) fn new() -> Self { + Self(CancellationToken::new()) + } + + pub(super) fn check(&self) -> Result<()> { + ensure!( + !self.0.is_cancelled(), + "world attachment operation was cancelled" + ); + Ok(()) + } + + pub(super) fn cancel(&self) { + self.0.cancel(); + } +} + +pub(super) struct CancelOnDrop { + cancellation: OperationCancellation, + armed: bool, +} + +impl CancelOnDrop { + pub(super) fn new(cancellation: OperationCancellation) -> Self { + Self { + cancellation, + armed: true, + } + } + + pub(super) fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for CancelOnDrop { + fn drop(&mut self) { + if self.armed { + self.cancellation.cancel(); + } + } +} + +impl WebotsAttachments { + pub(super) async fn cancel_and_join_workers(&self) -> Result<()> { + let mut failures = Vec::new(); + let mut tasks = { + let mut workers = self.workers.lock().await; + if let Err(error) = workers.reap_finished() { + failures.push(error.to_string()); + } + workers.close_admission() + }; + while let Some(result) = tasks.join_next().await { + if let Err(error) = result { + failures.push(error.to_string()); + } + } + if failures.is_empty() { + Ok(()) + } else { + anyhow::bail!("attachment worker failed: {}", failures.join("; ")) + } + } +} diff --git a/simulators/webots/host/src/evidence.rs b/simulators/webots/host/src/evidence.rs new file mode 100644 index 00000000..4a65b218 --- /dev/null +++ b/simulators/webots/host/src/evidence.rs @@ -0,0 +1,413 @@ +//! Durable owner-only world-session evidence. + +use std::fs::{File, OpenOptions}; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use anyhow::{Context, Result, ensure}; +use phoxal::bundle::WorldBundle; +use phoxal::identity::ExecutionId; +use phoxal::model::world::{WorldInstanceId, WorldProgress, WorldProvenance}; +use phoxal::world::api::session::document::{ + ProcessIdentity, TerminalCleanup, TerminalFailure, TerminalOutcome, TerminalRetention, + WORLD_CHECKPOINT_SCHEMA, WORLD_MEMBER_TERMINAL_SCHEMA, WORLD_TERMINAL_SUMMARY_SCHEMA, + WorldCheckpoint, WorldMemberEvidence, WorldMemberEvidenceIndex, WorldTerminalSummary, +}; +use phoxal::world::api::session::state::WorldSessionState; +use phoxal::world::api::session::{WorldMember, WorldMemberTerminal}; +use serde::{Deserialize, Serialize}; + +use crate::lifecycle::NativeProcessIdentity; + +const ACTUATION_SCHEMA: &str = "phoxal/world-member-actuation/v0"; + +/// Owner of one retained evidence directory. +#[derive(Debug)] +pub struct EvidenceSession { + root: PathBuf, + writes: Mutex<()>, + native_process: Mutex>, +} + +impl EvidenceSession { + /// Create the session directory and persist the canonical bundle before readiness. + pub fn create( + evidence_root: impl AsRef, + instance: WorldInstanceId, + bundle: &WorldBundle, + log_byte_limit: u64, + ) -> Result { + ensure!( + log_byte_limit > 0, + "simulation log byte limit must be positive" + ); + let evidence_root = evidence_root.as_ref().canonicalize().with_context(|| { + format!( + "failed to open evidence root {}", + evidence_root.as_ref().display() + ) + })?; + secure_directory(&evidence_root)?; + let root = evidence_root.join(instance.to_string()); + create_owner_directory(&root)?; + create_owner_directory(&root.join("members"))?; + bundle + .write(root.join("world-bundle")) + .context("failed to retain the canonical world bundle")?; + Ok(Self { + root, + writes: Mutex::new(()), + native_process: Mutex::new(None), + }) + } + + /// Record the separately grouped Webots process tree immediately after launch. + pub fn set_native_process(&self, identity: NativeProcessIdentity) { + *lock(&self.native_process) = Some(identity); + } + + #[must_use] + pub fn native_process(&self) -> Option { + lock(&self.native_process).clone() + } + + #[must_use] + pub fn webots_log(&self) -> PathBuf { + self.root.join("webots.log") + } + + /// Atomically retain one member-terminal record. + pub fn write_member(&self, member: &WorldMemberEvidence) -> Result<()> { + member.validate_structure(member.terminal.execution)?; + let _write = lock(&self.writes); + atomic_owner_json( + &self + .root + .join("members") + .join(format!("{}.json", member.terminal.execution)), + member, + ) + } + + /// Persist the bounded typed applied-action record for one terminal member. + pub fn write_actuation( + &self, + execution: ExecutionId, + records: Vec, + dropped_records: u64, + ) -> Result { + let relative = format!("members/{execution}.actuation.json"); + let retained_records = u64::try_from(records.len()) + .context("retained applied-action record count does not fit in u64")?; + let document = MemberActuationEvidence { + schema: ACTUATION_SCHEMA.to_owned(), + execution, + retention: ActuationRetention { + retained_records, + dropped_records, + }, + records, + }; + let _write = lock(&self.writes); + atomic_owner_json(&self.root.join(&relative), &document)?; + Ok(relative) + } + + /// Atomically retain the last host identity and typed public world state. + pub fn write_checkpoint(&self, checkpoint: &WorldCheckpoint) -> Result<()> { + ensure!( + checkpoint.schema == WORLD_CHECKPOINT_SCHEMA, + "invalid world checkpoint schema" + ); + let _write = lock(&self.writes); + atomic_owner_json(&self.root.join("checkpoint.json"), checkpoint) + } + + /// Enumerate the immutable member records retained by this session. + pub fn member_evidence(&self) -> Result> { + let mut evidence = Vec::new(); + for entry in std::fs::read_dir(self.root.join("members"))? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(std::ffi::OsStr::to_str) != Some("json") { + continue; + } + let stem = path + .file_stem() + .and_then(std::ffi::OsStr::to_str) + .context("member evidence filename is not UTF-8")?; + if stem.ends_with(".actuation") { + continue; + } + let execution = ExecutionId::parse(stem) + .context("member evidence filename is not an ExecutionId")?; + evidence.push(WorldMemberEvidenceIndex { + execution, + path: format!("members/{execution}.json"), + }); + } + evidence.sort_by_key(|item| item.execution.to_string()); + Ok(evidence) + } + + /// Write `summary.json` last. Its presence is the complete terminal marker. + pub fn write_summary(&self, summary: &WorldTerminalSummary) -> Result<()> { + summary.validate_structure(summary.instance)?; + let _write = lock(&self.writes); + atomic_owner_json(&self.root.join("summary.json"), summary) + } +} + +#[must_use] +pub fn world_checkpoint( + process: ProcessIdentity, + native_process: Option, + state: WorldSessionState, +) -> WorldCheckpoint { + WorldCheckpoint { + schema: WORLD_CHECKPOINT_SCHEMA.to_owned(), + process, + native_process, + state, + updated_at_unix_ms: unix_ms(), + } +} + +#[allow(clippy::too_many_arguments)] +#[must_use] +pub fn world_terminal_summary( + instance: WorldInstanceId, + provenance: WorldProvenance, + outcome: TerminalOutcome, + progress: WorldProgress, + members: Vec, + member_evidence: Vec, + failing: TerminalFailure, + cleanup: TerminalCleanup, + retention: TerminalRetention, +) -> WorldTerminalSummary { + WorldTerminalSummary { + schema: WORLD_TERMINAL_SUMMARY_SCHEMA.to_owned(), + instance, + provenance, + outcome, + progress, + members, + member_evidence, + failing, + evidence: vec!["host.log".to_owned(), "webots.log".to_owned()], + cleanup, + retention, + ended_at_unix_ms: unix_ms(), + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MemberActuationEvidence { + pub schema: String, + pub execution: ExecutionId, + pub retention: ActuationRetention, + pub records: Vec, +} + +/// Exact bounded-retention accounting for one applied-action artifact. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActuationRetention { + pub retained_records: u64, + pub dropped_records: u64, +} + +#[must_use] +pub fn world_member_evidence(terminal: WorldMemberTerminal) -> WorldMemberEvidence { + WorldMemberEvidence { + schema: WORLD_MEMBER_TERMINAL_SCHEMA.to_owned(), + terminal, + } +} + +fn atomic_owner_json(path: &Path, value: &impl Serialize) -> Result<()> { + let parent = path.parent().context("evidence path has no parent")?; + let temporary = parent.join(format!( + ".{}-{}.tmp", + path.file_name() + .and_then(std::ffi::OsStr::to_str) + .unwrap_or("evidence"), + std::process::id() + )); + let result = (|| -> Result<()> { + let mut file = owner_file(&temporary)?; + serde_json::to_writer(&mut file, value)?; + file.write_all(b"\n")?; + file.sync_all()?; + std::fs::rename(&temporary, path)?; + File::open(parent)?.sync_all()?; + Ok(()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + result +} + +fn owner_file(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + Ok(options.open(path)?) +} + +fn create_owner_directory(path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt as _; + let mut builder = std::fs::DirBuilder::new(); + builder.mode(0o700); + builder.create(path)?; + } + #[cfg(not(unix))] + std::fs::create_dir(path)?; + Ok(()) +} + +fn secure_directory(path: &Path) -> Result<()> { + let metadata = std::fs::symlink_metadata(path)?; + ensure!(metadata.is_dir(), "evidence root is not a directory"); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + // SAFETY: `geteuid` has no pointer arguments or side effects. + ensure!( + metadata.uid() == unsafe { libc::geteuid() }, + "evidence root is owned by another user" + ); + ensure!( + metadata.mode() & 0o077 == 0, + "evidence root must have mode 0700 or stricter" + ); + } + Ok(()) +} + +fn unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) + }) +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[cfg(test)] +mod tests { + use super::*; + use phoxal::model::identity::WorldId; + use phoxal::version::FrameworkVersion; + use phoxal::world::api::session::WorldLifecycle; + + #[test] + fn summary_requires_both_failing_fields_even_when_absent() { + let failing = TerminalFailure { + process: None, + producer: None, + }; + assert_eq!( + serde_json::to_value(failing).expect("failing identity encodes"), + serde_json::json!({"process": null, "producer": null}) + ); + } + + #[test] + fn actuation_artifacts_disclose_exact_bounded_retention() { + let execution = + ExecutionId::parse("10000000000000000000000000000001").expect("canonical execution"); + let directory = tempfile::tempdir().expect("temporary evidence directory"); + std::fs::create_dir(directory.path().join("members")).expect("member evidence directory"); + let evidence = EvidenceSession { + root: directory.path().to_path_buf(), + writes: Mutex::new(()), + native_process: Mutex::new(None), + }; + + let relative = evidence + .write_actuation(execution, Vec::new(), 19) + .expect("actuation evidence writes"); + let encoded: serde_json::Value = serde_json::from_slice( + &std::fs::read(directory.path().join(relative)).expect("actuation evidence bytes"), + ) + .expect("actuation evidence decodes"); + assert_eq!( + encoded["retention"], + serde_json::json!({"retained_records": 0, "dropped_records": 19}) + ); + } + + #[test] + fn checkpoint_atomically_replaces_owner_only_typed_process_state() { + let instance = WorldInstanceId::parse("10000000000000000000000000000001") + .expect("canonical world instance"); + let state = WorldSessionState { + revision: 0, + instance, + provenance: WorldProvenance { + world: WorldId::new("warehouse").expect("world id"), + digest: phoxal::model::world::WorldDigest::parse(&"0".repeat(64)).expect("digest"), + random_seed: 0, + framework: FrameworkVersion::CURRENT, + adapter: "webots".to_owned(), + adapter_version: env!("CARGO_PKG_VERSION").to_owned(), + simulator_version: "R2025a".to_owned(), + platform: "test".to_owned(), + time_step_ns: 12_000_000, + }, + lifecycle: WorldLifecycle::Starting, + progress: WorldProgress::zero(12_000_000).expect("zero progress"), + members: Vec::new(), + }; + let native = NativeProcessIdentity { + process: ProcessIdentity { + pid: 123, + started_at_unix_s: 456, + }, + executable: PathBuf::from("/Applications/Webots.app/Contents/MacOS/webots"), + process_group: Some(123), + }; + let mut checkpoint = world_checkpoint( + ProcessIdentity { + pid: 42, + started_at_unix_s: 99, + }, + Some(native.clone()), + state, + ); + let directory = tempfile::tempdir().expect("temporary evidence directory"); + let path = directory.path().join("checkpoint.json"); + atomic_owner_json(&path, &checkpoint).expect("initial checkpoint"); + checkpoint.state.revision = 1; + atomic_owner_json(&path, &checkpoint).expect("replacement checkpoint"); + let observed: WorldCheckpoint = + serde_json::from_slice(&std::fs::read(&path).expect("checkpoint bytes")) + .expect("typed checkpoint"); + assert_eq!(observed.state.revision, 1); + assert_eq!(observed.native_process, Some(native)); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + assert_eq!( + std::fs::metadata(path).expect("metadata").mode() & 0o777, + 0o600 + ); + } + } +} diff --git a/simulators/webots/host/src/generation.rs b/simulators/webots/host/src/generation.rs new file mode 100644 index 00000000..34ae145c --- /dev/null +++ b/simulators/webots/host/src/generation.rs @@ -0,0 +1,675 @@ +//! Deterministic, self-contained Webots project generation from one compiled world bundle. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail, ensure}; +use phoxal::bundle::WorldBundle; +use phoxal::model::asset::AssetId; +use phoxal::model::geometry::Geometry; +use phoxal::model::structure::Pose; + +use crate::glb::DecodedMesh; +use crate::{ROBOT_CONTROLLER_PACKAGE, WORLD_CONTROLLER_PACKAGE}; + +/// Exact native executables copied into a generated Webots project. +#[derive(Clone, Debug)] +pub struct ControllerExecutables { + pub world: PathBuf, + pub robot: PathBuf, +} + +/// Paths of one fully staged Webots project. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GeneratedProject { + world: PathBuf, +} + +impl GeneratedProject { + #[must_use] + pub fn world(&self) -> &Path { + &self.world + } +} + +struct DecodedWorldAssets { + assets: BTreeMap, +} + +impl DecodedWorldAssets { + fn decode(bundle: &WorldBundle) -> Result { + Self::decode_with(bundle, |_, bytes| DecodedMesh::decode(bytes)) + } + + fn decode_with( + bundle: &WorldBundle, + mut decode: impl FnMut(&AssetId, &[u8]) -> Result, + ) -> Result { + let collision_assets = bundle + .world() + .entities() + .filter_map(|entity| entity.collision().asset_id()) + .collect::>(); + let mut assets = BTreeMap::new(); + for (id, bytes) in bundle.assets() { + ensure!( + Path::new(id.as_str()) + .extension() + .and_then(std::ffi::OsStr::to_str) + == Some("glb"), + "Webots world mesh '{}' must be an embedded GLB asset", + id + ); + let decoded = + decode(id, bytes).with_context(|| format!("invalid embedded GLB asset '{id}'"))?; + if collision_assets.contains(id) { + decoded.validate_collision().with_context(|| { + format!("GLB collision asset '{id}' exceeds the accepted subset") + })?; + } + let previous = assets.insert(id.clone(), decoded); + debug_assert!(previous.is_none(), "WorldBundle asset ids are distinct"); + } + Ok(Self { assets }) + } + + fn get(&self, id: &AssetId) -> Result<&DecodedMesh> { + self.assets + .get(id) + .with_context(|| format!("world mesh asset {id} is absent from the decoded cache")) + } +} + +/// Stage one deterministic project into a new, empty directory. +/// +/// Generated projects contain no `EXTERNPROTO` declarations and never resolve network content. +pub fn stage_project( + bundle: &WorldBundle, + root: impl AsRef, + host_connect: &str, + controllers: &ControllerExecutables, +) -> Result { + let root = root.as_ref(); + ensure!( + !root.exists(), + "generated Webots project already exists at {}", + root.display() + ); + validate_endpoint(host_connect)?; + let decoded_assets = DecodedWorldAssets::decode(bundle)?; + + let worlds = root.join("worlds"); + let assets = root.join("assets"); + let textures = root.join(".phoxal").join("textures").join("world"); + std::fs::create_dir_all(&worlds) + .with_context(|| format!("failed to create {}", worlds.display()))?; + std::fs::create_dir_all(&assets) + .with_context(|| format!("failed to create {}", assets.display()))?; + + for (id, bytes) in bundle.assets() { + let path = assets.join(id.as_str()); + ensure!( + path.starts_with(&assets), + "asset '{}' escapes the generated project", + id + ); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + std::fs::write(&path, bytes) + .with_context(|| format!("failed to stage world asset {}", path.display()))?; + let decoded = decoded_assets.get(id)?; + stage_decoded_images(&textures, id.as_str(), decoded)?; + } + + stage_controller(&controllers.world, root, WORLD_CONTROLLER_PACKAGE)?; + stage_controller(&controllers.robot, root, ROBOT_CONTROLLER_PACKAGE)?; + + let source = render_world_with_decoded(bundle, host_connect, &decoded_assets)?; + let _: webots_proto_ast::Proto = source + .parse() + .context("generated Webots world did not parse as R2025a VRML")?; + let world = worlds.join("world.wbt"); + std::fs::write(&world, source.as_bytes()) + .with_context(|| format!("failed to write generated world {}", world.display()))?; + Ok(GeneratedProject { world }) +} + +/// Render stable R2025a source without touching the filesystem. +#[cfg(test)] +pub fn render_world(bundle: &WorldBundle, host_connect: &str) -> Result { + validate_endpoint(host_connect)?; + let decoded_assets = DecodedWorldAssets::decode(bundle)?; + render_world_with_decoded(bundle, host_connect, &decoded_assets) +} + +fn render_world_with_decoded( + bundle: &WorldBundle, + host_connect: &str, + decoded_assets: &DecodedWorldAssets, +) -> Result { + let world = bundle.world(); + let quantum_ns = world.time_step_ns(); + ensure!( + quantum_ns.is_multiple_of(1_000_000), + "Webots requires time_step_ns to be an exact whole number of milliseconds" + ); + let quantum_ms = quantum_ns / 1_000_000; + ensure!( + i32::try_from(quantum_ms).is_ok() && quantum_ms > 0, + "Webots basicTimeStep does not fit its positive millisecond range" + ); + let [gx, gy, gz] = world.gravity_mps2(); + ensure!( + gx == 0.0 && gy == 0.0 && gz.is_sign_negative(), + "Webots R2025a ENU generation requires gravity_mps2 [0, 0, negative]" + ); + + let mut out = String::new(); + writeln!(out, "#VRML_SIM R2025a utf8")?; + writeln!(out)?; + render_world_info(&mut out, quantum_ms, -gz)?; + // R2025a Viewpoint uses FLU, unlike the OpenGL -Z camera convention. + let forward = nalgebra::Vector3::new(-6.0, 6.0, -5.0).normalize(); + let left = nalgebra::Vector3::z().cross(&forward).normalize(); + let up = forward.cross(&left); + let view = nalgebra::UnitQuaternion::from_matrix(&nalgebra::Matrix3::from_columns(&[ + forward, left, up, + ])); + let (axis, angle) = view + .axis_angle() + .context("default camera orientation has no axis")?; + writeln!(out, "Viewpoint {{")?; + writeln!( + out, + " orientation {} {} {} {}", + number(axis.x), + number(axis.y), + number(axis.z), + number(angle) + )?; + writeln!(out, " position 6 -6 5")?; + writeln!(out, "}}")?; + writeln!(out, "Background {{ skyColor [ 0.12 0.15 0.20 ] }}")?; + writeln!( + out, + "DirectionalLight {{ direction -0.3 -0.5 -1 intensity 1 ambientIntensity 0.4 }}" + )?; + writeln!(out, "Robot {{")?; + writeln!(out, " name \"__phoxal_world_controller\"")?; + writeln!(out, " controller \"{WORLD_CONTROLLER_PACKAGE}\"")?; + writeln!( + out, + " controllerArgs [\"--host-connect\", \"{}\"]", + quoted(host_connect) + )?; + writeln!(out, " supervisor TRUE")?; + writeln!(out, " synchronization TRUE")?; + writeln!(out, "}}")?; + + for entity in world.entities() { + let pose = entity.pose(); + let [x, y, z] = pose.xyz(); + let [ax, ay, az, angle] = axis_angle(pose); + writeln!( + out, + "DEF PHOXAL_{}_{} Solid {{", + entity + .declaration() + .as_str() + .to_ascii_uppercase() + .replace('-', "_"), + entity.instance() + )?; + writeln!( + out, + " translation {} {} {}", + number(x), + number(y), + number(z) + )?; + writeln!( + out, + " rotation {} {} {} {}", + number(ax), + number(ay), + number(az), + number(angle) + )?; + writeln!( + out, + " name \"{}[{}]\"", + entity.declaration(), + entity.instance() + )?; + writeln!(out, " children [")?; + render_visual(&mut out, decoded_assets, entity.geometry(), 4)?; + writeln!(out, " ]")?; + writeln!(out, " boundingObject")?; + render_collision(&mut out, decoded_assets, entity.collision(), 2)?; + writeln!(out, " locked TRUE")?; + writeln!(out, "}}")?; + } + ensure!( + !out.contains(""), + "generated source contains the forbidden external controller" + ); + ensure!( + !out.contains("EXTERNPROTO"), + "generated source contains an external PROTO dependency" + ); + Ok(out) +} + +fn render_world_info(out: &mut String, quantum_ms: u64, gravity: f64) -> Result<()> { + writeln!(out, "WorldInfo {{")?; + writeln!(out, " basicTimeStep {quantum_ms}")?; + writeln!(out, " coordinateSystem \"ENU\"")?; + // The authoring schema intentionally has no global georeference yet. Use a deterministic + // WGS84 origin so every admitted geographic GNSS publishes latitude/longitude/altitude rather + // than silently relabeling local metres as degrees. + writeln!(out, " gpsCoordinateSystem \"WGS84\"")?; + writeln!(out, " gpsReference 0 0 0")?; + writeln!(out, " gravity {}", number(gravity))?; + writeln!(out, " randomSeed 0")?; + writeln!(out, "}}")?; + Ok(()) +} + +fn render_visual( + out: &mut String, + decoded_assets: &DecodedWorldAssets, + geometry: &Geometry, + indent: usize, +) -> Result<()> { + match geometry { + Geometry::Mesh { asset, scale } => { + let decoded = decoded_assets.get(asset)?; + if let Some(scale) = scale { + writeln!(out, "{:indent$}Transform {{", "")?; + writeln!( + out, + "{:width$}scale {} {} {}", + "", + number(scale[0]), + number(scale[1]), + number(scale[2]), + width = indent + 2 + )?; + writeln!(out, "{:width$}children [", "", width = indent + 2)?; + render_decoded_visual(out, decoded, asset, indent + 4)?; + writeln!(out, "{:width$}]", "", width = indent + 2)?; + writeln!(out, "{:indent$}}}", "")?; + } else { + render_decoded_visual(out, decoded, asset, indent)?; + } + } + primitive => { + writeln!(out, "{:indent$}Shape {{", "")?; + writeln!( + out, + "{:width$}appearance PBRAppearance {{", + "", + width = indent + 2 + )?; + writeln!( + out, + "{:width$}baseColor 0.6 0.6 0.6", + "", + width = indent + 4 + )?; + writeln!(out, "{:width$}roughness 0.7", "", width = indent + 4)?; + writeln!(out, "{:width$}}}", "", width = indent + 2)?; + writeln!(out, "{:width$}geometry", "", width = indent + 2)?; + render_primitive(out, primitive, indent + 4)?; + writeln!(out, "{:indent$}}}", "")?; + } + } + Ok(()) +} + +fn render_collision( + out: &mut String, + decoded_assets: &DecodedWorldAssets, + geometry: &Geometry, + indent: usize, +) -> Result<()> { + match geometry { + Geometry::Mesh { asset, scale } => { + let decoded = decoded_assets.get(asset)?; + if let Some(scale) = scale { + decoded.render_collision_scaled(out, indent, *scale)?; + } else { + decoded.render_collision(out, indent)?; + } + } + primitive => render_primitive(out, primitive, indent)?, + } + Ok(()) +} + +fn render_decoded_visual( + out: &mut String, + decoded: &DecodedMesh, + asset: &AssetId, + indent: usize, +) -> Result<()> { + decoded.render_visual(out, indent, |primitive| { + Ok(format!( + "../.phoxal/textures/world/{}", + extracted_image_path(asset.as_str(), primitive) + )) + }) +} + +pub(crate) fn stage_decoded_images(root: &Path, asset: &str, decoded: &DecodedMesh) -> Result<()> { + for index in 0..decoded.primitives.len() { + let Some(bytes) = decoded.staged_texture(index)? else { + continue; + }; + let relative = extracted_image_path(asset, index); + let path = root.join(&relative); + ensure!( + path.starts_with(root), + "decoded texture path escapes asset staging" + ); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + std::fs::write(&path, bytes) + .with_context(|| format!("failed to extract GLB texture {}", path.display()))?; + } + Ok(()) +} + +pub(crate) fn extracted_image_path(asset: &str, primitive: usize) -> String { + format!("{asset}.images/{primitive}.png") +} + +fn render_primitive(out: &mut String, geometry: &Geometry, indent: usize) -> Result<()> { + match geometry { + Geometry::Box { size } => writeln!( + out, + "{:indent$}Box {{ size {} {} {} }}", + "", + number(size[0]), + number(size[1]), + number(size[2]) + )?, + Geometry::Cylinder { radius, length } => writeln!( + out, + "{:indent$}Cylinder {{ radius {} height {} }}", + "", + number(*radius), + number(*length) + )?, + Geometry::Capsule { radius, length } => writeln!( + out, + "{:indent$}Capsule {{ radius {} height {} }}", + "", + number(*radius), + number(*length) + )?, + Geometry::Sphere { radius } => { + writeln!(out, "{:indent$}Sphere {{ radius {} }}", "", number(*radius))? + } + Geometry::Mesh { .. } => bail!("mesh geometry must use its dedicated renderer"), + } + Ok(()) +} + +fn validate_endpoint(endpoint: &str) -> Result<()> { + let address = endpoint + .strip_prefix("tcp://127.0.0.1:") + .context("private Webots host endpoint must be tcp://127.0.0.1:")?; + let port: u16 = address + .parse() + .context("private Webots host port is invalid")?; + ensure!(port != 0, "private Webots host port must be nonzero"); + Ok(()) +} + +fn stage_controller(source: &Path, root: &Path, name: &str) -> Result<()> { + ensure!( + source.is_file(), + "native controller is missing at {}", + source.display() + ); + let directory = root.join("controllers").join(name); + std::fs::create_dir_all(&directory) + .with_context(|| format!("failed to create {}", directory.display()))?; + let target = directory.join(name); + std::fs::copy(source, &target).with_context(|| { + format!( + "failed to stage controller {} as {}", + source.display(), + target.display() + ) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mut permissions = std::fs::metadata(&target)?.permissions(); + permissions.set_mode(permissions.mode() | 0o500); + std::fs::set_permissions(&target, permissions)?; + } + Ok(()) +} + +pub(crate) fn axis_angle(pose: Pose) -> [f64; 4] { + let [roll, pitch, yaw] = pose.rpy(); + let (sr, cr) = (roll * 0.5).sin_cos(); + let (sp, cp) = (pitch * 0.5).sin_cos(); + let (sy, cy) = (yaw * 0.5).sin_cos(); + let x = sr * cp * cy - cr * sp * sy; + let y = cr * sp * cy + sr * cp * sy; + let z = cr * cp * sy - sr * sp * cy; + let w = (cr * cp * cy + sr * sp * sy).clamp(-1.0, 1.0); + let angle = 2.0 * w.acos(); + let length = (x * x + y * y + z * z).sqrt(); + if length <= f64::EPSILON || angle.abs() <= f64::EPSILON { + [0.0, 0.0, 1.0, 0.0] + } else { + [x / length, y / length, z / length, angle] + } +} + +pub(crate) fn number(value: f64) -> String { + if value == 0.0 { + return "0".to_owned(); + } + let mut rendered = format!("{value:.17}"); + while rendered.ends_with('0') { + rendered.pop(); + } + if rendered.ends_with('.') { + rendered.pop(); + } + rendered +} + +pub(crate) fn quoted(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(test)] +pub(crate) mod tests { + use sha2::Digest as _; + + use super::*; + + fn triangle_glb() -> Vec { + let mut binary = Vec::new(); + for value in [0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0] { + binary.extend_from_slice(&value.to_le_bytes()); + } + for index in [0_u16, 1, 2] { + binary.extend_from_slice(&index.to_le_bytes()); + } + let document = serde_json::json!({ + "asset": { "version": "2.0" }, + "scene": 0, + "scenes": [{ "nodes": [0] }], + "nodes": [{ "mesh": 0 }], + "meshes": [{ "primitives": [{ + "attributes": { "POSITION": 0 }, + "indices": 1 + }] }], + "buffers": [{ "byteLength": binary.len() }], + "bufferViews": [ + { "buffer": 0, "byteOffset": 0, "byteLength": 36 }, + { "buffer": 0, "byteOffset": 36, "byteLength": 6 } + ], + "accessors": [ + { "bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3" }, + { "bufferView": 1, "componentType": 5123, "count": 3, "type": "SCALAR" } + ] + }); + let mut json = serde_json::to_vec(&document).expect("fixture JSON"); + while !json.len().is_multiple_of(4) { + json.push(b' '); + } + let padded_binary = binary.len().div_ceil(4) * 4; + let total = 12 + 8 + json.len() + 8 + padded_binary; + let mut glb = Vec::new(); + glb.extend_from_slice(b"glTF"); + glb.extend_from_slice(&2_u32.to_le_bytes()); + glb.extend_from_slice(&u32::try_from(total).expect("fixture length").to_le_bytes()); + glb.extend_from_slice( + &u32::try_from(json.len()) + .expect("fixture JSON length") + .to_le_bytes(), + ); + glb.extend_from_slice(&0x4e4f_534a_u32.to_le_bytes()); + glb.extend_from_slice(&json); + glb.extend_from_slice( + &u32::try_from(padded_binary) + .expect("fixture BIN length") + .to_le_bytes(), + ); + glb.extend_from_slice(&0x004e_4942_u32.to_le_bytes()); + glb.extend_from_slice(&binary); + glb.resize(total, 0); + glb + } + + pub(crate) fn compile_mesh_world(visual: &[u8], collision: Option<&[u8]>) -> WorldBundle { + mesh_world_instances(visual, collision, 1) + } + + fn mesh_world_instances(visual: &[u8], collision: Option<&[u8]>, count: u32) -> WorldBundle { + let root = tempfile::tempdir().expect("fixture root"); + let assets = root.path().join("assets"); + std::fs::create_dir_all(assets.join("sha256")).expect("fixture assets"); + let visual_id = format!("sha256/{:x}.glb", sha2::Sha256::digest(visual)); + std::fs::write(assets.join(&visual_id), visual).expect("fixture visual"); + let geometry = serde_json::json!({ + "kind": "mesh", "filename": visual_id, "scale": [0.5, 0.75, 1.25] + }); + let mut collision_geometry = geometry.clone(); + if let Some(collision) = collision { + let id = format!("sha256/{:x}.glb", sha2::Sha256::digest(collision)); + std::fs::write(assets.join(&id), collision).expect("fixture collision"); + collision_geometry = serde_json::json!({ + "kind": "mesh", "filename": id, "scale": null + }); + } + let entities = (0..count) + .map(|instance| { + serde_json::json!({ + "declaration": "exhibit", "instance": instance, + "pose": { "xyz": [f64::from(instance), 0.0, 0.0], "rpy": [0.0, 0.0, 0.0] }, + "geometry": geometry, "collision": collision_geometry + }) + }) + .collect::>(); + let source = serde_json::json!({ + "schema": "phoxal/world-bundle/v0", "id": "webots-glb", + "time_step_ns": 12_000_000, "gravity_mps2": [0.0, 0.0, -9.81], + "spawn_points": {}, "entities": entities + }); + std::fs::write( + root.path().join("world.json"), + serde_json::to_vec(&source).expect("JSON"), + ) + .expect("fixture document"); + WorldBundle::open(root.path()).expect("canonical fixture opens") + } + + #[test] + fn repeated_instances_decode_each_distinct_asset_once() { + let triangle = triangle_glb(); + let bundle = mesh_world_instances(&triangle, None, 1_000); + let mut calls = 0; + let decoded = DecodedWorldAssets::decode_with(&bundle, |_, bytes| { + calls += 1; + DecodedMesh::decode(bytes) + }) + .expect("distinct assets decode"); + assert_eq!(calls, 1); + let source = render_world_with_decoded(&bundle, "tcp://127.0.0.1:7000", &decoded) + .expect("large world renders"); + assert_eq!(source.matches("IndexedFaceSet").count(), 2_000); + assert_eq!(calls, 1); + assert_eq!( + source, + render_world(&bundle, "tcp://127.0.0.1:7000").expect("public renderer") + ); + } + + #[test] + fn axis_angle_has_a_canonical_identity() { + let pose: Pose = serde_json::from_value(serde_json::json!({ + "xyz": [0.0, 0.0, 0.0], + "rpy": [0.0, 0.0, 0.0] + })) + .expect("pose decodes"); + assert_eq!(axis_angle(pose), [0.0, 0.0, 1.0, 0.0]); + } + + #[test] + fn generated_number_spelling_is_stable() { + assert_eq!(number(-0.0), "0"); + assert_eq!(number(12.0), "12"); + assert_eq!(number(0.5), "0.5"); + } + + #[test] + fn world_info_selects_deterministic_wgs84_gps() { + let mut source = String::new(); + render_world_info(&mut source, 12, 9.81).expect("WorldInfo renders"); + assert!(source.contains("gpsCoordinateSystem \"WGS84\"")); + assert!(source.contains("gpsReference 0 0 0")); + assert!(!source.contains("GPS {")); + } + + #[test] + fn authored_implicit_glb_collision_renders_native_triangles() { + let triangle = triangle_glb(); + let bundle = compile_mesh_world(&triangle, None); + let source = render_world(&bundle, "tcp://127.0.0.1:7000").expect("world renders"); + assert_eq!(source.matches("IndexedFaceSet").count(), 2); + assert!(source.contains("scale 0.5 0.75 1.25")); + assert!(!source.contains("CadShape")); + assert!(!source.contains("url [\"../assets/")); + let _: webots_proto_ast::Proto = source.parse().expect("native world parses"); + } + + #[test] + fn detailed_visual_uses_explicit_bounded_glb_collision_override() { + let visual = + include_bytes!("../../../../fixture/components/drive_motor/meshes/drive_motor.glb"); + let collision = triangle_glb(); + let bundle = compile_mesh_world(visual, Some(&collision)); + let entity = bundle.world().entities().next().expect("fixture entity"); + assert_ne!(entity.geometry().asset_id(), entity.collision().asset_id()); + let source = render_world(&bundle, "tcp://127.0.0.1:7000").expect("world renders"); + assert!(source.matches("IndexedFaceSet").count() >= 4); + assert!(!source.contains("CadShape")); + assert!(!source.contains("url [\"../assets/")); + let _: webots_proto_ast::Proto = source.parse().expect("native world parses"); + } +} diff --git a/simulators/webots/host/src/glb.rs b/simulators/webots/host/src/glb.rs new file mode 100644 index 00000000..3f92ecb2 --- /dev/null +++ b/simulators/webots/host/src/glb.rs @@ -0,0 +1,2867 @@ +//! Closed GLB 2.0 decoding for Webots-native indexed geometry. +//! +//! Webots R2025a does not load GLB files through `CadShape` or `Mesh`. The adapter therefore +//! validates and decodes the bounded subset it can reproduce exactly, bakes the selected glTF +//! scene graph into vertex data, and lets both world and Robot generation emit native +//! `IndexedFaceSet` nodes. + +use std::collections::BTreeSet; +use std::fmt::Write as _; +use std::io::Cursor; + +use anyhow::{Context, Result, bail, ensure}; +use image::ImageFormat; +use nalgebra::{Matrix3, Matrix4, Quaternion, Translation3, UnitQuaternion, Vector3, Vector4}; +use serde_json::{Map, Value}; + +const MAGIC: &[u8; 4] = b"glTF"; +const JSON_CHUNK: u32 = 0x4e4f_534a; +const BIN_CHUNK: u32 = 0x004e_4942; +const TRIANGLES: u64 = 4; +const FLOAT: u64 = 5126; +const UNSIGNED_BYTE: u64 = 5121; +const UNSIGNED_SHORT: u64 = 5123; +const UNSIGNED_INT: u64 = 5125; +const MAX_COLLISION_TRIANGLES: usize = 100_000; +const MAX_NODE_DEPTH: usize = 256; + +#[derive(Clone, Debug)] +pub(crate) struct DecodedMesh { + pub primitives: Vec, + pub images: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) struct DecodedPrimitive { + pub positions: Vec<[f64; 3]>, + pub normals: Option>, + pub texcoords: Option>, + pub indices: Vec, + pub material: DecodedMaterial, +} + +#[derive(Clone, Debug)] +pub(crate) struct DecodedMaterial { + pub base_color: [f64; 4], + pub metallic: f64, + pub roughness: f64, + pub emissive: [f64; 3], + pub double_sided: bool, + pub alpha_blend: bool, + pub base_color_texture: Option, +} + +impl Default for DecodedMaterial { + fn default() -> Self { + Self { + base_color: [1.0; 4], + metallic: 1.0, + roughness: 1.0, + emissive: [0.0; 3], + double_sided: false, + alpha_blend: false, + base_color_texture: None, + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct DecodedTexture { + pub image: usize, + pub repeat_s: bool, + pub repeat_t: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ImageKind { + Png, + Jpeg, +} + +impl ImageKind { + const fn format(self) -> ImageFormat { + match self { + Self::Png => ImageFormat::Png, + Self::Jpeg => ImageFormat::Jpeg, + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct DecodedImage { + pub kind: ImageKind, + pub bytes: Vec, +} + +#[derive(Clone)] +struct MeshPrimitive { + positions: Vec<[f64; 3]>, + normals: Option>, + texcoords: Option>, + indices: Vec, + material: DecodedMaterial, +} + +#[derive(Clone, Copy)] +struct BufferView { + buffer: usize, + offset: usize, + length: usize, + stride: Option, +} + +type AccessorData<'a> = (&'a [u8], usize, usize, u64, String, bool); + +impl DecodedMesh { + pub fn decode(bytes: &[u8]) -> Result { + let (document, binary) = container(bytes)?; + validate_top_level(&document)?; + let buffers = buffers(&document, binary)?; + let views = buffer_views(&document, &buffers)?; + validate_accessors(&document, views.len())?; + let images = images(&document, &buffers, &views)?; + let textures = textures(&document, images.len())?; + let materials = materials(&document, &textures)?; + let meshes = meshes(&document, &buffers, &views, &materials)?; + let primitives = scene_primitives(&document, &meshes)?; + ensure!( + !primitives.is_empty(), + "GLB selected scene contains no mesh primitive" + ); + Ok(Self { primitives, images }) + } + + pub fn validate_collision(&self) -> Result<()> { + let triangles = self + .primitives + .iter() + .try_fold(0_usize, |total, primitive| { + ensure!( + primitive.indices.len().is_multiple_of(3), + "collision primitive is not a triangle list" + ); + for triangle in primitive.indices.as_chunks::<3>().0 { + let a = primitive.positions[triangle[0] as usize]; + let b = primitive.positions[triangle[1] as usize]; + let c = primitive.positions[triangle[2] as usize]; + ensure!( + a != b && b != c && a != c, + "collision GLB contains a triangle with coincident vertices" + ); + } + total + .checked_add(primitive.indices.len() / 3) + .context("collision triangle count overflowed") + })?; + ensure!( + triangles <= MAX_COLLISION_TRIANGLES, + "collision GLB has {triangles} triangles, exceeding {MAX_COLLISION_TRIANGLES}" + ); + Ok(()) + } + + pub fn render_visual( + &self, + out: &mut String, + indent: usize, + texture_url: impl Fn(usize) -> Result, + ) -> Result<()> { + for (primitive_index, primitive) in self.primitives.iter().enumerate() { + writeln!(out, "{:indent$}Shape {{", "")?; + render_appearance( + out, + &primitive.material, + primitive_index, + indent + 2, + &texture_url, + )?; + writeln!( + out, + "{:width$}geometry IndexedFaceSet {{", + "", + width = indent + 2 + )?; + render_indexed_face_set(out, primitive, indent + 4, true)?; + writeln!(out, "{:width$}}}", "", width = indent + 2)?; + writeln!(out, "{:indent$}}}", "")?; + } + Ok(()) + } + + pub fn staged_texture(&self, primitive: usize) -> Result>> { + let primitive = self + .primitives + .get(primitive) + .context("decoded GLB texture names an absent primitive")?; + let Some(texture) = &primitive.material.base_color_texture else { + return Ok(None); + }; + let image = self + .images + .get(texture.image) + .context("decoded GLB material names an absent image")?; + let mut pixels = image::load_from_memory_with_format(&image.bytes, image.kind.format()) + .context("validated GLB image could not be decoded for material baking")? + .to_rgba8(); + for pixel in pixels.pixels_mut() { + for (sample, factor) in pixel.0[..3] + .iter_mut() + .zip(primitive.material.base_color[..3].iter()) + { + let linear = srgb_to_linear(f64::from(*sample) / 255.0) * factor; + *sample = (linear_to_srgb(linear).clamp(0.0, 1.0) * 255.0).round() as u8; + } + pixel.0[3] = if primitive.material.alpha_blend { + (f64::from(pixel.0[3]) * primitive.material.base_color[3]).round() as u8 + } else { + 255 + }; + } + let mut encoded = Cursor::new(Vec::new()); + let staged = if primitive.material.alpha_blend { + image::DynamicImage::ImageRgba8(pixels) + } else { + image::DynamicImage::ImageRgb8(image::DynamicImage::ImageRgba8(pixels).to_rgb8()) + }; + staged + .write_to(&mut encoded, ImageFormat::Png) + .context("failed to encode baked GLB material texture")?; + Ok(Some(encoded.into_inner())) + } + + pub fn render_collision(&self, out: &mut String, indent: usize) -> Result<()> { + self.render_collision_scaled(out, indent, [1.0; 3]) + } + + pub fn render_collision_scaled( + &self, + out: &mut String, + indent: usize, + scale: [f64; 3], + ) -> Result<()> { + ensure!( + scale.iter().all(|value| value.is_finite() && *value > 0.0), + "collision GLB scale must contain finite positive values" + ); + self.validate_collision()?; + if self.primitives.len() > 1 { + writeln!(out, "{:indent$}Group {{", "")?; + writeln!(out, "{:width$}children [", "", width = indent + 2)?; + } + let geometry_indent = if self.primitives.len() > 1 { + indent + 4 + } else { + indent + }; + for primitive in &self.primitives { + let mut primitive = primitive.clone(); + for position in &mut primitive.positions { + for axis in 0..3 { + position[axis] *= scale[axis]; + ensure!( + position[axis].is_finite(), + "collision GLB scale overflowed a vertex" + ); + } + } + writeln!(out, "{:geometry_indent$}IndexedFaceSet {{", "")?; + render_indexed_face_set(out, &primitive, geometry_indent + 2, false)?; + writeln!(out, "{:geometry_indent$}}}", "")?; + } + if self.primitives.len() > 1 { + writeln!(out, "{:width$}]", "", width = indent + 2)?; + writeln!(out, "{:indent$}}}", "")?; + } + Ok(()) + } +} + +fn render_appearance( + out: &mut String, + material: &DecodedMaterial, + primitive_index: usize, + indent: usize, + texture_url: &impl Fn(usize) -> Result, +) -> Result<()> { + writeln!(out, "{:indent$}appearance PBRAppearance {{", "")?; + let base_color = if material.base_color_texture.is_some() { + [1.0; 3] + } else { + [ + material.base_color[0], + material.base_color[1], + material.base_color[2], + ] + }; + writeln!( + out, + "{:width$}baseColor {} {} {}", + "", + crate::generation::number(base_color[0]), + crate::generation::number(base_color[1]), + crate::generation::number(base_color[2]), + width = indent + 2 + )?; + writeln!( + out, + "{:width$}metalness {}", + "", + crate::generation::number(material.metallic), + width = indent + 2 + )?; + writeln!( + out, + "{:width$}roughness {}", + "", + crate::generation::number(material.roughness), + width = indent + 2 + )?; + if material.base_color_texture.is_none() && material.alpha_blend && material.base_color[3] < 1.0 + { + writeln!( + out, + "{:width$}transparency {}", + "", + crate::generation::number(1.0 - material.base_color[3]), + width = indent + 2 + )?; + } + if material.emissive != [0.0; 3] { + writeln!( + out, + "{:width$}emissiveColor {} {} {}", + "", + crate::generation::number(material.emissive[0]), + crate::generation::number(material.emissive[1]), + crate::generation::number(material.emissive[2]), + width = indent + 2 + )?; + } + if let Some(texture) = &material.base_color_texture { + let image = texture_url(primitive_index)?; + writeln!( + out, + "{:width$}baseColorMap ImageTexture {{", + "", + width = indent + 2 + )?; + writeln!( + out, + "{:width$}url [\"{}\"]", + "", + crate::generation::quoted(&image), + width = indent + 4 + )?; + writeln!( + out, + "{:width$}repeatS {}", + "", + if texture.repeat_s { "TRUE" } else { "FALSE" }, + width = indent + 4 + )?; + writeln!( + out, + "{:width$}repeatT {}", + "", + if texture.repeat_t { "TRUE" } else { "FALSE" }, + width = indent + 4 + )?; + writeln!(out, "{:width$}}}", "", width = indent + 2)?; + } + writeln!(out, "{:indent$}}}", "")?; + Ok(()) +} + +fn srgb_to_linear(value: f64) -> f64 { + if value <= 0.04045 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } +} + +fn linear_to_srgb(value: f64) -> f64 { + if value <= 0.003_130_8 { + value * 12.92 + } else { + 1.055 * value.powf(1.0 / 2.4) - 0.055 + } +} + +fn render_indexed_face_set( + out: &mut String, + primitive: &DecodedPrimitive, + indent: usize, + visual: bool, +) -> Result<()> { + let backface_offset = if visual && primitive.material.double_sided { + Some(u32::try_from(primitive.positions.len()).context("GLB vertex count exceeds u32")?) + } else { + None + }; + writeln!(out, "{:indent$}coord Coordinate {{", "")?; + writeln!(out, "{:width$}point [", "", width = indent + 2)?; + for point in &primitive.positions { + writeln!( + out, + "{:width$}{} {} {}", + "", + crate::generation::number(point[0]), + crate::generation::number(point[1]), + crate::generation::number(point[2]), + width = indent + 4 + )?; + } + if backface_offset.is_some() { + for point in &primitive.positions { + writeln!( + out, + "{:width$}{} {} {}", + "", + crate::generation::number(point[0]), + crate::generation::number(point[1]), + crate::generation::number(point[2]), + width = indent + 4 + )?; + } + } + writeln!(out, "{:width$}]", "", width = indent + 2)?; + writeln!(out, "{:indent$}}}", "")?; + render_indices( + out, + "coordIndex", + &primitive.indices, + indent, + backface_offset, + )?; + if visual { + if let Some(normals) = &primitive.normals { + writeln!(out, "{:indent$}normal Normal {{", "")?; + writeln!(out, "{:width$}vector [", "", width = indent + 2)?; + for normal in normals { + writeln!( + out, + "{:width$}{} {} {}", + "", + crate::generation::number(normal[0]), + crate::generation::number(normal[1]), + crate::generation::number(normal[2]), + width = indent + 4 + )?; + } + if backface_offset.is_some() { + for normal in normals { + writeln!( + out, + "{:width$}{} {} {}", + "", + crate::generation::number(-normal[0]), + crate::generation::number(-normal[1]), + crate::generation::number(-normal[2]), + width = indent + 4 + )?; + } + } + writeln!(out, "{:width$}]", "", width = indent + 2)?; + writeln!(out, "{:indent$}}}", "")?; + render_indices( + out, + "normalIndex", + &primitive.indices, + indent, + backface_offset, + )?; + writeln!(out, "{:indent$}normalPerVertex TRUE", "")?; + } + if let Some(texcoords) = &primitive.texcoords { + writeln!(out, "{:indent$}texCoord TextureCoordinate {{", "")?; + writeln!(out, "{:width$}point [", "", width = indent + 2)?; + for texcoord in texcoords { + writeln!( + out, + "{:width$}{} {}", + "", + crate::generation::number(texcoord[0]), + crate::generation::number(1.0 - texcoord[1]), + width = indent + 4 + )?; + } + if backface_offset.is_some() { + for texcoord in texcoords { + writeln!( + out, + "{:width$}{} {}", + "", + crate::generation::number(texcoord[0]), + crate::generation::number(1.0 - texcoord[1]), + width = indent + 4 + )?; + } + } + writeln!(out, "{:width$}]", "", width = indent + 2)?; + writeln!(out, "{:indent$}}}", "")?; + render_indices( + out, + "texCoordIndex", + &primitive.indices, + indent, + backface_offset, + )?; + } + } + Ok(()) +} + +fn render_indices( + out: &mut String, + field: &str, + indices: &[u32], + indent: usize, + backface_offset: Option, +) -> Result<()> { + writeln!(out, "{:indent$}{field} [", "")?; + for triangle in indices.as_chunks::<3>().0 { + writeln!( + out, + "{:width$}{} {} {} -1", + "", + triangle[0], + triangle[1], + triangle[2], + width = indent + 2 + )?; + } + if let Some(offset) = backface_offset { + for triangle in indices.as_chunks::<3>().0 { + let first = triangle[0] + .checked_add(offset) + .context("double-sided GLB index overflowed")?; + let second = triangle[2] + .checked_add(offset) + .context("double-sided GLB index overflowed")?; + let third = triangle[1] + .checked_add(offset) + .context("double-sided GLB index overflowed")?; + writeln!( + out, + "{:width$}{first} {second} {third} -1", + "", + width = indent + 2 + )?; + } + } + writeln!(out, "{:indent$}]", "")?; + Ok(()) +} + +fn container(bytes: &[u8]) -> Result<(serde_json::Value, Option<&[u8]>)> { + ensure!(bytes.len() >= 20, "GLB header is truncated"); + ensure!(&bytes[..4] == MAGIC, "asset is not a GLB container"); + ensure!(read_u32(bytes, 4)? == 2, "only GLB version 2 is supported"); + ensure!( + usize::try_from(read_u32(bytes, 8)?)? == bytes.len(), + "GLB declared length is inconsistent" + ); + + let mut offset = 12_usize; + let mut json = None; + let mut binary = None; + while offset < bytes.len() { + ensure!(offset + 8 <= bytes.len(), "GLB chunk header is truncated"); + let length = usize::try_from(read_u32(bytes, offset)?)?; + let kind = read_u32(bytes, offset + 4)?; + ensure!( + length.is_multiple_of(4), + "GLB chunk is not four-byte aligned" + ); + let first = offset == 12; + offset = offset + .checked_add(8) + .context("GLB chunk offset overflowed")?; + let end = offset + .checked_add(length) + .context("GLB chunk length overflowed")?; + ensure!(end <= bytes.len(), "GLB chunk is truncated"); + match kind { + JSON_CHUNK if first && json.is_none() => json = Some(&bytes[offset..end]), + JSON_CHUNK => bail!("GLB JSON must be the first and only JSON chunk"), + BIN_CHUNK if json.is_some() && binary.is_none() => binary = Some(&bytes[offset..end]), + BIN_CHUNK => bail!("GLB may contain at most one BIN chunk after JSON"), + _ => bail!("unsupported GLB chunk type {kind:#010x}"), + } + offset = end; + } + let padded_json = json.context("GLB has no JSON chunk")?; + let json_end = padded_json + .iter() + .rposition(|byte| *byte != b' ') + .map_or(0, |index| index + 1); + ensure!(json_end > 0, "GLB JSON chunk is empty"); + let padding = &padded_json[json_end..]; + ensure!( + padding.len() <= 3, + "GLB JSON has more than three padding bytes" + ); + ensure!( + padding.iter().all(|byte| *byte == b' '), + "GLB JSON padding must contain only spaces" + ); + let document = serde_json::from_slice(&padded_json[..json_end])?; + Ok((document, binary)) +} + +fn validate_top_level(document: &serde_json::Value) -> Result<()> { + let root = document + .as_object() + .context("glTF document is not an object")?; + ensure_keys( + root, + &[ + "accessors", + "asset", + "bufferViews", + "buffers", + "extensionsRequired", + "extensionsUsed", + "extras", + "images", + "materials", + "meshes", + "nodes", + "samplers", + "scene", + "scenes", + "textures", + ], + "glTF document", + )?; + let asset = root + .get("asset") + .and_then(Value::as_object) + .context("glTF asset is not an object")?; + ensure_keys( + asset, + &["copyright", "extras", "generator", "minVersion", "version"], + "glTF asset", + )?; + ensure!( + asset.get("version").and_then(Value::as_str) == Some("2.0"), + "glTF asset.version must be exactly 2.0" + ); + if let Some(minimum) = asset.get("minVersion") { + ensure!( + minimum.as_str() == Some("2.0"), + "glTF asset.minVersion must be exactly 2.0 when present" + ); + } + let allowed_extensions = BTreeSet::from(["KHR_materials_clearcoat"]); + for field in ["extensionsUsed", "extensionsRequired"] { + if let Some(extensions) = root.get(field) { + let extensions = extensions + .as_array() + .with_context(|| format!("glTF {field} is not an array"))?; + for extension in extensions { + let extension = extension + .as_str() + .with_context(|| format!("glTF {field} contains a non-string"))?; + ensure!( + allowed_extensions.contains(extension), + "unsupported glTF extension {extension}" + ); + } + } + } + Ok(()) +} + +fn buffers(document: &serde_json::Value, binary: Option<&[u8]>) -> Result>> { + let entries = document + .get("buffers") + .and_then(serde_json::Value::as_array) + .context("glTF buffers must be a non-empty array")?; + ensure!(!entries.is_empty(), "glTF buffers array is empty"); + let mut decoded = Vec::with_capacity(entries.len()); + let mut binary_owned = false; + for (index, entry) in entries.iter().enumerate() { + let entry = entry + .as_object() + .with_context(|| format!("glTF buffer[{index}] is not an object"))?; + ensure_keys( + entry, + &["byteLength", "extras", "name", "uri"], + &format!("glTF buffer[{index}]"), + )?; + let byte_length = required_usize(entry.get("byteLength"), "buffer byteLength")?; + ensure!(byte_length > 0, "glTF buffer[{index}] byteLength is zero"); + let body = match entry.get("uri") { + None => { + ensure!(index == 0, "only glTF buffer[0] may omit uri"); + ensure!(!binary_owned, "multiple buffers claim the GLB BIN chunk"); + let binary = binary.context("glTF buffer[0] omits uri but GLB has no BIN chunk")?; + ensure!( + binary.len() >= byte_length, + "GLB BIN chunk is shorter than buffer[0]" + ); + let padding = &binary[byte_length..]; + ensure!( + padding.len() <= 3, + "GLB BIN has more than three padding bytes" + ); + ensure!( + padding.iter().all(|byte| *byte == 0), + "GLB BIN padding must contain only zero bytes" + ); + binary_owned = true; + binary[..byte_length].to_vec() + } + Some(uri) => { + let uri = uri + .as_str() + .with_context(|| format!("glTF buffer[{index}].uri is not a string"))?; + let (mime, body) = data_uri(uri, None) + .with_context(|| format!("glTF buffer[{index}] has an invalid data URI"))?; + ensure!( + matches!( + mime.as_str(), + "application/octet-stream" | "application/gltf-buffer" + ), + "glTF buffer[{index}] has unsupported data URI mimeType '{mime}'" + ); + ensure!( + body.len() == byte_length, + "glTF buffer[{index}] data length does not equal byteLength" + ); + body + } + }; + decoded.push(body); + } + ensure!( + binary.is_none() || binary_owned, + "GLB contains a stray BIN chunk not owned by buffer[0]" + ); + Ok(decoded) +} + +fn buffer_views(document: &serde_json::Value, buffers: &[Vec]) -> Result> { + let Some(entries) = document.get("bufferViews") else { + return Ok(Vec::new()); + }; + let entries = entries + .as_array() + .context("glTF bufferViews is not an array")?; + entries + .iter() + .enumerate() + .map(|(index, entry)| { + let entry = entry + .as_object() + .with_context(|| format!("glTF bufferView[{index}] is not an object"))?; + ensure_keys( + entry, + &[ + "buffer", + "byteLength", + "byteOffset", + "byteStride", + "extras", + "name", + "target", + ], + &format!("glTF bufferView[{index}]"), + )?; + let buffer = required_usize(entry.get("buffer"), "bufferView buffer")?; + let offset = + optional_usize(entry.get("byteOffset"), "bufferView byteOffset")?.unwrap_or(0); + let length = required_usize(entry.get("byteLength"), "bufferView byteLength")?; + ensure!(length > 0, "glTF bufferView[{index}] byteLength is zero"); + let stride = optional_usize(entry.get("byteStride"), "bufferView byteStride")?; + let body = buffers + .get(buffer) + .with_context(|| format!("bufferView[{index}] names absent buffer {buffer}"))?; + ensure!( + offset + .checked_add(length) + .is_some_and(|end| end <= body.len()), + "bufferView[{index}] exceeds its buffer" + ); + if let Some(stride) = stride { + ensure!( + (4..=252).contains(&stride) && stride.is_multiple_of(4), + "invalid bufferView byteStride" + ); + } + if let Some(target) = entry.get("target") { + ensure!( + matches!(target.as_u64(), Some(34_962) | Some(34_963)), + "invalid bufferView target" + ); + } + Ok(BufferView { + buffer, + offset, + length, + stride, + }) + }) + .collect() +} + +fn validate_accessors(document: &Value, view_count: usize) -> Result<()> { + let entries = document + .get("accessors") + .and_then(Value::as_array) + .context("glTF accessors is not an array")?; + for (index, accessor) in entries.iter().enumerate() { + let accessor = accessor + .as_object() + .with_context(|| format!("glTF accessor[{index}] is not an object"))?; + ensure_keys( + accessor, + &[ + "bufferView", + "byteOffset", + "componentType", + "count", + "extras", + "max", + "min", + "name", + "normalized", + "type", + ], + &format!("glTF accessor[{index}]"), + )?; + let view = required_usize(accessor.get("bufferView"), "accessor bufferView")?; + ensure!( + view < view_count, + "accessor[{index}] names absent bufferView" + ); + optional_usize(accessor.get("byteOffset"), "accessor byteOffset")?; + ensure!( + required_usize(accessor.get("count"), "accessor count")? > 0, + "accessor[{index}] count is zero" + ); + ensure!( + matches!( + required_u64(accessor.get("componentType"), "accessor componentType")?, + FLOAT | UNSIGNED_BYTE | UNSIGNED_SHORT | UNSIGNED_INT + ), + "accessor[{index}] has an unsupported componentType" + ); + let dimensions = match accessor.get("type").and_then(Value::as_str) { + Some("SCALAR") => 1, + Some("VEC2") => 2, + Some("VEC3") => 3, + Some(other) => bail!("accessor[{index}] has unsupported type '{other}'"), + None => bail!("accessor[{index}] type is missing or not a string"), + }; + optional_bool(accessor.get("normalized"), "accessor normalized")?; + for field in ["min", "max"] { + if let Some(values) = accessor.get(field) { + let values = values + .as_array() + .with_context(|| format!("accessor[{index}] {field} is not an array"))?; + ensure!( + values.len() == dimensions, + "accessor[{index}] {field} has the wrong dimensions" + ); + for value in values { + let value = value.as_f64().with_context(|| { + format!("accessor[{index}] {field} contains a non-number") + })?; + ensure!( + value.is_finite(), + "accessor[{index}] {field} contains a non-finite number" + ); + } + } + } + } + Ok(()) +} + +fn images( + document: &serde_json::Value, + buffers: &[Vec], + views: &[BufferView], +) -> Result> { + let Some(entries) = document.get("images") else { + return Ok(Vec::new()); + }; + let entries = entries.as_array().context("glTF images is not an array")?; + entries + .iter() + .enumerate() + .map(|(index, entry)| { + let entry = entry + .as_object() + .with_context(|| format!("glTF image[{index}] is not an object"))?; + ensure_keys( + entry, + &["bufferView", "extras", "mimeType", "name", "uri"], + &format!("glTF image[{index}]"), + )?; + let (mime, bytes) = match (entry.get("uri"), entry.get("bufferView")) { + (Some(uri), None) => { + let uri = uri + .as_str() + .with_context(|| format!("glTF image[{index}].uri is not a string"))?; + let decoded = data_uri(uri, Some("image/"))?; + if let Some(declared) = entry.get("mimeType") { + let declared = declared.as_str().with_context(|| { + format!("glTF image[{index}].mimeType is not a string") + })?; + ensure!( + declared == decoded.0, + "glTF image[{index}] mimeType conflicts with its data URI" + ); + } + decoded + } + (None, Some(view)) => { + let view = required_usize(Some(view), "image bufferView")?; + let mime = entry + .get("mimeType") + .and_then(serde_json::Value::as_str) + .context("bufferView-backed glTF image has no mimeType")? + .to_owned(); + let view = views + .get(view) + .with_context(|| format!("glTF image[{index}] names absent bufferView"))?; + let body = &buffers[view.buffer][view.offset..view.offset + view.length]; + (mime, body.to_vec()) + } + _ => bail!("glTF image[{index}] must use exactly one of uri or bufferView"), + }; + let kind = match mime.as_str() { + "image/png" => ImageKind::Png, + "image/jpeg" => ImageKind::Jpeg, + _ => bail!("glTF image[{index}] has unsupported mimeType '{mime}'"), + }; + let decoded = image::load_from_memory_with_format(&bytes, kind.format()) + .with_context(|| format!("glTF image[{index}] is not decodable {mime}"))?; + ensure!( + decoded.width() > 0 && decoded.height() > 0, + "glTF image[{index}] has zero dimensions" + ); + Ok(DecodedImage { kind, bytes }) + }) + .collect() +} + +fn textures(document: &serde_json::Value, image_count: usize) -> Result> { + let samplers = document.get("samplers").map_or(Ok(&[][..]), |value| { + value + .as_array() + .map(Vec::as_slice) + .context("glTF samplers is not an array") + })?; + for (index, sampler) in samplers.iter().enumerate() { + let sampler = sampler + .as_object() + .with_context(|| format!("glTF sampler[{index}] is not an object"))?; + ensure_keys( + sampler, + &["extras", "magFilter", "minFilter", "name", "wrapS", "wrapT"], + &format!("glTF sampler[{index}]"), + )?; + ensure!( + sampler.get("magFilter").is_none() && sampler.get("minFilter").is_none(), + "explicit glTF texture filtering cannot be reproduced exactly" + ); + texture_wrap(sampler.get("wrapS"), "wrapS")?; + texture_wrap(sampler.get("wrapT"), "wrapT")?; + } + let Some(entries) = document.get("textures") else { + return Ok(Vec::new()); + }; + let entries = entries + .as_array() + .context("glTF textures is not an array")?; + entries + .iter() + .enumerate() + .map(|(index, entry)| { + let entry = entry + .as_object() + .with_context(|| format!("glTF texture[{index}] is not an object"))?; + ensure_keys( + entry, + &["extras", "name", "sampler", "source"], + &format!("glTF texture[{index}]"), + )?; + let image = required_usize(entry.get("source"), "texture source")?; + ensure!( + image < image_count, + "glTF texture[{index}] names absent image {image}" + ); + let sampler = entry + .get("sampler") + .map(|value| required_usize(Some(value), "texture sampler")) + .transpose()? + .map(|sampler| { + samplers + .get(sampler) + .with_context(|| format!("texture[{index}] names absent sampler {sampler}")) + }) + .transpose()?; + let (repeat_s, repeat_t) = if let Some(sampler) = sampler { + let sampler = sampler + .as_object() + .with_context(|| format!("glTF texture[{index}] sampler is not an object"))?; + ensure_keys( + sampler, + &["extras", "magFilter", "minFilter", "name", "wrapS", "wrapT"], + &format!("glTF texture[{index}] sampler"), + )?; + ensure!( + sampler.get("magFilter").is_none() && sampler.get("minFilter").is_none(), + "explicit glTF texture filtering cannot be reproduced exactly" + ); + ( + texture_wrap(sampler.get("wrapS"), "wrapS")?, + texture_wrap(sampler.get("wrapT"), "wrapT")?, + ) + } else { + (true, true) + }; + Ok(DecodedTexture { + image, + repeat_s, + repeat_t, + }) + }) + .collect() +} + +fn texture_wrap(value: Option<&serde_json::Value>, field: &str) -> Result { + let value = value + .map(|value| { + value + .as_u64() + .with_context(|| format!("glTF {field} is not an unsigned integer")) + }) + .transpose()? + .unwrap_or(10_497); + match value { + 10_497 => Ok(true), + 33_071 => Ok(false), + 33_648 => bail!("glTF mirrored-repeat {field} is unsupported"), + other => bail!("invalid glTF {field} value {other}"), + } +} + +fn materials( + document: &serde_json::Value, + textures: &[DecodedTexture], +) -> Result> { + let Some(entries) = document.get("materials") else { + return Ok(Vec::new()); + }; + let entries = entries + .as_array() + .context("glTF materials is not an array")?; + entries + .iter() + .enumerate() + .map(|(index, entry)| { + let entry = entry + .as_object() + .with_context(|| format!("glTF material[{index}] is not an object"))?; + ensure_keys( + entry, + &[ + "alphaCutoff", + "alphaMode", + "doubleSided", + "emissiveFactor", + "emissiveTexture", + "extensions", + "extras", + "name", + "normalTexture", + "occlusionTexture", + "pbrMetallicRoughness", + ], + &format!("glTF material[{index}]"), + )?; + for unsupported in ["normalTexture", "occlusionTexture", "emissiveTexture"] { + ensure!( + entry.get(unsupported).is_none(), + "glTF material[{index}] {unsupported} is unsupported" + ); + } + if let Some(extensions) = entry.get("extensions") { + let extensions = extensions + .as_object() + .context("glTF material extensions is not an object")?; + ensure!( + extensions.len() == 1 && extensions.contains_key("KHR_materials_clearcoat"), + "glTF material[{index}] has unsupported extensions" + ); + let clearcoat = extensions["KHR_materials_clearcoat"] + .as_object() + .context("KHR_materials_clearcoat is not an object")?; + ensure_keys( + clearcoat, + &[ + "clearcoatFactor", + "clearcoatNormalTexture", + "clearcoatRoughnessFactor", + "clearcoatRoughnessTexture", + "clearcoatTexture", + "extras", + ], + "KHR_materials_clearcoat", + )?; + let factor = + finite_factor(clearcoat.get("clearcoatFactor"), 0.0, "clearcoatFactor")?; + let _roughness = finite_factor( + clearcoat.get("clearcoatRoughnessFactor"), + 0.0, + "clearcoatRoughnessFactor", + )?; + ensure!( + factor == 0.0 + && clearcoat.get("clearcoatTexture").is_none() + && clearcoat.get("clearcoatRoughnessTexture").is_none() + && clearcoat.get("clearcoatNormalTexture").is_none(), + "only semantically inactive KHR_materials_clearcoat is supported" + ); + } + let pbr = entry + .get("pbrMetallicRoughness") + .map(|value| { + value + .as_object() + .context("pbrMetallicRoughness is not an object") + }) + .transpose()?; + if let Some(pbr) = pbr { + ensure_keys( + pbr, + &[ + "baseColorFactor", + "baseColorTexture", + "extras", + "metallicFactor", + "metallicRoughnessTexture", + "roughnessFactor", + ], + "glTF pbrMetallicRoughness", + )?; + ensure!( + pbr.get("metallicRoughnessTexture").is_none(), + "glTF metallic-roughness textures are unsupported" + ); + } + let base_color = pbr + .and_then(|pbr| pbr.get("baseColorFactor")) + .map(|value| finite_array::<4>(value, "baseColorFactor")) + .transpose()? + .unwrap_or([1.0; 4]); + ensure!( + base_color.iter().all(|value| (0.0..=1.0).contains(value)), + "glTF baseColorFactor is outside [0, 1]" + ); + let metallic = finite_factor( + pbr.and_then(|pbr| pbr.get("metallicFactor")), + 1.0, + "metallicFactor", + )?; + let roughness = finite_factor( + pbr.and_then(|pbr| pbr.get("roughnessFactor")), + 1.0, + "roughnessFactor", + )?; + let base_color_texture = pbr + .and_then(|pbr| pbr.get("baseColorTexture")) + .map(|texture| material_texture(texture, textures, "baseColorTexture")) + .transpose()?; + let emissive = entry + .get("emissiveFactor") + .map(|value| finite_array::<3>(value, "emissiveFactor")) + .transpose()? + .unwrap_or([0.0; 3]); + ensure!( + emissive.iter().all(|value| (0.0..=1.0).contains(value)), + "glTF emissiveFactor is outside [0, 1]" + ); + let alpha_mode = entry + .get("alphaMode") + .map(|value| value.as_str().context("glTF alphaMode is not a string")) + .transpose()? + .unwrap_or("OPAQUE"); + let alpha_blend = match alpha_mode { + "OPAQUE" => false, + "BLEND" => true, + "MASK" => bail!("glTF alpha MASK cannot be reproduced exactly"), + other => bail!("invalid glTF alphaMode '{other}'"), + }; + ensure!( + entry.get("alphaCutoff").is_none() || alpha_mode == "MASK", + "glTF alphaCutoff is present without alpha MASK" + ); + Ok(DecodedMaterial { + base_color, + metallic, + roughness, + emissive, + double_sided: optional_bool(entry.get("doubleSided"), "doubleSided")? + .unwrap_or(false), + alpha_blend, + base_color_texture, + }) + }) + .collect() +} + +fn material_texture( + value: &serde_json::Value, + textures: &[DecodedTexture], + name: &str, +) -> Result { + let value = value + .as_object() + .with_context(|| format!("glTF {name} is not an object"))?; + ensure_keys( + value, + &["extensions", "extras", "index", "texCoord"], + &format!("glTF {name}"), + )?; + ensure!( + value.get("extensions").is_none(), + "glTF {name} extensions are unsupported" + ); + ensure!( + value + .get("texCoord") + .map(|value| required_u64(Some(value), "material texture texCoord")) + .transpose()? + .unwrap_or(0) + == 0, + "only glTF TEXCOORD_0 is supported" + ); + let index = required_usize(value.get("index"), "material texture index")?; + textures + .get(index) + .cloned() + .with_context(|| format!("glTF {name} names absent texture {index}")) +} + +fn meshes( + document: &serde_json::Value, + buffers: &[Vec], + views: &[BufferView], + materials: &[DecodedMaterial], +) -> Result>> { + let entries = document + .get("meshes") + .and_then(serde_json::Value::as_array) + .context("glTF meshes is not an array")?; + entries + .iter() + .enumerate() + .map(|(mesh_index, mesh)| { + let mesh = mesh + .as_object() + .with_context(|| format!("glTF mesh[{mesh_index}] is not an object"))?; + ensure_keys( + mesh, + &["extras", "name", "primitives", "weights"], + &format!("glTF mesh[{mesh_index}]"), + )?; + ensure!( + mesh.get("weights").is_none(), + "glTF mesh weights are unsupported" + ); + let primitives = mesh + .get("primitives") + .and_then(serde_json::Value::as_array) + .context("glTF mesh primitives is not an array")?; + ensure!(!primitives.is_empty(), "glTF mesh has no primitives"); + primitives + .iter() + .enumerate() + .map(|(primitive_index, primitive)| { + let primitive = primitive.as_object().with_context(|| { + format!( + "glTF mesh[{mesh_index}] primitive[{primitive_index}] is not an object" + ) + })?; + ensure_keys( + primitive, + &[ + "attributes", + "extensions", + "extras", + "indices", + "material", + "mode", + "targets", + ], + &format!("glTF mesh[{mesh_index}] primitive[{primitive_index}]"), + )?; + ensure!( + primitive.get("targets").is_none(), + "glTF morph targets are unsupported" + ); + ensure!( + primitive.get("extensions").is_none(), + "glTF primitive extensions are unsupported" + ); + ensure!( + primitive + .get("mode") + .map(|value| required_u64(Some(value), "primitive mode")) + .transpose()? + .unwrap_or(TRIANGLES) + == TRIANGLES, + "only glTF TRIANGLES primitives are supported" + ); + let attributes = primitive + .get("attributes") + .and_then(serde_json::Value::as_object) + .context("glTF primitive attributes is not an object")?; + for attribute in attributes.keys() { + ensure!( + matches!(attribute.as_str(), "POSITION" | "NORMAL" | "TEXCOORD_0"), + "unsupported glTF vertex attribute {attribute}" + ); + } + let positions = read_f32_vectors::<3>( + document, + buffers, + views, + required_usize(attributes.get("POSITION"), "POSITION accessor")?, + "VEC3", + )?; + ensure!( + !positions.is_empty(), + "glTF selected primitive has zero POSITION count" + ); + let normals = attributes + .get("NORMAL") + .map(|value| { + read_f32_vectors::<3>( + document, + buffers, + views, + required_usize(Some(value), "NORMAL accessor")?, + "VEC3", + ) + }) + .transpose()?; + let texcoords = attributes + .get("TEXCOORD_0") + .map(|value| { + read_f32_vectors::<2>( + document, + buffers, + views, + required_usize(Some(value), "TEXCOORD_0 accessor")?, + "VEC2", + ) + }) + .transpose()?; + if let Some(normals) = &normals { + ensure!( + normals.len() == positions.len(), + "NORMAL count differs from POSITION" + ); + ensure!( + normals.iter().all(|normal| { + let length = (normal[0] * normal[0] + + normal[1] * normal[1] + + normal[2] * normal[2]) + .sqrt(); + (length - 1.0).abs() <= 1.0e-4 + }), + "glTF NORMAL is not unit length" + ); + } + if let Some(texcoords) = &texcoords { + ensure!( + texcoords.len() == positions.len(), + "TEXCOORD_0 count differs from POSITION" + ); + } + let indices = if let Some(value) = primitive.get("indices") { + read_indices( + document, + buffers, + views, + required_usize(Some(value), "indices accessor")?, + )? + } else { + (0..positions.len()) + .map(|index| { + u32::try_from(index) + .context("unindexed glTF primitive exceeds u32 indices") + }) + .collect::>>()? + }; + ensure!( + !indices.is_empty(), + "glTF selected primitive has zero triangle index count" + ); + ensure!( + indices.len().is_multiple_of(3), + "triangle index count is not divisible by three" + ); + ensure!( + indices.iter().all(|index| usize::try_from(*index) + .is_ok_and(|index| index < positions.len())), + "triangle index is outside POSITION" + ); + let material = primitive + .get("material") + .map(|value| required_usize(Some(value), "primitive material")) + .transpose()? + .map(|index| { + materials + .get(index) + .cloned() + .with_context(|| format!("primitive names absent material {index}")) + }) + .transpose()? + .unwrap_or_default(); + ensure!( + material.base_color_texture.is_none() || texcoords.is_some(), + "textured glTF primitive has no TEXCOORD_0" + ); + Ok(MeshPrimitive { + positions, + normals, + texcoords, + indices, + material, + }) + }) + .collect() + }) + .collect() +} + +fn scene_primitives( + document: &serde_json::Value, + meshes: &[Vec], +) -> Result> { + let scenes = document + .get("scenes") + .and_then(serde_json::Value::as_array) + .context("glTF scenes is not an array")?; + ensure!(!scenes.is_empty(), "glTF scenes array is empty"); + for (index, scene) in scenes.iter().enumerate() { + let scene = scene + .as_object() + .with_context(|| format!("glTF scene[{index}] is not an object"))?; + ensure_keys( + scene, + &["extras", "name", "nodes"], + &format!("glTF scene[{index}]"), + )?; + } + let scene_index = document + .get("scene") + .map(|value| required_usize(Some(value), "default scene")) + .transpose()? + .unwrap_or(0); + let scene = scenes + .get(scene_index) + .and_then(serde_json::Value::as_object) + .with_context(|| format!("glTF default scene {scene_index} is absent"))?; + ensure_keys( + scene, + &["extras", "name", "nodes"], + &format!("glTF scene[{scene_index}]"), + )?; + let roots = scene + .get("nodes") + .and_then(serde_json::Value::as_array) + .context("glTF scene nodes is not an array")?; + let nodes = document + .get("nodes") + .and_then(serde_json::Value::as_array) + .context("glTF nodes is not an array")?; + for (index, node) in nodes.iter().enumerate() { + let node = node + .as_object() + .with_context(|| format!("glTF node[{index}] is not an object"))?; + ensure_keys( + node, + &[ + "camera", + "children", + "extensions", + "extras", + "matrix", + "mesh", + "name", + "rotation", + "scale", + "skin", + "translation", + "weights", + ], + &format!("glTF node[{index}]"), + )?; + } + let mut visiting = BTreeSet::new(); + let mut visited = BTreeSet::new(); + let mut primitives = Vec::new(); + for root in roots { + let root = required_usize(Some(root), "scene root node")?; + visit_node( + root, + &Matrix4::identity(), + nodes, + meshes, + &mut visiting, + &mut visited, + &mut primitives, + )?; + } + Ok(primitives) +} + +#[allow( + clippy::too_many_arguments, + reason = "scene traversal carries immutable node/mesh tables plus cycle and output state" +)] +fn visit_node( + index: usize, + parent: &Matrix4, + nodes: &[serde_json::Value], + meshes: &[Vec], + visiting: &mut BTreeSet, + visited: &mut BTreeSet, + output: &mut Vec, +) -> Result<()> { + ensure!( + visiting.len() < MAX_NODE_DEPTH, + "glTF scene exceeds {MAX_NODE_DEPTH} nested nodes" + ); + ensure!( + visiting.insert(index), + "glTF node graph contains a cycle at {index}" + ); + ensure!( + visited.insert(index), + "glTF node {index} has multiple selected-scene parents" + ); + let node = nodes + .get(index) + .and_then(serde_json::Value::as_object) + .with_context(|| format!("glTF node {index} is absent or not an object"))?; + ensure_keys( + node, + &[ + "camera", + "children", + "extensions", + "extras", + "matrix", + "mesh", + "name", + "rotation", + "scale", + "skin", + "translation", + "weights", + ], + &format!("glTF node {index}"), + )?; + for unsupported in ["camera", "skin", "weights", "extensions"] { + ensure!( + node.get(unsupported).is_none(), + "glTF node {index} {unsupported} is unsupported" + ); + } + let transform = parent * node_transform(node)?; + ensure!( + transform.iter().all(|value| value.is_finite()), + "glTF composed node transform overflowed" + ); + if let Some(mesh) = node.get("mesh") { + let mesh_index = required_usize(Some(mesh), "node mesh")?; + let mesh = meshes + .get(mesh_index) + .with_context(|| format!("glTF node {index} names absent mesh {mesh_index}"))?; + for primitive in mesh { + output.push(transform_primitive(primitive, &transform)?); + } + } + if let Some(children) = node.get("children") { + let children = children + .as_array() + .context("glTF node children is not an array")?; + for child in children { + visit_node( + required_usize(Some(child), "child node")?, + &transform, + nodes, + meshes, + visiting, + visited, + output, + )?; + } + } + visiting.remove(&index); + Ok(()) +} + +fn node_transform(node: &serde_json::Map) -> Result> { + if let Some(matrix) = node.get("matrix") { + ensure!( + node.get("translation").is_none() + && node.get("rotation").is_none() + && node.get("scale").is_none(), + "glTF node may not combine matrix with TRS" + ); + let values = finite_array::<16>(matrix, "node matrix")?; + ensure!( + values[3] == 0.0 && values[7] == 0.0 && values[11] == 0.0 && values[15] == 1.0, + "glTF node matrix must be affine" + ); + return Ok(Matrix4::from_column_slice(&values)); + } + let translation = node + .get("translation") + .map(|value| finite_array::<3>(value, "node translation")) + .transpose()? + .unwrap_or([0.0; 3]); + let rotation = node + .get("rotation") + .map(|value| finite_array::<4>(value, "node rotation")) + .transpose()? + .unwrap_or([0.0, 0.0, 0.0, 1.0]); + let scale = node + .get("scale") + .map(|value| finite_array::<3>(value, "node scale")) + .transpose()? + .unwrap_or([1.0; 3]); + ensure!( + scale.iter().all(|value| *value != 0.0), + "glTF node scale is singular" + ); + let quaternion = Quaternion::new(rotation[3], rotation[0], rotation[1], rotation[2]); + ensure!( + (quaternion.norm() - 1.0).abs() <= 1.0e-5, + "glTF node quaternion is not normalized" + ); + let rotation = UnitQuaternion::from_quaternion(quaternion); + Ok( + Translation3::new(translation[0], translation[1], translation[2]).to_homogeneous() + * rotation.to_homogeneous() + * Matrix4::new_nonuniform_scaling(&Vector3::new(scale[0], scale[1], scale[2])), + ) +} + +fn transform_primitive( + primitive: &MeshPrimitive, + transform: &Matrix4, +) -> Result { + let positions = primitive + .positions + .iter() + .map(|position| { + let transformed = transform * Vector4::new(position[0], position[1], position[2], 1.0); + ensure!( + transformed.w != 0.0, + "glTF node transform produced a point at infinity" + ); + let position = [ + transformed.x / transformed.w, + transformed.y / transformed.w, + transformed.z / transformed.w, + ]; + ensure!( + position.iter().all(|value| value.is_finite()), + "glTF node transform overflowed a vertex" + ); + Ok(position) + }) + .collect::>>()?; + let linear = transform.fixed_view::<3, 3>(0, 0).into_owned(); + let determinant = linear.determinant(); + ensure!( + determinant.is_finite() && determinant != 0.0, + "glTF node transform is singular or overflowed" + ); + let normal_matrix: Matrix3 = linear + .try_inverse() + .context("glTF normal transform is singular")? + .transpose(); + let normals = primitive + .normals + .as_ref() + .map(|normals| { + normals + .iter() + .map(|normal| { + let transformed = normal_matrix * Vector3::new(normal[0], normal[1], normal[2]); + let norm = transformed.norm(); + ensure!( + norm.is_finite() && norm > 0.0, + "glTF normal is zero or overflowed after transform" + ); + let transformed = transformed / norm; + Ok([transformed.x, transformed.y, transformed.z]) + }) + .collect::>>() + }) + .transpose()?; + let mut indices = primitive.indices.clone(); + if determinant.is_sign_negative() { + for triangle in indices.as_chunks_mut::<3>().0 { + triangle.swap(1, 2); + } + } + Ok(DecodedPrimitive { + positions, + normals, + texcoords: primitive.texcoords.clone(), + indices, + material: primitive.material.clone(), + }) +} + +fn read_f32_vectors( + document: &serde_json::Value, + buffers: &[Vec], + views: &[BufferView], + index: usize, + expected_type: &str, +) -> Result> { + let (bytes, count, stride, component_type, accessor_type, normalized) = + accessor(document, buffers, views, index, N * 4)?; + ensure!(component_type == FLOAT, "accessor {index} is not FLOAT"); + ensure!( + accessor_type == expected_type, + "accessor {index} is not {expected_type}" + ); + ensure!(!normalized, "FLOAT accessor {index} may not be normalized"); + let vectors = (0..count) + .map(|element| { + let start = element * stride; + let mut vector = [0.0; N]; + for (component, value) in vector.iter_mut().enumerate() { + let offset = start + component * 4; + let parsed = f32::from_le_bytes(bytes[offset..offset + 4].try_into()?); + ensure!( + parsed.is_finite(), + "accessor {index} contains non-finite FLOAT" + ); + *value = f64::from(parsed); + } + Ok(vector) + }) + .collect::>>()?; + validate_declared_bounds(document, index, &vectors)?; + Ok(vectors) +} + +fn validate_declared_bounds( + document: &Value, + index: usize, + values: &[[f64; N]], +) -> Result<()> { + let accessor = document + .get("accessors") + .and_then(Value::as_array) + .and_then(|accessors| accessors.get(index)) + .and_then(Value::as_object) + .with_context(|| format!("glTF accessor {index} is absent or not an object"))?; + for (field, minimum) in [("min", true), ("max", false)] { + let Some(declared) = accessor.get(field) else { + continue; + }; + let declared = finite_array::(declared, &format!("accessor {field}"))?; + for component in 0..N { + let observed = values + .iter() + .map(|value| value[component]) + .reduce(if minimum { f64::min } else { f64::max }) + .context("zero-count accessor cannot declare bounds")?; + let tolerance = declared[component].abs().max(1.0) * f64::EPSILON * 4.0; + ensure!( + (observed - declared[component]).abs() <= tolerance, + "glTF accessor {index} {field}[{component}] is {}, decoded data is {observed}", + declared[component] + ); + } + } + Ok(()) +} + +fn read_indices( + document: &serde_json::Value, + buffers: &[Vec], + views: &[BufferView], + index: usize, +) -> Result> { + let accessors = document + .get("accessors") + .and_then(serde_json::Value::as_array) + .context("glTF accessors is not an array")?; + let metadata = accessors + .get(index) + .and_then(serde_json::Value::as_object) + .with_context(|| format!("glTF accessor {index} is absent or not an object"))?; + let component_type = required_u64(metadata.get("componentType"), "accessor componentType")?; + let width = match component_type { + UNSIGNED_BYTE => 1, + UNSIGNED_SHORT => 2, + UNSIGNED_INT => 4, + _ => bail!("index accessor {index} has unsupported componentType {component_type}"), + }; + let (bytes, count, stride, _, accessor_type, normalized) = + accessor(document, buffers, views, index, width)?; + ensure!( + accessor_type == "SCALAR", + "index accessor {index} is not SCALAR" + ); + ensure!(!normalized, "index accessor {index} may not be normalized"); + let indices = (0..count) + .map(|element| { + let start = element * stride; + Ok(match width { + 1 => u32::from(bytes[start]), + 2 => u32::from(u16::from_le_bytes(bytes[start..start + 2].try_into()?)), + 4 => u32::from_le_bytes(bytes[start..start + 4].try_into()?), + _ => unreachable!(), + }) + }) + .collect::>>()?; + for (field, minimum) in [("min", true), ("max", false)] { + let Some(declared) = metadata.get(field) else { + continue; + }; + let declared = finite_array::<1>(declared, &format!("index accessor {field}"))?[0]; + let observed = indices + .iter() + .copied() + .reduce(if minimum { u32::min } else { u32::max }) + .context("zero-count index accessor cannot declare bounds")?; + ensure!( + f64::from(observed) == declared, + "glTF index accessor {index} {field} does not match decoded data" + ); + } + Ok(indices) +} + +fn accessor<'a>( + document: &serde_json::Value, + buffers: &'a [Vec], + views: &[BufferView], + index: usize, + element_size: usize, +) -> Result> { + let accessors = document + .get("accessors") + .and_then(serde_json::Value::as_array) + .context("glTF accessors is not an array")?; + let accessor = accessors + .get(index) + .and_then(serde_json::Value::as_object) + .with_context(|| format!("glTF accessor {index} is absent or not an object"))?; + ensure_keys( + accessor, + &[ + "bufferView", + "byteOffset", + "componentType", + "count", + "extras", + "max", + "min", + "name", + "normalized", + "type", + ], + &format!("glTF accessor {index}"), + )?; + ensure!( + accessor.get("sparse").is_none(), + "sparse accessors are unsupported" + ); + let view_index = required_usize(accessor.get("bufferView"), "accessor bufferView")?; + let view = views + .get(view_index) + .with_context(|| format!("accessor {index} names absent bufferView {view_index}"))?; + let offset = optional_usize(accessor.get("byteOffset"), "accessor byteOffset")?.unwrap_or(0); + let count = required_usize(accessor.get("count"), "accessor count")?; + let component_type = required_u64(accessor.get("componentType"), "accessor componentType")?; + let component_width = match component_type { + UNSIGNED_BYTE => 1, + UNSIGNED_SHORT => 2, + UNSIGNED_INT | FLOAT => 4, + _ => bail!("accessor {index} has unsupported componentType {component_type}"), + }; + let accessor_type = accessor + .get("type") + .and_then(serde_json::Value::as_str) + .context("accessor type is not a string")? + .to_owned(); + let normalized = + optional_bool(accessor.get("normalized"), "accessor normalized")?.unwrap_or(false); + let stride = view.stride.unwrap_or(element_size); + ensure!( + stride >= element_size, + "accessor stride is smaller than its element" + ); + let absolute_offset = view + .offset + .checked_add(offset) + .context("accessor absolute byte offset overflowed")?; + ensure!( + stride.is_multiple_of(component_width) && absolute_offset.is_multiple_of(component_width), + "accessor {index} is not aligned to its component width" + ); + let required = if count == 0 { + offset + } else { + offset + .checked_add( + (count - 1) + .checked_mul(stride) + .context("accessor stride overflowed")?, + ) + .and_then(|end| end.checked_add(element_size)) + .context("accessor byte range overflowed")? + }; + ensure!( + required <= view.length, + "accessor {index} exceeds its bufferView" + ); + let buffer = &buffers[view.buffer]; + let start = absolute_offset; + Ok(( + &buffer[start..start + required.saturating_sub(offset)], + count, + stride, + component_type, + accessor_type, + normalized, + )) +} + +fn data_uri(uri: &str, required_prefix: Option<&str>) -> Result<(String, Vec)> { + let (metadata, payload) = uri + .strip_prefix("data:") + .and_then(|uri| uri.split_once(',')) + .context("URI is external or is not a data URI")?; + let mime = metadata + .strip_suffix(";base64") + .context("only base64 data URIs are supported")?; + if let Some(prefix) = required_prefix { + ensure!( + mime.starts_with(prefix), + "data URI mimeType '{mime}' is invalid" + ); + } + Ok((mime.to_owned(), decode_base64(payload)?)) +} + +fn decode_base64(value: &str) -> Result> { + ensure!( + value.len().is_multiple_of(4), + "base64 length is not divisible by four" + ); + let mut output = Vec::with_capacity(value.len() / 4 * 3); + for (chunk_index, chunk) in value.as_bytes().as_chunks::<4>().0.iter().enumerate() { + let last = chunk_index + 1 == value.len() / 4; + let a = base64_value(chunk[0])?; + let b = base64_value(chunk[1])?; + ensure!( + chunk[0] != b'=' && chunk[1] != b'=', + "invalid base64 padding" + ); + let c = if chunk[2] == b'=' { + 0 + } else { + base64_value(chunk[2])? + }; + let d = if chunk[3] == b'=' { + 0 + } else { + base64_value(chunk[3])? + }; + ensure!( + last || (chunk[2] != b'=' && chunk[3] != b'='), + "base64 padding appears before the final quantum" + ); + ensure!( + chunk[2] != b'=' || chunk[3] == b'=', + "invalid base64 padding order" + ); + ensure!( + chunk[2] != b'=' || b & 0x0f == 0, + "nonzero base64 padding bits" + ); + ensure!( + chunk[3] != b'=' || c & 0x03 == 0, + "nonzero base64 padding bits" + ); + output.push((a << 2) | (b >> 4)); + if chunk[2] != b'=' { + output.push((b << 4) | (c >> 2)); + } + if chunk[3] != b'=' { + output.push((c << 6) | d); + } + } + Ok(output) +} + +fn base64_value(value: u8) -> Result { + match value { + b'A'..=b'Z' => Ok(value - b'A'), + b'a'..=b'z' => Ok(value - b'a' + 26), + b'0'..=b'9' => Ok(value - b'0' + 52), + b'+' => Ok(62), + b'/' => Ok(63), + b'=' => Ok(0), + _ => bail!("invalid base64 character"), + } +} + +fn finite_array(value: &serde_json::Value, name: &str) -> Result<[f64; N]> { + let values = value + .as_array() + .with_context(|| format!("glTF {name} is not an array"))?; + ensure!(values.len() == N, "glTF {name} must have {N} values"); + let mut result = [0.0; N]; + for (target, value) in result.iter_mut().zip(values) { + *target = value + .as_f64() + .with_context(|| format!("glTF {name} contains a non-number"))?; + ensure!( + target.is_finite(), + "glTF {name} contains a non-finite number" + ); + } + Ok(result) +} + +fn finite_factor(value: Option<&serde_json::Value>, default: f64, name: &str) -> Result { + let value = value.map_or(Ok(default), |value| { + value + .as_f64() + .with_context(|| format!("glTF {name} is not a number")) + })?; + ensure!( + value.is_finite() && (0.0..=1.0).contains(&value), + "glTF {name} is outside [0, 1]" + ); + Ok(value) +} + +fn optional_bool(value: Option<&serde_json::Value>, name: &str) -> Result> { + value + .map(|value| { + value + .as_bool() + .with_context(|| format!("glTF {name} is not a boolean")) + }) + .transpose() +} + +fn required_usize(value: Option<&serde_json::Value>, name: &str) -> Result { + usize::try_from(required_u64(value, name)?) + .with_context(|| format!("glTF {name} exceeds usize")) +} + +fn optional_usize(value: Option<&serde_json::Value>, name: &str) -> Result> { + value + .map(|value| required_usize(Some(value), name)) + .transpose() +} + +fn required_u64(value: Option<&serde_json::Value>, name: &str) -> Result { + value + .and_then(serde_json::Value::as_u64) + .with_context(|| format!("glTF {name} is missing or not an unsigned integer")) +} + +fn ensure_keys(object: &Map, accepted: &[&str], name: &str) -> Result<()> { + for key in object.keys() { + ensure!( + accepted.contains(&key.as_str()), + "{name} contains unsupported field '{key}'" + ); + } + Ok(()) +} + +fn read_u32(bytes: &[u8], offset: usize) -> Result { + let end = offset + .checked_add(4) + .context("GLB integer offset overflowed")?; + Ok(u32::from_le_bytes( + bytes + .get(offset..end) + .context("GLB integer is truncated")? + .try_into()?, + )) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::io::Cursor; + use std::path::{Path, PathBuf}; + use std::process::{Command, Stdio}; + use std::thread; + use std::time::{Duration, Instant}; + + use super::*; + + fn glb(json: &serde_json::Value, binary: Option<&[u8]>) -> Vec { + let mut json = serde_json::to_vec(json).expect("JSON"); + while !json.len().is_multiple_of(4) { + json.push(b' '); + } + let binary_length = binary.map_or(0, |binary| 8 + binary.len().div_ceil(4) * 4); + let total = 12 + 8 + json.len() + binary_length; + let mut bytes = Vec::new(); + bytes.extend_from_slice(MAGIC); + bytes.extend_from_slice(&2_u32.to_le_bytes()); + bytes.extend_from_slice(&u32::try_from(total).expect("length").to_le_bytes()); + bytes.extend_from_slice(&u32::try_from(json.len()).expect("length").to_le_bytes()); + bytes.extend_from_slice(&JSON_CHUNK.to_le_bytes()); + bytes.extend_from_slice(&json); + if let Some(binary) = binary { + let padded = binary.len().div_ceil(4) * 4; + bytes.extend_from_slice(&u32::try_from(padded).expect("length").to_le_bytes()); + bytes.extend_from_slice(&BIN_CHUNK.to_le_bytes()); + bytes.extend_from_slice(binary); + bytes.resize(total, 0); + } + bytes + } + + fn triangle_document(buffer: serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "asset": { "version": "2.0" }, + "scene": 0, + "scenes": [{ "nodes": [0] }], + "nodes": [{ "mesh": 0 }], + "meshes": [{ "primitives": [{ "attributes": { "POSITION": 0 }, "indices": 1 }] }], + "buffers": [buffer], + "bufferViews": [ + { "buffer": 0, "byteOffset": 0, "byteLength": 36 }, + { "buffer": 0, "byteOffset": 36, "byteLength": 6 } + ], + "accessors": [ + { "bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3" }, + { "bufferView": 1, "componentType": 5123, "count": 3, "type": "SCALAR" } + ] + }) + } + + fn triangle_bytes() -> Vec { + let mut bytes = Vec::new(); + for value in [0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0] { + bytes.extend_from_slice(&value.to_le_bytes()); + } + for index in [0_u16, 1, 2] { + bytes.extend_from_slice(&index.to_le_bytes()); + } + bytes + } + + #[test] + fn strict_container_correlates_buffer_zero_and_bin_padding() { + let binary = triangle_bytes(); + let document = triangle_document(serde_json::json!({ "byteLength": binary.len() })); + DecodedMesh::decode(&glb(&document, Some(&binary))).expect("closed triangle"); + + let missing = glb(&document, None); + assert!(DecodedMesh::decode(&missing).is_err()); + let short = triangle_document(serde_json::json!({ "byteLength": binary.len() + 3 })); + assert!(DecodedMesh::decode(&glb(&short, Some(&binary))).is_err()); + let embedded = triangle_document(serde_json::json!({ + "byteLength": binary.len(), + "uri": format!("data:application/octet-stream;base64,{}", encode_base64(&binary)) + })); + DecodedMesh::decode(&glb(&embedded, None)).expect("embedded buffer data URI"); + assert!(DecodedMesh::decode(&glb(&embedded, Some(&binary))).is_err()); + + let too_much_padding = triangle_document(serde_json::json!({ + "byteLength": binary.len() - 4 + })); + assert!(DecodedMesh::decode(&glb(&too_much_padding, Some(&binary))).is_err()); + + let mut nonzero_padding = binary.clone(); + nonzero_padding[41] = 1; + let nonzero_padding_document = triangle_document(serde_json::json!({ + "byteLength": binary.len() - 1 + })); + assert!( + DecodedMesh::decode(&glb(&nonzero_padding_document, Some(&nonzero_padding))).is_err() + ); + } + + #[test] + fn collision_triangles_require_three_distinct_points() { + let mut binary = triangle_bytes(); + let first = binary[..12].to_vec(); + binary[12..24].copy_from_slice(&first); + let document = triangle_document(serde_json::json!({ "byteLength": binary.len() })); + let decoded = DecodedMesh::decode(&glb(&document, Some(&binary))) + .expect("degenerate visual geometry still decodes"); + let error = decoded + .validate_collision() + .expect_err("coincident collision vertices are rejected"); + assert!(error.to_string().contains("coincident vertices")); + } + + #[test] + fn strict_container_requires_json_first_and_rejects_unknown_chunks() { + let document = triangle_document(serde_json::json!({ + "byteLength": 42, + "uri": format!( + "data:application/octet-stream;base64,{}", + encode_base64(&triangle_bytes()) + ) + })); + let mut misordered = glb(&document, None); + misordered[16..20].copy_from_slice(&BIN_CHUNK.to_le_bytes()); + assert!(container(&misordered).is_err()); + + let mut unknown = glb(&document, None); + unknown.extend_from_slice(&0_u32.to_le_bytes()); + unknown.extend_from_slice(&0x1234_5678_u32.to_le_bytes()); + let length = u32::try_from(unknown.len()).expect("test length"); + unknown[8..12].copy_from_slice(&length.to_le_bytes()); + assert!(container(&unknown).is_err()); + + let mut json = serde_json::to_vec(&document).expect("JSON"); + while !json.len().is_multiple_of(4) { + json.push(b' '); + } + json.extend_from_slice(b" "); + let total = 12 + 8 + json.len(); + let mut overpadded = Vec::new(); + overpadded.extend_from_slice(MAGIC); + overpadded.extend_from_slice(&2_u32.to_le_bytes()); + overpadded.extend_from_slice(&u32::try_from(total).expect("length").to_le_bytes()); + overpadded.extend_from_slice(&u32::try_from(json.len()).expect("length").to_le_bytes()); + overpadded.extend_from_slice(&JSON_CHUNK.to_le_bytes()); + overpadded.extend_from_slice(&json); + assert!(container(&overpadded).is_err()); + } + + #[test] + fn buffers_are_typed_nonempty_and_byte_length_is_exact() { + let binary = triangle_bytes(); + for buffers in [ + serde_json::json!([]), + serde_json::json!([null]), + serde_json::json!([{}]), + serde_json::json!([{ "byteLength": "42" }]), + ] { + let mut document = triangle_document(serde_json::json!({ + "byteLength": binary.len() + })); + document["buffers"] = buffers; + assert!(DecodedMesh::decode(&glb(&document, Some(&binary))).is_err()); + } + } + + #[test] + fn external_uris_and_invalid_images_are_rejected() { + let mut document = triangle_document(serde_json::json!({ + "byteLength": 42, + "uri": "other.bin" + })); + assert!(DecodedMesh::decode(&glb(&document, None)).is_err()); + document["buffers"][0] = serde_json::json!({ + "byteLength": 42, + "uri": format!("data:application/octet-stream;base64,{}", encode_base64(&triangle_bytes())) + }); + document["images"] = serde_json::json!([{ + "uri": format!( + "data:image/png;base64,{}", + encode_base64(b"\x89PNG\r\n\x1a\ntruncated") + ) + }]); + assert!(DecodedMesh::decode(&glb(&document, None)).is_err()); + } + + #[test] + fn embedded_png_and_jpeg_are_fully_decoded_and_mime_checked() { + for (mime, format) in [ + ("image/png", ImageFormat::Png), + ("image/jpeg", ImageFormat::Jpeg), + ] { + let mut bytes = Cursor::new(Vec::new()); + image::DynamicImage::new_rgb8(1, 1) + .write_to(&mut bytes, format) + .expect("test image encodes"); + let mut document = triangle_document(serde_json::json!({ + "byteLength": 42, + "uri": format!( + "data:application/octet-stream;base64,{}", + encode_base64(&triangle_bytes()) + ) + })); + document["images"] = serde_json::json!([{ + "mimeType": mime, + "uri": format!("data:{mime};base64,{}", encode_base64(bytes.get_ref())) + }]); + let decoded = DecodedMesh::decode(&glb(&document, None)) + .expect("decodable embedded image is accepted"); + assert_eq!(decoded.images.len(), 1); + + document["images"][0]["mimeType"] = serde_json::json!(if mime == "image/png" { + "image/jpeg" + } else { + "image/png" + }); + assert!(DecodedMesh::decode(&glb(&document, None)).is_err()); + } + } + + #[test] + fn embedded_texture_renders_and_extracts_with_native_uv_coordinates() { + let mut png = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 1, + 1, + image::Rgba([128, 64, 255, 128]), + )) + .write_to(&mut png, ImageFormat::Png) + .expect("test PNG encodes"); + let mut binary = triangle_bytes(); + binary.extend_from_slice(&[0, 0]); + for value in [0.0_f32, 0.0, 1.0, 0.0, 0.0, 1.0] { + binary.extend_from_slice(&value.to_le_bytes()); + } + let mut document = triangle_document(serde_json::json!({ + "byteLength": binary.len() + })); + document["bufferViews"] + .as_array_mut() + .expect("views") + .push(serde_json::json!({ + "buffer": 0, + "byteOffset": 44, + "byteLength": 24 + })); + document["accessors"] + .as_array_mut() + .expect("accessors") + .push(serde_json::json!({ + "bufferView": 2, + "componentType": 5126, + "count": 3, + "type": "VEC2" + })); + document["images"] = serde_json::json!([{ + "uri": format!( + "data:image/png;base64,{}", + encode_base64(png.get_ref()) + ) + }]); + document["samplers"] = serde_json::json!([{ "wrapS": 33071, "wrapT": 10497 }]); + document["textures"] = serde_json::json!([{ "source": 0, "sampler": 0 }]); + document["materials"] = serde_json::json!([{ + "alphaMode": "BLEND", + "pbrMetallicRoughness": { + "baseColorFactor": [0.5, 0.25, 0.75, 0.5], + "baseColorTexture": { "index": 0 } + } + }]); + document["meshes"][0]["primitives"][0]["attributes"]["TEXCOORD_0"] = serde_json::json!(2); + document["meshes"][0]["primitives"][0]["material"] = serde_json::json!(0); + + let decoded = DecodedMesh::decode(&glb(&document, Some(&binary))) + .expect("textured primitive decodes"); + let mut source = String::from("Group { children [\n"); + decoded + .render_visual(&mut source, 2, |image| Ok(format!("textures/{image}.png"))) + .expect("textured geometry renders"); + source.push_str("] }\n"); + assert!(source.contains("baseColorMap ImageTexture")); + assert!(source.contains("baseColor 1 1 1")); + assert!(!source.contains("transparency")); + assert!(source.contains("url [\"textures/0.png\"]")); + assert!(source.contains("repeatS FALSE")); + assert!(source.contains("repeatT TRUE")); + assert!(source.contains("texCoord TextureCoordinate")); + assert!( + source.contains("0 1"), + "glTF V is flipped into Webots UV space" + ); + let _: webots_proto_ast::Proto = source.parse().expect("textured source parses"); + + let staged = tempfile::tempdir().expect("texture staging root"); + crate::generation::stage_decoded_images(staged.path(), "fixture.glb", &decoded) + .expect("texture extracts"); + let extracted = staged.path().join("fixture.glb.images/0.png"); + let extracted = image::load_from_memory_with_format( + &fs::read(extracted).expect("extracted PNG"), + ImageFormat::Png, + ) + .expect("extracted PNG decodes") + .to_rgba8(); + assert_eq!(extracted.get_pixel(0, 0).0, [92, 30, 225, 64]); + + document["materials"][0]["alphaMode"] = serde_json::json!("OPAQUE"); + let opaque = DecodedMesh::decode(&glb(&document, Some(&binary))) + .expect("opaque textured primitive decodes") + .staged_texture(0) + .expect("opaque texture bakes") + .expect("textured primitive has staged texture"); + let opaque = image::load_from_memory_with_format(&opaque, ImageFormat::Png) + .expect("opaque texture decodes"); + assert!(!opaque.color().has_alpha()); + let opaque = opaque.to_rgba8(); + assert_eq!(opaque.get_pixel(0, 0).0, [92, 30, 225, 255]); + } + + #[test] + fn selected_primitives_must_have_vertices_and_triangles() { + let binary = triangle_bytes(); + let mut document = triangle_document(serde_json::json!({ + "byteLength": binary.len() + })); + document["accessors"][0]["count"] = serde_json::json!(0); + assert!(DecodedMesh::decode(&glb(&document, Some(&binary))).is_err()); + + let mut document = triangle_document(serde_json::json!({ + "byteLength": binary.len() + })); + document["accessors"][1]["count"] = serde_json::json!(0); + assert!(DecodedMesh::decode(&glb(&document, Some(&binary))).is_err()); + } + + #[test] + fn selected_node_transform_is_baked_into_positions() { + let binary = triangle_bytes(); + let mut document = triangle_document(serde_json::json!({ + "byteLength": binary.len() + })); + document["nodes"][0]["translation"] = serde_json::json!([1.0, 2.0, 3.0]); + document["nodes"][0]["scale"] = serde_json::json!([2.0, 3.0, 4.0]); + let decoded = DecodedMesh::decode(&glb(&document, Some(&binary))) + .expect("transformed primitive decodes"); + assert_eq!( + decoded.primitives[0].positions, + vec![[1.0, 2.0, 3.0], [3.0, 2.0, 3.0], [1.0, 5.0, 3.0]] + ); + } + + #[test] + fn finite_authored_transforms_must_not_overflow_native_geometry() { + let binary = triangle_bytes(); + let mut document = triangle_document(serde_json::json!({ + "byteLength": binary.len() + })); + document["nodes"] = serde_json::json!([ + { "translation": [1.0e308, 0.0, 0.0], "children": [1] }, + { "translation": [1.0e308, 0.0, 0.0], "mesh": 0 } + ]); + assert!(DecodedMesh::decode(&glb(&document, Some(&binary))).is_err()); + + document["nodes"] = serde_json::json!([{ "scale": [2.0, 2.0, 2.0], "mesh": 0 }]); + let decoded = + DecodedMesh::decode(&glb(&document, Some(&binary))).expect("finite visual positions"); + assert!( + decoded + .render_collision_scaled(&mut String::new(), 0, [f64::MAX; 3]) + .is_err() + ); + } + + #[test] + fn scene_depth_is_bounded_before_recursive_expansion() { + let binary = triangle_bytes(); + let mut document = triangle_document(serde_json::json!({ "byteLength": binary.len() })); + let mut nodes: Vec<_> = (0..MAX_NODE_DEPTH) + .map(|index| serde_json::json!({ "children": [index + 1] })) + .collect(); + nodes.push(serde_json::json!({ "mesh": 0 })); + document["nodes"] = serde_json::json!(nodes); + let error = DecodedMesh::decode(&glb(&document, Some(&binary))).expect_err("bounded depth"); + assert!(error.to_string().contains("nested nodes")); + } + + #[test] + fn double_sided_material_emits_a_reversed_native_backface() { + let binary = triangle_bytes(); + let mut document = triangle_document(serde_json::json!({ + "byteLength": binary.len() + })); + document["materials"] = serde_json::json!([{ "doubleSided": true }]); + document["meshes"][0]["primitives"][0]["material"] = serde_json::json!(0); + let decoded = DecodedMesh::decode(&glb(&document, Some(&binary))) + .expect("double-sided primitive decodes"); + let mut source = String::new(); + decoded + .render_visual(&mut source, 0, |_| bail!("fixture has no texture")) + .expect("double-sided primitive renders"); + assert!(source.contains("0 1 2 -1")); + assert!(source.contains("3 5 4 -1")); + assert!(!source.contains("solid ")); + } + + #[test] + fn unknown_semantic_fields_and_malformed_inactive_clearcoat_are_rejected() { + let binary = triangle_bytes(); + for (array, index) in [ + ("buffers", 0), + ("bufferViews", 0), + ("accessors", 0), + ("meshes", 0), + ("scenes", 0), + ("nodes", 0), + ] { + let mut document = triangle_document(serde_json::json!({ + "byteLength": binary.len() + })); + document[array][index]["typo"] = serde_json::json!(true); + assert!(DecodedMesh::decode(&glb(&document, Some(&binary))).is_err()); + } + + let mut primitive = triangle_document(serde_json::json!({ + "byteLength": binary.len() + })); + primitive["meshes"][0]["primitives"][0]["typo"] = serde_json::json!(true); + assert!( + DecodedMesh::decode(&glb(&primitive, Some(&binary))) + .expect_err("primitive typo") + .to_string() + .contains("unsupported field") + ); + + for mutation in [ + serde_json::json!({ "images": [{ "typo": true }] }), + serde_json::json!({ "samplers": [{ "typo": true }] }), + serde_json::json!({ "textures": [{ "typo": true }] }), + serde_json::json!({ "materials": [{ "typo": true }] }), + serde_json::json!({ + "materials": [{ "pbrMetallicRoughness": { "typo": true } }] + }), + serde_json::json!({ + "extensionsUsed": ["KHR_materials_clearcoat"], + "materials": [{ + "extensions": { "KHR_materials_clearcoat": { "typo": true } } + }] + }), + ] { + let mut document = triangle_document(serde_json::json!({ + "byteLength": binary.len() + })); + for (key, value) in mutation.as_object().expect("mutation object") { + document[key] = value.clone(); + } + assert!( + DecodedMesh::decode(&glb(&document, Some(&binary))) + .expect_err("semantic typo") + .to_string() + .contains("unsupported field") + ); + } + + let mut document = triangle_document(serde_json::json!({ + "byteLength": binary.len() + })); + document["materials"] = serde_json::json!([{ + "extensions": { "KHR_materials_clearcoat": { + "clearcoatFactor": 0, + "clearcoatRoughnessFactor": 2 + } } + }]); + document["extensionsUsed"] = serde_json::json!(["KHR_materials_clearcoat"]); + document["meshes"][0]["primitives"][0]["material"] = serde_json::json!(0); + assert!(DecodedMesh::decode(&glb(&document, Some(&binary))).is_err()); + } + + #[test] + fn required_framework_glbs_decode_to_webots_native_geometry() { + for (name, bytes) in [ + ( + "ddsm115", + &include_bytes!("../../../../components/ddsm115/meshes/ddsm115.glb")[..], + ), + ( + "drive_motor", + &include_bytes!( + "../../../../fixture/components/drive_motor/meshes/drive_motor.glb" + )[..], + ), + ] { + let decoded = DecodedMesh::decode(bytes) + .unwrap_or_else(|error| panic!("{name} must decode: {error:#}")); + assert!(!decoded.primitives.is_empty()); + assert!( + decoded + .primitives + .iter() + .all(|primitive| !primitive.positions.is_empty()) + ); + let mut source = String::from("Group { children [\n"); + decoded + .render_visual(&mut source, 2, |_| bail!("fixture has no image")) + .expect("native visual renders"); + source.push_str("] }\n"); + assert!(source.contains("IndexedFaceSet")); + assert!(!source.contains("CadShape")); + assert!(!source.contains("url [\"")); + let _: webots_proto_ast::Proto = source + .parse() + .unwrap_or_else(|error| panic!("{name} native geometry parses: {error}")); + } + } + + #[test] + #[ignore = "requires an installed Webots R2025a runtime"] + fn installed_webots_loads_native_decoded_geometry_without_asset_warnings() { + let webots = webots_executable().expect("WEBOTS_HOME or installed Webots R2025a"); + let output = Command::new(&webots) + .arg("--version") + .output() + .expect("Webots version runs"); + let version = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + output.status.success() && version.contains("R2025a"), + "native renderer proof requires runnable Webots R2025a; {} returned {}:\n{version}", + webots.display(), + output.status + ); + + let bundle = crate::generation::tests::compile_mesh_world( + include_bytes!("../../../../fixture/components/drive_motor/meshes/drive_motor.glb"), + None, + ); + let project = tempfile::tempdir().expect("temporary Webots project"); + let root = project.path().join("generated"); + let executable = std::env::current_exe().expect("test executable"); + let generated = crate::generation::stage_project( + &bundle, + &root, + "tcp://127.0.0.1:7000", + &crate::generation::ControllerExecutables { + world: executable.clone(), + robot: executable, + }, + ) + .expect("production project staging"); + let world = fs::read_to_string(generated.world()) + .expect("generated world") + .replace(crate::WORLD_CONTROLLER_PACKAGE, "renderer_probe"); + assert!(!world.contains("CadShape")); + assert!(!world.contains("url [\"")); + let _: webots_proto_ast::Proto = world.parse().expect("probe world parses"); + + let worlds = root.join("worlds"); + let controller = root.join("controllers/renderer_probe"); + fs::create_dir_all(&worlds).expect("worlds directory"); + fs::create_dir_all(&controller).expect("controller directory"); + fs::write(worlds.join("native_renderer.wbt"), world).expect("probe world"); + let controller_path = controller.join("renderer_probe.py"); + let imported_controller = root.join("controllers/import_probe"); + fs::create_dir_all(&imported_controller).expect("imported controller directory"); + fs::write( + imported_controller.join("import_probe.py"), + r#"from controller import Robot +from pathlib import Path +import time +robot = Robot() +Path("started").write_text("ready") +while True: + robot.step(0) + time.sleep(0.01) +"#, + ) + .expect("imported controller"); + fs::write( + &controller_path, + r#"from controller import Supervisor +from pathlib import Path +import time +supervisor = Supervisor() +supervisor.simulationSetMode(Supervisor.SIMULATION_MODE_PAUSE) +supervisor.step(0) +before = supervisor.getTime() +supervisor.getRoot().getField("children").importMFNodeFromString(-1, 'DEF IMPORT_PROBE Robot { controller "import_probe" synchronization TRUE }') +supervisor.step(0) +time.sleep(0.05) +assert not Path("../import_probe/started").exists() +supervisor.simulationSetMode(Supervisor.SIMULATION_MODE_REAL_TIME) +print("PHOXAL_MODE_BEFORE_FLUSH", supervisor.simulationGetMode(), flush=True) +supervisor.step(0) +print("PHOXAL_MODE_AFTER_FLUSH", supervisor.simulationGetMode(), flush=True) +assert supervisor.getTime() == before +assert supervisor.simulationGetMode() == Supervisor.SIMULATION_MODE_REAL_TIME +deadline = time.monotonic() + 5 +while not Path("../import_probe/started").exists(): + assert time.monotonic() < deadline, "imported native controller did not start" + supervisor.step(0) + assert supervisor.getTime() == before, "controller startup advanced physics" + time.sleep(0.01) +supervisor.simulationSetMode(Supervisor.SIMULATION_MODE_PAUSE) +supervisor.step(0) +assert supervisor.getTime() == before +supervisor.getFromDef("IMPORT_PROBE").remove() +supervisor.step(0) +print("PHOXAL_ZERO_TIME_IMPORT_OK", flush=True) +supervisor.simulationSetMode(Supervisor.SIMULATION_MODE_REAL_TIME) +supervisor.step(0) +probe = supervisor.getFromDef("PHOXAL_EXHIBIT_0") +children = probe.getField("children") if probe is not None else None +bounding = probe.getField("boundingObject").getSFNode() if probe is not None else None +if children is None or children.getCount() <= 0 or bounding is None: + print("PHOXAL_NATIVE_GEOMETRY_MISSING", flush=True) + supervisor.simulationQuit(2) +else: + print("PHOXAL_NATIVE_GEOMETRY_OK", flush=True) + supervisor.step(int(supervisor.getBasicTimeStep())) + supervisor.simulationQuit(0) +"#, + ) + .expect("probe controller"); + + let port = std::net::TcpListener::bind("127.0.0.1:0") + .expect("available native port") + .local_addr() + .expect("native port address") + .port(); + let mut child = Command::new(webots) + .args([ + "--batch", + "--no-rendering", + "--mode=fast", + "--stdout", + "--stderr", + ]) + .arg(format!("--port={port}")) + .arg(worlds.join("native_renderer.wbt")) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("Webots starts"); + let deadline = Instant::now() + Duration::from_secs(45); + loop { + if child.try_wait().expect("Webots status").is_some() { + break; + } + if Instant::now() >= deadline { + child.kill().expect("terminate hung Webots proof"); + panic!("Webots native renderer proof exceeded 45 seconds"); + } + thread::sleep(Duration::from_millis(100)); + } + let output = child.wait_with_output().expect("Webots output"); + let combined = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.status.success(), "Webots failed:\n{combined}"); + assert!( + combined.contains("PHOXAL_NATIVE_GEOMETRY_OK"), + "supervisor did not prove emitted geometry:\n{combined}" + ); + for line in combined.lines().filter(|line| line.contains("WARNING:")) { + assert!( + is_software_renderer_warning(line, &combined), + "Webots reported an unexpected warning:\n{combined}" + ); + eprintln!("native geometry proof used software rendering: {line}"); + } + for rejected in [ + "ERROR:", + "Invalid URL", + "Invalid data", + "invalid IndexedFaceSet", + "PHOXAL_NATIVE_GEOMETRY_MISSING", + ] { + assert!( + !combined.contains(rejected), + "Webots reported asset failure '{rejected}':\n{combined}" + ); + } + } + + // Headless Linux uses Mesa software rendering. Its exact performance + // notice is not an asset-loader diagnostic or visual-quality acceptance. + fn is_software_renderer_warning(line: &str, output: &str) -> bool { + let mut message = line.trim(); + while let Some(rest) = message.strip_prefix("WARNING:") { + message = rest.trim_start(); + } + message == "System below the minimal requirements." + && output.contains("GPU vendor is 'Mesa'") + && output.contains("slow 3D software rendering system") + } + + #[test] + fn native_geometry_proof_rejects_asset_warnings_despite_software_rendering() { + let software = "GPU vendor is 'Mesa'; slow 3D software rendering system"; + for line in [ + "WARNING: System below the minimal requirements.", + "WARNING: WARNING: System below the minimal requirements.", + ] { + assert!(is_software_renderer_warning(line, software)); + assert!(!is_software_renderer_warning(line, "hardware renderer")); + } + for line in [ + "WARNING: Invalid URL", + "WARNING: invalid IndexedFaceSet", + "WARNING: System below the minimal requirements. Invalid URL", + ] { + assert!(!is_software_renderer_warning(line, software)); + } + } + + fn webots_executable() -> Option { + let configured = std::env::var_os("WEBOTS_HOME").map(PathBuf::from); + let candidates = configured + .iter() + .flat_map(|home| [home.join("webots"), home.join("Contents/MacOS/webots")]) + .chain([ + PathBuf::from("/Applications/Webots.app/Contents/MacOS/webots"), + PathBuf::from("/usr/local/webots/webots"), + PathBuf::from("/usr/bin/webots"), + ]); + candidates + .into_iter() + .find(|path| Path::new(path).is_file()) + } + + fn encode_base64(bytes: &[u8]) -> String { + const ALPHABET: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut encoded = String::new(); + for chunk in bytes.chunks(3) { + let a = chunk[0]; + let b = chunk.get(1).copied().unwrap_or(0); + let c = chunk.get(2).copied().unwrap_or(0); + encoded.push(char::from(ALPHABET[usize::from(a >> 2)])); + encoded.push(char::from(ALPHABET[usize::from((a & 0x03) << 4 | b >> 4)])); + encoded.push(if chunk.len() > 1 { + char::from(ALPHABET[usize::from((b & 0x0f) << 2 | c >> 6)]) + } else { + '=' + }); + encoded.push(if chunk.len() > 2 { + char::from(ALPHABET[usize::from(c & 0x3f)]) + } else { + '=' + }); + } + encoded + } +} diff --git a/simulators/webots/host/src/lifecycle.rs b/simulators/webots/host/src/lifecycle.rs new file mode 100644 index 00000000..f4dc3257 --- /dev/null +++ b/simulators/webots/host/src/lifecycle.rs @@ -0,0 +1,563 @@ +//! Webots installation validation and owned native process lifecycle. + +use std::io::{Read, Write}; +use std::num::NonZeroI32; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::Duration; + +use anyhow::{Context, Result, bail, ensure}; +pub use phoxal::world::api::session::document::NativeProcessIdentity; + +const SUPPORTED_WEBOTS_VERSION: &str = "R2025a"; +const GRACEFUL_BUDGET: Duration = Duration::from_secs(20); +const KILL_BUDGET: Duration = Duration::from_secs(2); +const POLL_INTERVAL: Duration = Duration::from_millis(100); + +/// One validated local Webots installation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WebotsInstallation { + home: PathBuf, + executable: PathBuf, + version: String, +} + +impl WebotsInstallation { + /// Discover Webots R2025a from `WEBOTS_HOME` or its platform default. + pub fn discover() -> Result { + let home = std::env::var_os("WEBOTS_HOME") + .map(PathBuf::from) + .unwrap_or_else(default_webots_home); + Self::at(home) + } + + /// Validate one explicit Webots home. + pub fn at(home: PathBuf) -> Result { + let executable = executable_in(&home); + ensure!( + executable.is_file(), + "Webots executable is missing at {}; install Webots {} or set WEBOTS_HOME", + executable.display(), + SUPPORTED_WEBOTS_VERSION + ); + let output = Command::new(&executable) + .arg("--version") + .output() + .with_context(|| format!("failed to run {} --version", executable.display()))?; + ensure!( + output.status.success(), + "{} --version failed with {}", + executable.display(), + output.status + ); + let text = + String::from_utf8(output.stdout).context("Webots version output is not UTF-8")?; + let version = parse_version(&text)?; + ensure!( + version == SUPPORTED_WEBOTS_VERSION, + "unsupported Webots version {version}; this framework train requires {SUPPORTED_WEBOTS_VERSION}" + ); + Ok(Self { + home, + executable, + version: version.to_owned(), + }) + } + + #[must_use] + pub fn executable(&self) -> &Path { + &self.executable + } + + #[must_use] + pub fn version(&self) -> &str { + &self.version + } +} + +/// The Webots process tree owned by one world host. +#[derive(Debug)] +pub struct WebotsProcess { + child: Child, + executable: PathBuf, + process_group: Option, + log: Arc>, + readers: Vec>, +} + +/// Kill-and-reap ownership installed immediately after `spawn` succeeds. +/// +/// Launch still has to acquire both output pipes and start their reader threads. +/// Keeping the child in this guard until all of that setup succeeds prevents any +/// intermediate error from orphaning Webots or its process group. +struct SpawnGuard { + child: Option, + process_group: Option, +} + +#[derive(Debug)] +struct LogState { + file: std::fs::File, + limit: u64, + written: u64, + truncated: bool, + error: Option, +} + +/// Final result of draining the bounded Webots output capture. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LogCaptureOutcome { + pub bytes: u64, + pub truncated: bool, +} + +impl WebotsProcess { + pub fn identity(&self) -> Result { + Ok(NativeProcessIdentity { + process: crate::registration::process_identity(self.child.id())?, + executable: self.executable.clone(), + process_group: self + .process_group + .map(|group| u32::try_from(group.get())) + .transpose() + .context("Webots process group is negative")?, + }) + } + + /// Launch one generated world in real time for its controller-owned bootstrap. + /// + /// The world controller immediately enters `PAUSE` before its first `wb_robot_step`, reports + /// readiness, and stays outside that call while paused so host directives remain observable. + pub fn launch( + installation: &WebotsInstallation, + world: &Path, + log: &Path, + log_byte_limit: u64, + no_rendering: bool, + ) -> Result { + ensure!( + world.is_file(), + "generated Webots world is missing at {}", + world.display() + ); + let parent = log.parent().context("Webots log path has no parent")?; + std::fs::create_dir_all(parent).with_context(|| { + format!("failed to create Webots log directory {}", parent.display()) + })?; + ensure!(log_byte_limit > 0, "Webots log byte limit must be positive"); + let output = owner_log_file(log) + .with_context(|| format!("failed to create Webots log {}", log.display()))?; + let log_state = Arc::new(Mutex::new(LogState { + file: output, + limit: log_byte_limit, + written: 0, + truncated: false, + error: None, + })); + // Each native instance needs its own auxiliary Webots port even though + // Phoxal never uses the external-controller or robot-window protocol. + let port = std::net::TcpListener::bind("127.0.0.1:0") + .context("failed to select an available native Webots port")? + .local_addr()? + .port(); + let args = launch_args(world, no_rendering, port); + let executable = installation + .executable() + .canonicalize() + .context("failed to canonicalize the Webots executable")?; + let mut command = Command::new(&executable); + command + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + let child = command.spawn().with_context(|| { + format!( + "failed to launch Webots {} for {}", + installation.version(), + world.display() + ) + })?; + let mut spawned = SpawnGuard::new(child); + #[cfg(unix)] + let process_group = Some( + NonZeroI32::new( + libc::pid_t::try_from(spawned.child()?.id()) + .context("Webots process id does not fit in a process-group id")?, + ) + .context("Webots process id must be positive")?, + ); + #[cfg(not(unix))] + let process_group = None; + spawned.set_process_group(process_group); + let stdout = spawned + .child_mut()? + .stdout + .take() + .context("Webots stdout pipe is missing")?; + let stderr = spawned + .child_mut()? + .stderr + .take() + .context("Webots stderr pipe is missing")?; + let readers = vec![ + spawn_log_reader("webots-stdout", stdout, Arc::clone(&log_state))?, + spawn_log_reader("webots-stderr", stderr, Arc::clone(&log_state))?, + ]; + let (child, process_group) = spawned.disarm()?; + Ok(Self { + child, + executable, + process_group, + log: log_state, + readers, + }) + } + + /// Observe whether the direct Webots process has exited. + pub fn exited(&mut self) -> Result> { + Ok(self.child.try_wait()?) + } + + /// Stop Webots gracefully, then kill only its owned process tree if needed. + pub async fn stop(mut self) -> Result { + #[cfg(unix)] + { + let process_group = self + .process_group + .context("Webots process group ownership was already released")?; + for (signal, budget) in [ + (libc::SIGTERM, GRACEFUL_BUDGET), + (libc::SIGKILL, KILL_BUDGET), + ] { + signal_process_group(process_group, signal)?; + let deadline = tokio::time::Instant::now() + budget; + loop { + if !process_group_alive(&mut self.child, process_group)? { + self.process_group = None; + return self.finish_log_capture(); + } + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + tokio::time::sleep(POLL_INTERVAL.min(remaining)).await; + } + } + bail!("Webots process group remained alive after SIGKILL") + } + #[cfg(not(unix))] + { + self.child.kill().context("failed to stop Webots")?; + self.child.wait().context("failed to reap Webots")?; + self.finish_log_capture() + } + } + + fn finish_log_capture(&mut self) -> Result { + for reader in self.readers.drain(..) { + reader + .join() + .map_err(|_| anyhow::anyhow!("Webots log capture thread panicked"))?; + } + let state = lock(&self.log); + if let Some(error) = &state.error { + bail!("Webots log capture failed: {error}"); + } + state + .file + .sync_all() + .context("failed to persist Webots log")?; + Ok(LogCaptureOutcome { + bytes: state.written, + truncated: state.truncated, + }) + } +} + +impl SpawnGuard { + fn new(child: Child) -> Self { + Self { + child: Some(child), + process_group: None, + } + } + + fn child(&self) -> Result<&Child> { + self.child + .as_ref() + .context("Webots launch ownership was already released") + } + + fn child_mut(&mut self) -> Result<&mut Child> { + self.child + .as_mut() + .context("Webots launch ownership was already released") + } + + fn set_process_group(&mut self, process_group: Option) { + self.process_group = process_group; + } + + fn disarm(mut self) -> Result<(Child, Option)> { + let child = self + .child + .take() + .context("Webots launch ownership was already released")?; + Ok((child, self.process_group.take())) + } +} + +impl Drop for SpawnGuard { + fn drop(&mut self) { + #[cfg(unix)] + if let Some(process_group) = self.process_group.take() { + let _ = signal_process_group(process_group, libc::SIGKILL); + } + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +impl Drop for WebotsProcess { + fn drop(&mut self) { + #[cfg(unix)] + if let Some(process_group) = self.process_group.take() { + let _ = signal_process_group(process_group, libc::SIGKILL); + } + let _ = self.child.kill(); + let _ = self.child.wait(); + for reader in self.readers.drain(..) { + let _ = reader.join(); + } + } +} + +fn spawn_log_reader( + name: &str, + reader: impl std::io::Read + Send + 'static, + state: Arc>, +) -> Result> { + Ok(std::thread::Builder::new() + .name(name.to_owned()) + .spawn(move || capture_log(reader, &state))?) +} + +fn capture_log(mut reader: impl Read, state: &Arc>) { + let mut buffer = [0_u8; 8192]; + loop { + let bytes = match reader.read(&mut buffer) { + Ok(0) => return, + Ok(bytes) => bytes, + Err(error) => { + lock(state).error = Some(error.to_string()); + return; + } + }; + let mut state = lock(state); + let remaining = state.limit.saturating_sub(state.written); + let retained = usize::try_from(remaining.min(bytes as u64)).unwrap_or(bytes); + if retained > 0 { + if let Err(error) = state.file.write_all(&buffer[..retained]) { + state.error = Some(error.to_string()); + return; + } + state.written = state.written.saturating_add(retained as u64); + } + if retained < bytes { + state.truncated = true; + } + } +} + +fn owner_log_file(path: &Path) -> Result { + let mut options = std::fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + Ok(options.open(path)?) +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn default_webots_home() -> PathBuf { + #[cfg(target_os = "macos")] + { + PathBuf::from("/Applications/Webots.app") + } + #[cfg(target_os = "linux")] + { + PathBuf::from("/usr/local/webots") + } + #[cfg(target_os = "windows")] + { + PathBuf::from(r"C:\Program Files\Webots") + } +} + +fn executable_in(home: &Path) -> PathBuf { + #[cfg(target_os = "macos")] + { + home.join("Contents/MacOS/webots") + } + #[cfg(target_os = "linux")] + { + home.join("webots") + } + #[cfg(target_os = "windows")] + { + home.join("msys64/mingw64/bin/webots.exe") + } +} + +fn parse_version(output: &str) -> Result<&str> { + output + .split_ascii_whitespace() + .find(|word| word.starts_with('R')) + .context("Webots --version output did not contain an R-prefixed release") +} + +fn launch_args(world: &Path, no_rendering: bool, port: u16) -> Vec { + let mut args = vec![ + "--mode=realtime".to_owned(), + "--batch".to_owned(), + "--stdout".to_owned(), + "--stderr".to_owned(), + format!("--port={port}"), + ]; + if no_rendering { + args.push("--no-rendering".to_owned()); + } + args.push(world.display().to_string()); + args +} + +#[cfg(unix)] +fn signal_process_group(process_group: NonZeroI32, signal: libc::c_int) -> Result<()> { + // SAFETY: `kill` takes no pointer and the negative id targets only the owned group. + if unsafe { libc::kill(-process_group.get(), signal) } == 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + return Ok(()); + } + Err(error).context("failed to signal the Webots process group") +} + +#[cfg(unix)] +fn process_group_alive(child: &mut Child, process_group: NonZeroI32) -> Result { + let _ = child.try_wait()?; + // SAFETY: signal zero performs no mutation and the negative id selects the owned group. + if unsafe { libc::kill(-process_group.get(), 0) } == 0 { + return Ok(true); + } + let error = std::io::Error::last_os_error(); + match error.raw_os_error() { + Some(libc::ESRCH) => Ok(false), + Some(libc::EPERM) => Ok(true), + _ => Err(error).context("failed to inspect the Webots process group"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_supported_native_release_is_selected() { + assert_eq!( + parse_version("Webots version: R2025a\n").expect("the version parses"), + SUPPORTED_WEBOTS_VERSION + ); + } + + #[cfg(unix)] + #[test] + fn installation_validation_executes_and_rejects_an_unsupported_release() { + use std::os::unix::fs::PermissionsExt as _; + + let directory = tempfile::tempdir().expect("temporary Webots home"); + let executable = executable_in(directory.path()); + std::fs::create_dir_all(executable.parent().expect("executable has a parent")) + .expect("fake Webots executable directory"); + std::fs::write( + &executable, + "#!/bin/sh\nprintf '%s\\n' 'Webots version: R2024b'\n", + ) + .expect("fake Webots executable"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake executable metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&executable, permissions).expect("fake executable permissions"); + + let error = WebotsInstallation::at(directory.path().to_path_buf()) + .expect_err("an unsupported Webots release is rejected"); + assert_eq!( + error.to_string(), + "unsupported Webots version R2024b; this framework train requires R2025a" + ); + } + + #[test] + fn generated_launch_uses_real_time_only_for_bootstrap() { + let args = launch_args(Path::new("/tmp/world.wbt"), true, 49152); + assert_eq!(args[0], "--mode=realtime"); + assert!(args.iter().any(|arg| arg == "--port=49152")); + assert!(args.iter().any(|arg| arg == "--no-rendering")); + assert!( + !args + .iter() + .any(|arg| arg.contains("fast") || arg == "--mode=run") + ); + } + + #[cfg(unix)] + #[test] + fn failed_post_spawn_setup_kills_and_reaps_the_owned_process_group() { + use std::os::unix::process::CommandExt as _; + + let mut command = Command::new("/bin/sh"); + command + .arg("-c") + .arg("sleep 30") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .process_group(0); + let child = command.spawn().expect("the test child launches"); + let process_group = NonZeroI32::new( + libc::pid_t::try_from(child.id()).expect("the child id fits a process group"), + ) + .expect("the child id is positive"); + let mut spawned = SpawnGuard::new(child); + spawned.set_process_group(Some(process_group)); + + drop(spawned); + + // SAFETY: signal zero performs no mutation and the negative id selects the test group. + let result = unsafe { libc::kill(-process_group.get(), 0) }; + assert_eq!(result, -1); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ESRCH) + ); + } +} diff --git a/simulators/webots/host/src/logging.rs b/simulators/webots/host/src/logging.rs new file mode 100644 index 00000000..760f6478 --- /dev/null +++ b/simulators/webots/host/src/logging.rs @@ -0,0 +1,65 @@ +use super::*; + +pub(super) fn required_log_limit() -> Result { + let value = std::env::var(LOG_BYTE_LIMIT_ENV).with_context(|| { + format!("required environment variable {LOG_BYTE_LIMIT_ENV} is missing") + })?; + let value = value + .parse::() + .with_context(|| format!("{LOG_BYTE_LIMIT_ENV} must contain decimal bytes"))?; + ensure!(value >= 2, "{LOG_BYTE_LIMIT_ENV} must be at least 2 bytes"); + Ok(value) +} + +#[derive(Clone)] +pub(super) struct BoundedStderr { + state: Arc>, +} + +struct BoundedStderrState { + limit: u64, + written: u64, + truncated: bool, +} + +impl BoundedStderr { + pub(super) fn new(limit: u64) -> Self { + Self { + state: Arc::new(Mutex::new(BoundedStderrState { + limit, + written: 0, + truncated: false, + })), + } + } + + pub(super) fn truncated(&self) -> bool { + lock(&self.state).truncated + } +} + +impl std::io::Write for BoundedStderr { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + let mut state = lock(&self.state); + let remaining = state.limit.saturating_sub(state.written); + let retained = usize::try_from(remaining.min(bytes.len() as u64)).unwrap_or(bytes.len()); + if retained > 0 { + std::io::stderr().write_all(&bytes[..retained])?; + state.written = state.written.saturating_add(retained as u64); + } + if retained < bytes.len() { + state.truncated = true; + } + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + std::io::stderr().flush() + } +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} diff --git a/simulators/webots/host/src/main.rs b/simulators/webots/host/src/main.rs new file mode 100644 index 00000000..718d6e3d --- /dev/null +++ b/simulators/webots/host/src/main.rs @@ -0,0 +1,90 @@ +//! Long-lived Webots world-session host. + +use std::io::Write as _; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use crate::attachment::WebotsAttachments; +use crate::evidence::{EvidenceSession, world_terminal_summary}; +use crate::generation::{ControllerExecutables, stage_project}; +use crate::lifecycle::{ + LogCaptureOutcome, NativeProcessIdentity, WebotsInstallation, WebotsProcess, +}; +use crate::registration::{ + EVIDENCE_DIRECTORY_ENV, LOG_BYTE_LIMIT_ENV, REGISTRY_DIRECTORY_ENV, RegistrationGuard, + current_process_identity, +}; +use crate::runtime::{WebotsWorldSession, WorldRuntime}; +use crate::server::HostServer; +use crate::state::{NativeWorldFailure, NativeWorldLifecycle}; +use anyhow::{Context, Result, bail, ensure}; +use clap::Parser; +use phoxal::bundle::WorldBundle; +use phoxal::model::world::WorldInstanceId; +use phoxal::supervisor::api::simulation::SimulationEndReason; +use phoxal::world::WorldSessionServer; +use phoxal::world::api::session::document::{ + TerminalCleanup, TerminalFailure, TerminalOutcome, TerminalRetention, +}; +use phoxal::world::api::session::{WorldLifecycle, WorldMember, WorldMemberPhase}; +use tracing_subscriber::EnvFilter; + +const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(30); +const WORLD_STOP_TIMEOUT: Duration = Duration::from_secs(5); +const RECONCILE_INTERVAL: Duration = Duration::from_millis(5); + +mod application; +mod assets; +mod attachment; +mod evidence; +mod generation; +mod glb; +mod lifecycle; +mod logging; +mod obj; +mod plan; +mod registration; +mod robot_generation; +mod runtime; +mod server; +mod shutdown; +mod state; + +/// The exact native controller executable names generated into a Webots project. +const WORLD_CONTROLLER_PACKAGE: &str = "phoxal-simulator-webots-world-controller"; +const ROBOT_CONTROLLER_PACKAGE: &str = "phoxal-simulator-webots-robot-controller"; + +use application::run; +use logging::{BoundedStderr, required_log_limit}; + +#[derive(Debug, Parser)] +#[command(version, about)] +struct Args { + /// Canonical compiled WorldBundle directory. + #[arg(long, value_name = "PATH")] + world_bundle: PathBuf, +} + +#[tokio::main] +async fn main() { + let args = Args::parse(); + let log_byte_limit = match required_log_limit() { + Ok(value) => value, + Err(error) => { + eprintln!("webots host configuration failed: {error:#}"); + std::process::exit(2); + } + }; + let host_log_limit = (log_byte_limit / 2).max(1); + let host_log = BoundedStderr::new(host_log_limit); + let host_log_observer = host_log.clone(); + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .with_writer(move || host_log.clone()) + .init(); + if let Err(error) = run(args, log_byte_limit, host_log_observer).await { + tracing::error!(error = %format!("{error:#}"), "Webots world host failed"); + std::process::exit(1); + } +} diff --git a/simulators/webots/host/src/obj.rs b/simulators/webots/host/src/obj.rs new file mode 100644 index 00000000..822e2b03 --- /dev/null +++ b/simulators/webots/host/src/obj.rs @@ -0,0 +1,312 @@ +//! Robot-only Wavefront decoding from the already fetched bundle. +//! +//! World authoring remains GLB-only. Existing URDF robots use triangle OBJ meshes with +//! diffuse MTL materials; no path is ever opened by the Wavefront loader. + +use std::collections::BTreeMap; +use std::io::Cursor; +use std::path::Path; + +use anyhow::{Context, Result, bail, ensure}; +use phoxal::model::AssetId; + +use crate::glb::{DecodedMaterial, DecodedMesh, DecodedPrimitive}; + +pub(crate) fn material_dependencies(asset: &AssetId, bytes: &[u8]) -> Result> { + if Path::new(asset.as_str()) + .extension() + .is_none_or(|ext| ext != "obj") + { + return Ok(Vec::new()); + } + let mut dependencies = std::collections::BTreeSet::new(); + for line in std::str::from_utf8(bytes)?.lines() { + let mut fields = line + .split('#') + .next() + .unwrap_or_default() + .split_whitespace(); + if fields.next() == Some("mtllib") { + let path = fields.next().context("OBJ mtllib needs a material path")?; + ensure!( + fields.next().is_none(), + "OBJ needs one material path per mtllib" + ); + dependencies.insert(material_asset(asset, Path::new(path))?); + } + } + Ok(dependencies.into_iter().collect()) +} + +pub(crate) fn decode(asset: &AssetId, assets: &BTreeMap>) -> Result { + let bytes = assets + .get(asset) + .with_context(|| format!("missing mesh {asset}"))?; + if bytes.starts_with(b"glTF") { + return DecodedMesh::decode(bytes); + } + ensure!( + Path::new(asset.as_str()) + .extension() + .is_some_and(|ext| ext == "obj"), + "Robot mesh {asset} must be GLB or triangle OBJ" + ); + let source = std::str::from_utf8(bytes).context("OBJ is not UTF-8")?; + for line in source.lines() { + let fields: Vec<_> = line + .split('#') + .next() + .unwrap_or_default() + .split_whitespace() + .collect(); + let Some(kind) = fields.first() else { continue }; + match *kind { + "v" | "vn" => ensure!( + fields.len() == 4, + "OBJ {kind} needs exactly three coordinates" + ), + "vt" => ensure!( + (2..=4).contains(&fields.len()) + && fields[1..] + .iter() + .all(|field| field.parse::().is_ok_and(f64::is_finite)), + "OBJ vt needs one to three finite coordinates" + ), + "f" => ensure!(fields.len() == 4, "OBJ supports triangle faces only"), + "mtllib" => ensure!(fields.len() == 2, "OBJ needs one material path per mtllib"), + "o" | "g" | "s" | "usemtl" => {} + _ => bail!("unsupported OBJ statement {kind}"), + } + } + let (models, materials) = tobj::load_obj_buf( + &mut Cursor::new(bytes), + &tobj::LoadOptions { + single_index: true, + ..Default::default() + }, + |path| { + let material = material_asset(asset, path).map_err(|_| tobj::LoadError::ReadError)?; + let bytes = assets.get(&material).ok_or(tobj::LoadError::ReadError)?; + validate_material(bytes).map_err(|_| tobj::LoadError::MaterialParseError)?; + tobj::load_mtl_buf(&mut Cursor::new(bytes)) + }, + ) + .context("invalid OBJ geometry")?; + let materials = materials + .context("OBJ material is missing, escapes its directory, or uses unsupported fields")?; + for line in source.lines() { + let mut fields = line + .split('#') + .next() + .unwrap_or_default() + .split_whitespace(); + if fields.next() == Some("usemtl") { + let name = fields.next().context("OBJ usemtl needs a material name")?; + ensure!( + fields.next().is_none() && materials.iter().any(|material| material.name == name), + "OBJ names unknown material {name}" + ); + } + } + let mut primitives = Vec::new(); + for model in models { + let mesh = model.mesh; + ensure!( + !mesh.positions.is_empty() && mesh.positions.len().is_multiple_of(3), + "OBJ has no complete positions" + ); + ensure!( + mesh.positions.iter().all(|v| v.is_finite()), + "OBJ positions must be finite" + ); + let positions = mesh.positions.as_chunks::<3>().0.to_vec(); + ensure!( + !mesh.indices.is_empty() + && mesh.indices.len().is_multiple_of(3) + && mesh.indices.iter().all(|&i| (i as usize) < positions.len()), + "OBJ indices must describe valid triangles" + ); + let normals = if mesh.normals.is_empty() { + None + } else { + ensure!( + mesh.normals.len() == positions.len() * 3, + "OBJ normals must cover every vertex" + ); + let normals = mesh.normals.as_chunks::<3>().0.to_vec(); + ensure!( + normals.iter().all(|v| { + let length = v.iter().map(|x| x * x).sum::(); + length.is_finite() && length > 0.0 + }), + "OBJ normals must have finite nonzero length" + ); + Some(normals) + }; + ensure!( + mesh.texcoords.iter().all(|v| v.is_finite()), + "OBJ texture coordinates must be finite" + ); + let color = mesh + .material_id + .map(|index| { + materials + .get(index) + .context("OBJ names an unknown material")? + .diffuse + .context("OBJ material needs a diffuse color") + }) + .transpose()? + .unwrap_or([1.0; 3]); + ensure!( + color + .iter() + .all(|v| v.is_finite() && (0.0..=1.0).contains(v)), + "OBJ diffuse color must be in [0, 1]" + ); + primitives.push(DecodedPrimitive { + positions, + normals, + texcoords: None, + indices: mesh.indices, + material: DecodedMaterial { + base_color: [color[0], color[1], color[2], 1.0], + metallic: 0.0, + ..Default::default() + }, + }); + } + ensure!(!primitives.is_empty(), "OBJ contains no mesh"); + Ok(DecodedMesh { + primitives, + images: Vec::new(), + }) +} + +fn material_asset(mesh: &AssetId, relative: &Path) -> Result { + let relative = relative.to_str().context("MTL path is not UTF-8")?; + // AssetId forbids absolute paths, traversal, and platform separators. + let relative = AssetId::new(relative)?; + let parent = Path::new(mesh.as_str()) + .parent() + .context("mesh has no parent")?; + AssetId::new( + parent + .join(relative.as_str()) + .to_str() + .context("MTL path is not UTF-8")?, + ) + .map_err(Into::into) +} + +fn validate_material(bytes: &[u8]) -> Result<()> { + for line in std::str::from_utf8(bytes)?.lines() { + let fields: Vec<_> = line + .split('#') + .next() + .unwrap_or_default() + .split_whitespace() + .collect(); + let Some(kind) = fields.first() else { continue }; + match *kind { + "newmtl" => ensure!(fields.len() == 2, "MTL requires a single material name"), + "Kd" => ensure!(fields.len() == 4, "MTL requires three diffuse channels"), + _ => bail!("unsupported MTL statement {kind}"), + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(source: &str, material: &str) -> (AssetId, BTreeMap>) { + let mesh = AssetId::new("robot/meshes/test.obj").expect("mesh id"); + let mtl = AssetId::new("robot/meshes/test.mtl").expect("material id"); + ( + mesh.clone(), + BTreeMap::from([ + (mesh, source.as_bytes().to_vec()), + (mtl, material.as_bytes().to_vec()), + ]), + ) + } + + const TRIANGLE: &str = "mtllib test.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl paint\nf 1 2 3\n"; + + #[test] + fn robot_obj_preserves_coordinates_and_diffuse_material() { + let (id, assets) = fixture(TRIANGLE, "newmtl paint\nKd 0.2 0.4 0.8\n"); + let mesh = decode(&id, &assets).expect("closed OBJ"); + assert_eq!( + material_dependencies(&id, &assets[&id]).expect("closure"), + vec![AssetId::new("robot/meshes/test.mtl").expect("material id")] + ); + assert_eq!( + mesh.primitives[0].positions, + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + ); + assert_eq!(mesh.primitives[0].material.base_color, [0.2, 0.4, 0.8, 1.0]); + mesh.validate_collision() + .expect("exact collision triangles"); + } + + #[test] + fn obj_refuses_missing_escaping_and_unsupported_materials() { + for reference in [ + "missing.mtl", + "../test.mtl", + "/tmp/test.mtl", + "https://example.com/test.mtl", + ] { + let (id, assets) = fixture( + &TRIANGLE.replace("test.mtl", reference), + "newmtl paint\nKd 1 1 1\n", + ); + assert!(decode(&id, &assets).is_err(), "{reference}"); + } + let (id, assets) = fixture(TRIANGLE, "newmtl paint\nKd 1 1 1\nmap_Kd outside.png\n"); + assert!(decode(&id, &assets).is_err()); + } + + #[test] + fn obj_refuses_invalid_geometry_and_material_values() { + for source in [ + TRIANGLE.replace("v 1 0 0", "v NaN 0 0"), + TRIANGLE.replace("f 1 2 3", "f 1 2 9"), + TRIANGLE.replace("f 1 2 3", "f 1 2 3 1"), + ] { + let (id, assets) = fixture(&source, "newmtl paint\nKd 1 1 1\n"); + assert!(decode(&id, &assets).is_err()); + } + let (id, assets) = fixture(TRIANGLE, "newmtl paint\nKd 2 1 1\n"); + assert!(decode(&id, &assets).is_err()); + } + + #[test] + fn official_wheel_obj_preserves_its_native_robot_coordinates() { + let id = AssetId::new("components/ddsm115/meshes/ddsm115.obj").expect("mesh id"); + let mtl = AssetId::new("components/ddsm115/meshes/motorized_wheel.mtl").expect("MTL id"); + let assets = BTreeMap::from([ + ( + id.clone(), + include_bytes!("../../../../components/ddsm115/meshes/ddsm115.obj").to_vec(), + ), + ( + mtl, + include_bytes!("../../../../components/ddsm115/meshes/motorized_wheel.mtl") + .to_vec(), + ), + ]); + let mesh = decode(&id, &assets).expect("official wheel decodes"); + assert_eq!(mesh.primitives.len(), 3); + let low_y = mesh + .primitives + .iter() + .flat_map(|p| &p.positions) + .map(|p| p[1]) + .fold(f64::INFINITY, f64::min); + assert_eq!(low_y, -0.099); + } +} diff --git a/simulators/webots/host/src/plan.rs b/simulators/webots/host/src/plan.rs new file mode 100644 index 00000000..0a722701 --- /dev/null +++ b/simulators/webots/host/src/plan.rs @@ -0,0 +1,460 @@ +//! Host-private lowering from a compiled Robot to controller wire records. + +use std::collections::BTreeMap; + +use phoxal::model::Robot; +use phoxal::model::asset::AssetId; +use phoxal::model::component::capability::{ + Capability as DeclaredCapability, CapabilityKind, GnssCoordinateSystem, MotorCommand, + StructuralTarget, +}; +use phoxal::model::identity::CapabilityRef; +use phoxal::model::simulation::{ + ActuatorType, Capability as SimulatedCapability, FullSimulationError, FullSimulationPlan, +}; +use phoxal_simulator_webots_shared::plan::{ + CapabilityBinding, DriverSubstitution, LinkSimulation, PlannedAsset, PlannedTarget, + RobotSimulationPlan, SampledCapabilityKind, SamplingPlan, +}; +use sha2::{Digest as _, Sha256}; + +const NANOS_PER_SECOND: f64 = 1_000_000_000.0; + +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub(crate) enum PlanError { + #[error("Webots basicTimeStep must be a positive whole millisecond")] + InvalidTimeStep, + #[error("Webots does not support {kind} capability '{capability}': {detail}")] + UnsupportedCapability { + capability: String, + kind: String, + detail: String, + }, + #[error( + "motor '{capability}' declares {declared:?}, but simulation config requests {simulated:?}" + )] + ActuationMismatch { + capability: String, + declared: MotorCommand, + simulated: ActuatorType, + }, + #[error("capability '{capability}' has invalid cadence: {detail}")] + InvalidCadence { capability: String, detail: String }, + #[error(transparent)] + DriveAuthority(#[from] phoxal::drive::authority::DriveAuthorityError), + #[error("native Webots device name '{device}' is claimed by both {first} and {second}")] + DuplicateDevice { + device: String, + first: String, + second: String, + }, + #[error("required simulation asset '{asset}' is unavailable: {detail}")] + MissingAsset { asset: String, detail: String }, + #[error("required simulation asset '{asset}' is empty")] + EmptyAsset { asset: String }, + #[error(transparent)] + FullSimulation(#[from] FullSimulationError), +} + +pub(crate) fn required_assets(robot: &Robot) -> Result { + Ok(FullSimulationPlan::derive(robot)?) +} + +#[cfg(test)] +pub(crate) fn derive_robot_plan( + robot: &Robot, + basic_time_step_ms: i32, + resolve_asset: F, +) -> Result +where + F: FnMut(&AssetId) -> Result, E>, + E: std::fmt::Display, +{ + let full = required_assets(robot)?; + lower_robot_plan(robot, &full, basic_time_step_ms, resolve_asset) +} + +pub(crate) fn lower_robot_plan( + robot: &Robot, + full: &FullSimulationPlan, + basic_time_step_ms: i32, + mut resolve_asset: F, +) -> Result +where + F: FnMut(&AssetId) -> Result, E>, + E: std::fmt::Display, +{ + if basic_time_step_ms <= 0 { + return Err(PlanError::InvalidTimeStep); + } + let substitutions = full + .substitutions() + .map(|substitution| DriverSubstitution { + participant: substitution.participant().clone(), + capabilities: substitution.capabilities().cloned().collect(), + }) + .collect(); + let mut capabilities = Vec::new(); + let mut links = Vec::new(); + let mut claimed_devices = BTreeMap::::new(); + for component in robot.components() { + let component_id = component.id(); + let declared = component.component_type(); + let simulation = component.simulation().ok_or_else(|| { + PlanError::FullSimulation(FullSimulationError::MissingSimulation { + component: component_id.clone(), + }) + })?; + for (capability_id, capability) in declared.capabilities() { + let reference = CapabilityRef::new(component_id.clone(), capability_id.clone()); + let simulated = simulation + .capability(capability_id.as_str()) + .ok_or_else(|| { + PlanError::FullSimulation(FullSimulationError::MissingCapability { + capability: reference.clone(), + }) + })?; + let binding = + bind_capability(reference.clone(), capability, simulated, basic_time_step_ms)?; + if matches!(capability, DeclaredCapability::Motor(_)) { + phoxal::drive::authority::DriveCommandAuthority::validate_motor(robot, &reference)?; + } + validate_target(&reference, capability, declared.structure())?; + for device in native_device_names(&binding) { + if let Some(first) = claimed_devices.insert(device.clone(), reference.to_string()) { + return Err(PlanError::DuplicateDevice { + device, + first, + second: reference.to_string(), + }); + } + } + capabilities.push(binding); + } + for (link, config) in simulation.links() { + if declared.structure().link(link.as_str()).is_none() { + return Err(unsupported( + component_id.to_string(), + "link_simulation", + format!("simulation link '{link}' is absent from the component structure"), + )); + } + if config.contact_material().is_some() + && !has_movable_parent(declared.structure(), link.as_str()) + { + return Err(unsupported( + component_id.to_string(), + "contact_material", + format!( + "contact material on rigidly mounted link '{link}' cannot be represented independently after fixed-body aggregation" + ), + )); + } + links.push(LinkSimulation { + component: component_id.clone(), + link: link.to_string(), + contact_material: config.contact_material().map(str::to_owned), + }); + } + } + let mut assets = Vec::new(); + for id in full.required_assets() { + let bytes = resolve_asset(id).map_err(|error| PlanError::MissingAsset { + asset: id.to_string(), + detail: error.to_string(), + })?; + if bytes.is_empty() { + return Err(PlanError::EmptyAsset { + asset: id.to_string(), + }); + } + assets.push(PlannedAsset { + id: id.clone(), + bytes: u64::try_from(bytes.len()).map_err(|_| PlanError::MissingAsset { + asset: id.to_string(), + detail: "length does not fit u64".to_owned(), + })?, + sha256: format!("{:x}", Sha256::digest(&bytes)), + }); + } + Ok(RobotSimulationPlan { + robot: robot.id().to_string(), + basic_time_step_ms, + substitutions, + capabilities, + links, + assets, + }) +} + +fn unsupported(capability: String, kind: impl Into, detail: String) -> PlanError { + PlanError::UnsupportedCapability { + capability, + kind: kind.into(), + detail, + } +} + +fn validate_target( + reference: &CapabilityRef, + capability: &DeclaredCapability, + structure: &phoxal::model::structure::Structure, +) -> Result<(), PlanError> { + let kind = capability.kind(); + match capability.target() { + StructuralTarget::Joint { id } => { + let joint = structure.joint(id.as_str()).ok_or_else(|| { + unsupported( + reference.to_string(), + kind.to_string(), + format!("target joint '{id}' is absent from component structure"), + ) + })?; + if !matches!( + joint.kind(), + phoxal::model::structure::JointKind::Revolute + | phoxal::model::structure::JointKind::Continuous + | phoxal::model::structure::JointKind::Prismatic + ) { + return Err(unsupported( + reference.to_string(), + kind.to_string(), + format!( + "target joint '{id}' has unsupported {:?} native kind", + joint.kind() + ), + )); + } + if matches!(kind, CapabilityKind::Motor | CapabilityKind::Encoder) + && joint.kind() == phoxal::model::structure::JointKind::Prismatic + { + return Err(unsupported(reference.to_string(), kind.to_string(), "the current typed motor and encoder contracts use rotational units and cannot bind a linear Webots joint".to_owned())); + } + } + StructuralTarget::Link { id } if structure.link(id.as_str()).is_none() => { + return Err(unsupported( + reference.to_string(), + kind.to_string(), + format!("target link '{id}' is absent from component structure"), + )); + } + StructuralTarget::Link { .. } => {} + } + Ok(()) +} + +fn has_movable_parent(structure: &phoxal::model::structure::Structure, link: &str) -> bool { + structure + .parent_joint(link) + .is_some_and(|joint| joint.kind() != phoxal::model::structure::JointKind::Fixed) +} + +fn bind_capability( + reference: CapabilityRef, + declared: &DeclaredCapability, + simulated: &SimulatedCapability, + step_ms: i32, +) -> Result { + let kind = declared.kind(); + let supported = matches!( + kind, + CapabilityKind::Motor + | CapabilityKind::Encoder + | CapabilityKind::Accelerometer + | CapabilityKind::Gyroscope + | CapabilityKind::Imu + | CapabilityKind::Gnss + | CapabilityKind::Camera + | CapabilityKind::Depth + | CapabilityKind::Range + ); + if !supported { + return Err(unsupported(reference.to_string(), kind.to_string(), "the R2025a adapter currently has no complete native generation and typed I/O path for this capability".to_owned())); + } + if let DeclaredCapability::Gnss(config) = declared + && config.coordinate_system != GnssCoordinateSystem::Wgs84 + { + return Err(unsupported(reference.to_string(), kind.to_string(), "the typed GNSS sample is geographic, so Webots admission requires an explicit wgs84 coordinate system".to_owned())); + } + if let SimulatedCapability::Motor(config) = simulated { + if config.sampling_period_torque_hz.is_some() { + return Err(unsupported( + reference.to_string(), + kind.to_string(), + "torque-feedback sampling has no typed publication path in the v0 adapter" + .to_owned(), + )); + } + if let Some(pid) = &config.control_pid + && pid.len() != 3 + { + return Err(unsupported( + reference.to_string(), + kind.to_string(), + "Webots control_pid must contain exactly P, I, and D".to_owned(), + )); + } + } + let joint_device = matches!(kind, CapabilityKind::Motor | CapabilityKind::Encoder); + if joint_device != matches!(declared.target(), StructuralTarget::Joint { .. }) { + return Err(unsupported( + reference.to_string(), + kind.to_string(), + if joint_device { + "Webots motor and encoder devices must target a movable joint" + } else { + "Webots sampled body devices must target a link" + } + .to_owned(), + )); + } + let target = match declared.target().namespaced(&reference.component_id) { + StructuralTarget::Link { id } => PlannedTarget::Link { id: id.to_string() }, + StructuralTarget::Joint { id } => PlannedTarget::Joint { id: id.to_string() }, + }; + let native_device = reference.to_string(); + if let (DeclaredCapability::Motor(config), SimulatedCapability::Motor(native)) = + (declared, simulated) + { + let expected = match config.command { + MotorCommand::Position => ActuatorType::Position, + MotorCommand::Velocity => ActuatorType::Velocity, + MotorCommand::Torque => ActuatorType::Torque, + }; + if native.actuator_type != expected { + return Err(PlanError::ActuationMismatch { + capability: reference.to_string(), + declared: config.command, + simulated: native.actuator_type, + }); + } + return Ok(CapabilityBinding::Motor { + reference, + native_device, + target, + command: config.command, + }); + } + let (publish_rate_hz, native_sampling_rate_hz) = sampling_rates(declared, simulated) + .ok_or_else(|| { + unsupported( + reference.to_string(), + kind.to_string(), + "the compiled native I/O contract is incomplete for this capability".to_owned(), + ) + })?; + let sampling = sampling_plan( + &reference, + step_ms, + publish_rate_hz, + native_sampling_rate_hz, + )?; + if kind == CapabilityKind::Encoder { + return Ok(CapabilityBinding::Encoder { + reference, + native_device, + target, + sampling, + }); + } + let capability = SampledCapabilityKind::from_capability_kind(kind).ok_or_else(|| { + unsupported( + reference.to_string(), + kind.to_string(), + "the compiled native I/O contract is incomplete for this capability".to_owned(), + ) + })?; + Ok(CapabilityBinding::Sampled { + reference, + native_device, + target, + capability, + sampling, + }) +} + +fn native_device_names(binding: &CapabilityBinding) -> Vec { + let mut devices = vec![binding.native_device().to_owned()]; + if binding.kind() == CapabilityKind::Imu { + devices.extend([ + format!("{}__accel", binding.native_device()), + format!("{}__gyro", binding.native_device()), + ]); + } + devices +} + +fn sampling_rates( + declared: &DeclaredCapability, + simulated: &SimulatedCapability, +) -> Option<(f64, f64)> { + Some(match (declared, simulated) { + (DeclaredCapability::Encoder(a), SimulatedCapability::Encoder(b)) => { + (a.publish_rate_hz, b.sampling_period_hz) + } + (DeclaredCapability::Accelerometer(a), SimulatedCapability::Accelerometer(b)) => { + (a.publish_rate_hz, b.sampling_period_hz) + } + (DeclaredCapability::Gyroscope(a), SimulatedCapability::Gyroscope(b)) => { + (a.publish_rate_hz, b.sampling_period_hz) + } + (DeclaredCapability::Imu(a), SimulatedCapability::Imu(b)) => { + (a.publish_rate_hz, b.sampling_period_hz) + } + (DeclaredCapability::Gnss(a), SimulatedCapability::Gnss(b)) => { + (a.publish_rate_hz, b.sampling_period_hz) + } + (DeclaredCapability::Camera(a), SimulatedCapability::Camera(b)) => { + (a.publish_rate_hz, b.sampling_period_hz) + } + (DeclaredCapability::Depth(a), SimulatedCapability::Depth(b)) => { + (a.publish_rate_hz, b.sampling_period_hz) + } + (DeclaredCapability::Range(a), SimulatedCapability::Range(b)) => { + (a.publish_rate_hz, b.sampling_period_hz) + } + _ => return None, + }) +} + +fn sampling_plan( + reference: &CapabilityRef, + step_ms: i32, + publish_rate_hz: f64, + native_rate_hz: f64, +) -> Result { + let failure = |detail: &str| PlanError::InvalidCadence { + capability: reference.to_string(), + detail: detail.to_owned(), + }; + if !publish_rate_hz.is_finite() || publish_rate_hz <= 0.0 { + return Err(failure("publish rate must be finite and positive")); + } + if !native_rate_hz.is_finite() || native_rate_hz <= 0.0 { + return Err(failure("native sampling rate must be finite and positive")); + } + let requested = (1000.0 / native_rate_hz).round().max(1.0); + if requested > f64::from(i32::MAX) { + return Err(failure("native sampling period exceeds Webots range")); + } + let basic = u64::try_from(step_ms).map_err(|_| failure("world step is invalid"))?; + let requested = requested as u64; + let periods = requested + .checked_add(basic - 1) + .and_then(|value| value.checked_div(basic)) + .ok_or_else(|| failure("native sampling quantization overflowed"))?; + let native_period = periods + .checked_mul(basic) + .ok_or_else(|| failure("native sampling quantization overflowed"))?; + let publish_period = (NANOS_PER_SECOND / publish_rate_hz).round(); + if !(1.0..=u64::MAX as f64).contains(&publish_period) { + return Err(failure("publish period does not fit nanoseconds")); + } + Ok(SamplingPlan { + publish_rate_hz, + native_sampling_rate_hz: native_rate_hz, + native_period_ms: i32::try_from(native_period) + .map_err(|_| failure("native sampling period exceeds Webots range"))?, + publish_period_ns: publish_period as u64, + }) +} diff --git a/simulators/webots/host/src/registration.rs b/simulators/webots/host/src/registration.rs new file mode 100644 index 00000000..3d217fbd --- /dev/null +++ b/simulators/webots/host/src/registration.rs @@ -0,0 +1,326 @@ +//! Owner-only atomic local registration and host-held lease. + +use std::fs::{File, OpenOptions}; +use std::io::Write as _; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, ensure}; +use phoxal::bundle::WorldBundle; +use phoxal::model::world::WorldInstanceId; +use phoxal::version::FrameworkVersion; +use phoxal::world::api::session::document::LOCAL_WORLD_REGISTRATION_SCHEMA; +pub use phoxal::world::api::session::document::{ + LocalWorldRegistration, ProcessIdentity, RegisteredWorld, +}; +use sysinfo::{Pid, System}; + +pub const REGISTRY_DIRECTORY_ENV: &str = "PHOXAL_SIMULATION_REGISTRY_DIR"; +pub const EVIDENCE_DIRECTORY_ENV: &str = "PHOXAL_SIMULATION_EVIDENCE_DIR"; +pub const LOG_BYTE_LIMIT_ENV: &str = "PHOXAL_SIMULATION_LOG_BYTE_LIMIT"; +/// A live registration whose adjacent lease remains exclusively locked. +pub struct RegistrationGuard { + registration_path: PathBuf, + lease_path: PathBuf, + lease: Option, + #[cfg(test)] + document: LocalWorldRegistration, +} + +impl RegistrationGuard { + /// Atomically publish one new registration while retaining its exclusive lease. + pub fn create( + root: impl AsRef, + instance: WorldInstanceId, + endpoint: String, + bundle: &WorldBundle, + process: ProcessIdentity, + ) -> Result { + let root = secure_directory(root.as_ref())?; + let lease_name = format!("{instance}.lease"); + let lease_path = root.join(&lease_name); + let registration_path = root.join(format!("{instance}.json")); + let mut lease = owner_file(&lease_path)?; + lock_exclusive(&lease) + .with_context(|| format!("failed to lock world lease {}", lease_path.display()))?; + lease + .write_all(instance.to_string().as_bytes()) + .context("failed to initialize the world lease")?; + lease + .sync_all() + .context("failed to persist the world lease")?; + + let document = LocalWorldRegistration { + schema: LOCAL_WORLD_REGISTRATION_SCHEMA.to_owned(), + instance, + endpoint, + process, + framework: FrameworkVersion::CURRENT, + world: RegisteredWorld { + id: bundle.world().id().clone(), + digest: bundle.digest(), + }, + lease: lease_name, + }; + document.validate_structure(instance)?; + let body = serde_json::to_vec(&document)?; + atomic_owner_write(&root, ®istration_path, &body)?; + Ok(Self { + registration_path, + lease_path, + lease: Some(lease), + #[cfg(test)] + document, + }) + } + + #[must_use] + #[cfg(test)] + pub const fn document(&self) -> &LocalWorldRegistration { + &self.document + } +} + +impl Drop for RegistrationGuard { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.registration_path); + if let Some(lease) = self.lease.take() { + unlock(&lease); + drop(lease); + } + let _ = std::fs::remove_file(&self.lease_path); + } +} + +pub fn current_process_identity() -> Result { + process_identity(std::process::id()) +} + +pub(crate) fn process_identity(pid: u32) -> Result { + let mut system = System::new(); + system.refresh_processes( + sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(pid)]), + true, + ); + let process = system + .process(Pid::from_u32(pid)) + .context("current process is absent from the host process table")?; + Ok(ProcessIdentity { + pid, + started_at_unix_s: process.start_time(), + }) +} + +fn secure_directory(path: &Path) -> Result { + let path = path + .canonicalize() + .with_context(|| format!("failed to open local registry directory {}", path.display()))?; + let metadata = std::fs::symlink_metadata(&path)?; + ensure!( + metadata.is_dir(), + "local registry root is not a directory: {}", + path.display() + ); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + // SAFETY: `geteuid` has no pointer arguments or side effects. + let owner = unsafe { libc::geteuid() }; + ensure!( + metadata.uid() == owner, + "local registry root is owned by another user" + ); + ensure!( + metadata.mode() & 0o077 == 0, + "local registry root must have mode 0700 or stricter" + ); + } + Ok(path) +} + +fn owner_file(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + options + .open(path) + .with_context(|| format!("failed to create owner-only file {}", path.display())) +} + +fn atomic_owner_write(root: &Path, target: &Path, bytes: &[u8]) -> Result<()> { + let temporary = root.join(format!( + ".registration-{}-{}.tmp", + std::process::id(), + target + .file_stem() + .and_then(std::ffi::OsStr::to_str) + .unwrap_or("world") + )); + let outcome = (|| -> Result<()> { + let mut file = owner_file(&temporary)?; + file.write_all(bytes)?; + file.sync_all()?; + std::fs::rename(&temporary, target).with_context(|| { + format!( + "failed to atomically publish registration {}", + target.display() + ) + })?; + File::open(root)?.sync_all()?; + Ok(()) + })(); + if outcome.is_err() { + let _ = std::fs::remove_file(&temporary); + } + outcome +} + +#[cfg(unix)] +fn lock_exclusive(file: &File) -> Result<()> { + use std::os::fd::AsRawFd as _; + // SAFETY: this locks the valid descriptor borrowed from `file` and retains the file in guard. + ensure!( + unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0, + "world lease is already held: {}", + std::io::Error::last_os_error() + ); + Ok(()) +} + +#[cfg(not(unix))] +fn lock_exclusive(_file: &File) -> Result<()> { + anyhow::bail!("local world leases are not implemented on this platform") +} + +#[cfg(unix)] +fn unlock(file: &File) { + use std::os::fd::AsRawFd as _; + // SAFETY: the descriptor belongs to this guard; unlock occurs before it is dropped. + let _ = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) }; +} + +#[cfg(not(unix))] +fn unlock(_file: &File) {} + +#[cfg(test)] +mod tests { + use super::*; + use phoxal::model::world::WorldDigest; + + fn primitive_bundle(root: &Path) -> WorldBundle { + let source = root.join("bundle"); + std::fs::create_dir(&source).expect("world bundle directory"); + std::fs::create_dir(source.join("assets")).expect("world bundle assets"); + let document = source.join("world.json"); + std::fs::write( + &document, + serde_json::to_vec_pretty(&serde_json::json!({ + "schema": "phoxal/world-bundle/v0", + "id": "warehouse", + "time_step_ns": 12_000_000, + "gravity_mps2": [0.0, 0.0, -9.81], + "spawn_points": {}, + "entities": [{ + "declaration": "floor", + "instance": 0, + "pose": { "xyz": [0.0, 0.0, -0.05], "rpy": [0.0, 0.0, 0.0] }, + "geometry": { "kind": "box", "size": [10.0, 10.0, 0.1] }, + "collision": { "kind": "box", "size": [10.0, 10.0, 0.1] } + }] + })) + .expect("world document JSON"), + ) + .expect("world bundle document"); + WorldBundle::open(source).expect("primitive world bundle") + } + + #[test] + fn registration_wire_shape_is_pinned() { + let instance = + WorldInstanceId::parse("10000000000000000000000000000001").expect("canonical instance"); + let document = LocalWorldRegistration { + schema: LOCAL_WORLD_REGISTRATION_SCHEMA.to_owned(), + instance, + endpoint: "tcp://127.0.0.1:1234".to_owned(), + process: ProcessIdentity { + pid: 42, + started_at_unix_s: 99, + }, + framework: "0.68.0".parse().expect("canonical framework version"), + world: RegisteredWorld { + id: "warehouse".parse().expect("canonical world id"), + digest: WorldDigest::parse(&"aa".repeat(32)).expect("canonical digest"), + }, + lease: format!("{instance}.lease"), + }; + let value = serde_json::to_value(document).expect("registration encodes"); + assert_eq!(value["schema"], LOCAL_WORLD_REGISTRATION_SCHEMA); + assert_eq!(value["process"]["started_at_unix_s"], 99); + assert!(value.get("controller_endpoint").is_none()); + } + + #[cfg(unix)] + #[test] + fn registration_is_owner_only_atomically_visible_and_lease_guarded() { + use std::os::fd::AsRawFd as _; + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + + let temporary = tempfile::tempdir().expect("temporary root"); + let registry = temporary.path().join("registry"); + std::fs::create_dir(®istry).expect("registry directory"); + std::fs::set_permissions(®istry, std::fs::Permissions::from_mode(0o700)) + .expect("owner-only registry"); + let bundle = primitive_bundle(temporary.path()); + let instance = + WorldInstanceId::parse("10000000000000000000000000000001").expect("instance"); + let guard = RegistrationGuard::create( + ®istry, + instance, + "tcp://127.0.0.1:1234".to_owned(), + &bundle, + ProcessIdentity { + pid: 42, + started_at_unix_s: 99, + }, + ) + .expect("registration"); + + let registration_path = registry.join(format!("{instance}.json")); + let lease_path = registry.join(format!("{instance}.lease")); + for path in [®istration_path, &lease_path] { + let metadata = std::fs::symlink_metadata(path).expect("published owner file"); + assert!(metadata.is_file()); + assert_eq!(metadata.mode() & 0o777, 0o600); + } + assert!( + std::fs::read_dir(®istry) + .expect("registry listing") + .all(|entry| !entry + .expect("registry entry") + .file_name() + .to_string_lossy() + .starts_with(".registration-")), + "the atomic staging file is never discoverable after publication" + ); + let published: LocalWorldRegistration = + serde_json::from_slice(&std::fs::read(®istration_path).expect("registration bytes")) + .expect("registration JSON"); + assert_eq!(published, *guard.document()); + + let competing = File::open(&lease_path).expect("competing lease descriptor"); + // SAFETY: `flock` acts on the valid descriptor retained by `competing`. + let locked = unsafe { libc::flock(competing.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + assert_eq!(locked, -1, "a live registration keeps its lease locked"); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EWOULDBLOCK) + ); + + drop(guard); + assert!(!registration_path.exists()); + assert!(!lease_path.exists()); + } +} diff --git a/simulators/webots/host/src/robot_generation.rs b/simulators/webots/host/src/robot_generation.rs new file mode 100644 index 00000000..cd9edbc7 --- /dev/null +++ b/simulators/webots/host/src/robot_generation.rs @@ -0,0 +1,1588 @@ +//! Deterministic native Robot source derived from one admitted plan. + +use std::collections::BTreeMap; +use std::fmt::Write as _; + +use anyhow::{Context, Result, bail, ensure}; +use nalgebra::{Isometry3, Matrix3, Translation3, UnitQuaternion, Vector3}; +use phoxal::identity::ExecutionId; +use phoxal::model::AssetId; +use phoxal::model::Robot; +use phoxal::model::component::capability::{Capability as DeclaredCapability, CapabilityKind}; +use phoxal::model::geometry::Geometry; +use phoxal::model::identity::ComponentInstanceId; +use phoxal::model::simulation::{CameraProjection, Capability as SimulatedCapability}; +use phoxal::model::structure::{Joint, JointKind, Link, Material, Pose, Structure}; + +use crate::{ROBOT_CONTROLLER_PACKAGE, generation}; +use phoxal_simulator_webots_shared::plan::{CapabilityBinding, PlannedTarget, RobotSimulationPlan}; + +/// Stable DEF used for import, verification, rollback, and removal. +#[must_use] +pub fn robot_definition(execution: ExecutionId) -> String { + let suffix = execution + .to_string() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character.to_ascii_uppercase() + } else { + '_' + } + }) + .collect::(); + format!("PHOXAL_ROBOT_{suffix}") +} + +/// Render one complete built-in Webots Robot without external dependencies. +pub fn render_robot( + robot: &Robot, + plan: &RobotSimulationPlan, + assets: &BTreeMap>, + execution: ExecutionId, + pose: Pose, + supervisor_endpoint: &str, + host_endpoint: &str, +) -> Result { + ensure!( + plan.robot == robot.id().to_string(), + "plan and Robot disagree" + ); + for binding in &plan.capabilities { + ensure!( + robot.capability(binding.reference()).is_some(), + "binding {} names a missing capability", + binding.reference() + ); + } + let definition = robot_definition(execution); + let [x, y, z] = pose.xyz(); + let [ax, ay, az, angle] = generation::axis_angle(pose); + let mut out = String::new(); + writeln!(out, "DEF {definition} Robot {{")?; + writeln!( + out, + " translation {} {} {}", + generation::number(x), + generation::number(y), + generation::number(z) + )?; + writeln!( + out, + " rotation {} {} {} {}", + generation::number(ax), + generation::number(ay), + generation::number(az), + generation::number(angle) + )?; + writeln!(out, " name \"phoxal-{execution}\"")?; + writeln!(out, " controller \"{ROBOT_CONTROLLER_PACKAGE}\"")?; + writeln!( + out, + " controllerArgs [\"--connect\", \"{}\", \"--host-connect\", \"{}\"]", + generation::quoted(supervisor_endpoint), + generation::quoted(host_endpoint) + )?; + writeln!(out, " synchronization TRUE")?; + let structure = robot.structure(); + let root = structure + .link(structure.root_link().as_str()) + .context("validated Robot structure has no root link")?; + render_link_body( + &mut out, robot, plan, assets, execution, structure, None, root, 2, true, + )?; + writeln!(out, "}}")?; + ensure!( + !out.contains(""), + "Robot source contains external controller" + ); + ensure!( + !out.contains("EXTERNPROTO"), + "Robot source contains EXTERNPROTO" + ); + Ok(out) +} + +#[allow( + clippy::too_many_arguments, + reason = "recursive rendering carries one immutable robot/plan/structure context" +)] +fn render_link_body( + out: &mut String, + robot: &Robot, + plan: &RobotSimulationPlan, + assets: &BTreeMap>, + execution: ExecutionId, + structure: &Structure, + namespace: Option<&ComponentInstanceId>, + link: &Link, + indent: usize, + root: bool, +) -> Result<()> { + if !root { + writeln!( + out, + "{:indent$}name \"{}\"", + "", + generation::quoted(&structural_name(namespace, link.name().as_str())) + )?; + } + if let Some(material) = contact_material(plan, namespace, link.name().as_str()) { + writeln!( + out, + "{:indent$}contactMaterial \"{}\"", + "", + generation::quoted(material) + )?; + } + let assembly = + resolve_fixed_assembly(robot, structure, namespace, link, &Isometry3::identity())?; + writeln!(out, "{:indent$}children [", "")?; + render_fixed_assembly(out, robot, plan, assets, execution, &assembly, indent + 2)?; + writeln!(out, "{:indent$}]", "")?; + let collisions = collect_assembly_collisions(&assembly); + if !collisions.is_empty() { + writeln!(out, "{:indent$}boundingObject Group {{", "")?; + writeln!(out, "{:width$}children [", "", width = indent + 2)?; + for collision in &collisions { + render_shape_at( + out, + &collision.origin, + collision.geometry, + None, + assets, + execution, + indent + 4, + false, + )?; + } + writeln!(out, "{:width$}]", "", width = indent + 2)?; + writeln!(out, "{:indent$}}}", "")?; + } + let mass = collect_assembly_mass(&assembly); + if let Some(inertial) = mass.finalize() { + let center = inertial.center; + let matrix = inertial.inertia; + writeln!(out, "{:indent$}physics Physics {{", "")?; + writeln!(out, "{:width$}density -1", "", width = indent + 2)?; + writeln!( + out, + "{:width$}mass {}", + "", + generation::number(inertial.mass), + width = indent + 2 + )?; + writeln!( + out, + "{:width$}centerOfMass [ {} {} {} ]", + "", + generation::number(center[0]), + generation::number(center[1]), + generation::number(center[2]), + width = indent + 2 + )?; + writeln!( + out, + "{:width$}inertiaMatrix [ {} {} {} {} {} {} ]", + "", + generation::number(matrix[(0, 0)]), + generation::number(matrix[(1, 1)]), + generation::number(matrix[(2, 2)]), + generation::number(matrix[(0, 1)]), + generation::number(matrix[(0, 2)]), + generation::number(matrix[(1, 2)]), + width = indent + 2 + )?; + writeln!(out, "{:indent$}}}", "")?; + } + Ok(()) +} + +fn contact_material<'a>( + plan: &'a RobotSimulationPlan, + namespace: Option<&ComponentInstanceId>, + link: &str, +) -> Option<&'a str> { + let component = namespace?; + plan.links + .iter() + .find(|planned| planned.component == *component && planned.link == link) + .and_then(|planned| planned.contact_material.as_deref()) +} + +fn structural_name(namespace: Option<&ComponentInstanceId>, local: &str) -> String { + namespace.map_or_else( + || local.to_owned(), + |component| format!("{component}__{local}"), + ) +} + +fn render_pose_wrapper( + out: &mut String, + transform: &Isometry3, + indent: usize, + render: impl FnOnce(&mut String) -> Result<()>, +) -> Result<()> { + if is_identity(transform) { + return render(out); + } + writeln!(out, "{:indent$}Pose {{", "")?; + render_isometry(out, transform, indent + 2)?; + writeln!(out, "{:width$}children [", "", width = indent + 2)?; + render(out)?; + writeln!(out, "{:width$}]", "", width = indent + 2)?; + writeln!(out, "{:indent$}}}", "")?; + Ok(()) +} + +struct StagedCollision<'a> { + origin: Isometry3, + geometry: &'a Geometry, +} + +/// One fixed rigid assembly, resolved once for its rendering-adjacent physics +/// facts. The same transform tree feeds collision and inertial lowering. +struct ResolvedAssemblyLink<'a> { + structure: &'a Structure, + namespace: Option<&'a ComponentInstanceId>, + link: &'a Link, + transform: Isometry3, +} + +fn resolve_fixed_assembly<'a>( + robot: &'a Robot, + structure: &'a Structure, + namespace: Option<&'a ComponentInstanceId>, + root: &'a Link, + transform: &Isometry3, +) -> Result>> { + let mut resolved = Vec::new(); + resolve_fixed_assembly_at(&mut resolved, robot, structure, namespace, root, transform)?; + Ok(resolved) +} + +fn resolve_fixed_assembly_at<'a>( + resolved: &mut Vec>, + robot: &'a Robot, + structure: &'a Structure, + namespace: Option<&'a ComponentInstanceId>, + link: &'a Link, + transform: &Isometry3, +) -> Result<()> { + resolved.push(ResolvedAssemblyLink { + structure, + namespace, + link, + transform: *transform, + }); + if namespace.is_none() { + for component in robot + .components() + .filter(|component| component.instance().mount_link() == link.name()) + { + let mounted = component.component_type().structure(); + let root = mounted + .link(mounted.root_link().as_str()) + .with_context(|| format!("component {} has no root link", component.id()))?; + resolve_fixed_assembly_at( + resolved, + robot, + mounted, + Some(component.id()), + root, + transform, + )?; + } + } + for joint in structure + .child_joints(link.name().as_str()) + .filter(|joint| joint.kind() == JointKind::Fixed) + { + let child = structure + .link(joint.child().as_str()) + .with_context(|| format!("joint {} has no child link", joint.name()))?; + let child_transform = transform * pose_to_isometry(joint.origin()); + resolve_fixed_assembly_at( + resolved, + robot, + structure, + namespace, + child, + &child_transform, + )?; + } + Ok(()) +} + +#[allow( + clippy::too_many_arguments, + reason = "lowering needs the complete native context" +)] +fn render_fixed_assembly( + out: &mut String, + robot: &Robot, + plan: &RobotSimulationPlan, + assets: &BTreeMap>, + execution: ExecutionId, + assembly: &[ResolvedAssemblyLink<'_>], + indent: usize, +) -> Result<()> { + for resolved in assembly { + let mut devices = String::new(); + render_link_devices( + &mut devices, + robot, + plan, + execution, + resolved.namespace, + resolved.link, + indent + 2, + )?; + if !devices.is_empty() { + render_pose_wrapper(out, &resolved.transform, indent, |out| { + out.push_str(&devices); + Ok(()) + })?; + } + for visual in resolved.link.visuals() { + let origin = resolved.transform * pose_to_isometry(visual.origin()); + render_shape_at( + out, + &origin, + visual.geometry(), + visual.material(), + assets, + execution, + indent, + true, + )?; + } + for joint in resolved + .structure + .child_joints(resolved.link.name().as_str()) + .filter(|joint| joint.kind() != JointKind::Fixed) + { + let mut rendered = String::new(); + render_joint( + &mut rendered, + robot, + plan, + assets, + execution, + resolved.structure, + resolved.namespace, + joint, + indent + 2, + )?; + render_pose_wrapper(out, &resolved.transform, indent, |out| { + out.push_str(&rendered); + Ok(()) + })?; + } + } + Ok(()) +} + +fn collect_assembly_collisions<'a>( + assembly: &'a [ResolvedAssemblyLink<'a>], +) -> Vec> { + assembly + .iter() + .flat_map(|resolved| { + resolved.link.collisions().map(|collision| StagedCollision { + origin: resolved.transform * pose_to_isometry(collision.origin()), + geometry: collision.geometry(), + }) + }) + .collect() +} + +#[derive(Default)] +struct MassProperties { + mass: f64, + weighted_center: Vector3, + inertia_about_root: Matrix3, +} + +struct ResolvedMassProperties { + mass: f64, + center: Vector3, + inertia: Matrix3, +} + +impl MassProperties { + fn add_link(&mut self, link: &Link, transform: &Isometry3) { + let inertial = link.inertial(); + let mass = inertial.mass_kg(); + if mass <= 0.0 { + return; + } + let inertial_transform = transform * pose_to_isometry(inertial.origin()); + let center = inertial_transform.translation.vector; + let [ixx, ixy, ixz, iyy, iyz, izz] = inertial.inertia().values(); + let local = Matrix3::new(ixx, ixy, ixz, ixy, iyy, iyz, ixz, iyz, izz); + let rotation = inertial_transform.rotation.to_rotation_matrix(); + let rotated = rotation.matrix() * local * rotation.matrix().transpose(); + self.mass += mass; + self.weighted_center += center * mass; + self.inertia_about_root += rotated + parallel_axis(mass, ¢er); + } + + #[cfg(test)] + fn extend(&mut self, other: Self) { + self.mass += other.mass; + self.weighted_center += other.weighted_center; + self.inertia_about_root += other.inertia_about_root; + } + + fn finalize(self) -> Option { + if self.mass <= 0.0 { + return None; + } + let center = self.weighted_center / self.mass; + Some(ResolvedMassProperties { + mass: self.mass, + center, + inertia: self.inertia_about_root - parallel_axis(self.mass, ¢er), + }) + } +} + +fn collect_assembly_mass(assembly: &[ResolvedAssemblyLink<'_>]) -> MassProperties { + let mut mass = MassProperties::default(); + for resolved in assembly { + mass.add_link(resolved.link, &resolved.transform); + } + mass +} + +fn parallel_axis(mass: f64, displacement: &Vector3) -> Matrix3 { + mass * (Matrix3::identity() * displacement.dot(displacement) + - displacement * displacement.transpose()) +} + +fn pose_to_isometry(pose: Pose) -> Isometry3 { + let [x, y, z] = pose.xyz(); + let [roll, pitch, yaw] = pose.rpy(); + Isometry3::from_parts( + Translation3::new(x, y, z), + UnitQuaternion::from_euler_angles(roll, pitch, yaw), + ) +} + +fn is_identity(transform: &Isometry3) -> bool { + transform.translation.vector.norm() <= 1.0e-12 && transform.rotation.angle() <= 1.0e-12 +} + +fn render_isometry(out: &mut String, transform: &Isometry3, indent: usize) -> Result<()> { + let translation = transform.translation.vector; + let rotation = transform + .rotation + .axis_angle() + .map_or([0.0, 0.0, 1.0, 0.0], |(axis, angle)| { + [axis.x, axis.y, axis.z, angle] + }); + render_pose( + out, + [translation.x, translation.y, translation.z], + rotation, + indent, + ) +} + +fn render_link_devices( + out: &mut String, + robot: &Robot, + plan: &RobotSimulationPlan, + execution: ExecutionId, + namespace: Option<&ComponentInstanceId>, + link: &Link, + indent: usize, +) -> Result<()> { + let target = structural_name(namespace, link.name().as_str()); + for binding in plan + .capabilities + .iter() + .filter(|binding| matches!(binding.target(), PlannedTarget::Link { id } if id == &target)) + { + let declared = robot + .capability(binding.reference()) + .with_context(|| format!("planned capability {} disappeared", binding.reference()))?; + let simulated = robot + .component(binding.reference().component_id.as_str()) + .and_then(|component| component.simulation()) + .and_then(|simulation| { + simulation.capability(binding.reference().capability_id.as_str()) + }) + .with_context(|| format!("planned simulation {} disappeared", binding.reference()))?; + render_link_device(out, binding, declared, simulated, execution, indent)?; + } + Ok(()) +} + +fn render_link_device( + out: &mut String, + binding: &CapabilityBinding, + declared: &DeclaredCapability, + simulated: &SimulatedCapability, + execution: ExecutionId, + indent: usize, +) -> Result<()> { + let name = generation::quoted(binding.native_device()); + match (declared, simulated) { + (DeclaredCapability::Accelerometer(_), SimulatedCapability::Accelerometer(config)) => { + writeln!(out, "{:indent$}Accelerometer {{", "")?; + writeln!(out, "{:width$}name \"{name}\"", "", width = indent + 2)?; + render_resolution(out, config.resolution, indent + 2)?; + render_lookup_table(out, config.lookup_table.as_deref(), indent + 2)?; + writeln!(out, "{:indent$}}}", "")?; + } + (DeclaredCapability::Gyroscope(_), SimulatedCapability::Gyroscope(config)) => { + writeln!(out, "{:indent$}Gyro {{", "")?; + writeln!(out, "{:width$}name \"{name}\"", "", width = indent + 2)?; + render_resolution(out, config.resolution, indent + 2)?; + render_lookup_table(out, config.lookup_table.as_deref(), indent + 2)?; + writeln!(out, "{:indent$}}}", "")?; + } + (DeclaredCapability::Imu(_), SimulatedCapability::Imu(config)) => { + writeln!(out, "{:indent$}InertialUnit {{", "")?; + writeln!(out, "{:width$}name \"{name}\"", "", width = indent + 2)?; + render_resolution(out, config.resolution, indent + 2)?; + if let Some(noise) = config.noise { + writeln!( + out, + "{:width$}noise {}", + "", + generation::number(noise), + width = indent + 2 + )?; + } + writeln!(out, "{:indent$}}}", "")?; + for (node, suffix) in [("Accelerometer", "__accel"), ("Gyro", "__gyro")] { + writeln!(out, "{:indent$}{node} {{", "")?; + writeln!( + out, + "{:width$}name \"{}{}\"", + "", + name, + suffix, + width = indent + 2 + )?; + writeln!(out, "{:indent$}}}", "")?; + } + } + (DeclaredCapability::Gnss(_), SimulatedCapability::Gnss(config)) => { + render_gnss_node(out, &name, config, indent)?; + } + (DeclaredCapability::Camera(declared), SimulatedCapability::Camera(config)) => { + writeln!(out, "{:indent$}Camera {{", "")?; + writeln!(out, "{:width$}name \"{name}\"", "", width = indent + 2)?; + writeln!( + out, + "{:width$}width {}", + "", + declared.width_px, + width = indent + 2 + )?; + writeln!( + out, + "{:width$}height {}", + "", + declared.height_px, + width = indent + 2 + )?; + if let Some(fov) = declared.field_of_view_rad { + writeln!( + out, + "{:width$}fieldOfView {}", + "", + generation::number(fov), + width = indent + 2 + )?; + } + if let Some(projection) = config.projection { + let projection = match projection { + CameraProjection::Planar => "planar", + CameraProjection::Cylindrical => "cylindrical", + CameraProjection::Spherical => "spherical", + }; + writeln!( + out, + "{:width$}projection \"{projection}\"", + "", + width = indent + 2 + )?; + } + render_camera_bounds(out, config.near, config.far, indent + 2)?; + render_optional_number(out, "exposure", config.exposure, indent + 2)?; + render_optional_bool(out, "antiAliasing", config.anti_aliasing, indent + 2)?; + render_optional_number( + out, + "ambientOcclusionRadius", + config.ambient_occlusion_radius, + indent + 2, + )?; + render_optional_number(out, "bloomThreshold", config.bloom_threshold, indent + 2)?; + render_optional_number(out, "motionBlur", config.motion_blur, indent + 2)?; + render_noise(out, config.noise, indent + 2)?; + if let Some(mask) = &config.noise_mask_url { + writeln!( + out, + "{:width$}noiseMaskUrl \"../assets/robots/{}/{}\"", + "", + execution, + generation::quoted(mask), + width = indent + 2 + )?; + } + writeln!(out, "{:indent$}}}", "")?; + } + (DeclaredCapability::Depth(declared), SimulatedCapability::Depth(config)) => { + writeln!(out, "{:indent$}RangeFinder {{", "")?; + writeln!(out, "{:width$}name \"{name}\"", "", width = indent + 2)?; + writeln!( + out, + "{:width$}width {}", + "", + declared.width_px, + width = indent + 2 + )?; + writeln!( + out, + "{:width$}height {}", + "", + declared.height_px, + width = indent + 2 + )?; + if let Some(fov) = declared.field_of_view_rad { + writeln!( + out, + "{:width$}fieldOfView {}", + "", + generation::number(fov), + width = indent + 2 + )?; + } + render_optional_number(out, "minRange", declared.min_range_m, indent + 2)?; + render_optional_number(out, "maxRange", declared.max_range_m, indent + 2)?; + render_resolution(out, config.resolution, indent + 2)?; + render_noise(out, config.noise, indent + 2)?; + render_optional_number(out, "motionBlur", config.motion_blur, indent + 2)?; + writeln!(out, "{:indent$}}}", "")?; + } + (DeclaredCapability::Range(declared), SimulatedCapability::Range(config)) => { + writeln!(out, "{:indent$}DistanceSensor {{", "")?; + writeln!(out, "{:width$}name \"{name}\"", "", width = indent + 2)?; + writeln!(out, "{:width$}type \"laser\"", "", width = indent + 2)?; + writeln!( + out, + "{:width$}aperture {}", + "", + generation::number(declared.field_of_view_rad), + width = indent + 2 + )?; + writeln!(out, "{:width$}lookupTable [", "", width = indent + 2)?; + for distance in [declared.min_range_m, declared.max_range_m] { + writeln!( + out, + "{:width$}{} {} {}", + "", + generation::number(distance), + generation::number(distance), + generation::number(config.noise.unwrap_or(0.0)), + width = indent + 4 + )?; + } + writeln!(out, "{:width$}]", "", width = indent + 2)?; + render_resolution(out, config.resolution, indent + 2)?; + writeln!(out, "{:indent$}}}", "")?; + } + _ => bail!( + "planned link device {} does not match its compiled capability", + binding.reference() + ), + } + Ok(()) +} + +fn render_gnss_node( + out: &mut String, + name: &str, + config: &phoxal::model::simulation::Gnss, + indent: usize, +) -> Result<()> { + writeln!(out, "{:indent$}GPS {{", "")?; + writeln!(out, "{:width$}name \"{name}\"", "", width = indent + 2)?; + render_resolution(out, config.resolution, indent + 2)?; + render_optional_number(out, "accuracy", config.accuracy, indent + 2)?; + render_optional_number( + out, + "noiseCorrelation", + config.noise_correlation, + indent + 2, + )?; + render_optional_number(out, "speedResolution", config.speed_resolution, indent + 2)?; + render_optional_number(out, "speedNoise", config.speed_noise, indent + 2)?; + writeln!(out, "{:indent$}}}", "")?; + Ok(()) +} + +fn render_resolution(out: &mut String, value: Option, indent: usize) -> Result<()> { + if let Some(value) = value { + writeln!( + out, + "{:indent$}resolution {}", + "", + generation::number(value) + )?; + } + Ok(()) +} + +fn render_noise(out: &mut String, value: Option, indent: usize) -> Result<()> { + if let Some(value) = value { + writeln!(out, "{:indent$}noise {}", "", generation::number(value))?; + } + Ok(()) +} + +fn render_optional_number( + out: &mut String, + field: &str, + value: Option, + indent: usize, +) -> Result<()> { + if let Some(value) = value { + writeln!(out, "{:indent$}{field} {}", "", generation::number(value))?; + } + Ok(()) +} + +fn render_optional_bool( + out: &mut String, + field: &str, + value: Option, + indent: usize, +) -> Result<()> { + if let Some(value) = value { + writeln!( + out, + "{:indent$}{field} {}", + "", + if value { "TRUE" } else { "FALSE" } + )?; + } + Ok(()) +} + +fn render_camera_bounds( + out: &mut String, + near: Option, + far: Option, + indent: usize, +) -> Result<()> { + if let Some(value) = near { + writeln!(out, "{:indent$}near {}", "", generation::number(value))?; + } + if let Some(value) = far { + writeln!(out, "{:indent$}far {}", "", generation::number(value))?; + } + Ok(()) +} + +fn render_lookup_table(out: &mut String, table: Option<&[Vec]>, indent: usize) -> Result<()> { + let Some(table) = table else { + return Ok(()); + }; + writeln!(out, "{:indent$}lookupTable [", "")?; + for row in table { + ensure!( + row.len() == 3, + "Webots lookup-table rows must have three values" + ); + writeln!( + out, + "{:width$}{} {} {}", + "", + generation::number(row[0]), + generation::number(row[1]), + generation::number(row[2]), + width = indent + 2 + )?; + } + writeln!(out, "{:indent$}]", "")?; + Ok(()) +} + +#[allow( + clippy::too_many_arguments, + reason = "joint rendering needs the immutable context and its indentation" +)] +fn render_joint( + out: &mut String, + robot: &Robot, + plan: &RobotSimulationPlan, + assets: &BTreeMap>, + execution: ExecutionId, + structure: &Structure, + namespace: Option<&ComponentInstanceId>, + joint: &Joint, + indent: usize, +) -> Result<()> { + let child = structure + .link(joint.child().as_str()) + .with_context(|| format!("joint {} has no child link", joint.name()))?; + let pose = joint.origin(); + let [x, y, z] = pose.xyz(); + let [ax, ay, az, angle] = generation::axis_angle(pose); + match joint.kind() { + JointKind::Fixed => { + writeln!(out, "{:indent$}Solid {{", "")?; + render_pose(out, [x, y, z], [ax, ay, az, angle], indent + 2)?; + render_link_body( + out, + robot, + plan, + assets, + execution, + structure, + namespace, + child, + indent + 2, + false, + )?; + writeln!(out, "{:indent$}}}", "")?; + } + kind @ (JointKind::Revolute | JointKind::Continuous | JointKind::Prismatic) => { + let (node, parameters, motor) = match kind { + JointKind::Revolute | JointKind::Continuous => { + ("HingeJoint", "HingeJointParameters", "RotationalMotor") + } + JointKind::Prismatic => ("SliderJoint", "JointParameters", "LinearMotor"), + _ => unreachable!(), + }; + writeln!(out, "{:indent$}{node} {{", "")?; + writeln!( + out, + "{:width$}jointParameters {parameters} {{", + "", + width = indent + 2 + )?; + let dynamics = joint.dynamics(); + render_joint_parameters( + out, + kind, + joint.axis(), + [x, y, z], + [joint.limit().lower(), joint.limit().upper()], + dynamics.map_or(0.0, |value| value.damping()), + dynamics.map_or(0.0, |value| value.friction()), + indent + 4, + )?; + writeln!(out, "{:width$}}}", "", width = indent + 2)?; + let devices = plan + .capabilities + .iter() + .filter(|binding| { + matches!(binding.target(), PlannedTarget::Joint { id } if id == &structural_name(namespace, joint.name().as_str())) + }) + .collect::>(); + if !devices.is_empty() { + writeln!(out, "{:width$}device [", "", width = indent + 2)?; + for binding in devices { + match binding.kind() { + CapabilityKind::Motor => { + let declared = + robot.capability(binding.reference()).with_context(|| { + format!("planned motor {} disappeared", binding.reference()) + })?; + let simulated = robot + .component(binding.reference().component_id.as_str()) + .and_then(|component| component.simulation()) + .and_then(|simulation| { + simulation + .capability(binding.reference().capability_id.as_str()) + }) + .with_context(|| { + format!( + "planned motor simulation {} disappeared", + binding.reference() + ) + })?; + let ( + DeclaredCapability::Motor(declared), + SimulatedCapability::Motor(simulated), + ) = (declared, simulated) + else { + bail!("planned motor {} changed kind", binding.reference()); + }; + writeln!(out, "{:width$}{motor} {{", "", width = indent + 4)?; + writeln!( + out, + "{:width$}name \"{}\"", + "", + generation::quoted(binding.native_device()), + width = indent + 6 + )?; + if let Some(velocity) = declared.max_velocity_radps { + writeln!( + out, + "{:width$}maxVelocity {}", + "", + generation::number(velocity / declared.gear_ratio.abs()), + width = indent + 6 + )?; + } + if let Some(torque) = declared.max_torque_nm { + writeln!( + out, + "{:width$}maxTorque {}", + "", + generation::number(torque * declared.gear_ratio.abs()), + width = indent + 6 + )?; + } + if let Some(acceleration) = simulated.acceleration_radps2 { + writeln!( + out, + "{:width$}acceleration {}", + "", + generation::number(acceleration / declared.gear_ratio.abs()), + width = indent + 6 + )?; + } + if let Some(pid) = &simulated.control_pid { + writeln!( + out, + "{:width$}controlPID {} {} {}", + "", + generation::number(pid[0]), + generation::number(pid[1]), + generation::number(pid[2]), + width = indent + 6 + )?; + } + writeln!(out, "{:width$}}}", "", width = indent + 4)?; + } + CapabilityKind::Encoder => { + let simulated = robot + .component(binding.reference().component_id.as_str()) + .and_then(|component| component.simulation()) + .and_then(|simulation| { + simulation + .capability(binding.reference().capability_id.as_str()) + }) + .with_context(|| { + format!( + "planned encoder simulation {} disappeared", + binding.reference() + ) + })?; + let SimulatedCapability::Encoder(simulated) = simulated else { + bail!("planned encoder {} changed kind", binding.reference()); + }; + writeln!(out, "{:width$}PositionSensor {{", "", width = indent + 4)?; + writeln!( + out, + "{:width$}name \"{}\"", + "", + generation::quoted(binding.native_device()), + width = indent + 6 + )?; + render_resolution(out, simulated.resolution, indent + 6)?; + render_noise(out, simulated.noise, indent + 6)?; + writeln!(out, "{:width$}}}", "", width = indent + 4)?; + } + other => bail!("unsupported planned joint device {other}"), + } + } + writeln!(out, "{:width$}]", "", width = indent + 2)?; + } + writeln!(out, "{:width$}endPoint Solid {{", "", width = indent + 2)?; + render_pose(out, [x, y, z], [ax, ay, az, angle], indent + 4)?; + render_link_body( + out, + robot, + plan, + assets, + execution, + structure, + namespace, + child, + indent + 4, + false, + )?; + writeln!(out, "{:width$}}}", "", width = indent + 2)?; + writeln!(out, "{:indent$}}}", "")?; + } + JointKind::Floating | JointKind::Planar | JointKind::Spherical => { + bail!( + "Webots generation does not support {:?} joint {}", + joint.kind(), + joint.name() + ); + } + } + Ok(()) +} + +#[allow( + clippy::too_many_arguments, + reason = "the Webots joint record has seven independent validated fields" +)] +fn render_joint_parameters( + out: &mut String, + kind: JointKind, + axis: [f64; 3], + anchor: [f64; 3], + limits: [f64; 2], + damping: f64, + friction: f64, + indent: usize, +) -> Result<()> { + writeln!( + out, + "{:indent$}axis {} {} {}", + "", + generation::number(axis[0]), + generation::number(axis[1]), + generation::number(axis[2]) + )?; + if matches!(kind, JointKind::Revolute | JointKind::Continuous) { + writeln!( + out, + "{:indent$}anchor {} {} {}", + "", + generation::number(anchor[0]), + generation::number(anchor[1]), + generation::number(anchor[2]) + )?; + } + writeln!( + out, + "{:indent$}dampingConstant {}", + "", + generation::number(damping) + )?; + writeln!( + out, + "{:indent$}staticFriction {}", + "", + generation::number(friction) + )?; + if matches!(kind, JointKind::Revolute | JointKind::Prismatic) { + writeln!( + out, + "{:indent$}minStop {}", + "", + generation::number(limits[0]) + )?; + writeln!( + out, + "{:indent$}maxStop {}", + "", + generation::number(limits[1]) + )?; + } + Ok(()) +} + +fn render_pose(out: &mut String, xyz: [f64; 3], rotation: [f64; 4], indent: usize) -> Result<()> { + writeln!( + out, + "{:indent$}translation {} {} {}", + "", + generation::number(xyz[0]), + generation::number(xyz[1]), + generation::number(xyz[2]) + )?; + writeln!( + out, + "{:indent$}rotation {} {} {} {}", + "", + generation::number(rotation[0]), + generation::number(rotation[1]), + generation::number(rotation[2]), + generation::number(rotation[3]) + )?; + Ok(()) +} + +#[allow( + clippy::too_many_arguments, + reason = "native shape rendering carries its authored facts plus staging context" +)] +fn render_shape_at( + out: &mut String, + transform: &Isometry3, + geometry: &Geometry, + material: Option<&Material>, + assets: &BTreeMap>, + execution: ExecutionId, + indent: usize, + appearance: bool, +) -> Result<()> { + let wrapper = if appearance { "Transform" } else { "Pose" }; + writeln!(out, "{:indent$}{wrapper} {{", "")?; + render_isometry(out, transform, indent + 2)?; + writeln!(out, "{:width$}children [", "", width = indent + 2)?; + match geometry { + Geometry::Mesh { asset, scale } => { + ensure!( + material.is_none(), + "Robot mesh visuals must use their bundled materials" + ); + let decoded = crate::obj::decode(asset, assets).with_context(|| { + format!("Robot mesh asset {asset} is outside the supported subset") + })?; + if appearance { + writeln!(out, "{:width$}Transform {{", "", width = indent + 4)?; + if let Some(scale) = scale { + writeln!( + out, + "{:width$}scale {} {} {}", + "", + generation::number(scale[0]), + generation::number(scale[1]), + generation::number(scale[2]), + width = indent + 6 + )?; + } + writeln!(out, "{:width$}children [", "", width = indent + 6)?; + decoded.render_visual(out, indent + 8, |primitive| { + Ok(format!( + "../.phoxal/textures/robots/{}/{}", + execution, + generation::extracted_image_path(asset.as_str(), primitive) + )) + })?; + writeln!(out, "{:width$}]", "", width = indent + 6)?; + writeln!(out, "{:width$}}}", "", width = indent + 4)?; + } else { + decoded.render_collision_scaled(out, indent + 4, scale.unwrap_or([1.0; 3]))?; + } + } + primitive => { + writeln!(out, "{:width$}Shape {{", "", width = indent + 4)?; + if appearance { + render_material(out, material, assets, execution, indent + 6)?; + } + write!(out, "{:width$}geometry ", "", width = indent + 6)?; + render_primitive(out, primitive)?; + writeln!(out, "{:width$}}}", "", width = indent + 4)?; + } + } + writeln!(out, "{:width$}]", "", width = indent + 2)?; + writeln!(out, "{:indent$}}}", "")?; + Ok(()) +} + +fn render_material( + out: &mut String, + material: Option<&Material>, + assets: &BTreeMap>, + execution: ExecutionId, + indent: usize, +) -> Result<()> { + let explicit_color = material.and_then(Material::color); + let color = explicit_color.unwrap_or([0.6, 0.6, 0.6, 1.0]); + let texture = material.and_then(Material::texture); + ensure!( + texture.is_none() || explicit_color.is_none() || color == [1.0; 4], + "Robot visual material cannot combine a texture with a non-white color exactly" + ); + writeln!(out, "{:indent$}appearance PBRAppearance {{", "")?; + writeln!( + out, + "{:width$}baseColor {} {} {}", + "", + generation::number(if texture.is_some() { 1.0 } else { color[0] }), + generation::number(if texture.is_some() { 1.0 } else { color[1] }), + generation::number(if texture.is_some() { 1.0 } else { color[2] }), + width = indent + 2 + )?; + if texture.is_none() && color[3] < 1.0 { + writeln!( + out, + "{:width$}transparency {}", + "", + generation::number(1.0 - color[3]), + width = indent + 2 + )?; + } + writeln!(out, "{:width$}roughness 0.7", "", width = indent + 2)?; + if let Some(texture) = texture { + let bytes = assets.get(texture).with_context(|| { + format!("Robot material texture asset {texture} was not prefetched") + })?; + let format = image::guess_format(bytes).with_context(|| { + format!("Robot material texture asset {texture} has no recognized image format") + })?; + ensure!( + matches!(format, image::ImageFormat::Png | image::ImageFormat::Jpeg), + "Robot material texture asset {texture} must be PNG or JPEG" + ); + let decoded = image::load_from_memory_with_format(bytes, format) + .with_context(|| format!("Robot material texture asset {texture} is not decodable"))?; + ensure!( + decoded.width() > 0 && decoded.height() > 0, + "Robot material texture asset {texture} has zero dimensions" + ); + writeln!( + out, + "{:width$}baseColorMap ImageTexture {{", + "", + width = indent + 2 + )?; + writeln!( + out, + "{:width$}url [\"../assets/robots/{}/{}\"]", + "", + execution, + generation::quoted(texture.as_str()), + width = indent + 4 + )?; + writeln!(out, "{:width$}}}", "", width = indent + 2)?; + } + writeln!(out, "{:indent$}}}", "")?; + Ok(()) +} + +fn render_primitive(out: &mut String, geometry: &Geometry) -> Result<()> { + match geometry { + Geometry::Box { size } => writeln!( + out, + "Box {{ size {} {} {} }}", + generation::number(size[0]), + generation::number(size[1]), + generation::number(size[2]) + )?, + Geometry::Cylinder { radius, length } => writeln!( + out, + "Cylinder {{ radius {} height {} }}", + generation::number(*radius), + generation::number(*length) + )?, + Geometry::Capsule { radius, length } => writeln!( + out, + "Capsule {{ radius {} height {} }}", + generation::number(*radius), + generation::number(*length) + )?, + Geometry::Sphere { radius } => { + writeln!(out, "Sphere {{ radius {} }}", generation::number(*radius))?; + } + Geometry::Mesh { .. } => bail!("mesh must use the decoded native renderer"), + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use phoxal::model::builder::{ + Collision, Inertial, Link, Material as BuilderMaterial, RobotBuilder, Visual, + }; + use phoxal::model::geometry::Geometry; + use phoxal::model::simulation; + + #[test] + fn joint_parameters_retain_anchor_damping_friction_and_limits() { + let mut revolute = String::new(); + render_joint_parameters( + &mut revolute, + JointKind::Revolute, + [0.0, 1.0, 0.0], + [1.0, 2.0, 3.0], + [-0.5, 0.75], + 0.125, + 0.25, + 0, + ) + .expect("revolute parameters render"); + assert!(revolute.contains("axis 0 1 0")); + assert!(revolute.contains("anchor 1 2 3")); + assert!(revolute.contains("dampingConstant 0.125")); + assert!(revolute.contains("staticFriction 0.25")); + assert!(revolute.contains("minStop -0.5")); + assert!(revolute.contains("maxStop 0.75")); + + let mut prismatic = String::new(); + render_joint_parameters( + &mut prismatic, + JointKind::Prismatic, + [1.0, 0.0, 0.0], + [4.0, 5.0, 6.0], + [-1.0, 2.0], + 0.0, + 0.0, + 0, + ) + .expect("prismatic parameters render"); + assert!(!prismatic.contains("anchor")); + assert!(prismatic.contains("minStop -1")); + assert!(prismatic.contains("maxStop 2")); + } + + #[test] + fn fixed_transform_composition_moves_child_facts_into_one_assembly() { + let parent: Pose = serde_json::from_value(serde_json::json!({ + "xyz": [1.0, 0.0, 0.0], + "rpy": [0.0, 0.0, std::f64::consts::FRAC_PI_2] + })) + .expect("parent pose"); + let child: Pose = serde_json::from_value(serde_json::json!({ + "xyz": [1.0, 0.0, 0.0], + "rpy": [0.0, 0.0, 0.0] + })) + .expect("child pose"); + let combined = pose_to_isometry(parent) * pose_to_isometry(child); + assert!((combined.translation.x - 1.0).abs() < 1.0e-12); + assert!((combined.translation.y - 1.0).abs() < 1.0e-12); + assert!(combined.translation.z.abs() < 1.0e-12); + assert!((combined.rotation.angle() - std::f64::consts::FRAC_PI_2).abs() < 1.0e-12); + } + + #[test] + fn fixed_point_masses_use_parallel_axis_combination_about_the_final_center() { + let mut combined = MassProperties { + mass: 1.0, + weighted_center: Vector3::zeros(), + inertia_about_root: Matrix3::zeros(), + }; + let offset = Vector3::new(2.0, 0.0, 0.0); + combined.extend(MassProperties { + mass: 1.0, + weighted_center: offset, + inertia_about_root: parallel_axis(1.0, &offset), + }); + let resolved = combined.finalize().expect("positive combined mass"); + assert_eq!(resolved.mass, 2.0); + assert!((resolved.center.x - 1.0).abs() < 1.0e-12); + assert!(resolved.center.y.abs() < 1.0e-12); + assert!(resolved.center.z.abs() < 1.0e-12); + assert!(resolved.inertia[(0, 0)].abs() < 1.0e-12); + assert!((resolved.inertia[(1, 1)] - 2.0).abs() < 1.0e-12); + assert!((resolved.inertia[(2, 2)] - 2.0).abs() < 1.0e-12); + } + + #[test] + fn gps_node_uses_world_info_for_its_coordinate_system() { + let mut source = String::new(); + render_gnss_node( + &mut source, + "gnss", + &simulation::Gnss { + sampling_period_hz: 10.0, + resolution: Some(0.01), + accuracy: Some(0.5), + ..Default::default() + }, + 0, + ) + .expect("GPS renders"); + assert!(source.starts_with("GPS {\n")); + assert!(source.contains("name \"gnss\"")); + assert!(source.contains("resolution 0.01")); + assert!(source.contains("accuracy 0.5")); + assert!(!source.contains("coordinateSystem")); + } + + #[test] + fn mounted_component_structure_is_inlined_with_namespaced_devices() { + let robot = RobotBuilder::new("rover") + .component_type("wheel", |builder| { + builder.encoder("encoder", "axle").simulated( + "encoder", + simulation::Capability::Encoder(simulation::Encoder { + sampling_period_hz: 50.0, + ..Default::default() + }), + ) + }) + .component("left", "wheel") + .build() + .expect("robot"); + let plan = crate::plan::derive_robot_plan(&robot, 12, |_id| { + Result::, &'static str>::Ok(vec![1]) + }) + .expect("complete plan"); + let pose: Pose = serde_json::from_value(serde_json::json!({ + "xyz": [0.0, 0.0, 0.0], + "rpy": [0.0, 0.0, 0.0] + })) + .expect("pose"); + let source = render_robot( + &robot, + &plan, + &BTreeMap::new(), + ExecutionId::try_from(0x1000_0000_0000_0000_0000_0000_0000_0001).expect("execution"), + pose, + "tcp/127.0.0.1:7447", + "tcp://127.0.0.1:7000", + ) + .expect("Robot renders"); + assert!( + !source.contains("name \"left__mount\""), + "the massless component mount frame must not become an intermediate Solid" + ); + assert!(source.contains("HingeJoint {")); + assert!(source.contains("PositionSensor {")); + assert!(source.contains("name \"left.encoder\"")); + assert!(!source.contains("EXTERNPROTO")); + let _: webots_proto_ast::Proto = source.parse().expect("R2025a Robot source parses"); + } + + #[test] + fn dynamically_imported_robot_mesh_uses_native_indexed_geometry() { + let asset = AssetId::new("meshes/drive_motor.glb").expect("asset id"); + let bytes = + include_bytes!("../../../../fixture/components/drive_motor/meshes/drive_motor.glb") + .to_vec(); + let robot = RobotBuilder::new("mesh-robot") + .link(Link { + name: "body", + visuals: vec![Visual::new(Geometry::Mesh { + asset: asset.clone(), + scale: Some([0.5, 0.75, 1.25]), + })], + collisions: vec![Collision::new(Geometry::Box { + size: [0.2, 0.3, 0.4], + })], + ..Link::default() + }) + .build() + .expect("mesh Robot"); + let assets = BTreeMap::from([(asset, bytes)]); + let plan = crate::plan::derive_robot_plan(&robot, 12, |id| { + assets + .get(id) + .cloned() + .ok_or_else(|| format!("missing {id}")) + }) + .expect("mesh plan"); + let pose: Pose = serde_json::from_value(serde_json::json!({ + "xyz": [0.0, 0.0, 0.0], + "rpy": [0.0, 0.0, 0.0] + })) + .expect("pose"); + let source = render_robot( + &robot, + &plan, + &assets, + ExecutionId::try_from(0x1000_0000_0000_0000_0000_0000_0000_0002).expect("execution"), + pose, + "tcp/127.0.0.1:7447", + "tcp://127.0.0.1:7000", + ) + .expect("Robot renders"); + assert!(source.contains("geometry IndexedFaceSet")); + assert!(source.contains("scale 0.5 0.75 1.25")); + assert!(!source.contains("CadShape")); + assert!(!source.contains("url [\"../assets/robots/")); + let bounding = source + .split_once("boundingObject Group") + .expect("Robot has bounding object") + .1; + assert!(bounding.contains("Pose {")); + assert!(!bounding.contains("Transform {")); + let _: webots_proto_ast::Proto = source.parse().expect("native Robot source parses"); + } + + #[test] + fn primitive_robot_visual_retains_its_authored_material() { + let robot = RobotBuilder::new("painted-robot") + .link(Link { + name: "body", + visuals: vec![Visual { + material: Some(BuilderMaterial { + name: "paint", + color: Some([0.2, 0.4, 0.6, 0.25]), + texture: None, + }), + ..Visual::new(Geometry::Box { + size: [0.2, 0.3, 0.4], + }) + }], + collisions: vec![Collision::new(Geometry::Box { + size: [0.2, 0.3, 0.4], + })], + ..Link::default() + }) + .build() + .expect("painted Robot"); + let plan = + crate::plan::derive_robot_plan(&robot, 12, |_| Err("unexpected asset".to_owned())) + .expect("painted plan"); + let source = render_robot( + &robot, + &plan, + &BTreeMap::new(), + ExecutionId::try_from(0x1000_0000_0000_0000_0000_0000_0000_0003).expect("execution"), + serde_json::from_value(serde_json::json!({ + "xyz": [0.0, 0.0, 0.0], + "rpy": [0.0, 0.0, 0.0] + })) + .expect("pose"), + "tcp/127.0.0.1:7447", + "tcp://127.0.0.1:7000", + ) + .expect("painted Robot renders"); + assert!(source.contains("baseColor 0.20000000000000001"), "{source}"); + assert!(!source.contains("baseColor 0.6 0.6 0.6"), "{source}"); + assert!(source.contains("transparency 0.75"), "{source}"); + let _: webots_proto_ast::Proto = source.parse().expect("painted Robot source parses"); + } + + #[test] + fn component_mounted_on_fixed_descendant_contributes_collision_and_mass() { + let robot = RobotBuilder::new("carrier") + .link(Link { + name: "payload_mount", + ..Link::default() + }) + .component_type("payload", |builder| { + builder.link(Link { + name: "body", + inertial: Inertial { + mass_kg: 2.0, + ..Inertial::default() + }, + collisions: vec![Collision::new(Geometry::Box { + size: [0.2, 0.3, 0.4], + })], + ..Link::default() + }) + }) + .component_with("cargo", "payload", |mounted| { + mounted.mounted_on("payload_mount") + }) + .build() + .expect("robot"); + let structure = robot.structure(); + let root = structure + .link(structure.root_link().as_str()) + .expect("physical root"); + assert_ne!( + robot + .component("cargo") + .expect("mounted component") + .instance() + .mount_link(), + root.name(), + "the fixture must exercise a fixed descendant mount" + ); + + let assembly = + resolve_fixed_assembly(&robot, structure, None, root, &Isometry3::identity()) + .expect("resolved assembly"); + let collisions = collect_assembly_collisions(&assembly); + assert!(collisions.iter().any(|collision| { + matches!(collision.geometry, Geometry::Box { size } if *size == [0.2, 0.3, 0.4]) + })); + let mass = collect_assembly_mass(&assembly) + .finalize() + .expect("positive mounted mass"); + assert!(mass.mass >= 2.0); + } +} diff --git a/simulators/webots/host/src/runtime/checkpoint.rs b/simulators/webots/host/src/runtime/checkpoint.rs new file mode 100644 index 00000000..8815017a --- /dev/null +++ b/simulators/webots/host/src/runtime/checkpoint.rs @@ -0,0 +1,247 @@ +use super::*; + +const CHECKPOINT_QUEUE_CAPACITY: usize = 64; + +enum WriterCommand { + Checkpoint(Box), + Flush(mpsc::Sender>), + Finish(mpsc::Sender>), +} + +/// Single bounded ordered owner for durable checkpoint writes. +pub(super) struct CheckpointWriter { + sender: Mutex>>, + failure: Arc>>, + worker: Mutex>>, +} + +impl CheckpointWriter { + pub(super) fn new(evidence: Arc) -> Result { + Self::with_writer(move |checkpoint| { + evidence + .write_checkpoint(checkpoint) + .map_err(|error| format!("failed to persist world checkpoint: {error:#}")) + }) + } + + fn with_writer( + write: impl Fn(&WorldCheckpoint) -> Result<(), String> + Send + 'static, + ) -> Result { + let (sender, receiver) = mpsc::sync_channel(CHECKPOINT_QUEUE_CAPACITY); + let failure = Arc::new(Mutex::new(None)); + let worker_failure = Arc::clone(&failure); + let worker = thread::Builder::new() + .name("phoxal-world-checkpoint".to_owned()) + .spawn(move || run_writer(receiver, &worker_failure, write)) + .map_err(|error| format!("failed to start checkpoint writer thread: {error}"))?; + Ok(Self { + sender: Mutex::new(Some(sender)), + failure, + worker: Mutex::new(Some(worker)), + }) + } + + /// Assign queue order without waiting for filesystem I/O. + pub(super) fn submit(&self, checkpoint: WorldCheckpoint) -> Result<(), String> { + self.check_failure()?; + let sender = lock(&self.sender); + let sender = sender + .as_ref() + .ok_or_else(|| "world checkpoint writer is finished".to_owned())?; + sender + .try_send(WriterCommand::Checkpoint(Box::new(checkpoint))) + .map_err(|error| match error { + mpsc::TrySendError::Full(_) => { + "world checkpoint writer queue is saturated".to_owned() + } + mpsc::TrySendError::Disconnected(_) => "world checkpoint writer stopped".to_owned(), + }) + } + + /// Wait until every earlier assigned checkpoint is durable. + pub(super) fn flush(&self) -> Result<(), String> { + self.check_failure()?; + let (acknowledgement, complete) = mpsc::channel(); + { + let sender = lock(&self.sender); + sender + .as_ref() + .ok_or_else(|| "world checkpoint writer is finished".to_owned())? + .send(WriterCommand::Flush(acknowledgement)) + .map_err(|_| "world checkpoint writer stopped".to_owned())?; + } + complete + .recv() + .map_err(|_| "world checkpoint writer stopped before flush".to_owned())??; + self.check_failure() + } + + /// Flush, stop, and join the ordered writer before terminal evidence. + pub(super) fn finish(&self) -> Result<(), String> { + let mut failures = Vec::new(); + let sender = lock(&self.sender).take(); + if let Some(sender) = sender { + let (acknowledgement, complete) = mpsc::channel(); + if sender.send(WriterCommand::Finish(acknowledgement)).is_err() { + failures.push("world checkpoint writer stopped".to_owned()); + } else { + match complete.recv() { + Ok(Ok(())) => {} + Ok(Err(error)) => failures.push(error), + Err(_) => { + failures.push("world checkpoint writer stopped before finish".to_owned()) + } + } + } + } + if let Some(worker) = lock(&self.worker).take() + && worker.join().is_err() + { + failures.push("world checkpoint writer panicked".to_owned()); + } + if let Err(error) = self.check_failure() + && !failures.contains(&error) + { + failures.push(error); + } + if failures.is_empty() { + Ok(()) + } else { + Err(failures.join("; ")) + } + } + + fn check_failure(&self) -> Result<(), String> { + match lock(&self.failure).clone() { + Some(error) => Err(error), + None => Ok(()), + } + } +} + +impl Drop for CheckpointWriter { + fn drop(&mut self) { + self.sender + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(worker) = self + .worker + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + let _ = worker.join(); + } + } +} + +fn run_writer( + receiver: mpsc::Receiver, + failure: &Mutex>, + write: impl Fn(&WorldCheckpoint) -> Result<(), String>, +) { + while let Ok(command) = receiver.recv() { + match command { + WriterCommand::Checkpoint(checkpoint) => { + if lock(failure).is_none() + && let Err(error) = write(&checkpoint) + { + *lock(failure) = Some(error); + } + } + WriterCommand::Flush(acknowledgement) => { + let result = lock(failure).clone().map_or(Ok(()), Err); + let _ = acknowledgement.send(result); + } + WriterCommand::Finish(acknowledgement) => { + let result = lock(failure).clone().map_or(Ok(()), Err); + let _ = acknowledgement.send(result); + return; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use phoxal::model::identity::WorldId; + use phoxal::model::world::{WorldDigest, WorldInstanceId, WorldProgress, WorldProvenance}; + use phoxal::version::FrameworkVersion; + use phoxal::world::api::session::WorldLifecycle; + + fn checkpoint(revision: u64) -> WorldCheckpoint { + world_checkpoint( + ProcessIdentity { + pid: 42, + started_at_unix_s: 99, + }, + None, + WorldSessionState { + revision, + instance: WorldInstanceId::parse("10000000000000000000000000000001") + .expect("instance"), + provenance: WorldProvenance { + world: WorldId::new("warehouse").expect("world"), + digest: WorldDigest::parse(&"0".repeat(64)).expect("digest"), + random_seed: 0, + framework: FrameworkVersion::CURRENT, + adapter: "webots".to_owned(), + adapter_version: "test".to_owned(), + simulator_version: "R2025a".to_owned(), + platform: "test".to_owned(), + time_step_ns: 12_000_000, + }, + lifecycle: WorldLifecycle::Starting, + progress: WorldProgress::zero(12_000_000).expect("progress"), + members: Vec::new(), + }, + ) + } + + #[test] + fn a_delayed_older_write_cannot_be_overtaken_by_a_newer_revision() { + let observed = Arc::new(Mutex::new(Vec::new())); + let writes = Arc::clone(&observed); + let writer = CheckpointWriter::with_writer(move |checkpoint| { + if checkpoint.state.revision == 1 { + std::thread::sleep(Duration::from_millis(20)); + } + lock(&writes).push(checkpoint.state.revision); + Ok(()) + }) + .expect("writer"); + writer.submit(checkpoint(1)).expect("older admission"); + writer.submit(checkpoint(2)).expect("newer admission"); + writer.finish().expect("terminal flush"); + assert_eq!(*lock(&observed), [1, 2]); + } + + #[test] + fn a_write_failure_is_observable_at_the_durability_fence() { + let writer = CheckpointWriter::with_writer(|_| Err("injected write failure".to_owned())) + .expect("writer"); + writer.submit(checkpoint(1)).expect("admission"); + assert_eq!(writer.flush().unwrap_err(), "injected write failure"); + assert_eq!(writer.finish().unwrap_err(), "injected write failure"); + } + + #[test] + fn terminal_finish_flushes_and_refuses_any_later_checkpoint() { + let observed = Arc::new(Mutex::new(Vec::new())); + let writes = Arc::clone(&observed); + let writer = CheckpointWriter::with_writer(move |checkpoint| { + lock(&writes).push(checkpoint.state.revision); + Ok(()) + }) + .expect("writer"); + writer.submit(checkpoint(7)).expect("checkpoint admission"); + writer.finish().expect("terminal flush and join"); + assert_eq!(*lock(&observed), [7]); + assert_eq!( + writer.submit(checkpoint(8)).unwrap_err(), + "world checkpoint writer is finished" + ); + } +} diff --git a/simulators/webots/host/src/runtime/control.rs b/simulators/webots/host/src/runtime/control.rs new file mode 100644 index 00000000..126832dc --- /dev/null +++ b/simulators/webots/host/src/runtime/control.rs @@ -0,0 +1,125 @@ +use super::*; + +impl WorldRuntime { + pub(crate) async fn apply_control( + &self, + request: WorldControl, + ) -> Result { + let _operation = self.operation.lock().await; + match request { + WorldControl::Pause => { + let state = self.snapshot(); + if matches!( + state.lifecycle, + WorldLifecycle::Ready { + motion: WorldMotion::Paused + } + ) { + return Ok(state); + } + if !matches!(state.lifecycle, WorldLifecycle::Ready { .. }) { + return Err("only a Ready world can be paused".to_owned()); + } + self.native + .request_motion(NativeMotion::Paused) + .map_err(|error| format!("native pause failed: {error:?}"))?; + self.clear_pacing()?; + self.await_motion(NativeMotion::Paused).await + } + WorldControl::Resume => { + let state = self.snapshot(); + if matches!( + state.lifecycle, + WorldLifecycle::Ready { + motion: WorldMotion::Running + } + ) { + return Ok(state); + } + if !matches!(state.lifecycle, WorldLifecycle::Ready { .. }) { + return Err("only a Ready world can be resumed".to_owned()); + } + self.native + .request_motion(NativeMotion::RealTime) + .map_err(|error| format!("native resume failed: {error:?}"))?; + self.clear_pacing()?; + self.await_motion(NativeMotion::RealTime).await + } + WorldControl::Stop => { + let state = self.snapshot(); + if matches!(state.lifecycle, WorldLifecycle::Stopping) { + return Ok(state); + } + if matches!(state.lifecycle, WorldLifecycle::Failed { .. }) { + return Err("a failed world cannot be stopped again".to_owned()); + } + self.mark_stopping() + } + } + } + + async fn await_motion(&self, expected: NativeMotion) -> Result { + let deadline = tokio::time::Instant::now() + CONTROL_TIMEOUT; + loop { + let snapshot = self.reconcile_latest_native()?; + let native_observed = match snapshot.lifecycle() { + NativeWorldLifecycle::Ready { observed, .. } => Some(observed), + NativeWorldLifecycle::Failed(_) => None, + NativeWorldLifecycle::Starting | NativeWorldLifecycle::Stopping => None, + }; + let state = self.snapshot(); + if let WorldLifecycle::Failed { reason } = state.lifecycle { + return Err(format!( + "native motion request failed the world: {reason:?}" + )); + } + if native_observed == Some(&expected) && snapshot.robots_observe_motion(expected) { + return Ok(state); + } + if tokio::time::Instant::now() >= deadline { + return Err(format!( + "Webots did not confirm {expected:?} within {CONTROL_TIMEOUT:?}" + )); + } + tokio::time::sleep(Duration::from_millis(2)).await; + } + } + + pub(crate) async fn lock_operation(&self) -> tokio::sync::MutexGuard<'_, ()> { + self.operation.lock().await + } + + pub(crate) async fn pause_native_for_operation(&self) -> Result { + self.reconcile_latest_native()?; + if let WorldLifecycle::Failed { reason } = self.snapshot().lifecycle { + return Err(format!("native isolation is unavailable: {reason:?}")); + } + if matches!( + self.snapshot().lifecycle, + WorldLifecycle::Ready { + motion: WorldMotion::Paused + } + ) { + return Ok(self.snapshot()); + } + self.native + .request_motion(NativeMotion::Paused) + .map_err(|error| format!("native pause failed: {error:?}"))?; + self.clear_pacing()?; + self.await_motion(NativeMotion::Paused).await + } + + pub(crate) async fn restore_native_after_operation( + &self, + was_running: bool, + ) -> Result { + if !was_running { + return Ok(self.snapshot()); + } + self.native + .request_motion(NativeMotion::RealTime) + .map_err(|error| format!("native resume failed: {error:?}"))?; + self.clear_pacing()?; + self.await_motion(NativeMotion::RealTime).await + } +} diff --git a/simulators/webots/host/src/runtime/handler.rs b/simulators/webots/host/src/runtime/handler.rs new file mode 100644 index 00000000..8fe9288f --- /dev/null +++ b/simulators/webots/host/src/runtime/handler.rs @@ -0,0 +1,65 @@ +//! Concrete public world-session handler for the Webots adapter. + +use std::sync::Arc; + +use phoxal::identity::ExecutionId; +use phoxal::model::identity::SpawnId; +use phoxal::world::api::session::connect::WorldSessionBootstrap; +use phoxal::world::api::session::control::WorldControl; +use phoxal::world::api::session::diagnostics::WorldSessionDiagnostics; +use phoxal::world::api::session::state::WorldSessionState; +use phoxal::world::{WorldSessionHandler, WorldSessionOperation}; +use tokio::sync::broadcast; + +use super::WorldRuntime; +use crate::attachment::WebotsAttachments; + +pub struct WebotsWorldSession { + runtime: Arc, + attachments: Arc, +} + +impl WebotsWorldSession { + pub fn new(runtime: Arc, attachments: Arc) -> Self { + Self { + runtime, + attachments, + } + } +} + +impl WorldSessionHandler for WebotsWorldSession { + fn bootstrap(&self) -> WorldSessionBootstrap { + self.runtime.bootstrap() + } + + fn state(&self) -> WorldSessionState { + self.runtime.snapshot() + } + + fn subscribe_state(&self) -> broadcast::Receiver { + self.runtime.subscribe_state() + } + + fn diagnostics(&self) -> WorldSessionDiagnostics { + self.runtime.diagnostics() + } + + fn subscribe_diagnostics(&self) -> broadcast::Receiver { + self.runtime.subscribe_diagnostics() + } + + fn control(&self, request: WorldControl) -> WorldSessionOperation<'_, WorldSessionState> { + Box::pin(async move { self.runtime.apply_control(request).await }) + } + + fn attach( + &self, + execution: ExecutionId, + supervisor_endpoint: String, + spawn: Option, + ) -> WorldSessionOperation<'_, WorldSessionState> { + self.attachments + .attach(&self.runtime, execution, supervisor_endpoint, spawn) + } +} diff --git a/simulators/webots/host/src/runtime/mod.rs b/simulators/webots/host/src/runtime/mod.rs new file mode 100644 index 00000000..de64fa03 --- /dev/null +++ b/simulators/webots/host/src/runtime/mod.rs @@ -0,0 +1,43 @@ +//! Backend-neutral world-session projection over validated native Webots state. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex, mpsc}; +use std::thread; +use std::time::{Duration, Instant}; + +use phoxal::bundle::WorldBundle; +use phoxal::identity::ExecutionId; +use phoxal::model::world::{WorldInstanceId, WorldProgress, WorldProvenance}; +use phoxal::supervisor::api::simulation::SimulationEndReason; +use phoxal::version::FrameworkVersion; +use phoxal::world::api::session::connect::WorldSessionBootstrap; +use phoxal::world::api::session::control::WorldControl; +use phoxal::world::api::session::diagnostics::{ObservedWorldPacing, WorldSessionDiagnostics}; +use phoxal::world::api::session::document::WorldCheckpoint; +use phoxal::world::api::session::state::WorldSessionState; +use phoxal::world::api::session::{WorldLifecycle, WorldMotion}; +use tokio::sync::broadcast; + +use crate::evidence::{EvidenceSession, world_checkpoint}; +use crate::registration::ProcessIdentity; +use crate::server::HostServer; +use crate::state::{NativeWorldFailure, NativeWorldLifecycle, NativeWorldState}; +use phoxal_simulator_webots_shared::protocol::NativeMotion; + +const STREAM_CAPACITY: usize = 64; +const PACING_WINDOW_TRANSITIONS: usize = 128; +const DIAGNOSTICS_EMISSION_INTERVAL: Duration = Duration::from_secs(1); +const CONTROL_TIMEOUT: Duration = Duration::from_secs(5); + +mod checkpoint; +mod control; +mod handler; +mod pacing; +mod projection; + +pub use handler::WebotsWorldSession; +pub use projection::WorldRuntime; + +use checkpoint::CheckpointWriter; +use pacing::{DiagnosticsState, clear_pacing_state, project_diagnostics, record_pacing}; +use projection::{lock, next_revision}; diff --git a/simulators/webots/host/src/runtime/pacing.rs b/simulators/webots/host/src/runtime/pacing.rs new file mode 100644 index 00000000..15cdf3fc --- /dev/null +++ b/simulators/webots/host/src/runtime/pacing.rs @@ -0,0 +1,187 @@ +use super::*; + +pub(super) struct DiagnosticsState { + pub(super) revision: u64, + pub(super) pacing: VecDeque, + pub(super) last_transition: Option, + pub(super) last_emission: Option, +} + +#[derive(Clone, Copy)] +pub(super) struct PacingPoint { + pub(super) progress: WorldProgress, + pub(super) host: Instant, +} + +pub(super) fn record_pacing( + diagnostics: &mut DiagnosticsState, + progress: WorldProgress, + running: bool, + now: Instant, +) -> Result { + if !running { + diagnostics.pacing.clear(); + } else { + if diagnostics.pacing.len() == PACING_WINDOW_TRANSITIONS { + diagnostics.pacing.pop_front(); + } + diagnostics.pacing.push_back(PacingPoint { + progress, + host: now, + }); + } + diagnostics.last_transition = Some(now); + let emit = diagnostics + .last_emission + .is_none_or(|last| now.duration_since(last) >= DIAGNOSTICS_EMISSION_INTERVAL); + if emit { + diagnostics.last_emission = Some(now); + diagnostics.revision = next_revision(diagnostics.revision)?; + } + Ok(emit) +} + +pub(super) fn clear_pacing_state( + diagnostics: &mut DiagnosticsState, + now: Instant, +) -> Result<(), String> { + diagnostics.pacing.clear(); + diagnostics.last_emission = Some(now); + diagnostics.revision = next_revision(diagnostics.revision)?; + Ok(()) +} + +pub(super) fn project_diagnostics(state: &DiagnosticsState) -> WorldSessionDiagnostics { + let pacing = match (state.pacing.front(), state.pacing.back()) { + (Some(first), Some(last)) if state.pacing.len() >= 2 => { + let world_elapsed_ns = last + .progress + .elapsed_ns() + .saturating_sub(first.progress.elapsed_ns()); + let host_elapsed_ns = + u64::try_from(last.host.duration_since(first.host).as_nanos()).unwrap_or(u64::MAX); + let completed_transitions = last + .progress + .completed_step() + .saturating_sub(first.progress.completed_step()); + let observed = ObservedWorldPacing { + world_elapsed_ns, + host_elapsed_ns, + completed_transitions, + }; + observed.is_valid().then_some(observed) + } + _ => None, + }; + WorldSessionDiagnostics { + revision: state.revision, + pacing, + last_transition_age_ns: state + .last_transition + .map(|instant| u64::try_from(instant.elapsed().as_nanos()).unwrap_or(u64::MAX)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pacing_samples_every_transition_but_publishes_at_most_once_per_second() { + let start = Instant::now(); + let mut diagnostics = DiagnosticsState { + revision: 0, + pacing: VecDeque::new(), + last_transition: None, + last_emission: None, + }; + assert!( + record_pacing( + &mut diagnostics, + WorldProgress::at(1, 10).expect("progress"), + true, + start, + ) + .expect("first pacing sample") + ); + assert_eq!(diagnostics.revision, 1); + assert!( + !record_pacing( + &mut diagnostics, + WorldProgress::at(2, 10).expect("progress"), + true, + start + Duration::from_millis(500), + ) + .expect("second pacing sample") + ); + assert_eq!(diagnostics.pacing.len(), 2); + assert_eq!(diagnostics.revision, 1); + assert!( + record_pacing( + &mut diagnostics, + WorldProgress::at(3, 10).expect("progress"), + true, + start + DIAGNOSTICS_EMISSION_INTERVAL, + ) + .expect("third pacing sample") + ); + assert_eq!(diagnostics.pacing.len(), 3); + assert_eq!(diagnostics.revision, 2); + } + + #[test] + fn pause_clears_only_the_window_and_publishes_a_revision() { + let transition = Instant::now(); + let mut diagnostics = DiagnosticsState { + revision: 7, + pacing: VecDeque::from([PacingPoint { + progress: WorldProgress::at(4, 10).expect("progress"), + host: transition, + }]), + last_transition: Some(transition), + last_emission: Some(transition), + }; + clear_pacing_state(&mut diagnostics, transition + Duration::from_millis(1)) + .expect("pause pacing clear"); + assert!(diagnostics.pacing.is_empty()); + assert_eq!(diagnostics.last_transition, Some(transition)); + assert_eq!(diagnostics.revision, 8); + let projection = project_diagnostics(&diagnostics); + assert_eq!(projection.revision, 8); + assert!(projection.pacing.is_none()); + assert!(projection.last_transition_age_ns.is_some()); + } + + #[test] + fn below_one_pacing_is_retained_as_observation_without_becoming_a_target() { + let start = Instant::now(); + let mut diagnostics = DiagnosticsState { + revision: 0, + pacing: VecDeque::new(), + last_transition: None, + last_emission: None, + }; + record_pacing( + &mut diagnostics, + WorldProgress::at(1, 10).expect("first progress"), + true, + start, + ) + .expect("first pacing sample"); + record_pacing( + &mut diagnostics, + WorldProgress::at(2, 10).expect("second progress"), + true, + start + Duration::from_nanos(40), + ) + .expect("second pacing sample"); + + let pacing = project_diagnostics(&diagnostics) + .pacing + .expect("two transitions form one observation"); + assert_eq!(pacing.world_elapsed_ns, 10); + assert_eq!(pacing.host_elapsed_ns, 40); + assert_eq!(pacing.completed_transitions, 1); + assert!(pacing.world_elapsed_ns < pacing.host_elapsed_ns); + } +} diff --git a/simulators/webots/host/src/runtime/projection.rs b/simulators/webots/host/src/runtime/projection.rs new file mode 100644 index 00000000..f5f97047 --- /dev/null +++ b/simulators/webots/host/src/runtime/projection.rs @@ -0,0 +1,434 @@ +use super::*; + +/// Adapter-private implementation of one host-authoritative session. +#[derive(Clone)] +pub struct WorldRuntime { + pub(super) bootstrap: WorldSessionBootstrap, + pub(super) projection: Arc>, + pub(super) state_updates: broadcast::Sender, + pub(super) diagnostics_updates: broadcast::Sender, + pub(super) native: Arc, + pub(super) operation: Arc>, + pub(super) evidence: Arc, + pub(super) checkpoints: Arc, + pub(super) process: ProcessIdentity, +} + +/// All revisioned facts projected from the native world share one owner. +/// +/// Keeping state, pacing, and checkpoint throttling together prevents a stale +/// native observation or delayed pacing update from overtaking a newer world +/// transition. +pub(super) struct WorldProjection { + pub(super) state: WorldSessionState, + pub(super) diagnostics: DiagnosticsState, + pub(super) last_progress_checkpoint: Option, +} + +impl WorldRuntime { + pub fn new( + instance: WorldInstanceId, + bundle: &WorldBundle, + simulator_version: &str, + native: Arc, + evidence: Arc, + process: ProcessIdentity, + ) -> Result { + let provenance = WorldProvenance { + world: bundle.world().id().clone(), + digest: bundle.digest(), + random_seed: 0, + framework: FrameworkVersion::CURRENT, + adapter: "webots".to_owned(), + adapter_version: env!("CARGO_PKG_VERSION").to_owned(), + simulator_version: simulator_version.to_owned(), + platform: format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH), + time_step_ns: bundle.world().time_step_ns(), + }; + let state = WorldSessionState { + revision: 0, + instance, + progress: WorldProgress::zero(provenance.time_step_ns) + .map_err(|error| error.to_string())?, + provenance, + lifecycle: WorldLifecycle::Starting, + members: Vec::new(), + }; + let (state_updates, _) = broadcast::channel(STREAM_CAPACITY); + let (diagnostics_updates, _) = broadcast::channel(STREAM_CAPACITY); + let runtime = Self { + bootstrap: WorldSessionBootstrap { + instance, + framework: FrameworkVersion::CURRENT, + world: bundle.world().id().clone(), + digest: bundle.digest(), + }, + projection: Arc::new(Mutex::new(WorldProjection { + state: state.clone(), + diagnostics: DiagnosticsState { + revision: 0, + pacing: VecDeque::with_capacity(PACING_WINDOW_TRANSITIONS), + last_transition: None, + last_emission: None, + }, + last_progress_checkpoint: None, + })), + state_updates, + diagnostics_updates, + native, + operation: Arc::new(tokio::sync::Mutex::new(())), + checkpoints: Arc::new(CheckpointWriter::new(Arc::clone(&evidence))?), + evidence, + process, + }; + runtime.persist_checkpoint(&state)?; + runtime.checkpoints.flush()?; + Ok(runtime) + } + + /// Publish the first truthful Ready/Paused projection after native bootstrap. + pub fn mark_ready(&self) -> Result { + self.replace_lifecycle(WorldLifecycle::Ready { + motion: WorldMotion::Paused, + }) + } + + /// Retain public stopping intent while native member cleanup converges. + pub fn mark_stopping(&self) -> Result { + let state = { + let mut projection = lock(&self.projection); + self.clear_pacing_locked(&mut projection)?; + self.replace_lifecycle_locked(&mut projection, WorldLifecycle::Stopping)? + }; + self.checkpoints.flush()?; + Ok(state) + } + + /// Publish a host-classified fatal world outcome before terminal cleanup begins. + pub fn fail(&self, reason: SimulationEndReason) -> Result { + let state = { + let mut projection = lock(&self.projection); + self.clear_pacing_locked(&mut projection)?; + self.replace_lifecycle_locked(&mut projection, WorldLifecycle::Failed { reason })? + }; + self.checkpoints.flush()?; + Ok(state) + } + + /// Reconcile one latest native snapshot into world progress and fatal state. + /// + /// Snapshot acquisition and projection publication share one synchronous + /// boundary so an older observation cannot overtake a newer projection. + pub fn reconcile_latest_native(&self) -> Result { + let native = self.native.snapshot(); + { + let mut projection = lock(&self.projection); + self.reconcile_observed_native_locked(&mut projection, &native)?; + } + self.checkpoints.flush()?; + Ok(native) + } + + fn reconcile_observed_native_locked( + &self, + projection: &mut WorldProjection, + native: &NativeWorldState, + ) -> Result<(), String> { + match native.lifecycle() { + NativeWorldLifecycle::Failed(failure) => { + let reason = failure_reason(failure); + self.replace_lifecycle_locked(projection, WorldLifecycle::Failed { reason })?; + return Ok(()); + } + NativeWorldLifecycle::Stopping => { + self.replace_lifecycle_locked(projection, WorldLifecycle::Stopping)?; + } + NativeWorldLifecycle::Ready { observed, .. } => { + if !matches!(projection.state.lifecycle, WorldLifecycle::Stopping) { + self.replace_lifecycle_locked( + projection, + WorldLifecycle::Ready { + motion: match observed { + NativeMotion::Paused => WorldMotion::Paused, + NativeMotion::RealTime => WorldMotion::Running, + }, + }, + )?; + } + } + NativeWorldLifecycle::Starting => {} + } + let observed = native.progress(); + let progress = WorldProgress::at( + observed.completed_step, + projection.state.provenance.time_step_ns, + ) + .map_err(|error| error.to_string())?; + if progress == projection.state.progress { + return Ok(()); + } + if progress.completed_step() < projection.state.progress.completed_step() { + return Err("validated native projection regressed world progress".to_owned()); + } + projection.state.progress = progress; + projection.state.revision = next_revision(projection.state.revision)?; + let projected = projection.state.clone(); + let running = matches!( + projection.state.lifecycle, + WorldLifecycle::Ready { + motion: WorldMotion::Running + } + ); + self.persist_progress_checkpoint_locked(projection, &projected)?; + let _ = self.state_updates.send(projected.clone()); + self.observe_pacing_locked(projection, progress, running)?; + Ok(()) + } + + fn update_state( + &self, + change: impl FnOnce(&mut WorldSessionState) -> Result, + ) -> Result { + let state = { + let mut projection = lock(&self.projection); + let mut candidate = projection.state.clone(); + if change(&mut candidate)? { + candidate + .members + .sort_by_key(|member| member.execution.to_string()); + candidate.revision = next_revision(candidate.revision)?; + candidate.validate().map_err(|error| error.to_string())?; + projection.state = candidate; + let projected = projection.state.clone(); + self.persist_checkpoint(&projected)?; + let _ = self.state_updates.send(projected.clone()); + projected + } else { + projection.state.clone() + } + }; + self.checkpoints.flush()?; + Ok(state) + } + + pub fn prepare_member( + &self, + member: phoxal::world::api::session::WorldMember, + ) -> Result { + self.update_state(|state| { + if state + .members + .iter() + .any(|existing| existing.execution == member.execution) + { + return Err(format!("execution {} joined twice", member.execution)); + } + if state + .members + .iter() + .any(|existing| existing.spawn == member.spawn) + { + return Err(format!("spawn point '{}' became occupied", member.spawn)); + } + state.members.push(member); + Ok(true) + }) + } + + pub fn activate_member( + &self, + member: phoxal::world::api::session::WorldMember, + ) -> Result { + self.update_state(|state| { + if let Some(existing) = state + .members + .iter_mut() + .find(|existing| existing.execution == member.execution) + { + *existing = member; + } else { + if state + .members + .iter() + .any(|existing| existing.spawn == member.spawn) + { + return Err(format!("spawn point '{}' became occupied", member.spawn)); + } + state.members.push(member); + } + Ok(true) + }) + } + + pub fn mark_member_removing( + &self, + execution: ExecutionId, + ) -> Result { + self.update_state(|state| { + let Some(member) = state + .members + .iter_mut() + .find(|member| member.execution == execution) + else { + return Ok(false); + }; + member.phase = phoxal::world::api::session::WorldMemberPhase::Removing; + Ok(true) + }) + } + + pub fn complete_member_removal( + &self, + execution: ExecutionId, + ) -> Result { + self.update_state(|state| { + let before = state.members.len(); + state.members.retain(|member| member.execution != execution); + Ok(state.members.len() != before) + }) + } + + #[must_use] + pub fn snapshot(&self) -> WorldSessionState { + lock(&self.projection).state.clone() + } + + pub(crate) fn bootstrap(&self) -> WorldSessionBootstrap { + self.bootstrap.clone() + } + + pub(crate) fn subscribe_state(&self) -> broadcast::Receiver { + self.state_updates.subscribe() + } + + pub(crate) fn diagnostics(&self) -> WorldSessionDiagnostics { + project_diagnostics(&lock(&self.projection).diagnostics) + } + + pub(crate) fn subscribe_diagnostics(&self) -> broadcast::Receiver { + self.diagnostics_updates.subscribe() + } + + fn replace_lifecycle(&self, lifecycle: WorldLifecycle) -> Result { + let state = { + let mut projection = lock(&self.projection); + self.replace_lifecycle_locked(&mut projection, lifecycle)? + }; + self.checkpoints.flush()?; + Ok(state) + } + + fn replace_lifecycle_locked( + &self, + projection: &mut WorldProjection, + lifecycle: WorldLifecycle, + ) -> Result { + if projection.state.lifecycle == lifecycle + || matches!(projection.state.lifecycle, WorldLifecycle::Failed { .. }) + { + return Ok(projection.state.clone()); + } + projection.state.lifecycle = lifecycle; + projection.state.revision = next_revision(projection.state.revision)?; + projection + .state + .validate() + .map_err(|error| error.to_string())?; + let state = projection.state.clone(); + self.persist_checkpoint(&state)?; + let _ = self.state_updates.send(state.clone()); + Ok(state) + } + + fn observe_pacing_locked( + &self, + projection: &mut WorldProjection, + progress: WorldProgress, + running: bool, + ) -> Result<(), String> { + let diagnostics = &mut projection.diagnostics; + let now = Instant::now(); + if !record_pacing(diagnostics, progress, running, now)? { + return Ok(()); + } + let projection = project_diagnostics(diagnostics); + let _ = self.diagnostics_updates.send(projection); + Ok(()) + } + + pub(super) fn clear_pacing(&self) -> Result<(), String> { + let mut projection = lock(&self.projection); + self.clear_pacing_locked(&mut projection) + } + + fn clear_pacing_locked(&self, projection: &mut WorldProjection) -> Result<(), String> { + let diagnostics = &mut projection.diagnostics; + clear_pacing_state(diagnostics, Instant::now())?; + let projection = project_diagnostics(diagnostics); + let _ = self.diagnostics_updates.send(projection); + Ok(()) + } + + fn persist_checkpoint(&self, state: &WorldSessionState) -> Result<(), String> { + self.checkpoints.submit(world_checkpoint( + self.process, + self.evidence.native_process(), + state.clone(), + )) + } + + /// Refresh ownership evidence after the separately grouped native process starts. + pub fn refresh_checkpoint(&self) -> Result<(), String> { + self.persist_checkpoint(&self.snapshot())?; + self.checkpoints.flush() + } + + /// Stop the checkpoint owner before terminal summary publication. + pub fn finish_evidence_writer(&self) -> Result<(), String> { + self.checkpoints.finish() + } + + fn persist_progress_checkpoint_locked( + &self, + projection: &mut WorldProjection, + state: &WorldSessionState, + ) -> Result<(), String> { + let now = Instant::now(); + if projection + .last_progress_checkpoint + .is_some_and(|last| now.duration_since(last) < DIAGNOSTICS_EMISSION_INTERVAL) + { + return Ok(()); + } + self.persist_checkpoint(state)?; + projection.last_progress_checkpoint = Some(now); + Ok(()) + } +} + +const fn failure_reason(failure: &NativeWorldFailure) -> SimulationEndReason { + match failure { + NativeWorldFailure::UnsupportedMode(_) => SimulationEndReason::UnsupportedNativeMode, + NativeWorldFailure::InvalidProgress { .. } => SimulationEndReason::InvalidProgress, + NativeWorldFailure::WorldControllerLost => SimulationEndReason::WorldControllerLost, + NativeWorldFailure::RobotControllerLost { .. } => SimulationEndReason::ControllerLost, + NativeWorldFailure::Controller(_) + | NativeWorldFailure::DuplicateWorldController + | NativeWorldFailure::DuplicateRobot { .. } + | NativeWorldFailure::IncompatibleController { .. } + | NativeWorldFailure::InvalidTimeStep + | NativeWorldFailure::Protocol(_) => SimulationEndReason::ProtocolViolation, + } +} + +pub(super) fn next_revision(revision: u64) -> Result { + revision + .checked_add(1) + .ok_or_else(|| "world-session revision exhausted".to_owned()) +} + +pub(super) fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} diff --git a/simulators/webots/host/src/server.rs b/simulators/webots/host/src/server.rs new file mode 100644 index 00000000..8bbaea46 --- /dev/null +++ b/simulators/webots/host/src/server.rs @@ -0,0 +1,1094 @@ +//! Loopback-only private host server for native Webots controllers. + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::net::{Shutdown, TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::thread::JoinHandle; +use std::time::Duration; + +use anyhow::{Context, Result}; + +use crate::state::{NativeRobotFailure, NativeWorldFailure, NativeWorldState}; +use phoxal_simulator_webots_shared::plan::RobotSimulationPlan; +use phoxal_simulator_webots_shared::protocol::{ + ActuationEvidence, ControllerEvent, ControllerRole, HostDirective, HostRequest, HostResponse, + NativeMutation, read_frame, write_frame, +}; + +const ACCEPT_POLL: Duration = Duration::from_millis(10); +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(2); +const CONTROLLER_LIVENESS_TIMEOUT: Duration = Duration::from_secs(30); +const MUTATION_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_ACTUATION_RECORDS_PER_ROBOT: usize = 256; + +/// One loopback listener and its validated native-world state. +pub struct HostServer { + endpoint: String, + state: Arc>, + plans: Arc>>, + mutation: Arc<(Mutex, Condvar)>, + retiring: Arc>>, + actuation: Arc>>, + controller_threads: Arc>>>, + connections: Arc>>, + stop: Arc, + acceptor: Option>, +} + +#[derive(Default)] +struct MutationState { + next_transaction: u64, + pending: Option, +} + +struct PendingMutation { + mutation: NativeMutation, + result: Option>, +} + +#[derive(Default)] +struct ActuationBuffer { + records: VecDeque, + dropped: u64, +} + +impl ActuationBuffer { + fn push(&mut self, record: ActuationEvidence) { + if self.records.len() == MAX_ACTUATION_RECORDS_PER_ROBOT { + self.records.pop_front(); + self.dropped = self.dropped.saturating_add(1); + } + self.records.push_back(record); + } +} + +impl HostServer { + /// Bind an ephemeral loopback endpoint and begin accepting controllers. + pub fn bind() -> Result { + let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .context("failed to bind the private Webots host endpoint")?; + listener + .set_nonblocking(true) + .context("failed to make the private Webots host endpoint nonblocking")?; + let address = listener + .local_addr() + .context("failed to read the private Webots host endpoint")?; + let endpoint = format!("tcp://{address}"); + let state = Arc::new(Mutex::new(NativeWorldState::default())); + let stop = Arc::new(AtomicBool::new(false)); + let plans = Arc::new(Mutex::new(BTreeMap::new())); + let mutation = Arc::new((Mutex::new(MutationState::default()), Condvar::new())); + let retiring = Arc::new(Mutex::new(BTreeSet::new())); + let actuation = Arc::new(Mutex::new(BTreeMap::new())); + let controller_threads = Arc::new(Mutex::new(Vec::new())); + let connections = Arc::new(Mutex::new(BTreeMap::new())); + let server_state = Arc::clone(&state); + let server_plans = Arc::clone(&plans); + let server_mutation = Arc::clone(&mutation); + let server_retiring = Arc::clone(&retiring); + let server_actuation = Arc::clone(&actuation); + let server_controller_threads = Arc::clone(&controller_threads); + let server_connections = Arc::clone(&connections); + let server_stop = Arc::clone(&stop); + let acceptor = std::thread::Builder::new() + .name("webots-host-accept".to_owned()) + .spawn(move || { + accept_loop( + listener, + &server_state, + &server_plans, + &server_mutation, + &server_retiring, + &server_actuation, + &server_controller_threads, + &server_connections, + &server_stop, + ); + }) + .context("failed to start the private Webots host listener")?; + Ok(Self { + endpoint, + state, + plans, + mutation, + retiring, + actuation, + controller_threads, + connections, + stop, + acceptor: Some(acceptor), + }) + } + + #[must_use] + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + #[must_use] + pub fn snapshot(&self) -> NativeWorldState { + lock(&self.state).clone() + } + + /// Apply the host-monotonic stopped-answering deadline to synchronized native roles. + pub fn enforce_liveness(&self) { + let world_mutation_active = lock(&self.mutation.0) + .pending + .as_ref() + .is_some_and(|pending| pending.result.is_none()); + lock(&self.state).enforce_liveness( + std::time::Instant::now(), + CONTROLLER_LIVENESS_TIMEOUT, + world_mutation_active, + ); + } + + /// Reserve the exact derived plan before any Robot may join the native barrier. + pub fn reserve_robot( + &self, + execution: phoxal::identity::ExecutionId, + plan: RobotSimulationPlan, + ) -> Result<(), NativeWorldFailure> { + let execution = execution.to_string(); + let mut plans = lock(&self.plans); + if plans.insert(execution.clone(), plan).is_some() { + return Err(NativeWorldFailure::DuplicateRobot { execution }); + } + Ok(()) + } + + /// Release a reservation after rollback or completed removal. + pub fn release_robot(&self, execution: phoxal::identity::ExecutionId) { + lock(&self.state).release_robot(execution); + lock(&self.plans).remove(&execution.to_string()); + } + + #[must_use] + pub fn robot_controller( + &self, + execution: phoxal::identity::ExecutionId, + ) -> Option { + lock(&self.state).robot_controller(execution) + } + + #[must_use] + #[cfg(test)] + pub fn has_robot(&self, execution: phoxal::identity::ExecutionId) -> bool { + lock(&self.state).has_robot(execution) + } + + #[must_use] + pub fn robot_active_revision(&self, execution: phoxal::identity::ExecutionId) -> Option { + lock(&self.state).robot_active_revision(execution) + } + + /// Request an execution-specific cooperative park before native removal. + pub fn retire_robot(&self, execution: phoxal::identity::ExecutionId) { + lock(&self.retiring).insert(execution.to_string()); + } + + #[must_use] + pub fn robot_is_parked(&self, execution: phoxal::identity::ExecutionId) -> bool { + lock(&self.state).robot_is_parked(execution) + } + + #[must_use] + pub fn robot_failure( + &self, + execution: phoxal::identity::ExecutionId, + ) -> Option { + lock(&self.state).robot_failure(execution) + } + + /// Drain the bounded applied-action record when durable member evidence is written. + pub fn take_actuation_evidence( + &self, + execution: phoxal::identity::ExecutionId, + ) -> (Vec, u64) { + let buffer = lock(&self.actuation) + .remove(&execution.to_string()) + .unwrap_or_default(); + (buffer.records.into_iter().collect(), buffer.dropped) + } + + /// Import one fully rendered Robot while the shared native world is paused. + pub fn import_robot( + &self, + execution: phoxal::identity::ExecutionId, + definition: String, + source: String, + ) -> Result<()> { + phoxal_simulator_webots_shared::protocol::validate_robot_import(&definition, &source)?; + self.mutate(|transaction| NativeMutation::ImportRobot { + transaction, + execution, + definition, + source, + }) + } + + /// Remove one imported Robot during rollback or orderly detachment. + pub fn remove_robot(&self, definition: String) -> Result<()> { + self.mutate(|transaction| NativeMutation::RemoveRobot { + transaction, + definition, + }) + } + + /// Idempotently remove any residue after an import attempt with an uncertain outcome. + pub fn rollback_robot(&self, definition: String) -> Result<()> { + self.mutate(|transaction| NativeMutation::RollbackRobot { + transaction, + definition, + }) + } + + /// Request the only supported Live native motion policy. + pub fn request_motion( + &self, + motion: phoxal_simulator_webots_shared::protocol::NativeMotion, + ) -> Result<(), NativeWorldFailure> { + lock(&self.state).request_motion(motion).map(|_| ()) + } + + /// Begin orderly world stop. + pub fn stop_world(&self) { + lock(&self.state).stop(); + } + + /// Whether the world controller acknowledged the host terminal directive. + #[must_use] + pub fn world_is_stopped(&self) -> bool { + lock(&self.state).world_is_stopped() + } + + /// Whether a world controller joined this native world at any point. + #[must_use] + pub fn has_world_controller(&self) -> bool { + lock(&self.state).has_world_controller() + } + + fn mutate(&self, build: impl FnOnce(u64) -> NativeMutation) -> Result<()> { + let (mutex, completed) = &*self.mutation; + let mut state = lock(mutex); + anyhow::ensure!( + state.pending.is_none(), + "another native scene mutation is in progress" + ); + state.next_transaction = state + .next_transaction + .checked_add(1) + .context("native mutation counter exhausted")?; + let transaction = state.next_transaction; + state.pending = Some(PendingMutation { + mutation: build(transaction), + result: None, + }); + let (mut state, timeout) = completed + .wait_timeout_while(state, MUTATION_TIMEOUT, |state| { + state + .pending + .as_ref() + .is_some_and(|pending| pending.result.is_none()) + }) + .unwrap_or_else(std::sync::PoisonError::into_inner); + if timeout.timed_out() { + state.pending = None; + anyhow::bail!("native scene mutation timed out after {MUTATION_TIMEOUT:?}"); + } + let pending = state + .pending + .take() + .context("native mutation completion disappeared")?; + pending + .result + .context("native mutation has no result")? + .map_err(anyhow::Error::msg)?; + Ok(()) + } +} + +impl Drop for HostServer { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + if let Some(acceptor) = self.acceptor.take() { + let _ = acceptor.join(); + } + for (_, connection) in std::mem::take(&mut *lock(&self.connections)) { + let _ = connection.shutdown(Shutdown::Both); + } + for controller in std::mem::take(&mut *lock(&self.controller_threads)) { + let _ = controller.join(); + } + } +} + +#[allow( + clippy::too_many_arguments, + reason = "the private listener shares six separately synchronized bounded authorities" +)] +fn accept_loop( + listener: TcpListener, + state: &Arc>, + plans: &Arc>>, + mutation: &Arc<(Mutex, Condvar)>, + retiring: &Arc>>, + actuation: &Arc>>, + controller_threads: &Arc>>>, + connections: &Arc>>, + stop: &Arc, +) { + let mut next_connection = 0_u64; + while !stop.load(Ordering::Acquire) { + match listener.accept() { + Ok((stream, _)) => { + let shutdown_connection = match stream.try_clone() { + Ok(connection) => connection, + Err(error) => { + lock(state).protocol_failure(format!( + "failed to retain private controller shutdown authority: {error}" + )); + continue; + } + }; + let Some(connection_id) = next_connection.checked_add(1) else { + let _ = shutdown_connection.shutdown(Shutdown::Both); + lock(state).protocol_failure( + "private controller connection identity exhausted".to_owned(), + ); + return; + }; + next_connection = connection_id; + lock(connections).insert(connection_id, shutdown_connection); + let worker_state = Arc::clone(state); + let plans = Arc::clone(plans); + let mutation = Arc::clone(mutation); + let retiring = Arc::clone(retiring); + let actuation = Arc::clone(actuation); + let worker_connections = Arc::clone(connections); + match std::thread::Builder::new() + .name("webots-host-controller".to_owned()) + .spawn(move || { + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + serve_controller( + stream, + &worker_state, + &plans, + &mutation, + &retiring, + &actuation, + ) + })); + match outcome { + Ok(Ok(())) => {} + Ok(Err(error)) => { + tracing::warn!(error = %error, "private Webots controller link ended"); + lock(worker_state.as_ref()).protocol_failure(format!( + "private Webots controller link failed: {error:#}" + )); + } + Err(_) => lock(worker_state.as_ref()).protocol_failure( + "private Webots controller worker panicked".to_owned(), + ), + } + if let Some(connection) = + lock(worker_connections.as_ref()).remove(&connection_id) + { + let _ = connection.shutdown(Shutdown::Both); + } + }) + { + Ok(thread) => lock(controller_threads).push(thread), + Err(error) => { + if let Some(connection) = lock(connections).remove(&connection_id) { + let _ = connection.shutdown(Shutdown::Both); + } + lock(state.as_ref()).protocol_failure(format!( + "failed to spawn private Webots controller worker: {error}" + )); + } + } + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(ACCEPT_POLL); + } + Err(error) => { + tracing::error!(error = %error, "private Webots host listener failed"); + lock(state) + .protocol_failure(format!("private Webots host listener failed: {error}")); + return; + } + } + } +} + +fn serve_controller( + mut stream: TcpStream, + state: &Arc>, + plans: &Arc>>, + mutation: &Arc<(Mutex, Condvar)>, + retiring: &Arc>>, + actuation: &Arc>>, +) -> Result<()> { + stream + .set_nonblocking(false) + .context("failed to make a private Webots controller connection blocking")?; + stream + .set_nodelay(true) + .context("failed to configure a private Webots controller connection")?; + stream + .set_read_timeout(Some(HANDSHAKE_TIMEOUT)) + .context("failed to bound private Webots controller reads")?; + let hello = read_frame::<_, HostRequest>(&mut stream)?; + let HostRequest::Hello { framework, role } = hello else { + write_frame( + &mut stream, + &HostResponse::Rejected { + reason: "the first private host message must be Hello".to_owned(), + }, + )?; + anyhow::bail!("the first private host message was not Hello"); + }; + let robot_plan = match role { + ControllerRole::World => None, + ControllerRole::Robot { execution } => { + let plan = lock(plans).get(&execution.to_string()).cloned(); + if plan.is_none() { + write_frame( + &mut stream, + &HostResponse::Rejected { + reason: "this execution has no fully validated RobotSimulationPlan" + .to_owned(), + }, + )?; + return Ok(()); + } + plan + } + }; + let admitted = lock(state).admit(framework, role); + match admitted { + Ok(directive) => write_frame( + &mut stream, + &HostResponse::Accepted { + directive, + robot_plan, + }, + )?, + Err(error) => { + write_frame( + &mut stream, + &HostResponse::Rejected { + reason: format!("{error:?}"), + }, + )?; + return Ok(()); + } + } + stream + .set_read_timeout(None) + .context("failed to remove the admitted controller handshake timeout")?; + + let outcome = (|| -> Result<()> { + loop { + match read_frame::<_, HostRequest>(&mut stream)? { + HostRequest::Event(event) => { + if !event_allowed(role, &event) { + write_frame( + &mut stream, + &HostResponse::Rejected { + reason: "the controller event does not match its admitted role" + .to_owned(), + }, + )?; + lock(state).protocol_failure( + "a controller published an event outside its admitted role".to_owned(), + ); + return Ok(()); + } + if let ControllerEvent::MutationCompleted { transaction, error } = &event { + complete_mutation(mutation, *transaction, error.clone())?; + } + if let ControllerEvent::RobotImported { transaction } = &event { + let mut state = lock(&mutation.0); + let pending = state + .pending + .as_mut() + .context("Robot imported outside a mutation")?; + let NativeMutation::ImportRobot { + execution, + transaction: expected, + .. + } = &pending.mutation + else { + anyhow::bail!("Robot imported outside the import phase"); + }; + anyhow::ensure!( + transaction == expected, + "Robot import transaction mismatch" + ); + pending.mutation = NativeMutation::StartRobotController { + transaction: *transaction, + execution: *execution, + ready: false, + }; + } + if let ( + ControllerRole::Robot { execution }, + ControllerEvent::ActuationEvidence(records), + ) = (role, &event) + { + let mut evidence = lock(actuation); + let retained = evidence.entry(execution.to_string()).or_default(); + for record in records { + retained.push(record.clone()); + } + } + let mut native = lock(state); + let response = match native.observe(role, event) { + Ok(directive) => HostResponse::Directive(directive_for( + role, directive, mutation, retiring, &native, + )), + Err(error) => HostResponse::Rejected { + reason: format!("{error:?}"), + }, + }; + drop(native); + write_frame(&mut stream, &response)?; + } + HostRequest::Hello { .. } => { + write_frame( + &mut stream, + &HostResponse::Rejected { + reason: "a controller may handshake only once".to_owned(), + }, + )?; + lock(state).protocol_failure( + "an admitted controller attempted a second handshake".to_owned(), + ); + return Ok(()); + } + } + } + })(); + // Every admitted controller owns one native synchronization role. Remove the directive + // tombstone at connection teardown, then let the state machine distinguish a parked/released + // retirement from an unexpected synchronized-controller loss. + if let ControllerRole::Robot { execution } = role { + lock(retiring).remove(&execution.to_string()); + } + lock(state).controller_lost(role); + if role == ControllerRole::World { + let mut pending = lock(&mutation.0); + if let Some(pending) = &mut pending.pending + && pending.result.is_none() + { + pending.result = Some(Err( + "world controller disconnected during mutation".to_owned() + )); + mutation.1.notify_all(); + } + } + if let Err(error) = outcome { + tracing::warn!(error = %error, ?role, "classified private Webots controller link ended"); + } + Ok(()) +} + +fn directive_for( + role: ControllerRole, + fallback: HostDirective, + mutation: &Arc<(Mutex, Condvar)>, + retiring: &Arc>>, + native: &NativeWorldState, +) -> HostDirective { + if matches!(fallback, HostDirective::Stop { .. }) { + return fallback; + } + if let ControllerRole::Robot { execution } = role + && lock(retiring).contains(&execution.to_string()) + { + return HostDirective::Stop { + reason: "the Robot attachment is being rolled back".to_owned(), + }; + } + if role != ControllerRole::World { + return fallback; + } + lock(&mutation.0) + .pending + .as_ref() + .filter(|pending| pending.result.is_none()) + .map_or(fallback, |pending| { + let mut mutation = pending.mutation.clone(); + if let NativeMutation::StartRobotController { + execution, ready, .. + } = &mut mutation + { + *ready = native.robot_controller(*execution).is_some(); + } + HostDirective::Mutate(mutation) + }) +} + +fn complete_mutation( + mutation: &Arc<(Mutex, Condvar)>, + transaction: u64, + error: Option, +) -> Result<()> { + let mut state = lock(&mutation.0); + let pending = state + .pending + .as_mut() + .context("world controller completed no pending mutation")?; + anyhow::ensure!( + pending.mutation.transaction() == transaction, + "world controller completed mutation {transaction}, expected {}", + pending.mutation.transaction() + ); + anyhow::ensure!(pending.result.is_none(), "native mutation completed twice"); + pending.result = Some(error.map_or(Ok(()), Err)); + mutation.1.notify_all(); + Ok(()) +} + +const fn event_allowed(role: ControllerRole, event: &ControllerEvent) -> bool { + match role { + ControllerRole::World => matches!( + event, + ControllerEvent::Heartbeat + | ControllerEvent::WorldReady { .. } + | ControllerEvent::WorldMode { .. } + | ControllerEvent::WorldProgress(_) + | ControllerEvent::MutationCompleted { .. } + | ControllerEvent::RobotImported { .. } + | ControllerEvent::Stopped + | ControllerEvent::Fault(_) + ), + ControllerRole::Robot { .. } => matches!( + event, + ControllerEvent::Heartbeat + | ControllerEvent::RobotReady { .. } + | ControllerEvent::RobotActive { .. } + | ControllerEvent::RobotBoundary { .. } + | ControllerEvent::RobotParked + | ControllerEvent::RobotStopping + | ControllerEvent::RobotSupervisorLost + | ControllerEvent::ActuationEvidence(_) + | ControllerEvent::Stopped + | ControllerEvent::Fault(_) + ), + } +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[cfg(test)] +mod tests { + use super::*; + use phoxal::bus::RobotInstant; + use phoxal::identity::TimelineId; + use phoxal::model::identity::{CapabilityId, CapabilityRef, ComponentInstanceId}; + use phoxal::model::world::WorldProgress; + use phoxal_simulator_webots_shared::protocol::{ + ActuationSelection, AppliedActuation, ControllerEvent, ControllerLink, ControllerRole, + NoActuationReason, ObservedNativeMode, + }; + + fn execution(value: u128) -> phoxal::identity::ExecutionId { + phoxal::identity::ExecutionId::try_from(value).expect("execution") + } + + fn empty_plan(robot: &str) -> RobotSimulationPlan { + RobotSimulationPlan { + robot: robot.to_owned(), + basic_time_step_ms: 12, + substitutions: Vec::new(), + capabilities: Vec::new(), + links: Vec::new(), + assets: Vec::new(), + } + } + + fn wait_for_robot_release(server: &HostServer, execution: phoxal::identity::ExecutionId) { + let deadline = std::time::Instant::now() + Duration::from_secs(1); + while server.has_robot(execution) { + assert!( + std::time::Instant::now() < deadline, + "controller did not release its native state" + ); + std::thread::sleep(Duration::from_millis(2)); + } + } + + #[test] + fn one_world_controller_handshakes_and_reports_ready() { + let server = HostServer::bind().expect("the loopback host binds"); + let link = ControllerLink::connect(server.endpoint(), ControllerRole::World) + .expect("the world controller connects"); + link.publish(ControllerEvent::WorldReady { + time_step_ns: 12_000_000, + mode: ObservedNativeMode::Paused, + }) + .expect("the ready event enters the bounded queue"); + let deadline = std::time::Instant::now() + Duration::from_secs(1); + loop { + match link.directive() { + Err(error) => panic!( + "private link failed: {error}; native snapshot: {:?}", + server.snapshot() + ), + Ok(HostDirective::Continue { + motion: phoxal_simulator_webots_shared::protocol::NativeMotion::Paused, + }) => break, + Ok(HostDirective::Park) if std::time::Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(5)); + } + Ok(directive) => panic!("unexpected private directive: {directive:?}"), + } + } + let snapshot = server.snapshot(); + assert!( + matches!( + snapshot.lifecycle(), + crate::state::NativeWorldLifecycle::Ready { .. } + ), + "unexpected native snapshot: {snapshot:?}" + ); + } + + #[test] + fn world_controller_loss_wakes_an_in_flight_mutation() { + let server = Arc::new(HostServer::bind().expect("host binds")); + let link = ControllerLink::connect(server.endpoint(), ControllerRole::World) + .expect("world controller connects"); + link.exchange(ControllerEvent::WorldReady { + time_step_ns: 12_000_000, + mode: ObservedNativeMode::Paused, + }) + .expect("world ready"); + let (sender, receiver) = std::sync::mpsc::channel(); + let worker = std::thread::spawn({ + let server = Arc::clone(&server); + move || { + sender + .send(server.import_robot( + execution(0x1000_0000_0000_0000_0000_0000_0000_0001), + "ROBOT".to_owned(), + "Robot {}".to_owned(), + )) + .expect("result delivered") + } + }); + let deadline = std::time::Instant::now() + Duration::from_secs(1); + while lock(&server.mutation.0).pending.is_none() { + assert!(std::time::Instant::now() < deadline, "mutation starts"); + std::thread::yield_now(); + } + drop(link); + let error = receiver + .recv_timeout(Duration::from_secs(1)) + .expect("loss wakes mutation promptly") + .expect_err("lost controller cannot import"); + assert!(error.to_string().contains("disconnected")); + worker.join().expect("worker completes"); + assert!(matches!( + server.snapshot().lifecycle(), + crate::state::NativeWorldLifecycle::Failed( + crate::state::NativeWorldFailure::WorldControllerLost + ) + )); + } + + #[test] + fn imported_scene_releases_source_before_zero_time_controller_bootstrap() { + let server = Arc::new(HostServer::bind().expect("host binds")); + let world = + ControllerLink::connect(server.endpoint(), ControllerRole::World).expect("world link"); + world + .exchange(ControllerEvent::WorldReady { + time_step_ns: 12_000_000, + mode: ObservedNativeMode::Paused, + }) + .expect("world ready"); + let execution = execution(0x1000_0000_0000_0000_0000_0000_0000_0001); + server + .reserve_robot(execution, empty_plan("test")) + .expect("reserved plan"); + let worker = std::thread::spawn({ + let server = Arc::clone(&server); + move || server.import_robot(execution, "ROBOT".to_owned(), "Robot {}".to_owned()) + }); + let deadline = std::time::Instant::now() + Duration::from_secs(1); + let transaction = loop { + world + .exchange(ControllerEvent::Heartbeat) + .expect("poll import"); + if let HostDirective::Mutate(NativeMutation::ImportRobot { transaction, .. }) = + world.directive().expect("directive") + { + break transaction; + } + assert!(std::time::Instant::now() < deadline, "import begins"); + std::thread::yield_now(); + }; + world + .exchange(ControllerEvent::RobotImported { transaction }) + .expect("scene imported"); + assert!(matches!( + world.directive().expect("bootstrap"), + HostDirective::Mutate(NativeMutation::StartRobotController { ready: false, .. }) + )); + let robot = ControllerLink::connect(server.endpoint(), ControllerRole::Robot { execution }) + .expect("robot link"); + robot + .exchange(ControllerEvent::RobotReady { + controller: phoxal::identity::ProducerId::try_from( + 0x2000_0000_0000_0000_0000_0000_0000_0001, + ) + .expect("producer"), + }) + .expect("robot ready"); + world + .exchange(ControllerEvent::Heartbeat) + .expect("poll ready"); + assert!(matches!( + world.directive().expect("ready"), + HostDirective::Mutate(NativeMutation::StartRobotController { ready: true, .. }) + )); + world + .exchange(ControllerEvent::MutationCompleted { + transaction, + error: None, + }) + .expect("paused import complete"); + worker + .join() + .expect("import worker") + .expect("import succeeds"); + assert_eq!(server.snapshot().progress().completed_step, 0); + } + + #[test] + fn admitted_controller_may_be_silent_beyond_the_handshake_budget() { + let server = HostServer::bind().expect("the loopback host binds"); + let link = ControllerLink::connect(server.endpoint(), ControllerRole::World) + .expect("the world controller connects"); + link.exchange(ControllerEvent::WorldReady { + time_step_ns: 12_000_000, + mode: ObservedNativeMode::Paused, + }) + .expect("the admitted controller reports its initial boundary"); + + std::thread::sleep(HANDSHAKE_TIMEOUT + Duration::from_millis(100)); + + assert!(matches!( + server.snapshot().lifecycle(), + crate::state::NativeWorldLifecycle::Ready { .. } + )); + link.exchange(ControllerEvent::Heartbeat) + .expect("admitted idle time is not classified as link loss"); + } + + #[test] + fn completed_controller_links_release_their_shutdown_handles() { + let server = HostServer::bind().expect("the loopback host binds"); + let baseline = lock(&server.connections).len(); + + for _ in 0..8 { + let address = server.endpoint().trim_start_matches("tcp://"); + let mut stream = TcpStream::connect(address).expect("raw client connects"); + write_frame(&mut stream, &HostRequest::Event(ControllerEvent::Heartbeat)) + .expect("invalid first frame is sent"); + assert!(matches!( + read_frame::<_, HostResponse>(&mut stream).expect("rejection frame"), + HostResponse::Rejected { .. } + )); + drop(stream); + + let deadline = std::time::Instant::now() + Duration::from_secs(1); + while lock(&server.connections).len() != baseline { + assert!( + std::time::Instant::now() < deadline, + "completed controller retained a shutdown handle" + ); + std::thread::sleep(Duration::from_millis(2)); + } + } + } + + #[test] + fn actuation_retention_reports_every_evicted_record() { + let mut retained = ActuationBuffer::default(); + let capability = CapabilityRef::new( + ComponentInstanceId::new("drive").expect("component"), + CapabilityId::new("motor").expect("capability"), + ); + let timeline = TimelineId::from_raw(1).expect("timeline"); + for index in 0..(MAX_ACTUATION_RECORDS_PER_ROBOT as u64 + 7) { + let progress = WorldProgress::at(index, 12).expect("progress"); + retained.push(ActuationEvidence { + capability: capability.clone(), + revision: 1, + selected_at: RobotInstant::new(timeline, index), + selected_from: progress, + progress, + instant: RobotInstant::new(timeline, index), + offered: Vec::new(), + selected: None, + selection: ActuationSelection::None { + reason: NoActuationReason::Missing, + }, + applied: AppliedActuation::Stop, + }); + } + assert_eq!(retained.records.len(), MAX_ACTUATION_RECORDS_PER_ROBOT); + assert_eq!(retained.dropped, 7); + assert_eq!( + retained + .records + .front() + .expect("oldest retained record") + .progress + .completed_step(), + 7 + ); + } + + #[test] + fn non_hello_handshake_is_a_true_private_protocol_failure() { + let server = HostServer::bind().expect("host binds"); + let address = server.endpoint().trim_start_matches("tcp://"); + let mut stream = TcpStream::connect(address).expect("raw client connects"); + write_frame(&mut stream, &HostRequest::Event(ControllerEvent::Heartbeat)) + .expect("invalid first frame is sent"); + assert!(matches!( + read_frame::<_, HostResponse>(&mut stream).expect("rejection frame"), + HostResponse::Rejected { .. } + )); + let deadline = std::time::Instant::now() + Duration::from_secs(1); + loop { + if matches!( + server.snapshot().lifecycle(), + crate::state::NativeWorldLifecycle::Failed( + crate::state::NativeWorldFailure::Protocol(_) + ) + ) { + break; + } + assert!( + std::time::Instant::now() < deadline, + "invalid handshake was not classified" + ); + std::thread::sleep(Duration::from_millis(2)); + } + } + + #[test] + fn two_robot_retirement_and_failure_isolate_before_acknowledged_world_stop() { + let server = HostServer::bind().expect("loopback host"); + let world = ControllerLink::connect(server.endpoint(), ControllerRole::World) + .expect("world controller"); + world + .exchange(ControllerEvent::WorldReady { + time_step_ns: 12_000_000, + mode: ObservedNativeMode::Paused, + }) + .expect("world ready"); + + let first = execution(0x1000_0000_0000_0000_0000_0000_0000_0001); + let second = execution(0x2000_0000_0000_0000_0000_0000_0000_0002); + server + .reserve_robot(first, empty_plan("first")) + .expect("first reservation"); + server + .reserve_robot(second, empty_plan("second")) + .expect("second reservation"); + let first_role = ControllerRole::Robot { execution: first }; + let second_role = ControllerRole::Robot { execution: second }; + let first_link = + ControllerLink::connect(server.endpoint(), first_role).expect("first controller"); + let second_link = + ControllerLink::connect(server.endpoint(), second_role).expect("second controller"); + first_link + .exchange(ControllerEvent::RobotReady { + controller: phoxal::identity::ProducerId::try_from( + 0x3000_0000_0000_0000_0000_0000_0000_0003, + ) + .expect("first producer"), + }) + .expect("first ready"); + second_link + .exchange(ControllerEvent::RobotReady { + controller: phoxal::identity::ProducerId::try_from( + 0x4000_0000_0000_0000_0000_0000_0000_0004, + ) + .expect("second producer"), + }) + .expect("second ready"); + + server.retire_robot(first); + first_link + .exchange(ControllerEvent::Heartbeat) + .expect("first retirement directive"); + assert!(matches!( + first_link.directive().expect("retirement directive"), + HostDirective::Stop { .. } + )); + first_link + .exchange(ControllerEvent::RobotParked) + .expect("first parked acknowledgement"); + assert!(server.robot_is_parked(first)); + server.release_robot(first); + drop(first_link); + wait_for_robot_release(&server, first); + + second_link + .exchange(ControllerEvent::RobotSupervisorLost) + .expect("second cooperative failure parks"); + assert!(matches!( + server.robot_failure(second), + Some(crate::state::NativeRobotFailure::SupervisorLost) + )); + assert!(matches!( + server.snapshot().lifecycle(), + crate::state::NativeWorldLifecycle::Ready { .. } + )); + server.retire_robot(second); + second_link + .exchange(ControllerEvent::RobotParked) + .expect("second parked acknowledgement"); + server.release_robot(second); + drop(second_link); + wait_for_robot_release(&server, second); + + // Release removed both controller records. The same identities can be + // admitted again, proving the native host retained no member tombstone. + server + .reserve_robot(first, empty_plan("first-retry")) + .expect("released first reservation can be reused"); + let retry = ControllerLink::connect(server.endpoint(), first_role) + .expect("released first controller identity can be reused"); + drop(retry); + wait_for_robot_release(&server, first); + server.release_robot(first); + + server.stop_world(); + world + .exchange(ControllerEvent::Heartbeat) + .expect("world stop directive"); + assert!(matches!( + world.directive().expect("world stop directive"), + HostDirective::Stop { .. } + )); + world + .exchange(ControllerEvent::Stopped) + .expect("world stopped acknowledgement"); + assert!(server.world_is_stopped()); + } +} diff --git a/simulators/webots/host/src/shutdown.rs b/simulators/webots/host/src/shutdown.rs new file mode 100644 index 00000000..83ed6dca --- /dev/null +++ b/simulators/webots/host/src/shutdown.rs @@ -0,0 +1,229 @@ +use super::*; + +pub(super) fn terminal_outcome( + live_error: Option, + failure_reason: Option, + cleanup_detail: Option, +) -> TerminalOutcome { + match (live_error, failure_reason, cleanup_detail) { + (None, None, None) => TerminalOutcome::Stopped { + reason: SimulationEndReason::WorldStopped, + }, + (live_error, Some(reason), cleanup_detail) => TerminalOutcome::Failed { + reason, + detail: [live_error, cleanup_detail] + .into_iter() + .flatten() + .reduce(|left, right| format!("{left}; {right}")) + .unwrap_or_else(|| format!("world session failed with {reason:?}")), + }, + (Some(detail), None, None) => TerminalOutcome::Failed { + reason: SimulationEndReason::ProtocolViolation, + detail, + }, + (live_error, None, Some(cleanup)) => TerminalOutcome::Failed { + reason: SimulationEndReason::ProtocolViolation, + detail: live_error.map_or(cleanup.clone(), |live| format!("{live}; {cleanup}")), + }, + } +} + +pub(super) async fn await_world_controller_stop( + native: &HostServer, + webots: &mut WebotsProcess, +) -> Result<()> { + if !native.has_world_controller() { + return Ok(()); + } + let deadline = tokio::time::Instant::now() + WORLD_STOP_TIMEOUT; + loop { + if native.world_is_stopped() { + return Ok(()); + } + if let Some(status) = webots.exited()? { + bail!("Webots exited with {status} before the world controller acknowledged stop"); + } + if tokio::time::Instant::now() >= deadline { + bail!("world controller did not acknowledge stop within {WORLD_STOP_TIMEOUT:?}"); + } + tokio::time::sleep(RECONCILE_INTERVAL).await; + } +} + +pub(super) fn failing_identity( + reason: Option, + native_lifecycle: &NativeWorldLifecycle, + members: &[WorldMember], + native_process: Option<&NativeProcessIdentity>, +) -> TerminalFailure { + let process = if reason == Some(SimulationEndReason::SimulatorLost) { + native_process.map(|identity| identity.process) + } else { + None + }; + let native_execution = match native_lifecycle { + NativeWorldLifecycle::Failed(NativeWorldFailure::RobotControllerLost { execution }) => { + Some(execution.as_str()) + } + NativeWorldLifecycle::Starting + | NativeWorldLifecycle::Ready { .. } + | NativeWorldLifecycle::Stopping + | NativeWorldLifecycle::Failed(_) => None, + }; + let producer = native_execution + .and_then(|execution| { + members + .iter() + .find(|member| member.execution.to_string() == execution) + .map(|member| member.controller) + }) + .or_else(|| match reason { + Some(SimulationEndReason::MutationFailed) => { + unique_member_producer(members, WorldMemberPhase::Preparing) + } + Some(SimulationEndReason::RemovalFailed) => { + unique_member_producer(members, WorldMemberPhase::Removing) + } + Some( + SimulationEndReason::WorldStopped + | SimulationEndReason::HostLost + | SimulationEndReason::SimulatorLost + | SimulationEndReason::WorldControllerLost + | SimulationEndReason::ControllerLost + | SimulationEndReason::UnsupportedNativeMode + | SimulationEndReason::InvalidProgress + | SimulationEndReason::ProtocolViolation, + ) + | None => None, + }); + TerminalFailure { process, producer } +} + +fn unique_member_producer( + members: &[WorldMember], + phase: WorldMemberPhase, +) -> Option { + let mut candidates = members + .iter() + .filter(|member| member.phase == phase) + .map(|member| member.controller); + let producer = candidates.next()?; + candidates.next().is_none().then_some(producer) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registration::ProcessIdentity; + use phoxal::bus::RobotInstant; + use phoxal::identity::{ExecutionId, ProducerId, TimelineId}; + use phoxal::model::identity::{RobotId, SpawnId}; + use phoxal::model::world::{LiveAttachmentBoundary, WorldProgress}; + + fn member(execution: &str, producer: u128, phase: WorldMemberPhase) -> WorldMember { + WorldMember { + execution: ExecutionId::parse(execution).expect("canonical execution"), + robot: RobotId::new("robot").expect("robot id"), + controller: ProducerId::try_from(producer).expect("producer id"), + phase, + attached_at: LiveAttachmentBoundary { + world: WorldProgress::zero(12_000_000).expect("world progress"), + execution: RobotInstant::new(TimelineId::from_raw(1).expect("timeline"), 0), + }, + spawn: SpawnId::new("spawn").expect("spawn id"), + initial_pose: serde_json::from_value(serde_json::json!({ + "xyz": [0.0, 0.0, 0.0], + "rpy": [0.0, 0.0, 0.0] + })) + .expect("pose"), + } + } + + #[test] + fn hard_controller_loss_names_the_exact_pre_cleanup_member() { + let first = member( + "10000000000000000000000000000001", + 0x3000_0000_0000_0000_0000_0000_0000_0003, + WorldMemberPhase::Active, + ); + let second = member( + "20000000000000000000000000000002", + 0x4000_0000_0000_0000_0000_0000_0000_0004, + WorldMemberPhase::Active, + ); + let lifecycle = NativeWorldLifecycle::Failed(NativeWorldFailure::RobotControllerLost { + execution: second.execution.to_string(), + }); + + let failing = failing_identity( + Some(SimulationEndReason::ControllerLost), + &lifecycle, + &[first, second.clone()], + None, + ); + + assert_eq!(failing.producer, Some(second.controller)); + assert_eq!(failing.process, None); + } + + #[test] + fn simulator_loss_names_the_owned_native_process() { + let identity = NativeProcessIdentity { + process: ProcessIdentity { + pid: 123, + started_at_unix_s: 456, + }, + executable: PathBuf::from("/Applications/Webots.app/Contents/MacOS/webots"), + process_group: Some(123), + }; + + let failing = failing_identity( + Some(SimulationEndReason::SimulatorLost), + &NativeWorldLifecycle::Starting, + &[], + Some(&identity), + ); + + assert_eq!(failing.process, Some(identity.process)); + assert_eq!(failing.producer, None); + } + + #[test] + fn removal_failure_names_only_an_unambiguous_removing_member() { + let active = member( + "10000000000000000000000000000001", + 0x3000_0000_0000_0000_0000_0000_0000_0003, + WorldMemberPhase::Active, + ); + let removing = member( + "20000000000000000000000000000002", + 0x4000_0000_0000_0000_0000_0000_0000_0004, + WorldMemberPhase::Removing, + ); + + let failing = failing_identity( + Some(SimulationEndReason::RemovalFailed), + &NativeWorldLifecycle::Starting, + &[active, removing.clone()], + None, + ); + + assert_eq!(failing.producer, Some(removing.controller)); + } + + #[test] + fn terminal_cleanup_failure_cannot_be_reported_as_an_orderly_stop() { + let outcome = terminal_outcome( + None, + Some(SimulationEndReason::RemovalFailed), + Some("Robot controller did not confirm parked".to_owned()), + ); + assert!(matches!( + outcome, + TerminalOutcome::Failed { + reason: SimulationEndReason::RemovalFailed, + ref detail, + } if detail.contains("did not confirm parked") + )); + } +} diff --git a/simulators/webots/host/src/state.rs b/simulators/webots/host/src/state.rs new file mode 100644 index 00000000..6ce64450 --- /dev/null +++ b/simulators/webots/host/src/state.rs @@ -0,0 +1,1557 @@ +//! Deterministic Webots host state transitions. +//! +//! The public world-session projection is owned by `phoxal`. +//! This module owns only Webots observations, validation, and the native directives from which the +//! host updates that projection. + +use std::collections::{BTreeMap, BTreeSet}; +use std::time::{Duration, Instant}; + +use phoxal::identity::{ExecutionId, ProducerId}; +use phoxal::model::world::WorldProgress; +use phoxal::version::FrameworkVersion; + +use phoxal_simulator_webots_shared::protocol::{ + ControllerEvent, ControllerFault, ControllerRole, HostDirective, NativeMotion, + NativeProgressObservation, ObservedNativeMode, +}; + +/// The internal lifecycle of the native world. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum NativeWorldLifecycle { + Starting, + Ready { + requested: NativeMotion, + observed: NativeMotion, + }, + Stopping, + Failed(NativeWorldFailure), +} + +/// Why shared native authority is no longer trustworthy. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum NativeWorldFailure { + DuplicateWorldController, + DuplicateRobot { + execution: String, + }, + IncompatibleController { + expected: FrameworkVersion, + observed: FrameworkVersion, + }, + InvalidTimeStep, + InvalidProgress { + expected_step: u64, + expected_elapsed_ns: u64, + observed: NativeProgressObservation, + }, + UnsupportedMode(ObservedNativeMode), + WorldControllerLost, + RobotControllerLost { + execution: String, + }, + Controller(ControllerFault), + Protocol(String), +} + +/// A cooperative per-member fault that leaves shared world authority intact. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum NativeRobotFailure { + Controller(ControllerFault), + SupervisorLost, +} + +/// A validated Webots host state machine. +#[derive(Clone, Debug)] +pub struct NativeWorldState { + lifecycle: NativeWorldLifecycle, + time_step_ns: Option, + progress: NativeProgressObservation, + world_controller: bool, + world_stopped: bool, + world_last_seen: Option, + robots: BTreeMap, + robot_failures: BTreeMap, + robot_last_seen: BTreeMap, + boundary: Option, +} + +#[derive(Clone, Debug)] +struct BoundaryLatch { + progress: WorldProgress, + completed_motion: NativeMotion, + next_motion: NativeMotion, + expected: BTreeSet, + arrivals: BTreeSet, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum BoundaryRole { + World, + Robot(String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum NativeRobotState { + Connected, + Ready { + controller: ProducerId, + active_revision: Option, + observed: NativeMotion, + }, + Faulted { + controller: ProducerId, + failure: NativeRobotFailure, + }, + Parked { + controller: ProducerId, + failure: Option, + }, + Stopped, + Released, +} + +impl Default for NativeWorldState { + fn default() -> Self { + Self { + lifecycle: NativeWorldLifecycle::Starting, + time_step_ns: None, + progress: NativeProgressObservation { + completed_step: 0, + elapsed_ns: 0, + mode: ObservedNativeMode::Paused, + }, + world_controller: false, + world_stopped: false, + world_last_seen: None, + robots: BTreeMap::new(), + robot_failures: BTreeMap::new(), + robot_last_seen: BTreeMap::new(), + boundary: None, + } + } +} + +impl NativeWorldState { + #[must_use] + pub const fn lifecycle(&self) -> &NativeWorldLifecycle { + &self.lifecycle + } + + #[must_use] + pub const fn progress(&self) -> NativeProgressObservation { + self.progress + } + + #[must_use] + pub fn directive(&self) -> HostDirective { + match &self.lifecycle { + NativeWorldLifecycle::Starting => HostDirective::Park, + NativeWorldLifecycle::Ready { requested, .. } => { + HostDirective::Continue { motion: *requested } + } + NativeWorldLifecycle::Stopping => HostDirective::Stop { + reason: "the world session is stopping".to_owned(), + }, + NativeWorldLifecycle::Failed(reason) => HostDirective::Stop { + reason: format!("the native world failed: {reason:?}"), + }, + } + } + + /// Admit one exact-train native controller. + pub fn admit( + &mut self, + framework: FrameworkVersion, + role: ControllerRole, + ) -> Result { + if framework != FrameworkVersion::CURRENT { + return Err(NativeWorldFailure::IncompatibleController { + expected: FrameworkVersion::CURRENT, + observed: framework, + }); + } + match role { + ControllerRole::World if self.world_controller => { + Err(NativeWorldFailure::DuplicateWorldController) + } + ControllerRole::World => { + self.world_controller = true; + self.world_last_seen = Some(Instant::now()); + Ok(self.directive()) + } + ControllerRole::Robot { execution } => { + let execution = execution_key(execution); + if self.robots.contains_key(&execution) + || self.robot_failures.contains_key(&execution) + { + return Err(NativeWorldFailure::DuplicateRobot { execution }); + } + self.robots + .insert(execution.clone(), NativeRobotState::Connected); + self.robot_last_seen.insert(execution, Instant::now()); + Ok(self.directive()) + } + } + } + + /// Apply one observation from a controller. + pub fn observe( + &mut self, + role: ControllerRole, + event: ControllerEvent, + ) -> Result { + self.touch(role); + match (role, event) { + (ControllerRole::World, ControllerEvent::WorldReady { time_step_ns, mode }) => { + if time_step_ns == 0 { + return self.fail(NativeWorldFailure::InvalidTimeStep); + } + if mode != ObservedNativeMode::Paused { + return self.fail(mode_failure(mode)); + } + self.time_step_ns = Some(time_step_ns); + self.world_stopped = false; + self.lifecycle = NativeWorldLifecycle::Ready { + requested: NativeMotion::Paused, + observed: NativeMotion::Paused, + }; + } + (ControllerRole::World, ControllerEvent::WorldMode { mode }) => { + self.observe_mode(mode)?; + } + (ControllerRole::World, ControllerEvent::WorldProgress(progress)) => { + self.observe_progress(progress)?; + let progress = self.public_progress()?; + return self.observe_completed_boundary( + BoundaryRole::World, + progress, + NativeMotion::RealTime, + ); + } + (ControllerRole::World, ControllerEvent::Fault(fault)) => { + return self.fail(match fault { + ControllerFault::UnsupportedMode { observed } => { + NativeWorldFailure::UnsupportedMode(observed) + } + other => NativeWorldFailure::Controller(other), + }); + } + (ControllerRole::World, ControllerEvent::Stopped) => { + if !matches!( + self.lifecycle, + NativeWorldLifecycle::Stopping | NativeWorldLifecycle::Failed(_) + ) { + return self.fail(NativeWorldFailure::WorldControllerLost); + } + self.world_stopped = true; + } + (ControllerRole::World, ControllerEvent::Heartbeat) => { + if matches!( + self.lifecycle, + NativeWorldLifecycle::Ready { + observed: NativeMotion::Paused, + .. + } + ) { + let progress = self.public_progress()?; + return self.observe_completed_boundary( + BoundaryRole::World, + progress, + NativeMotion::Paused, + ); + } + } + ( + ControllerRole::World, + ControllerEvent::MutationCompleted { .. } | ControllerEvent::RobotImported { .. }, + ) => {} + (ControllerRole::Robot { execution }, ControllerEvent::RobotReady { controller }) => { + let execution = execution_key(execution); + if !matches!( + self.robots.get(&execution), + Some(NativeRobotState::Connected) + ) { + return self.fail(NativeWorldFailure::Protocol(format!( + "robot {execution} reported Ready outside its admitted connection" + ))); + } + self.robots.insert( + execution, + NativeRobotState::Ready { + controller, + active_revision: None, + observed: NativeMotion::Paused, + }, + ); + } + (ControllerRole::Robot { execution }, ControllerEvent::RobotActive { revision }) => { + let execution = execution_key(execution); + let Some(NativeRobotState::Ready { + controller, + active_revision, + observed, + }) = self.robots.get(&execution) + else { + return self.fail(NativeWorldFailure::Protocol(format!( + "robot {execution} acknowledged Active before Ready" + ))); + }; + if active_revision.is_some_and(|current| revision < current) { + return self.fail(NativeWorldFailure::Protocol(format!( + "robot {execution} regressed Active revision" + ))); + } + self.robots.insert( + execution, + NativeRobotState::Ready { + controller: *controller, + active_revision: Some(revision), + observed: *observed, + }, + ); + } + ( + ControllerRole::Robot { execution }, + ControllerEvent::RobotBoundary { progress, motion }, + ) => { + let execution = execution_key(execution); + match self.robots.get_mut(&execution) { + Some(NativeRobotState::Ready { observed, .. }) => *observed = motion, + Some(NativeRobotState::Faulted { .. }) => {} + _ => { + return self.fail(NativeWorldFailure::Protocol(format!( + "robot {execution} reported a boundary before Ready" + ))); + } + } + return self.observe_completed_boundary( + BoundaryRole::Robot(execution), + progress, + motion, + ); + } + (ControllerRole::Robot { execution }, ControllerEvent::RobotParked) => { + let execution = execution_key(execution); + let (controller, failure) = match self.robots.get(&execution) { + Some(NativeRobotState::Ready { controller, .. }) => (*controller, None), + Some(NativeRobotState::Faulted { + controller, + failure, + }) => (*controller, Some(failure.clone())), + Some(NativeRobotState::Parked { + controller, + failure, + }) => (*controller, failure.clone()), + Some( + NativeRobotState::Connected + | NativeRobotState::Stopped + | NativeRobotState::Released, + ) + | None => { + return self.fail(NativeWorldFailure::Protocol(format!( + "robot {execution} parked before reporting its controller identity" + ))); + } + }; + self.robots.insert( + execution, + NativeRobotState::Parked { + controller, + failure, + }, + ); + } + (ControllerRole::Robot { execution }, ControllerEvent::Stopped) => { + let execution = execution_key(execution); + let expected = matches!( + self.lifecycle, + NativeWorldLifecycle::Stopping | NativeWorldLifecycle::Failed(_) + ) || matches!( + self.robots.get(&execution), + Some( + NativeRobotState::Parked { .. } + | NativeRobotState::Stopped + | NativeRobotState::Released + ) + ); + if !expected { + return self.fail(NativeWorldFailure::RobotControllerLost { execution }); + } + self.robots.insert(execution, NativeRobotState::Stopped); + } + (ControllerRole::Robot { .. }, ControllerEvent::ActuationEvidence(_)) => {} + (ControllerRole::Robot { .. }, ControllerEvent::Heartbeat) => {} + (ControllerRole::Robot { .. }, ControllerEvent::RobotStopping) => { + if let NativeWorldLifecycle::Ready { requested, .. } = &mut self.lifecycle { + *requested = NativeMotion::Paused; + } + } + (ControllerRole::Robot { execution }, ControllerEvent::Fault(fault)) => { + let execution = execution_key(execution); + match self.robots.get(&execution) { + Some(NativeRobotState::Ready { controller, .. }) => { + let controller = *controller; + self.robot_failures.insert( + execution.clone(), + NativeRobotFailure::Controller(fault.clone()), + ); + self.robots.insert( + execution, + NativeRobotState::Faulted { + controller, + failure: NativeRobotFailure::Controller(fault), + }, + ); + if let NativeWorldLifecycle::Ready { requested, .. } = &mut self.lifecycle { + *requested = NativeMotion::Paused; + } + return Ok(HostDirective::Park); + } + Some(NativeRobotState::Faulted { .. } | NativeRobotState::Parked { .. }) => {} + _ => { + return self.fail(NativeWorldFailure::Protocol(format!( + "robot {execution} faulted outside an active native barrier" + ))); + } + } + } + (ControllerRole::Robot { execution }, ControllerEvent::RobotSupervisorLost) => { + let execution = execution_key(execution); + let Some(NativeRobotState::Ready { controller, .. }) = self.robots.get(&execution) + else { + return self.fail(NativeWorldFailure::Protocol(format!( + "robot {execution} lost its supervisor outside an active native barrier" + ))); + }; + let controller = *controller; + self.robot_failures + .insert(execution.clone(), NativeRobotFailure::SupervisorLost); + self.robots.insert( + execution, + NativeRobotState::Faulted { + controller, + failure: NativeRobotFailure::SupervisorLost, + }, + ); + if let NativeWorldLifecycle::Ready { requested, .. } = &mut self.lifecycle { + *requested = NativeMotion::Paused; + } + return Ok(HostDirective::Park); + } + _ => { + return self.fail(NativeWorldFailure::Protocol( + "controller event does not match its admitted role".to_owned(), + )); + } + } + Ok(self.directive()) + } + + #[must_use] + pub fn robot_controller(&self, execution: ExecutionId) -> Option { + match self.robots.get(&execution_key(execution)) { + Some( + NativeRobotState::Ready { controller, .. } + | NativeRobotState::Faulted { controller, .. } + | NativeRobotState::Parked { controller, .. }, + ) => Some(*controller), + Some( + NativeRobotState::Connected + | NativeRobotState::Stopped + | NativeRobotState::Released, + ) + | None => None, + } + } + + /// Whether any native controller state remains for this execution. + #[must_use] + #[cfg(test)] + pub fn has_robot(&self, execution: ExecutionId) -> bool { + self.robots.contains_key(&execution_key(execution)) + } + + /// Whether the admitted world controller acknowledged the host terminal directive. + #[must_use] + pub const fn world_is_stopped(&self) -> bool { + self.world_stopped + } + + /// Whether a world controller joined this native world at any point. + #[must_use] + pub const fn has_world_controller(&self) -> bool { + self.world_controller + } + + #[must_use] + pub fn robot_active_revision(&self, execution: ExecutionId) -> Option { + match self.robots.get(&execution_key(execution)) { + Some(NativeRobotState::Ready { + active_revision, .. + }) => *active_revision, + _ => None, + } + } + + #[must_use] + pub fn robot_is_parked(&self, execution: ExecutionId) -> bool { + matches!( + self.robots.get(&execution_key(execution)), + Some( + NativeRobotState::Parked { .. } + | NativeRobotState::Stopped + | NativeRobotState::Released + ) + ) + } + + #[must_use] + pub fn robot_failure(&self, execution: ExecutionId) -> Option { + let execution = execution_key(execution); + self.robot_failures + .get(&execution) + .cloned() + .or_else(|| match self.robots.get(&execution) { + Some(NativeRobotState::Faulted { failure, .. }) + | Some(NativeRobotState::Parked { + failure: Some(failure), + .. + }) => Some(failure.clone()), + _ => None, + }) + } + + /// Mark controller state released after native removal or pre-commit rollback. + /// Retain a tombstone only while its controller connection can still close. + pub fn release_robot(&mut self, execution: ExecutionId) { + let execution = execution_key(execution); + if !self.robot_last_seen.contains_key(&execution) { + self.robots.remove(&execution); + } else if self.robots.contains_key(&execution) { + self.robots + .insert(execution.clone(), NativeRobotState::Released); + } + self.robot_failures.remove(&execution); + self.robot_last_seen.remove(&execution); + // Release is admitted only while the native world is isolated at a + // completed paused boundary. A subsequent heartbeat establishes a + // fresh latch from the remaining synchronized roles. + self.boundary = None; + } + + /// Fail a Ready native world when an admitted synchronized controller stops answering. + /// + /// The world role is exempt only while it owns the bounded native mutation call. Robot roles + /// remain monitored because they continue polling outside `wb_robot_step` while paused. + pub fn enforce_liveness( + &mut self, + now: Instant, + timeout: Duration, + world_mutation_active: bool, + ) { + if !matches!(self.lifecycle, NativeWorldLifecycle::Ready { .. }) { + return; + } + if !world_mutation_active + && self + .world_last_seen + .is_some_and(|last_seen| now.saturating_duration_since(last_seen) > timeout) + { + self.lifecycle = NativeWorldLifecycle::Failed(NativeWorldFailure::WorldControllerLost); + self.boundary = None; + return; + } + let unresponsive = self.robots.iter().find_map(|(execution, state)| { + matches!(state, NativeRobotState::Ready { .. }) + .then(|| { + self.robot_last_seen + .get(execution) + .is_some_and(|last_seen| { + now.saturating_duration_since(*last_seen) > timeout + }) + .then(|| execution.clone()) + }) + .flatten() + }); + if let Some(execution) = unresponsive { + self.lifecycle = + NativeWorldLifecycle::Failed(NativeWorldFailure::RobotControllerLost { execution }); + self.boundary = None; + } + } + + /// Whether every admitted Robot has confirmed the requested completed boundary. + #[must_use] + pub fn robots_observe_motion(&self, motion: NativeMotion) -> bool { + self.robots.values().all(|robot| match robot { + NativeRobotState::Ready { observed, .. } => *observed == motion, + NativeRobotState::Parked { .. } + | NativeRobotState::Stopped + | NativeRobotState::Released => motion == NativeMotion::Paused, + NativeRobotState::Connected | NativeRobotState::Faulted { .. } => false, + }) + } + + /// Classify an unexpected private controller disconnect. + pub fn controller_lost(&mut self, role: ControllerRole) { + let failure = match role { + ControllerRole::World + if matches!( + self.lifecycle, + NativeWorldLifecycle::Stopping | NativeWorldLifecycle::Failed(_) + ) => + { + return; + } + ControllerRole::World => NativeWorldFailure::WorldControllerLost, + ControllerRole::Robot { execution } => { + let execution = execution_key(execution); + if matches!( + self.lifecycle, + NativeWorldLifecycle::Stopping | NativeWorldLifecycle::Failed(_) + ) { + self.robots.remove(&execution); + self.robot_last_seen.remove(&execution); + return; + } + match self.robots.get(&execution) { + Some(NativeRobotState::Connected) => { + // A controller that disconnects before publishing its typed identity has + // not joined the synchronized barrier. Let the owning attach transaction + // roll back this reservation without failing unrelated members. + self.robots.remove(&execution); + self.robot_last_seen.remove(&execution); + return; + } + Some(NativeRobotState::Parked { .. } | NativeRobotState::Stopped) => { + // The removal worker may not have observed the parking acknowledgement + // yet. Preserve it until native removal releases this reservation. + self.robot_last_seen.remove(&execution); + return; + } + Some(NativeRobotState::Released) => { + self.robots.remove(&execution); + self.robot_last_seen.remove(&execution); + return; + } + Some(NativeRobotState::Ready { .. } | NativeRobotState::Faulted { .. }) + | None => NativeWorldFailure::RobotControllerLost { execution }, + } + } + }; + self.lifecycle = NativeWorldLifecycle::Failed(failure); + self.boundary = None; + } + + /// Request one of the two supported Live motion states. + pub fn request_motion( + &mut self, + requested: NativeMotion, + ) -> Result { + let NativeWorldLifecycle::Ready { + requested: current, .. + } = &mut self.lifecycle + else { + return self.fail(NativeWorldFailure::Protocol( + "motion can change only while the native world is ready".to_owned(), + )); + }; + *current = requested; + Ok(self.directive()) + } + + pub fn stop(&mut self) -> HostDirective { + self.lifecycle = NativeWorldLifecycle::Stopping; + self.directive() + } + + /// Fail the native world when its private coordination authority is unavailable. + pub fn protocol_failure(&mut self, detail: String) { + self.lifecycle = NativeWorldLifecycle::Failed(NativeWorldFailure::Protocol(detail)); + } + + fn observe_mode(&mut self, mode: ObservedNativeMode) -> Result<(), NativeWorldFailure> { + let observed = native_motion(mode).ok_or_else(|| mode_failure(mode))?; + let NativeWorldLifecycle::Ready { + observed: current, .. + } = &mut self.lifecycle + else { + return self.fail(NativeWorldFailure::Protocol( + "a native mode observation arrived before the world was ready".to_owned(), + )); + }; + *current = observed; + self.progress.mode = mode; + Ok(()) + } + + fn observe_progress( + &mut self, + observed: NativeProgressObservation, + ) -> Result<(), NativeWorldFailure> { + if observed.mode != ObservedNativeMode::RealTime { + return self.fail(mode_failure(observed.mode)); + } + let Some(time_step_ns) = self.time_step_ns else { + return self.fail(NativeWorldFailure::Protocol( + "native progress arrived before the world declared its time step".to_owned(), + )); + }; + let expected_step = self.progress.completed_step.checked_add(1).ok_or_else(|| { + NativeWorldFailure::Protocol("the native step counter exhausted".to_owned()) + })?; + let expected_elapsed_ns = expected_step.checked_mul(time_step_ns).ok_or_else(|| { + NativeWorldFailure::Protocol("native elapsed time overflowed".to_owned()) + })?; + if observed.completed_step != expected_step || observed.elapsed_ns != expected_elapsed_ns { + return self.fail(NativeWorldFailure::InvalidProgress { + expected_step, + expected_elapsed_ns, + observed, + }); + } + self.progress = observed; + if let NativeWorldLifecycle::Ready { + observed: motion, .. + } = &mut self.lifecycle + { + *motion = NativeMotion::RealTime; + } + Ok(()) + } + + fn public_progress(&mut self) -> Result { + let Some(time_step_ns) = self.time_step_ns else { + return self.fail(NativeWorldFailure::Protocol( + "a completed boundary arrived before the world declared its time step".to_owned(), + )); + }; + WorldProgress::at(self.progress.completed_step, time_step_ns).map_err(|error| { + let failure = NativeWorldFailure::Protocol(format!( + "the validated native progress could not form WorldProgress: {error}" + )); + self.lifecycle = NativeWorldLifecycle::Failed(failure.clone()); + failure + }) + } + + fn observe_completed_boundary( + &mut self, + role: BoundaryRole, + progress: WorldProgress, + completed_motion: NativeMotion, + ) -> Result { + let NativeWorldLifecycle::Ready { requested, .. } = self.lifecycle else { + return Ok(self.directive()); + }; + let Some(time_step_ns) = self.time_step_ns else { + return self.fail(NativeWorldFailure::Protocol( + "a completed boundary arrived before the world declared its time step".to_owned(), + )); + }; + if progress.validate(time_step_ns).is_err() { + return self.fail(self.invalid_progress(progress, completed_motion)); + } + + let expected_roles = self.synchronized_roles(); + if !expected_roles.contains(&role) { + return self.fail(NativeWorldFailure::Protocol(format!( + "inactive native role {role:?} reported a synchronized boundary" + ))); + } + + if let Some(latch) = &self.boundary { + // A role that already received PAUSE can poll at that unchanged boundary while + // another native role is still finishing its previous transition's local work. + if latch.arrivals.contains(&role) + && latch.progress == progress + && completed_motion == latch.next_motion + { + return Ok(HostDirective::Continue { + motion: latch.next_motion, + }); + } + if latch.progress != progress || latch.completed_motion != completed_motion { + return self.fail(NativeWorldFailure::Protocol(format!( + "native boundary disagreement: expected {:?} in {:?}, observed {progress:?} in {completed_motion:?}", + latch.progress, latch.completed_motion + ))); + } + } else { + let current = self.public_progress()?; + let allowed = progress == current + || (completed_motion == NativeMotion::RealTime + && current + .completed_step() + .checked_add(1) + .and_then(|step| WorldProgress::at(step, time_step_ns).ok()) + == Some(progress)); + if !allowed { + return self.fail(self.invalid_progress(progress, completed_motion)); + } + self.boundary = Some(BoundaryLatch { + progress, + completed_motion, + next_motion: requested, + expected: expected_roles.clone(), + arrivals: BTreeSet::new(), + }); + } + + let latch = match self.boundary.as_mut() { + Some(latch) => latch, + None => { + return self.fail(NativeWorldFailure::Protocol( + "completed boundary latch disappeared".to_owned(), + )); + } + }; + latch.expected = expected_roles; + latch + .arrivals + .retain(|arrived| latch.expected.contains(arrived)); + latch.arrivals.insert(role); + let next_motion = latch.next_motion; + if latch.arrivals == latch.expected { + self.boundary = None; + } + Ok(HostDirective::Continue { + motion: next_motion, + }) + } + + fn synchronized_roles(&self) -> BTreeSet { + let mut roles = BTreeSet::new(); + if self.world_controller { + roles.insert(BoundaryRole::World); + } + roles.extend( + self.robots + .iter() + .filter(|(_, state)| { + matches!( + state, + NativeRobotState::Ready { .. } | NativeRobotState::Faulted { .. } + ) + }) + .map(|(execution, _)| BoundaryRole::Robot(execution.clone())), + ); + roles + } + + fn invalid_progress( + &self, + progress: WorldProgress, + motion: NativeMotion, + ) -> NativeWorldFailure { + let time_step_ns = self.time_step_ns.unwrap_or(0); + let expected_step = if motion == NativeMotion::RealTime { + self.progress.completed_step.saturating_add(1) + } else { + self.progress.completed_step + }; + NativeWorldFailure::InvalidProgress { + expected_step, + expected_elapsed_ns: expected_step.saturating_mul(time_step_ns), + observed: NativeProgressObservation { + completed_step: progress.completed_step(), + elapsed_ns: progress.elapsed_ns(), + mode: match motion { + NativeMotion::Paused => ObservedNativeMode::Paused, + NativeMotion::RealTime => ObservedNativeMode::RealTime, + }, + }, + } + } + + fn touch(&mut self, role: ControllerRole) { + match role { + ControllerRole::World => self.world_last_seen = Some(Instant::now()), + ControllerRole::Robot { execution } => { + self.robot_last_seen + .insert(execution_key(execution), Instant::now()); + } + } + } + + fn fail(&mut self, failure: NativeWorldFailure) -> Result { + self.boundary = None; + self.lifecycle = NativeWorldLifecycle::Failed(failure.clone()); + Err(failure) + } +} + +fn mode_failure(mode: ObservedNativeMode) -> NativeWorldFailure { + match mode { + ObservedNativeMode::Run | ObservedNativeMode::Fast => { + NativeWorldFailure::UnsupportedMode(mode) + } + ObservedNativeMode::Paused | ObservedNativeMode::RealTime => NativeWorldFailure::Protocol( + format!("native mode {mode:?} is invalid at this transition"), + ), + } +} + +const fn native_motion(mode: ObservedNativeMode) -> Option { + match mode { + ObservedNativeMode::Paused => Some(NativeMotion::Paused), + ObservedNativeMode::RealTime => Some(NativeMotion::RealTime), + ObservedNativeMode::Run | ObservedNativeMode::Fast => None, + } +} + +fn execution_key(execution: ExecutionId) -> String { + execution.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn compatible_patch_other_than_current() -> FrameworkVersion { + let current = FrameworkVersion::CURRENT; + let patch = if current.patch() == u16::MAX { + current.patch() - 1 + } else { + current.patch() + 1 + }; + FrameworkVersion::new(current.major(), current.minor(), patch) + } + + fn ready_two_robot_barrier() -> (NativeWorldState, [ControllerRole; 2]) { + let mut state = NativeWorldState::default(); + state + .admit(FrameworkVersion::CURRENT, ControllerRole::World) + .expect("world role"); + state + .observe( + ControllerRole::World, + ControllerEvent::WorldReady { + time_step_ns: 12_000_000, + mode: ObservedNativeMode::Paused, + }, + ) + .expect("world ready"); + let executions = [ + ExecutionId::try_from(0x1000_0000_0000_0000_0000_0000_0000_0001) + .expect("first execution"), + ExecutionId::try_from(0x2000_0000_0000_0000_0000_0000_0000_0002) + .expect("second execution"), + ]; + let controllers = [ + ProducerId::try_from(0x3000_0000_0000_0000_0000_0000_0000_0003) + .expect("first controller"), + ProducerId::try_from(0x4000_0000_0000_0000_0000_0000_0000_0004) + .expect("second controller"), + ]; + let roles = executions.map(|execution| ControllerRole::Robot { execution }); + for (role, controller) in roles.into_iter().zip(controllers) { + state + .admit(FrameworkVersion::CURRENT, role) + .expect("Robot role"); + state + .observe(role, ControllerEvent::RobotReady { controller }) + .expect("Robot ready"); + } + state + .request_motion(NativeMotion::RealTime) + .expect("world starts running"); + (state, roles) + } + + fn robot_boundary(step: u64) -> ControllerEvent { + ControllerEvent::RobotBoundary { + progress: WorldProgress::at(step, 12_000_000).expect("boundary progress"), + motion: NativeMotion::RealTime, + } + } + + fn world_boundary(step: u64) -> ControllerEvent { + ControllerEvent::WorldProgress(NativeProgressObservation { + completed_step: step, + elapsed_ns: step * 12_000_000, + mode: ObservedNativeMode::RealTime, + }) + } + + fn assert_motion(directive: HostDirective, motion: NativeMotion) { + assert_eq!(directive, HostDirective::Continue { motion }); + } + + #[test] + fn native_controller_admission_requires_the_exact_patch_train() { + let mut state = NativeWorldState::default(); + let observed = compatible_patch_other_than_current(); + assert!(observed.is_compatible_with(FrameworkVersion::CURRENT)); + + assert_eq!( + state + .admit(observed, ControllerRole::World) + .expect_err("a compatible but non-exact controller train is rejected"), + NativeWorldFailure::IncompatibleController { + expected: FrameworkVersion::CURRENT, + observed, + } + ); + assert_eq!(state.lifecycle(), &NativeWorldLifecycle::Starting); + + state + .admit(FrameworkVersion::CURRENT, ControllerRole::World) + .expect("the failed handshake did not consume the world-controller role"); + } + + #[test] + fn robot_first_boundary_latches_one_next_motion_for_every_role() { + let (mut state, [first, second]) = ready_two_robot_barrier(); + assert_motion( + state + .observe(first, robot_boundary(1)) + .expect("first Robot closes step one"), + NativeMotion::RealTime, + ); + state + .request_motion(NativeMotion::Paused) + .expect("pause is requested between arrivals"); + assert_motion( + state + .observe(ControllerRole::World, world_boundary(1)) + .expect("world closes step one"), + NativeMotion::RealTime, + ); + assert_motion( + state + .observe(second, robot_boundary(1)) + .expect("second Robot closes step one"), + NativeMotion::RealTime, + ); + + assert_motion( + state + .observe(second, robot_boundary(2)) + .expect("second Robot closes step two first"), + NativeMotion::Paused, + ); + assert_motion( + state + .observe(ControllerRole::World, world_boundary(2)) + .expect("world closes step two"), + NativeMotion::Paused, + ); + assert_motion( + state + .observe(first, robot_boundary(2)) + .expect("first Robot closes step two"), + NativeMotion::Paused, + ); + } + + #[test] + fn world_first_boundary_latches_one_next_motion_for_every_role() { + let (mut state, [first, second]) = ready_two_robot_barrier(); + assert_motion( + state + .observe(ControllerRole::World, world_boundary(1)) + .expect("world closes step one first"), + NativeMotion::RealTime, + ); + state + .request_motion(NativeMotion::Paused) + .expect("pause is requested between arrivals"); + for role in [second, first] { + assert_motion( + state + .observe(role, robot_boundary(1)) + .expect("Robot closes step one"), + NativeMotion::RealTime, + ); + } + } + + #[test] + fn stopped_answering_deadline_exempts_only_the_bounded_world_mutation() { + let mut state = NativeWorldState::default(); + state + .admit(FrameworkVersion::CURRENT, ControllerRole::World) + .expect("world role"); + state + .observe( + ControllerRole::World, + ControllerEvent::WorldReady { + time_step_ns: 12_000_000, + mode: ObservedNativeMode::Paused, + }, + ) + .expect("world ready"); + let after_deadline = Instant::now() + Duration::from_secs(31); + state.enforce_liveness(after_deadline, Duration::from_secs(30), true); + assert!(matches!( + state.lifecycle(), + NativeWorldLifecycle::Ready { .. } + )); + state.enforce_liveness(after_deadline, Duration::from_secs(30), false); + assert_eq!( + state.lifecycle(), + &NativeWorldLifecycle::Failed(NativeWorldFailure::WorldControllerLost) + ); + } + + #[test] + fn paused_world_keeps_the_robot_stopped_answering_deadline_active() { + let (mut state, [first, _]) = ready_two_robot_barrier(); + assert!(matches!( + state.lifecycle(), + NativeWorldLifecycle::Ready { + requested: NativeMotion::RealTime, + observed: NativeMotion::Paused, + } + )); + let after_deadline = Instant::now() + Duration::from_secs(31); + state.enforce_liveness(after_deadline, Duration::from_secs(30), true); + assert!( + matches!( + state.lifecycle(), + NativeWorldLifecycle::Failed(NativeWorldFailure::RobotControllerLost { execution }) + if execution == &match first { + ControllerRole::Robot { execution } => execution.to_string(), + ControllerRole::World => unreachable!("fixture returned a Robot role"), + } + ), + "native pause and the world-mutation exemption must not suspend a Robot controller's host-monotonic deadline" + ); + } + + #[test] + fn one_exact_quantum_advances_progress_and_a_jump_fails_the_world() { + let mut state = NativeWorldState::default(); + state + .admit(FrameworkVersion::CURRENT, ControllerRole::World) + .expect("the world controller is admitted"); + state + .observe( + ControllerRole::World, + ControllerEvent::WorldReady { + time_step_ns: 12_000_000, + mode: ObservedNativeMode::Paused, + }, + ) + .expect("the paused world becomes ready"); + state + .request_motion(NativeMotion::RealTime) + .expect("the world can resume"); + state + .observe( + ControllerRole::World, + ControllerEvent::WorldProgress(NativeProgressObservation { + completed_step: 1, + elapsed_ns: 12_000_000, + mode: ObservedNativeMode::RealTime, + }), + ) + .expect("one exact quantum is valid"); + assert_eq!(state.progress().completed_step, 1); + + let error = state + .observe( + ControllerRole::World, + ControllerEvent::WorldProgress(NativeProgressObservation { + completed_step: 3, + elapsed_ns: 36_000_000, + mode: ObservedNativeMode::RealTime, + }), + ) + .expect_err("skipped progress must fail"); + assert!(matches!(error, NativeWorldFailure::InvalidProgress { .. })); + assert!(matches!( + state.lifecycle(), + NativeWorldLifecycle::Failed(NativeWorldFailure::InvalidProgress { .. }) + )); + } + + #[test] + fn rewind_is_refused_under_one_world_instance() { + let mut state = NativeWorldState::default(); + state + .admit(FrameworkVersion::CURRENT, ControllerRole::World) + .expect("the world controller is admitted"); + state + .observe( + ControllerRole::World, + ControllerEvent::WorldReady { + time_step_ns: 10, + mode: ObservedNativeMode::Paused, + }, + ) + .expect("the world becomes ready"); + state + .observe( + ControllerRole::World, + ControllerEvent::WorldProgress(NativeProgressObservation { + completed_step: 1, + elapsed_ns: 10, + mode: ObservedNativeMode::RealTime, + }), + ) + .expect("the first step is valid"); + assert!(matches!( + state.observe( + ControllerRole::World, + ControllerEvent::WorldProgress(NativeProgressObservation { + completed_step: 0, + elapsed_ns: 0, + mode: ObservedNativeMode::RealTime, + },) + ), + Err(NativeWorldFailure::InvalidProgress { .. }) + )); + } + + #[test] + fn fast_and_run_modes_are_typed_world_failures() { + for mode in [ObservedNativeMode::Run, ObservedNativeMode::Fast] { + let mut state = NativeWorldState::default(); + state + .admit(FrameworkVersion::CURRENT, ControllerRole::World) + .expect("the world controller is admitted"); + assert_eq!( + state + .observe( + ControllerRole::World, + ControllerEvent::WorldReady { + time_step_ns: 12_000_000, + mode, + } + ) + .expect_err("the mode must fail"), + NativeWorldFailure::UnsupportedMode(mode) + ); + } + } + + #[test] + fn cooperative_member_fault_is_isolated_but_hard_disconnect_is_world_fatal() { + let mut state = NativeWorldState::default(); + state + .admit(FrameworkVersion::CURRENT, ControllerRole::World) + .expect("the world controller is admitted"); + state + .observe( + ControllerRole::World, + ControllerEvent::WorldReady { + time_step_ns: 12_000_000, + mode: ObservedNativeMode::Paused, + }, + ) + .expect("the world becomes ready"); + state + .request_motion(NativeMotion::RealTime) + .expect("the world starts running"); + + let first = ExecutionId::try_from(0x1000_0000_0000_0000_0000_0000_0000_0001) + .expect("canonical execution"); + let second = ExecutionId::try_from(0x2000_0000_0000_0000_0000_0000_0000_0002) + .expect("canonical execution"); + let first_controller = ProducerId::try_from(0x3000_0000_0000_0000_0000_0000_0000_0003) + .expect("canonical producer"); + let second_controller = ProducerId::try_from(0x4000_0000_0000_0000_0000_0000_0000_0004) + .expect("canonical producer"); + for (execution, controller) in [(first, first_controller), (second, second_controller)] { + let role = ControllerRole::Robot { execution }; + state + .admit(FrameworkVersion::CURRENT, role) + .expect("the Robot role is admitted"); + state + .observe(role, ControllerEvent::RobotReady { controller }) + .expect("the Robot becomes ready"); + } + assert!(state.robots_observe_motion(NativeMotion::Paused)); + assert!(!state.robots_observe_motion(NativeMotion::RealTime)); + for execution in [first, second] { + state + .observe( + ControllerRole::Robot { execution }, + ControllerEvent::RobotBoundary { + progress: WorldProgress::at(1, 12_000_000).expect("first boundary"), + motion: NativeMotion::RealTime, + }, + ) + .expect("each Robot confirms the completed running boundary"); + } + assert!(state.robots_observe_motion(NativeMotion::RealTime)); + + let first_role = ControllerRole::Robot { execution: first }; + let directive = state + .observe( + first_role, + ControllerEvent::Fault(ControllerFault::Device { + detail: "encoder read failed".to_owned(), + }), + ) + .expect("one cooperative Robot fault is isolated"); + assert_eq!(directive, HostDirective::Park); + state + .observe(first_role, ControllerEvent::RobotParked) + .expect("the faulted Robot confirms its boundary"); + state.controller_lost(first_role); + assert!(matches!( + state.lifecycle(), + NativeWorldLifecycle::Ready { + requested: NativeMotion::Paused, + .. + } + )); + assert!(matches!( + state.robot_failure(first), + Some(NativeRobotFailure::Controller( + ControllerFault::Device { .. } + )) + )); + assert_eq!(state.robot_controller(second), Some(second_controller)); + + state.controller_lost(ControllerRole::Robot { execution: second }); + assert!(matches!( + state.lifecycle(), + NativeWorldLifecycle::Failed(NativeWorldFailure::RobotControllerLost { execution }) + if execution == &second.to_string() + )); + } + + #[test] + fn cooperative_fault_finishes_an_already_issued_native_quantum_before_parking() { + let mut state = NativeWorldState::default(); + let world = ControllerRole::World; + let execution = + ExecutionId::try_from(0x1000_0000_0000_0000_0000_0000_0000_0001).expect("execution"); + let robot = ControllerRole::Robot { execution }; + state + .admit(FrameworkVersion::CURRENT, world) + .expect("world"); + state + .observe( + world, + ControllerEvent::WorldReady { + time_step_ns: 12_000_000, + mode: ObservedNativeMode::Paused, + }, + ) + .expect("ready"); + state + .admit(FrameworkVersion::CURRENT, robot) + .expect("robot"); + state + .observe( + robot, + ControllerEvent::RobotReady { + controller: ProducerId::try_from(0x2000_0000_0000_0000_0000_0000_0000_0001) + .expect("producer"), + }, + ) + .expect("robot ready"); + state.request_motion(NativeMotion::RealTime).expect("run"); + let first = WorldProgress::at(1, 12_000_000).expect("progress"); + let second = WorldProgress::at(2, 12_000_000).expect("progress"); + let observed = |progress: WorldProgress| { + ControllerEvent::WorldProgress(NativeProgressObservation { + completed_step: progress.completed_step(), + elapsed_ns: progress.elapsed_ns(), + mode: ObservedNativeMode::RealTime, + }) + }; + assert_eq!( + state + .observe(world, observed(first)) + .expect("world enters next native step"), + HostDirective::Continue { + motion: NativeMotion::RealTime + } + ); + state + .observe( + robot, + ControllerEvent::Fault(ControllerFault::Device { + detail: "capture failed".to_owned(), + }), + ) + .expect("fault requests pause"); + assert_eq!( + state + .observe( + robot, + ControllerEvent::RobotBoundary { + progress: first, + motion: NativeMotion::RealTime + } + ) + .expect("faulted robot remains synchronized"), + HostDirective::Continue { + motion: NativeMotion::RealTime + } + ); + assert_eq!( + state + .observe(world, observed(second)) + .expect("world finishes issued quantum"), + HostDirective::Continue { + motion: NativeMotion::Paused + } + ); + state + .observe( + world, + ControllerEvent::WorldMode { + mode: ObservedNativeMode::Paused, + }, + ) + .expect("native pause"); + state + .observe(world, ControllerEvent::Heartbeat) + .expect("paused poll cannot disagree with a peer completing the same quantum"); + assert_eq!( + state + .observe( + robot, + ControllerEvent::RobotBoundary { + progress: second, + motion: NativeMotion::RealTime + } + ) + .expect("parked final quantum"), + HostDirective::Continue { + motion: NativeMotion::Paused + } + ); + state + .observe(robot, ControllerEvent::RobotParked) + .expect("parked after common pause"); + assert!(state.robot_is_parked(execution)); + assert!(matches!( + state.lifecycle(), + NativeWorldLifecycle::Ready { .. } + )); + } + + #[test] + fn pre_ready_robot_disconnect_remains_an_attachment_rollback() { + let mut state = NativeWorldState::default(); + state + .admit(FrameworkVersion::CURRENT, ControllerRole::World) + .expect("world role"); + state + .observe( + ControllerRole::World, + ControllerEvent::WorldReady { + time_step_ns: 12_000_000, + mode: ObservedNativeMode::Paused, + }, + ) + .expect("world ready"); + let execution = + ExecutionId::try_from(0x1000_0000_0000_0000_0000_0000_0000_0001).expect("execution"); + let role = ControllerRole::Robot { execution }; + state + .admit(FrameworkVersion::CURRENT, role) + .expect("pre-ready Robot connection"); + state.controller_lost(role); + assert!(matches!( + state.lifecycle(), + NativeWorldLifecycle::Ready { .. } + )); + assert_eq!(state.robot_controller(execution), None); + } + + #[test] + fn unsolicited_synchronized_controller_stops_are_world_fatal() { + let mut state = NativeWorldState::default(); + state + .admit(FrameworkVersion::CURRENT, ControllerRole::World) + .expect("world role"); + state + .observe( + ControllerRole::World, + ControllerEvent::WorldReady { + time_step_ns: 12_000_000, + mode: ObservedNativeMode::Paused, + }, + ) + .expect("world ready"); + assert_eq!( + state + .observe(ControllerRole::World, ControllerEvent::Stopped) + .expect_err("an unsolicited native world stop is fatal"), + NativeWorldFailure::WorldControllerLost + ); + + let mut state = NativeWorldState::default(); + state + .admit(FrameworkVersion::CURRENT, ControllerRole::World) + .expect("world role"); + state + .observe( + ControllerRole::World, + ControllerEvent::WorldReady { + time_step_ns: 12_000_000, + mode: ObservedNativeMode::Paused, + }, + ) + .expect("world ready"); + let execution = + ExecutionId::try_from(0x1000_0000_0000_0000_0000_0000_0000_0001).expect("execution"); + let role = ControllerRole::Robot { execution }; + state + .admit(FrameworkVersion::CURRENT, role) + .expect("Robot role"); + state + .observe( + role, + ControllerEvent::RobotReady { + controller: ProducerId::try_from(0x3000_0000_0000_0000_0000_0000_0000_0003) + .expect("producer"), + }, + ) + .expect("Robot ready"); + assert_eq!( + state + .observe(role, ControllerEvent::Stopped) + .expect_err("an unsolicited synchronized Robot stop is fatal"), + NativeWorldFailure::RobotControllerLost { + execution: execution.to_string(), + } + ); + } + + #[test] + fn stopped_acknowledgements_require_a_host_terminal_or_parked_role() { + let (mut state, [first, _]) = ready_two_robot_barrier(); + assert!(!state.world_is_stopped()); + state.stop(); + state + .observe(first, ControllerEvent::Stopped) + .expect("a Robot acknowledges the host stop"); + state + .observe(ControllerRole::World, ControllerEvent::Stopped) + .expect("the world controller acknowledges the host stop"); + assert!(state.world_is_stopped()); + state.controller_lost(first); + state.controller_lost(ControllerRole::World); + assert_eq!(state.lifecycle(), &NativeWorldLifecycle::Stopping); + + let (mut state, [first, _]) = ready_two_robot_barrier(); + state + .observe(first, ControllerEvent::RobotParked) + .expect("the host-directed retiring Robot parks first"); + state + .observe(first, ControllerEvent::Stopped) + .expect("the parked Robot may acknowledge retirement"); + state.controller_lost(first); + let ControllerRole::Robot { execution } = first else { + unreachable!(); + }; + assert!(state.robot_is_parked(execution)); + state.release_robot(execution); + assert!(!state.has_robot(execution)); + assert!(matches!( + state.lifecycle(), + NativeWorldLifecycle::Ready { .. } + )); + } +} diff --git a/simulators/webots/robot-controller/Cargo.toml b/simulators/webots/robot-controller/Cargo.toml new file mode 100644 index 00000000..68659ecd --- /dev/null +++ b/simulators/webots/robot-controller/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "phoxal-simulator-webots-robot-controller" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = ["phoxal"] +description = "Phoxal Webots per-robot typed component controller." +documentation.workspace = true +homepage.workspace = true +repository.workspace = true + +[[bin]] +name = "phoxal-simulator-webots-robot-controller" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true } +phoxal = { workspace = true, features = ["session", "simulator"] } +phoxal-simulator-webots-shared = { path = "../shared", version = "=0.67.1", registry = "phoxal" } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +webots-rs = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/simulators/webots/robot-controller/src/actuation_evidence.rs b/simulators/webots/robot-controller/src/actuation_evidence.rs new file mode 100644 index 00000000..e9d851af --- /dev/null +++ b/simulators/webots/robot-controller/src/actuation_evidence.rs @@ -0,0 +1,50 @@ +use super::*; + +pub(super) struct PendingActuationEvidence { + pub(super) capability: phoxal::model::identity::CapabilityRef, + pub(super) revision: u64, + pub(super) selected_at: phoxal::bus::RobotInstant, + pub(super) selected_from: WorldProgress, + pub(super) offered: Vec, + pub(super) selected: Option, + pub(super) selection: ActuationSelection, + pub(super) applied: AppliedActuation, +} + +impl PendingActuationEvidence { + pub(super) fn complete(self, transition: &LiveTransitionStamp) -> ActuationEvidence { + ActuationEvidence { + capability: self.capability, + revision: self.revision, + selected_at: self.selected_at, + selected_from: self.selected_from, + progress: transition.progress(), + instant: transition.instant(), + offered: self.offered, + selected: self.selected, + selection: self.selection, + applied: self.applied, + } + } +} + +pub(super) fn evidence_decision(decision: LeaseDecision) -> ActuationDecision { + match decision { + LeaseDecision::Acquired => ActuationDecision::Acquired, + LeaseDecision::Renewed => ActuationDecision::Renewed, + LeaseDecision::Rejected(rejection) => match rejection { + LeaseRejection::WrongParticipant => ActuationDecision::WrongParticipant, + LeaseRejection::ParticipantSource => ActuationDecision::ParticipantSource, + LeaseRejection::SourceAbsent => ActuationDecision::SourceAbsent, + LeaseRejection::SourceConflict => ActuationDecision::SourceConflict, + LeaseRejection::StaleSequence { accepted, observed } => { + ActuationDecision::StaleSequence { accepted, observed } + } + LeaseRejection::AuthorityHeld { owner } => ActuationDecision::AuthorityHeld { owner }, + LeaseRejection::NotOwner { owner, requested } => { + ActuationDecision::NotOwner { owner, requested } + } + LeaseRejection::ReadyStateOverflow => ActuationDecision::ReadyStateOverflow, + }, + } +} diff --git a/simulators/webots/robot-controller/src/controller_runtime_tests.rs b/simulators/webots/robot-controller/src/controller_runtime_tests.rs new file mode 100644 index 00000000..014b1208 --- /dev/null +++ b/simulators/webots/robot-controller/src/controller_runtime_tests.rs @@ -0,0 +1,190 @@ +use super::*; +use phoxal::bus::BusError; + +#[test] +fn removing_during_a_transition_keeps_the_orderly_shutdown_handshake() { + assert!(matches!( + authority_exit( + SimulatorError::AttachmentInactive, + Some(SimulationAttachmentPhase::Removing), + "completed transition", + ), + ControllerLoopExit::Removing + )); + for phase in [None, Some(SimulationAttachmentPhase::Active)] { + assert!(matches!( + authority_exit(SimulatorError::AttachmentInactive, phase, "boundary"), + ControllerLoopExit::SupervisorLost { .. } + )); + } +} + +#[test] +fn binary_abi_has_exactly_two_required_flags() { + let parsed = Args::try_parse_from([ + "robot-controller", + "--connect", + "tcp/127.0.0.1:7447", + "--host-connect", + "tcp://127.0.0.1:1234", + ]) + .expect("fixed ABI parses"); + assert_eq!(parsed.host_connect, "tcp://127.0.0.1:1234"); +} + +#[test] +fn drive_policy_distinguishes_new_reused_expired_source_loss_and_missing() { + assert_eq!( + classify_selection(true, true, false, 1, true), + ActuationSelection::SelectedNew + ); + assert_eq!( + classify_selection(true, false, true, 1, false), + ActuationSelection::Reused + ); + assert_eq!( + classify_selection(false, false, true, 1, false), + ActuationSelection::None { + reason: NoActuationReason::Expired + } + ); + assert_eq!( + classify_selection(false, false, false, 0, false), + ActuationSelection::None { + reason: NoActuationReason::SourceAbsent + } + ); + assert_eq!( + classify_selection(false, false, false, 1, false), + ActuationSelection::None { + reason: NoActuationReason::Missing + } + ); +} + +#[test] +fn late_attachment_begins_from_its_immutable_world_boundary() { + let attached = WorldProgress::at(42, 12_000_000).expect("late world boundary"); + assert_eq!(activation_progress(None, 7, attached), Some(attached)); + assert_eq!(activation_progress(Some(7), 7, attached), None); +} + +#[test] +fn geared_motor_commands_use_the_same_native_domain_as_rendered_limits() { + assert_eq!( + dispatch_motor( + MotorCommand::Position, + &api::component::motor::Command::Position(6.0), + 3.0, + ) + .expect("position command"), + MotorAction::Position(2.0) + ); + assert_eq!( + dispatch_motor( + MotorCommand::Velocity, + &api::component::motor::Command::Velocity(6.0), + 3.0, + ) + .expect("velocity command"), + MotorAction::Velocity(2.0) + ); + assert_eq!( + dispatch_motor( + MotorCommand::Torque, + &api::component::motor::Command::Torque(2.0), + 3.0, + ) + .expect("torque command"), + MotorAction::Torque(6.0) + ); +} + +#[test] +fn parking_attempts_every_motor_after_one_stop_fails() { + struct Probe { + name: &'static str, + parked: bool, + } + let mut motors = [ + Probe { + name: "first", + parked: false, + }, + Probe { + name: "second", + parked: false, + }, + ]; + let result = stop_every( + &mut motors, + |motor| motor.name.to_owned(), + |motor| { + motor.parked = true; + if motor.name == "first" { + anyhow::bail!("injected motor failure"); + } + Ok(()) + }, + ); + assert!(result.is_err()); + assert!(motors.iter().all(|motor| motor.parked)); +} + +#[test] +fn completed_transition_publishes_outputs_before_step() { + let order = std::cell::RefCell::new(Vec::new()); + publish_completed_transition( + || { + order.borrow_mut().push("output"); + Ok(()) + }, + || { + order.borrow_mut().push("step"); + Ok(()) + }, + ) + .expect("both publications succeed"); + assert_eq!(*order.borrow(), ["output", "step"]); +} + +#[test] +fn lossless_publication_refusal_is_a_controller_local_protocol_fault() { + let output_fault = publish_completed_transition( + || { + Err(anyhow::Error::new(SimulatorError::Bus( + BusError::WouldBlock { + topic: api::topics().drive().state().owner().key().to_owned(), + }, + ))) + }, + || panic!("StepEvent must not be attempted after an output refusal"), + ) + .expect_err("a refused output faults the controller"); + assert!(matches!( + output_fault, + ControllerFault::Protocol { ref detail } + if detail.contains("typed output publication failed") + && detail.contains("would block") + )); + + let step_fault = publish_completed_transition( + || Ok(()), + || { + Err(SimulatorError::Bus(BusError::WouldBlock { + topic: phoxal::simulation::api::topics() + .step() + .owner() + .key() + .to_owned(), + })) + }, + ) + .expect_err("a refused StepEvent faults the controller"); + assert!(matches!( + step_fault, + ControllerFault::Protocol { ref detail } + if detail.contains("StepEvent publication failed") + && detail.contains("would block") + )); +} diff --git a/simulators/webots/robot-controller/src/devices/encoder.rs b/simulators/webots/robot-controller/src/devices/encoder.rs new file mode 100644 index 00000000..7f14176e --- /dev/null +++ b/simulators/webots/robot-controller/src/devices/encoder.rs @@ -0,0 +1,36 @@ +use super::*; + +pub(super) struct EncoderDevice { + pub(super) native: webots_rs::device::position_sensor::PositionSensor, + pub(super) gear_ratio: f64, + pub(super) schedule: SampleSchedule, + pub(super) last: Option<(f64, u64)>, + pub(super) publisher: LiveSamplePublisher, +} + +impl EncoderDevice { + pub(super) fn publish_output(&mut self, transition: &LiveTransitionStamp) -> Result<()> { + let elapsed_ns = transition.progress().elapsed_ns(); + if !self.schedule.is_due_at(elapsed_ns)? { + return Ok(()); + } + let position = self.native.value()? * self.gear_ratio; + let velocity = self + .last + .map(|(previous, time)| { + let delta = elapsed_ns.saturating_sub(time); + if delta == 0 { + 0.0 + } else { + (position - previous) * 1_000_000_000.0 / delta as f64 + } + }) + .unwrap_or(0.0); + self.last = Some((position, elapsed_ns)); + self.publisher.publish( + transition, + api::component::encoder::Sample::try_new(position, velocity as f32)?, + )?; + Ok(()) + } +} diff --git a/simulators/webots/robot-controller/src/devices/mod.rs b/simulators/webots/robot-controller/src/devices/mod.rs new file mode 100644 index 00000000..30ac75a1 --- /dev/null +++ b/simulators/webots/robot-controller/src/devices/mod.rs @@ -0,0 +1,210 @@ +use super::*; + +pub(super) mod encoder; +pub(super) mod motor; + +use encoder::EncoderDevice; +use motor::{ + MotorAction, MotorDevice, classify_selection, dispatch_motor, ensure_motor_plan, stop_every, +}; + +pub(super) struct DeviceSet { + motors: Vec, + encoders: Vec, + sensors: SensorSet, +} + +impl DeviceSet { + pub(super) async fn bind( + session: &SimulatorSession, + webots: &Webots, + plan: &RobotSimulationPlan, + source_start_ns: u64, + ) -> Result { + let mut motors = Vec::new(); + let mut encoders = Vec::new(); + let drive_authority = DriveCommandAuthority::standard()?; + for binding in &plan.capabilities { + if !matches!( + binding.kind(), + CapabilityKind::Motor | CapabilityKind::Encoder + ) { + continue; + } + let declared = session + .robot() + .capability(binding.reference()) + .with_context(|| format!("plan capability {} is absent", binding.reference()))?; + let component = || api::topics().component(&binding.reference().component_id); + let id = &binding.reference().capability_id; + match (declared, binding.kind()) { + (DeclaredCapability::Motor(config), CapabilityKind::Motor) => { + ensure_motor_plan(binding, config.command)?; + let native = webots.motor(binding.native_device())?; + let position_velocity = config.max_velocity_radps.map_or_else( + || native.get_max_velocity(), + |velocity| Ok(velocity / config.gear_ratio.abs()), + )?; + ensure!( + position_velocity.is_finite() && position_velocity > 0.0, + "motor {} has no positive finite position velocity", + binding.reference() + ); + motors.push(MotorDevice { + capability: binding.reference().clone(), + native, + command: config.command, + gear_ratio: config.gear_ratio, + position_velocity, + receiver: session + .setpoint_receiver(component()?.motor(id)?.command().owner()) + .await?, + authority: drive_authority.motor_lease(), + ready: session + .participant_ready_events(drive_authority.source()) + .await?, + }); + } + (DeclaredCapability::Encoder(config), CapabilityKind::Encoder) => { + let sampling = binding + .sampling() + .context("encoder plan has no sampling policy")?; + let native = webots.position_sensor(binding.native_device())?; + native.enable(sampling.native_period_ms)?; + encoders.push(EncoderDevice { + native, + gear_ratio: config.gear_ratio, + schedule: sensors::schedule(binding, source_start_ns)?, + last: None, + publisher: session + .sample_publisher(component()?.encoder(id)?.sample().owner())?, + }); + } + _ => bail!( + "plan binding {} does not match its compiled capability kind", + binding.reference() + ), + } + } + let sensors = SensorSet::bind(session, webots, &plan.capabilities, source_start_ns)?; + Ok(Self { + motors, + encoders, + sensors, + }) + } + + pub(super) fn prepare_transition( + &mut self, + boundary: &ActiveBoundaryStamp, + selected_from: WorldProgress, + ) -> Result> { + let mut evidence = Vec::with_capacity(self.motors.len()); + for motor in &mut self.motors { + if motor.receiver.terminal().is_some() { + motor.apply_action(MotorAction::Stop)?; + evidence.push(PendingActuationEvidence { + capability: motor.capability.clone(), + revision: boundary.revision(), + selected_at: boundary.instant(), + selected_from, + offered: Vec::new(), + selected: None, + selection: ActuationSelection::None { + reason: NoActuationReason::ReceiverClosed, + }, + applied: AppliedActuation::Stop, + }); + continue; + } + while let Some(event) = motor.ready.try_recv() { + motor.authority.update_ready_event(&event); + } + if motor.ready.overflowed() { + motor.authority.mark_ready_overflow(); + } + let mut offered = Vec::new(); + while let Some(observed) = motor.receiver.try_recv_at(boundary) { + let command = observed.body.clone(); + let decision = motor.authority.offer( + observed.metadata.source.participant_source(), + observed.metadata.sequence, + observed.observed_at, + observed.body, + ); + offered.push(OfferedActuation { + producer: observed + .metadata + .source + .participant_source() + .map(|source| source.producer), + sequence: observed.metadata.sequence, + command, + decision: evidence_decision(decision), + }); + } + let held_before_selection = motor.authority.producer().is_some(); + let ready_count = motor.authority.ready_count(); + let accepted_new = offered.iter().any(|offered| { + matches!( + offered.decision, + ActuationDecision::Acquired | ActuationDecision::Renewed + ) + }); + let selected = motor.authority.live_host(boundary.local_instant()).cloned(); + let selection = classify_selection( + selected.is_some(), + accepted_new, + held_before_selection, + ready_count, + !offered.is_empty(), + ); + let action = selected + .as_ref() + .map(|command| dispatch_motor(motor.command, command, motor.gear_ratio)) + .transpose()? + .unwrap_or(MotorAction::Stop); + motor.apply_action(action)?; + evidence.push(PendingActuationEvidence { + capability: motor.capability.clone(), + revision: boundary.revision(), + selected_at: boundary.instant(), + selected_from, + offered, + selected, + selection, + applied: action.into(), + }); + } + Ok(evidence) + } + + pub(super) fn publish_outputs(&mut self, transition: &LiveTransitionStamp) -> Result<()> { + self.sensors.publish_outputs(transition)?; + for encoder in &mut self.encoders { + encoder.publish_output(transition)?; + } + Ok(()) + } + + pub(super) fn invalidate_and_park(&mut self) -> Result<()> { + for motor in &mut self.motors { + motor.receiver.flush(); + motor.authority.clear(); + } + self.stop_all_motors() + } + + pub(super) fn stop_native(&mut self) -> Result<()> { + self.stop_all_motors() + } + + /// Attempt every independent native motor stop before reporting cleanup failure. + fn stop_all_motors(&mut self) -> Result<()> { + stop_every( + &mut self.motors, + |motor| motor.capability.to_string(), + |motor| motor.stop(), + ) + } +} diff --git a/simulators/webots/robot-controller/src/devices/motor.rs b/simulators/webots/robot-controller/src/devices/motor.rs new file mode 100644 index 00000000..2b1fde44 --- /dev/null +++ b/simulators/webots/robot-controller/src/devices/motor.rs @@ -0,0 +1,168 @@ +use super::*; + +pub(super) struct MotorDevice { + pub(super) capability: phoxal::model::identity::CapabilityRef, + pub(super) native: webots_rs::device::motor::Motor, + pub(super) command: MotorCommand, + pub(super) gear_ratio: f64, + pub(super) position_velocity: f64, + pub(super) receiver: LiveSetpointReceiver, + pub(super) authority: FixedSourceLease, + pub(super) ready: ParticipantReadyEvents, +} + +/// Complete every independent stop attempt before returning their aggregate failure. +pub(crate) fn stop_every( + targets: &mut [T], + label: impl Fn(&T) -> String, + mut stop: impl FnMut(&mut T) -> Result<()>, +) -> Result<()> { + let mut failures = Vec::new(); + for target in targets { + let target_label = label(target); + if let Err(error) = stop(target) { + failures.push(format!("{target_label}: {error:#}")); + } + } + if failures.is_empty() { + Ok(()) + } else { + bail!("failed to stop native motors: {}", failures.join("; ")); + } +} + +pub(crate) const fn classify_selection( + selected: bool, + accepted_new: bool, + held_before_selection: bool, + ready_count: usize, + had_offers: bool, +) -> ActuationSelection { + if selected { + return if accepted_new { + ActuationSelection::SelectedNew + } else { + ActuationSelection::Reused + }; + } + ActuationSelection::None { + reason: if ready_count == 0 { + NoActuationReason::SourceAbsent + } else if ready_count > 1 { + NoActuationReason::SourceConflict + } else if held_before_selection { + NoActuationReason::Expired + } else if had_offers { + NoActuationReason::Rejected + } else { + NoActuationReason::Missing + }, + } +} + +impl MotorDevice { + pub(super) fn apply_action(&self, action: MotorAction) -> Result<()> { + match action { + MotorAction::Position(value) => { + self.native.set_velocity(self.position_velocity)?; + self.native.set_position(value)?; + } + MotorAction::Velocity(value) => { + self.native.set_position(f64::INFINITY)?; + self.native.set_velocity(value)?; + } + MotorAction::Torque(value) => { + self.native.set_position(f64::INFINITY)?; + self.native.set_torque(value)?; + } + MotorAction::Stop => self.stop()?, + } + Ok(()) + } + + pub(super) fn stop(&self) -> Result<()> { + match self.command { + MotorCommand::Position => self.native.set_velocity(0.0)?, + MotorCommand::Velocity => { + self.native.set_position(f64::INFINITY)?; + self.native.set_velocity(0.0)?; + } + MotorCommand::Torque => { + self.native.set_position(f64::INFINITY)?; + self.native.set_torque(0.0)?; + } + } + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum MotorAction { + Position(f64), + Velocity(f64), + Torque(f64), + Stop, +} + +impl From for AppliedActuation { + fn from(action: MotorAction) -> Self { + match action { + MotorAction::Position(value) => Self::Position(value), + MotorAction::Velocity(value) => Self::Velocity(value), + MotorAction::Torque(value) => Self::Torque(value), + MotorAction::Stop => Self::Stop, + } + } +} + +pub(crate) fn dispatch_motor( + configured: MotorCommand, + command: &api::component::motor::Command, + gear_ratio: f64, +) -> Result { + if matches!(command, api::component::motor::Command::Stop) { + return Ok(MotorAction::Stop); + } + ensure!( + gear_ratio.is_finite() && gear_ratio != 0.0, + "motor gear ratio must be finite and nonzero" + ); + let (mode, value) = match command { + api::component::motor::Command::Position(value) => { + (MotorCommand::Position, f64::from(*value)) + } + api::component::motor::Command::Velocity(value) => { + (MotorCommand::Velocity, f64::from(*value)) + } + api::component::motor::Command::Torque(value) => (MotorCommand::Torque, f64::from(*value)), + api::component::motor::Command::Stop => unreachable!(), + }; + ensure!( + mode == configured, + "motor command mode does not match its plan" + ); + let value = match mode { + MotorCommand::Position | MotorCommand::Velocity => value / gear_ratio, + MotorCommand::Torque => value * gear_ratio, + }; + ensure!( + value.is_finite(), + "motor command becomes non-finite after gearing" + ); + Ok(match mode { + MotorCommand::Position => MotorAction::Position(value), + MotorCommand::Velocity => MotorAction::Velocity(value), + MotorCommand::Torque => MotorAction::Torque(value), + }) +} + +pub(crate) fn ensure_motor_plan(binding: &PlannedBinding, command: MotorCommand) -> Result<()> { + let planned = binding + .motor_command() + .context("motor binding has no command contract")?; + ensure!( + planned == command, + "motor binding command mode does not match its plan" + ); + Ok(()) +} diff --git a/simulators/webots/robot-controller/src/main.rs b/simulators/webots/robot-controller/src/main.rs new file mode 100644 index 00000000..334b669f --- /dev/null +++ b/simulators/webots/robot-controller/src/main.rs @@ -0,0 +1,81 @@ +//! Per-Robot Webots controller and narrow simulator-SDK bridge. + +#[cfg(any(target_env = "musl", all(target_os = "linux", target_arch = "aarch64")))] +compile_error!( + "the Webots R2025a controller SDK is dynamically linked and unsupported on musl or Linux aarch64" +); + +use std::time::Duration; + +use anyhow::{Context, Result, bail, ensure}; +use clap::Parser; +use phoxal::SampleSchedule; +use phoxal::api; +use phoxal::bus::{FixedSourceLease, LeaseDecision, LeaseRejection, ParticipantReadyEvents}; +use phoxal::drive::authority::DriveCommandAuthority; +use phoxal::identity::ParticipantId; +use phoxal::model::component::capability::{ + Capability as DeclaredCapability, CapabilityKind, MotorCommand, +}; +use phoxal::model::world::{WorldProgress, WorldProgressError}; +use phoxal::simulation::api::step::StepEvent; +use phoxal::simulator::{ + ActiveBoundaryStamp, LiveSamplePublisher, LiveSetpointReceiver, LiveTransitionStamp, +}; +use phoxal::simulator::{SimulatorConnectOptions, SimulatorError, SimulatorSession}; +use phoxal::supervisor::api::simulation::SimulationAttachmentPhase; +use phoxal_simulator_webots_shared::plan::{ + CapabilityBinding as PlannedBinding, RobotSimulationPlan, +}; +use phoxal_simulator_webots_shared::protocol::{ + ActuationDecision, ActuationEvidence, ActuationSelection, AppliedActuation, ControllerEvent, + ControllerFault, ControllerLink, ControllerRole, HostDirective, NativeMotion, + NoActuationReason, OfferedActuation, +}; +use tracing_subscriber::EnvFilter; +use webots_rs::Webots; + +mod actuation_evidence; +mod devices; +mod parking; +mod runtime; +mod sensors; + +use sensors::SensorSet; + +use actuation_evidence::{PendingActuationEvidence, evidence_decision}; +use devices::DeviceSet; +use parking::{PARKED_POLL, park_after_cooperative_failure}; +use runtime::{observed_progress, run, synchronize_devices}; + +#[cfg(test)] +use devices::motor::{MotorAction, classify_selection, dispatch_motor, stop_every}; +#[cfg(test)] +use runtime::{ + ControllerLoopExit, activation_progress, authority_exit, publish_completed_transition, +}; + +#[derive(Debug, Parser)] +#[command(version, about)] +struct Args { + /// Supervisor endpoint identifying exactly one robot execution. + #[arg(long, value_name = "SUPERVISOR_ENDPOINT")] + connect: String, + /// Loopback-only endpoint owned by the world-session host. + #[arg(long, value_name = "LOCAL_ENDPOINT")] + host_connect: String, +} + +// Zenoh requires a multi-thread runtime. Tokio drives this root future on the calling +// thread, keeping every Webots SDK call on the controller's native main thread. +#[tokio::main(flavor = "multi_thread", worker_threads = 1)] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .with_writer(std::io::stderr) + .init(); + run(Args::parse()).await +} + +#[cfg(test)] +mod controller_runtime_tests; diff --git a/simulators/webots/robot-controller/src/parking.rs b/simulators/webots/robot-controller/src/parking.rs new file mode 100644 index 00000000..ed93d07b --- /dev/null +++ b/simulators/webots/robot-controller/src/parking.rs @@ -0,0 +1,67 @@ +use super::*; + +pub(super) const PARKED_POLL: Duration = Duration::from_millis(10); + +#[allow( + clippy::too_many_arguments, + reason = "fault convergence retains the exact native boundary and any issued transition" +)] +pub(super) async fn park_after_cooperative_failure( + devices: &mut DeviceSet, + webots: &Webots, + link: &ControllerLink, + event: ControllerEvent, + mut progress: WorldProgress, + mut motion: NativeMotion, + mut pending_native_entry: bool, + step_ms: i32, +) -> Result<()> { + // A controller that cannot park is not isolated and must disconnect so the host classifies + // the synchronization role as world-fatal. Once parked, stay outside `wb_robot_step` and keep + // driving the private request/response link until the host retires this one Robot. + devices.invalidate_and_park()?; + synchronize_devices(webots)?; + link.exchange(event)?; + // A peer may already have entered the next synchronized quantum. Finish every + // previously issued native transition with parked actuators, without publishing output, + // until the common boundary selects PAUSE. Leaving that barrier early would strand peers. + loop { + if pending_native_entry { + ensure!( + webots.step(step_ms)?, + "Webots stopped before cooperative parking completed" + ); + progress = observed_progress(webots.get_time()?, u64::try_from(step_ms)? * 1_000_000)?; + motion = NativeMotion::RealTime; + } + link.exchange(ControllerEvent::RobotBoundary { progress, motion })?; + match link.directive()? { + HostDirective::Continue { + motion: NativeMotion::RealTime, + } => pending_native_entry = true, + HostDirective::Continue { + motion: NativeMotion::Paused, + } + | HostDirective::Park => break, + HostDirective::Stop { .. } => break, + HostDirective::Mutate(_) => bail!("world mutation directed to parking Robot"), + } + } + link.exchange(ControllerEvent::RobotParked)?; + loop { + match link.directive()? { + HostDirective::Stop { reason } => { + tracing::info!(%reason, "retiring the cooperatively parked Robot"); + link.exchange(ControllerEvent::Stopped)?; + return Ok(()); + } + HostDirective::Continue { .. } | HostDirective::Park => { + link.exchange(ControllerEvent::Heartbeat)?; + tokio::time::sleep(PARKED_POLL).await; + } + HostDirective::Mutate(_) => { + bail!("the host sent a world-only scene mutation to a parked Robot controller"); + } + } + } +} diff --git a/simulators/webots/robot-controller/src/runtime.rs b/simulators/webots/robot-controller/src/runtime.rs new file mode 100644 index 00000000..3e4a60af --- /dev/null +++ b/simulators/webots/robot-controller/src/runtime.rs @@ -0,0 +1,415 @@ +use super::*; + +pub(super) async fn run(args: Args) -> Result<()> { + let mut session = SimulatorSession::connect(SimulatorConnectOptions::new( + args.connect, + "webots-robot-controller", + )) + .await + .context("failed to join the supervised robot execution")?; + let execution = session.execution(); + let mut link = ControllerLink::connect(&args.host_connect, ControllerRole::Robot { execution }) + .context("failed to join the private Webots world host")?; + let plan = link.take_robot_plan()?; + ensure!( + plan.robot == session.robot().id().to_string(), + "host plan names robot '{}', but supervisor bootstrap returned '{}'", + plan.robot, + session.robot().id() + ); + let webots = Webots::new().context("failed to initialize the Webots R2025a controller")?; + let step_ms = exact_step_ms(webots.get_basic_time_step()?)?; + ensure!( + plan.basic_time_step_ms == step_ms, + "host plan basicTimeStep {} does not match Webots {step_ms}", + plan.basic_time_step_ms + ); + let step_ns = u64::try_from(step_ms) + .context("Webots basicTimeStep is negative")? + .checked_mul(1_000_000) + .context("Webots basicTimeStep overflows nanoseconds")?; + let source_start_ns = observed_progress(webots.get_time()?, step_ns)?.elapsed_ns(); + let mut devices = DeviceSet::bind(&session, &webots, &plan, source_start_ns).await?; + devices.invalidate_and_park()?; + synchronize_devices(&webots)?; + for substitution in &plan.substitutions { + session + .present(&ParticipantId::new(substitution.participant.as_str())?) + .await + .with_context(|| { + format!( + "failed to present substituted driver {} after every native and typed binding succeeded", + substitution.participant + ) + })?; + } + link.exchange(ControllerEvent::RobotReady { + controller: session.producer(), + })?; + let mut active_revision = None; + let mut completed_progress = observed_progress(webots.get_time()?, step_ns)?; + let mut entered_motion = NativeMotion::Paused; + let mut pending_native_entry = false; + + let exit = loop { + let attachment = match session.attachment().await { + Ok(attachment) => attachment, + Err(error) => { + break ControllerLoopExit::SupervisorLost { + detail: format!("attachment authority failed: {error}"), + }; + } + }; + match attachment { + Some(attachment) if attachment.phase == SimulationAttachmentPhase::Preparing => { + if let Err(error) = devices.invalidate_and_park() { + break ControllerLoopExit::ControllerFault(ControllerFault::Device { + detail: format!("failed to fence Preparing devices: {error:#}"), + }); + } + if let Err(error) = session.acknowledge_preparing().await { + break ControllerLoopExit::SupervisorLost { + detail: format!( + "failed to acknowledge the exact Preparing revision: {error}" + ), + }; + } + } + Some(attachment) if attachment.phase == SimulationAttachmentPhase::Removing => { + break ControllerLoopExit::Removing; + } + Some(attachment) if attachment.phase == SimulationAttachmentPhase::Active => { + if let Some(attached_at) = activation_progress( + active_revision, + attachment.revision, + attachment.attached_at.world, + ) { + devices.invalidate_and_park()?; + completed_progress = attached_at; + link.exchange(ControllerEvent::RobotActive { + revision: attachment.revision, + })?; + active_revision = Some(attachment.revision); + } + } + Some(_) | None => {} + } + match link.directive()? { + HostDirective::Continue { + motion: NativeMotion::RealTime, + } => { + pending_native_entry = true; + entered_motion = NativeMotion::RealTime; + // Select commands at the exact Active revision and current monotonic boundary + // immediately before entering Webots. This also expires commands while paused, + // so the first resumed transition cannot reuse stale intent. + let boundary = match session.active_boundary() { + Ok(boundary) => boundary, + Err(error) => { + break authority_exit( + error, + session + .attachment() + .await + .ok() + .flatten() + .map(|state| state.phase), + "Active boundary", + ); + } + }; + let pending_evidence = match devices + .prepare_transition(&boundary, completed_progress) + { + Ok(evidence) => evidence, + Err(error) => { + break ControllerLoopExit::ControllerFault(ControllerFault::Device { + detail: format!("pre-transition device selection failed: {error:#}"), + }); + } + }; + let stepped = match webots.step(step_ms) { + Ok(stepped) => stepped, + Err(error) => { + break ControllerLoopExit::ControllerFault(ControllerFault::Device { + detail: format!("Webots transition failed: {error}"), + }); + } + }; + if !stepped { + link.exchange(ControllerEvent::Stopped)?; + break ControllerLoopExit::Clean; + } + pending_native_entry = false; + let progress = match webots + .get_time() + .map_err(anyhow::Error::from) + .and_then(|seconds| observed_progress(seconds, step_ns)) + { + Ok(progress) => progress, + Err(error) => { + break ControllerLoopExit::ControllerFault( + ControllerFault::InvalidProgress { + detail: format!("invalid completed Webots transition: {error:#}"), + }, + ); + } + }; + completed_progress = progress; + let transition = match session.live_transition(progress) { + Ok(transition) => transition, + Err(error) => { + break authority_exit( + error, + session + .attachment() + .await + .ok() + .flatten() + .map(|state| state.phase), + "completed transition", + ); + } + }; + let evidence = pending_evidence + .into_iter() + .map(|pending| pending.complete(&transition)) + .collect(); + link.exchange(ControllerEvent::ActuationEvidence(evidence))?; + if let Err(fault) = publish_completed_transition( + || devices.publish_outputs(&transition), + || { + session.publish_step( + &transition, + StepEvent { + index: transition.progress().completed_step(), + }, + ) + }, + ) { + if session + .attachment() + .await + .ok() + .flatten() + .is_some_and(|state| state.phase == SimulationAttachmentPhase::Removing) + { + break ControllerLoopExit::Removing; + } + break ControllerLoopExit::ControllerFault(fault); + } + // The bounded observation closes each native boundary. Its host response carries + // the next Pause/Stop directive before another synchronized transition begins. + link.exchange(ControllerEvent::RobotBoundary { + progress: completed_progress, + motion: NativeMotion::RealTime, + })?; + } + HostDirective::Continue { + motion: NativeMotion::Paused, + } + | HostDirective::Park => { + // Stay outside `wb_robot_step` while parked so removal and resume directives can + // be observed without breaking Webots synchronization. + if let Err(error) = devices.stop_native() { + break ControllerLoopExit::ControllerFault(ControllerFault::Device { + detail: format!("failed to park native devices: {error:#}"), + }); + } + synchronize_devices(&webots)?; + entered_motion = NativeMotion::Paused; + link.exchange(ControllerEvent::RobotBoundary { + progress: completed_progress, + motion: NativeMotion::Paused, + })?; + tokio::time::sleep(PARKED_POLL).await; + } + HostDirective::Mutate(_) => { + bail!("the host sent a world-only scene mutation to a Robot controller"); + } + HostDirective::Stop { reason } => { + tracing::info!(%reason, "stopping the per-Robot Webots controller"); + devices.invalidate_and_park()?; + link.exchange(ControllerEvent::RobotParked)?; + break ControllerLoopExit::Clean; + } + } + pending_native_entry = matches!( + link.directive()?, + HostDirective::Continue { + motion: NativeMotion::RealTime + } + ); + }; + match exit { + ControllerLoopExit::Removing => { + park_after_cooperative_failure( + &mut devices, + &webots, + &link, + ControllerEvent::RobotStopping, + completed_progress, + entered_motion, + pending_native_entry, + step_ms, + ) + .await?; + session + .close() + .await + .context("failed to close removed simulator session") + } + ControllerLoopExit::Clean => session + .close() + .await + .context("failed to close the simulator session"), + ControllerLoopExit::ControllerFault(fault) => { + tracing::error!(?fault, "parking a recoverably faulted Robot member"); + park_after_cooperative_failure( + &mut devices, + &webots, + &link, + ControllerEvent::Fault(fault), + completed_progress, + entered_motion, + pending_native_entry, + step_ms, + ) + .await?; + if let Err(error) = session.close().await { + tracing::warn!(%error, "simulator session close failed after member fault"); + } + Ok(()) + } + ControllerLoopExit::SupervisorLost { detail } => { + tracing::warn!(%detail, "parking after supervisor authority loss"); + park_after_cooperative_failure( + &mut devices, + &webots, + &link, + ControllerEvent::RobotSupervisorLost, + completed_progress, + entered_motion, + pending_native_entry, + step_ms, + ) + .await?; + if let Err(error) = session.close().await { + tracing::debug!(%error, "supervisor session was already unavailable at close"); + } + Ok(()) + } + } +} + +/// Admit one completed native transition directly into the execution bus. +/// +/// The output closure runs first and `StepEvent` runs only after it succeeds. +/// Both closures publish synchronously into the bus's bounded scheduler, so +/// this boundary adds no adapter-private transition queue. +pub(super) fn publish_completed_transition( + publish_outputs: impl FnOnce() -> Result<()>, + publish_step: impl FnOnce() -> Result<(), SimulatorError>, +) -> std::result::Result<(), ControllerFault> { + publish_outputs().map_err(classify_output_failure)?; + publish_step().map_err(|error| ControllerFault::Protocol { + detail: format!("StepEvent publication failed: {error}"), + }) +} + +fn classify_output_failure(error: anyhow::Error) -> ControllerFault { + if error.downcast_ref::().is_some() { + ControllerFault::Protocol { + detail: format!("typed output publication failed: {error:#}"), + } + } else { + ControllerFault::Device { + detail: format!("typed output capture failed: {error:#}"), + } + } +} + +pub(super) fn activation_progress( + current_revision: Option, + observed_revision: u64, + attached_at: WorldProgress, +) -> Option { + if current_revision == Some(observed_revision) { + None + } else { + Some(attached_at) + } +} + +#[derive(Debug)] +pub(super) enum ControllerLoopExit { + Clean, + Removing, + ControllerFault(ControllerFault), + SupervisorLost { detail: String }, +} + +pub(super) fn authority_exit( + error: SimulatorError, + phase: Option, + stage: &str, +) -> ControllerLoopExit { + // Removing may arrive during a native transition. Its intentional loss of Active + // authority must finish the removal handshake, not discard the host acknowledgement. + if matches!(error, SimulatorError::AttachmentInactive) + && phase == Some(SimulationAttachmentPhase::Removing) + { + ControllerLoopExit::Removing + } else { + ControllerLoopExit::SupervisorLost { + detail: format!("{stage} authority failed: {error}"), + } + } +} + +pub(super) fn synchronize_devices(webots: &Webots) -> Result<()> { + let before = webots.get_time()?; + ensure!( + webots.step(0)?, + "Webots stopped during device synchronization" + ); + ensure!( + webots.get_time()? == before, + "device synchronization advanced physics" + ); + Ok(()) +} + +pub(super) fn observed_progress(seconds: f64, step_ns: u64) -> Result { + ensure!( + seconds.is_finite() && seconds >= 0.0, + "Webots returned invalid simulation time" + ); + let elapsed = (seconds * 1_000_000_000.0).round(); + ensure!( + elapsed <= u64::MAX as f64, + "Webots simulation time overflows" + ); + let elapsed = elapsed as u64; + ensure!( + elapsed.is_multiple_of(step_ns), + "Webots simulation time is off the declared physics grid" + ); + WorldProgress::at(elapsed / step_ns, step_ns).map_err(|error: WorldProgressError| error.into()) +} + +pub(super) fn exact_step_ms(value: f64) -> Result { + ensure!( + value.is_finite() && value > 0.0, + "Webots basicTimeStep must be finite and positive" + ); + ensure!( + value.fract() == 0.0, + "Webots basicTimeStep must be an exact whole millisecond" + ); + ensure!( + value <= f64::from(i32::MAX), + "Webots basicTimeStep exceeds the controller ABI" + ); + Ok(value as i32) +} diff --git a/simulators/webots/robot-controller/src/sensors.rs b/simulators/webots/robot-controller/src/sensors.rs new file mode 100644 index 00000000..98946a69 --- /dev/null +++ b/simulators/webots/robot-controller/src/sensors.rs @@ -0,0 +1,501 @@ +//! Typed sampled Webots devices bound one-to-one to framework publishers. + +use anyhow::{Context, Result, bail}; +use phoxal::SampleSchedule; +use phoxal::api; +use phoxal::model::component::capability::{ + CameraMode, Capability as DeclaredCapability, GnssCoordinateSystem, +}; +use phoxal::simulator::{LiveSamplePublisher, LiveTransitionStamp, SimulatorSession}; +use phoxal_simulator_webots_shared::plan::CapabilityBinding; +use webots_rs::Webots; + +pub(crate) struct SensorSet { + devices: Vec, +} + +enum SensorDevice { + Accelerometer(VectorSensor), + Gyroscope(VectorSensor), + Imu(ImuSensor), + Camera(CameraSensor), + Depth(DepthSensor), + Gnss(GnssSensor), + Range(RangeSensor), +} + +struct VectorSensor { + device: D, + axes: Option<[bool; 3]>, + schedule: SampleSchedule, + publisher: VectorPublisher, +} + +enum VectorPublisher { + Accelerometer(LiveSamplePublisher), + Gyroscope(LiveSamplePublisher), +} + +struct ImuSensor { + inertial: webots_rs::device::inertial_unit::InertialUnit, + accelerometer: webots_rs::device::accelerometer::Accelerometer, + gyroscope: webots_rs::device::gyro::Gyro, + axes: Option<[bool; 3]>, + schedule: SampleSchedule, + publisher: LiveSamplePublisher, +} + +struct CameraSensor { + device: webots_rs::device::camera::Camera, + mode: CameraMode, + width: u32, + height: u32, + schedule: SampleSchedule, + publisher: LiveSamplePublisher, +} + +struct DepthSensor { + device: webots_rs::device::range_finder::RangeFinder, + width: u32, + height: u32, + schedule: SampleSchedule, + publisher: LiveSamplePublisher, +} + +struct GnssSensor { + device: webots_rs::device::gps::Gps, + schedule: SampleSchedule, + publisher: LiveSamplePublisher, +} + +struct RangeSensor { + device: webots_rs::device::distance_sensor::DistanceSensor, + min_m: f32, + max_m: f32, + schedule: SampleSchedule, + publisher: LiveSamplePublisher, +} + +impl SensorSet { + pub(crate) fn bind( + session: &SimulatorSession, + webots: &Webots, + bindings: &[CapabilityBinding], + source_start_ns: u64, + ) -> Result { + let mut devices = Vec::new(); + for binding in bindings { + let declared = session + .robot() + .capability(binding.reference()) + .with_context(|| format!("plan capability {} is absent", binding.reference()))?; + let component = || api::topics().component(&binding.reference().component_id); + let id = &binding.reference().capability_id; + let Some(sampling) = binding.sampling() else { + continue; + }; + let schedule = schedule(binding, source_start_ns)?; + let device = match declared { + DeclaredCapability::Accelerometer(config) => { + let native = webots.accelerometer(binding.native_device())?; + native.enable(sampling.native_period_ms)?; + SensorDevice::Accelerometer(VectorSensor { + device: native, + axes: config.axes, + schedule, + publisher: VectorPublisher::Accelerometer( + session.sample_publisher( + component()?.accelerometer(id)?.sample().owner(), + )?, + ), + }) + } + DeclaredCapability::Gyroscope(config) => { + let native = webots.gyro(binding.native_device())?; + native.enable(sampling.native_period_ms)?; + SensorDevice::Gyroscope(VectorSensor { + device: native, + axes: config.axes, + schedule, + publisher: VectorPublisher::Gyroscope( + session + .sample_publisher(component()?.gyroscope(id)?.sample().owner())?, + ), + }) + } + DeclaredCapability::Imu(config) => { + let inertial = webots.inertial_unit(binding.native_device())?; + let accelerometer = + webots.accelerometer(format!("{}__accel", binding.native_device()))?; + let gyroscope = webots.gyro(format!("{}__gyro", binding.native_device()))?; + inertial.enable(sampling.native_period_ms)?; + accelerometer.enable(sampling.native_period_ms)?; + gyroscope.enable(sampling.native_period_ms)?; + SensorDevice::Imu(ImuSensor { + inertial, + accelerometer, + gyroscope, + axes: config.axes, + schedule, + publisher: session + .sample_publisher(component()?.imu(id)?.sample().owner())?, + }) + } + DeclaredCapability::Camera(config) => { + let native = webots.camera(binding.native_device())?; + native.enable(sampling.native_period_ms)?; + SensorDevice::Camera(CameraSensor { + device: native, + mode: config.mode, + width: config.width_px, + height: config.height_px, + schedule, + publisher: session + .sample_publisher(component()?.camera(id)?.frame().owner())?, + }) + } + DeclaredCapability::Depth(config) => { + let native = webots.range_finder(binding.native_device())?; + native.enable(sampling.native_period_ms)?; + SensorDevice::Depth(DepthSensor { + device: native, + width: config.width_px, + height: config.height_px, + schedule, + publisher: session + .sample_publisher(component()?.depth(id)?.frame().owner())?, + }) + } + DeclaredCapability::Gnss(config) => { + if config.coordinate_system != GnssCoordinateSystem::Wgs84 { + bail!( + "GNSS binding {} is not explicitly wgs84", + binding.reference() + ); + } + let native = webots.gps(binding.native_device())?; + if native.get_coordinate_system()? + != webots_rs::device::gps::GpsCoordinateSystem::Wgs84 + { + bail!( + "GNSS binding {} resolved a non-WGS84 native GPS", + binding.reference() + ); + } + native.enable(sampling.native_period_ms)?; + SensorDevice::Gnss(GnssSensor { + device: native, + schedule, + publisher: session + .sample_publisher(component()?.gnss(id)?.sample().owner())?, + }) + } + DeclaredCapability::Range(config) => { + let native = webots.distance_sensor(binding.native_device())?; + native.enable(sampling.native_period_ms)?; + SensorDevice::Range(RangeSensor { + device: native, + min_m: config.min_range_m as f32, + max_m: config.max_range_m as f32, + schedule, + publisher: session + .sample_publisher(component()?.range(id)?.sample().owner())?, + }) + } + DeclaredCapability::Motor(_) | DeclaredCapability::Encoder(_) => continue, + other => bail!( + "plan admitted unsupported sampled capability {} ({})", + binding.reference(), + other.kind() + ), + }; + devices.push(device); + } + Ok(Self { devices }) + } + + pub(crate) fn publish_outputs(&mut self, transition: &LiveTransitionStamp) -> Result<()> { + for device in &mut self.devices { + device.publish_if_due(transition)?; + } + Ok(()) + } +} + +impl SensorDevice { + fn publish_if_due(&mut self, transition: &LiveTransitionStamp) -> Result<()> { + let elapsed_ns = transition.progress().elapsed_ns(); + match self { + Self::Accelerometer(sensor) => { + if sensor.schedule.is_due_at(elapsed_ns)? { + let values = mask( + sensor.device.values()?.map(|value| value as f32), + sensor.axes, + ); + let sample = api::component::accelerometer::Sample::try_new(values)?; + let VectorPublisher::Accelerometer(publisher) = &sensor.publisher else { + bail!("accelerometer publisher kind changed after binding"); + }; + publisher.publish(transition, sample)?; + } + } + Self::Gyroscope(sensor) => { + if sensor.schedule.is_due_at(elapsed_ns)? { + let values = mask( + sensor.device.values()?.map(|value| value as f32), + sensor.axes, + ); + let sample = api::component::gyroscope::Sample::try_new(values)?; + let VectorPublisher::Gyroscope(publisher) = &sensor.publisher else { + bail!("gyroscope publisher kind changed after binding"); + }; + publisher.publish(transition, sample)?; + } + } + Self::Imu(sensor) => { + if sensor.schedule.is_due_at(elapsed_ns)? { + let [roll, pitch, yaw] = sensor.inertial.get_roll_pitch_yaw()?; + let acceleration = mask( + sensor.accelerometer.values()?.map(|value| value as f32), + sensor.axes, + ); + let angular_velocity = mask( + sensor.gyroscope.values()?.map(|value| value as f32), + sensor.axes, + ); + sensor.publisher.publish( + transition, + api::component::imu::Sample::try_new( + Some(quaternion_wxyz_from_rpy(roll, pitch, yaw)), + angular_velocity, + acceleration, + None, + None, + None, + api::component::imu::SensorHealth::Nominal, + None, + )?, + )?; + } + } + Self::Camera(sensor) => { + if sensor.schedule.is_due_at(elapsed_ns)? { + let bgra = sensor.device.get_image()?; + let (encoding, data) = match sensor.mode { + CameraMode::Mono => { + (api::component::camera::Encoding::L8, bgra_to_luma(&bgra)) + } + CameraMode::Rgb => { + (api::component::camera::Encoding::Rgb8, bgra_to_rgb(&bgra)) + } + }; + sensor.publisher.publish( + transition, + api::component::camera::Frame::try_new( + sensor.width, + sensor.height, + encoding, + None, + None, + None, + None, + data, + )?, + )?; + } + } + Self::Depth(sensor) => { + if sensor.schedule.is_due_at(elapsed_ns)? { + let samples = sensor + .device + .get_range_image()? + .into_iter() + .map(meters_to_u16_mm) + .collect(); + sensor.publisher.publish( + transition, + api::component::depth::Frame::try_new( + samples, + api::component::depth::Encoding::U16Millimeters, + api::component::depth::InvalidSamplePolicy::ZeroIsInvalid, + sensor.width, + sensor.height, + None, + None, + None, + None, + )?, + )?; + } + } + Self::Gnss(sensor) => { + if sensor.schedule.is_due_at(elapsed_ns)? { + let reading = sensor.device.reading()?; + sensor.publisher.publish( + transition, + api::component::gnss::Sample::try_new( + reading.position[0], + reading.position[1], + reading.position[2], + [0.0; 9], + )?, + )?; + } + } + Self::Range(sensor) => { + if sensor.schedule.is_due_at(elapsed_ns)? { + sensor.publisher.publish( + transition, + api::component::range::Sample::try_new( + sensor.device.value()? as f32, + Some(api::component::range::Limits { + min_m: sensor.min_m, + max_m: sensor.max_m, + }), + Some(api::component::range::SampleQuality { + valid: true, + confidence: None, + }), + api::component::range::SensorHealth::Nominal, + )?, + )?; + } + } + } + Ok(()) + } +} + +pub(super) fn schedule( + binding: &CapabilityBinding, + source_start_ns: u64, +) -> Result { + let sampling = binding + .sampling() + .context("sampled binding has no cadence")?; + let source_period_ns = u64::try_from(sampling.native_period_ms)? + .checked_mul(1_000_000) + .context("native sampling period overflows nanoseconds")?; + let mut schedule = SampleSchedule::from_source_period_ns( + &binding.reference().to_string(), + source_period_ns, + sampling.publish_rate_hz, + )?; + schedule.reanchor_after(source_start_ns, source_period_ns)?; + Ok(schedule) +} + +fn mask(mut values: [f32; 3], axes: Option<[bool; 3]>) -> [f32; 3] { + if let Some(axes) = axes { + for (value, enabled) in values.iter_mut().zip(axes) { + if !enabled { + *value = 0.0; + } + } + } + values +} + +fn quaternion_wxyz_from_rpy(roll: f64, pitch: f64, yaw: f64) -> [f32; 4] { + let (sr, cr) = (roll * 0.5).sin_cos(); + let (sp, cp) = (pitch * 0.5).sin_cos(); + let (sy, cy) = (yaw * 0.5).sin_cos(); + [ + (cr * cp * cy + sr * sp * sy) as f32, + (sr * cp * cy - cr * sp * sy) as f32, + (cr * sp * cy + sr * cp * sy) as f32, + (cr * cp * sy - sr * sp * cy) as f32, + ] +} + +fn bgra_to_rgb(bgra: &[u8]) -> Vec { + bgra.as_chunks::<4>() + .0 + .iter() + .flat_map(|pixel| [pixel[2], pixel[1], pixel[0]]) + .collect() +} + +fn bgra_to_luma(bgra: &[u8]) -> Vec { + bgra.as_chunks::<4>() + .0 + .iter() + .map(|pixel| { + let red = u32::from(pixel[2]); + let green = u32::from(pixel[1]); + let blue = u32::from(pixel[0]); + ((299 * red + 587 * green + 114 * blue) / 1000) as u8 + }) + .collect() +} + +fn meters_to_u16_mm(meters: f32) -> u16 { + if !meters.is_finite() || meters <= 0.0 { + return 0; + } + let millimeters = (meters * 1000.0).round(); + if !(1.0..=f32::from(u16::MAX)).contains(&millimeters) { + return 0; + } + millimeters as u16 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn conversions_are_deterministic_and_fail_closed() { + assert_eq!(bgra_to_rgb(&[10, 20, 30, 255]), vec![30, 20, 10]); + assert_eq!(bgra_to_luma(&[10, 20, 30, 255]), vec![21]); + assert_eq!(meters_to_u16_mm(1.25), 1250); + assert_eq!(meters_to_u16_mm(f32::NAN), 0); + } + + #[test] + fn quaternion_is_wxyz() { + let quaternion = quaternion_wxyz_from_rpy(0.0, 0.0, std::f64::consts::FRAC_PI_2); + assert!((f64::from(quaternion[0]) - std::f64::consts::FRAC_1_SQRT_2).abs() < 1e-6); + assert!((f64::from(quaternion[3]) - std::f64::consts::FRAC_1_SQRT_2).abs() < 1e-6); + } + + #[test] + fn every_sample_waits_for_a_native_observation_after_late_attachment() { + use phoxal::model::identity::{CapabilityId, CapabilityRef, ComponentInstanceId}; + use phoxal_simulator_webots_shared::plan::{PlannedTarget, SamplingPlan}; + let binding = CapabilityBinding::Encoder { + reference: CapabilityRef { + component_id: ComponentInstanceId::new("wheel").expect("component"), + capability_id: CapabilityId::new("encoder").expect("capability"), + }, + native_device: "wheel.encoder".to_owned(), + target: PlannedTarget::Joint { + id: "axle".to_owned(), + }, + sampling: SamplingPlan { + publish_rate_hz: 50.0, + native_sampling_rate_hz: 50.0, + native_period_ms: 24, + publish_period_ns: 20_000_000, + }, + }; + for start in [0, 12_000_000_000] { + let mut schedule = schedule(&binding, start).expect("native schedule"); + assert!( + !schedule + .is_due_at(start + 12_000_000) + .expect("first transition") + ); + assert!( + schedule + .is_due_at(start + 24_000_000) + .expect("first captured sample") + ); + assert!( + !schedule + .is_due_at(start + 36_000_000) + .expect("no duplicate capture") + ); + } + } +} diff --git a/simulators/webots/shared/Cargo.toml b/simulators/webots/shared/Cargo.toml new file mode 100644 index 00000000..3f837e17 --- /dev/null +++ b/simulators/webots/shared/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "phoxal-simulator-webots-shared" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = ["phoxal"] +description = "Phoxal Webots typed controller plan and private wire contract." +documentation.workspace = true +homepage.workspace = true +repository.workspace = true + +[dependencies] +phoxal = { workspace = true, features = ["session"] } +rmp-serde = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[lints] +workspace = true diff --git a/simulators/webots/shared/src/lib.rs b/simulators/webots/shared/src/lib.rs new file mode 100644 index 00000000..181bbbb4 --- /dev/null +++ b/simulators/webots/shared/src/lib.rs @@ -0,0 +1,7 @@ +//! Narrow typed data shared by the Webots host and its native controllers. +//! +//! Process lifecycle, native generation, registration, retained evidence, and +//! session ownership remain private to the host executable. + +pub mod plan; +pub mod protocol; diff --git a/simulators/webots/shared/src/plan.rs b/simulators/webots/shared/src/plan.rs new file mode 100644 index 00000000..44a2c904 --- /dev/null +++ b/simulators/webots/shared/src/plan.rs @@ -0,0 +1,172 @@ +//! Cross-process plan records exchanged by the Webots host and controllers. +//! +//! The host derives and validates these records. This crate intentionally owns +//! only their stable wire representation. + +use phoxal::model::asset::AssetId; +use phoxal::model::component::capability::{CapabilityKind, MotorCommand}; +use phoxal::model::identity::{CapabilityRef, ComponentInstanceId}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RobotSimulationPlan { + pub robot: String, + pub basic_time_step_ms: i32, + pub substitutions: Vec, + pub capabilities: Vec, + pub links: Vec, + pub assets: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DriverSubstitution { + pub participant: ComponentInstanceId, + pub capabilities: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum CapabilityBinding { + Motor { + reference: CapabilityRef, + native_device: String, + target: PlannedTarget, + command: MotorCommand, + }, + Encoder { + reference: CapabilityRef, + native_device: String, + target: PlannedTarget, + sampling: SamplingPlan, + }, + Sampled { + reference: CapabilityRef, + native_device: String, + target: PlannedTarget, + capability: SampledCapabilityKind, + sampling: SamplingPlan, + }, +} + +impl CapabilityBinding { + #[must_use] + pub fn reference(&self) -> &CapabilityRef { + match self { + Self::Motor { reference, .. } + | Self::Encoder { reference, .. } + | Self::Sampled { reference, .. } => reference, + } + } + #[must_use] + pub fn native_device(&self) -> &str { + match self { + Self::Motor { native_device, .. } + | Self::Encoder { native_device, .. } + | Self::Sampled { native_device, .. } => native_device, + } + } + #[must_use] + pub fn target(&self) -> &PlannedTarget { + match self { + Self::Motor { target, .. } + | Self::Encoder { target, .. } + | Self::Sampled { target, .. } => target, + } + } + #[must_use] + pub const fn kind(&self) -> CapabilityKind { + match self { + Self::Motor { .. } => CapabilityKind::Motor, + Self::Encoder { .. } => CapabilityKind::Encoder, + Self::Sampled { capability, .. } => capability.capability_kind(), + } + } + #[must_use] + pub fn sampling(&self) -> Option<&SamplingPlan> { + match self { + Self::Encoder { sampling, .. } | Self::Sampled { sampling, .. } => Some(sampling), + Self::Motor { .. } => None, + } + } + #[must_use] + pub const fn motor_command(&self) -> Option { + match self { + Self::Motor { command, .. } => Some(*command), + Self::Encoder { .. } | Self::Sampled { .. } => None, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SampledCapabilityKind { + Accelerometer, + Gyroscope, + Imu, + Gnss, + Camera, + Depth, + Range, +} + +impl SampledCapabilityKind { + #[must_use] + pub const fn capability_kind(self) -> CapabilityKind { + match self { + Self::Accelerometer => CapabilityKind::Accelerometer, + Self::Gyroscope => CapabilityKind::Gyroscope, + Self::Imu => CapabilityKind::Imu, + Self::Gnss => CapabilityKind::Gnss, + Self::Camera => CapabilityKind::Camera, + Self::Depth => CapabilityKind::Depth, + Self::Range => CapabilityKind::Range, + } + } + #[must_use] + pub const fn from_capability_kind(kind: CapabilityKind) -> Option { + Some(match kind { + CapabilityKind::Accelerometer => Self::Accelerometer, + CapabilityKind::Gyroscope => Self::Gyroscope, + CapabilityKind::Imu => Self::Imu, + CapabilityKind::Gnss => Self::Gnss, + CapabilityKind::Camera => Self::Camera, + CapabilityKind::Depth => Self::Depth, + CapabilityKind::Range => Self::Range, + _ => return None, + }) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum PlannedTarget { + Link { id: String }, + Joint { id: String }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SamplingPlan { + pub publish_rate_hz: f64, + pub native_sampling_rate_hz: f64, + pub native_period_ms: i32, + pub publish_period_ns: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LinkSimulation { + pub component: ComponentInstanceId, + pub link: String, + pub contact_material: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PlannedAsset { + pub id: AssetId, + pub bytes: u64, + pub sha256: String, +} diff --git a/simulators/webots/shared/src/protocol/framing.rs b/simulators/webots/shared/src/protocol/framing.rs new file mode 100644 index 00000000..ca54f8bc --- /dev/null +++ b/simulators/webots/shared/src/protocol/framing.rs @@ -0,0 +1,38 @@ +use super::*; + +pub(super) const MAX_FRAME_BYTES: usize = MAX_ROBOT_SOURCE_BYTES + 1024; + +/// Encode one bounded private-link frame. +pub fn write_frame(writer: &mut W, value: &T) -> Result<(), LinkError> { + let body = rmp_serde::to_vec_named(value)?; + if body.len() > MAX_FRAME_BYTES { + return Err(LinkError::FrameTooLarge { + bytes: body.len(), + maximum: MAX_FRAME_BYTES, + }); + } + let length = u32::try_from(body.len()).map_err(|_| LinkError::FrameTooLarge { + bytes: body.len(), + maximum: MAX_FRAME_BYTES, + })?; + writer.write_all(&length.to_be_bytes())?; + writer.write_all(&body)?; + writer.flush()?; + Ok(()) +} + +/// Decode one bounded private-link frame. +pub fn read_frame(reader: &mut R) -> Result { + let mut length = [0_u8; 4]; + reader.read_exact(&mut length)?; + let bytes = u32::from_be_bytes(length) as usize; + if bytes > MAX_FRAME_BYTES { + return Err(LinkError::FrameTooLarge { + bytes, + maximum: MAX_FRAME_BYTES, + }); + } + let mut body = vec![0_u8; bytes]; + reader.read_exact(&mut body)?; + Ok(rmp_serde::from_slice(&body)?) +} diff --git a/simulators/webots/shared/src/protocol/link.rs b/simulators/webots/shared/src/protocol/link.rs new file mode 100644 index 00000000..dd303b00 --- /dev/null +++ b/simulators/webots/shared/src/protocol/link.rs @@ -0,0 +1,195 @@ +use super::*; + +/// A controller-side private link. +/// +/// `publish` only performs a bounded `try_send`. +/// The worker owns the socket and records the latest directive or terminal failure. +pub struct ControllerLink { + events: Option>, + state: Arc>, + robot_plan: Option, + worker: Option>, +} + +struct QueuedEvent { + event: ControllerEvent, + acknowledgement: Option>>, +} + +#[derive(Clone, Debug)] +enum LinkState { + Active(HostDirective), + Failed(String), +} + +impl ControllerLink { + /// Connect and complete the exact-train handshake before returning. + pub fn connect(endpoint: &str, role: ControllerRole) -> Result { + let address = endpoint + .strip_prefix("tcp://") + .unwrap_or(endpoint) + .to_owned(); + let mut addresses = address + .to_socket_addrs() + .map_err(|_| LinkError::InvalidEndpoint { + endpoint: endpoint.to_owned(), + })?; + let address = addresses.next().ok_or_else(|| LinkError::InvalidEndpoint { + endpoint: endpoint.to_owned(), + })?; + let mut stream = TcpStream::connect_timeout(&address, IO_TIMEOUT).map_err(|source| { + LinkError::Connect { + endpoint: endpoint.to_owned(), + source, + } + })?; + stream.set_nodelay(true)?; + stream.set_read_timeout(Some(IO_TIMEOUT))?; + stream.set_write_timeout(Some(IO_TIMEOUT))?; + + write_frame( + &mut stream, + &HostRequest::Hello { + framework: FrameworkVersion::CURRENT, + role, + }, + )?; + let (directive, robot_plan) = match read_frame::<_, HostResponse>(&mut stream)? { + HostResponse::Accepted { + directive, + robot_plan, + } => (directive, robot_plan), + HostResponse::Rejected { reason } => return Err(LinkError::Rejected { reason }), + HostResponse::Directive(_) => return Err(LinkError::InvalidHandshake), + }; + + let (events, receiver) = mpsc::sync_channel(EVENT_QUEUE_CAPACITY); + let state = Arc::new(Mutex::new(LinkState::Active(directive))); + let worker_state = Arc::clone(&state); + let worker = std::thread::Builder::new() + .name("webots-host-link".to_owned()) + .spawn(move || run_worker(stream, receiver, &worker_state))?; + Ok(Self { + events: Some(events), + state, + robot_plan, + worker: Some(worker), + }) + } + + /// Take the host-authoritative plan delivered during a Robot handshake. + pub fn take_robot_plan(&mut self) -> Result { + self.robot_plan.take().ok_or_else(|| LinkError::Rejected { + reason: "the host supplied no authoritative RobotSimulationPlan".to_owned(), + }) + } + + /// Publish one event without waiting for socket I/O. + pub fn publish(&self, event: ControllerEvent) -> Result<(), LinkError> { + self.enqueue(QueuedEvent { + event, + acknowledgement: None, + }) + } + + /// Publish one boundary event and wait for its host directive response. + /// + /// Controllers call this only outside `wb_robot_step`. The bounded exchange prevents a + /// stale Continue directive from admitting another transition after the host requested park. + pub fn exchange(&self, event: ControllerEvent) -> Result<(), LinkError> { + let (acknowledgement, received) = mpsc::sync_channel(0); + self.enqueue(QueuedEvent { + event, + acknowledgement: Some(acknowledgement), + })?; + received + .recv_timeout(IO_TIMEOUT) + .map_err(|error| LinkError::Failed { + detail: format!("timed out awaiting private host acknowledgement: {error}"), + })? + .map_err(|detail| LinkError::Failed { detail }) + } + + fn enqueue(&self, event: QueuedEvent) -> Result<(), LinkError> { + self.ensure_active()?; + match self + .events + .as_ref() + .ok_or(LinkError::Closed)? + .try_send(event) + { + Ok(()) => Ok(()), + Err(mpsc::TrySendError::Full(_)) => Err(LinkError::WouldBlock), + Err(mpsc::TrySendError::Disconnected(_)) => self.ensure_active(), + } + } + + /// Read the latest directive without waiting. + pub fn directive(&self) -> Result { + match &*lock(&self.state) { + LinkState::Active(directive) => Ok(directive.clone()), + LinkState::Failed(detail) => Err(LinkError::Failed { + detail: detail.clone(), + }), + } + } + + fn ensure_active(&self) -> Result<(), LinkError> { + self.directive().map(|_| ()) + } +} + +impl Drop for ControllerLink { + fn drop(&mut self) { + self.events.take(); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +fn run_worker( + mut stream: TcpStream, + receiver: mpsc::Receiver, + state: &Arc>, +) { + for queued in receiver { + let outcome = write_frame(&mut stream, &HostRequest::Event(queued.event)) + .and_then(|()| read_frame::<_, HostResponse>(&mut stream)); + match outcome { + Ok( + HostResponse::Directive(directive) + | HostResponse::Accepted { + directive, + robot_plan: _, + }, + ) => { + *lock(state) = LinkState::Active(directive); + if let Some(acknowledgement) = queued.acknowledgement { + let _ = acknowledgement.send(Ok(())); + } + } + Ok(HostResponse::Rejected { reason }) => { + *lock(state) = LinkState::Failed(reason.clone()); + if let Some(acknowledgement) = queued.acknowledgement { + let _ = acknowledgement.send(Err(reason)); + } + return; + } + Err(error) => { + let detail = error.to_string(); + *lock(state) = LinkState::Failed(detail.clone()); + if let Some(acknowledgement) = queued.acknowledgement { + let _ = acknowledgement.send(Err(detail)); + } + return; + } + } + } +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} diff --git a/simulators/webots/shared/src/protocol/mod.rs b/simulators/webots/shared/src/protocol/mod.rs new file mode 100644 index 00000000..503c745c --- /dev/null +++ b/simulators/webots/shared/src/protocol/mod.rs @@ -0,0 +1,40 @@ +//! Bounded private coordination shared by the Webots host and native controllers. +//! +//! This is not a public simulation API and it never leaves the local host. +//! Each controller publishes observations through a bounded nonblocking queue. +//! A socket worker performs the potentially blocking local I/O so Webots never waits for the host +//! or a robot participant while it owns a native transition. + +use std::io::{Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::sync::{Arc, Mutex, mpsc}; +use std::thread::JoinHandle; +use std::time::Duration; + +use phoxal::api; +use phoxal::bus::RobotInstant; +use phoxal::identity::{ExecutionId, ProducerId}; +use phoxal::model::identity::CapabilityRef; +use phoxal::model::world::WorldProgress; +use phoxal::version::FrameworkVersion; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +use crate::plan::RobotSimulationPlan; + +mod framing; +mod link; +mod records; + +pub use framing::{read_frame, write_frame}; +pub use link::ControllerLink; +pub use records::{ + ActuationDecision, ActuationEvidence, ActuationSelection, AppliedActuation, ControllerEvent, + ControllerFault, ControllerRole, HostDirective, HostRequest, HostResponse, LinkError, + NativeMotion, NativeMutation, NativeProgressObservation, NoActuationReason, ObservedNativeMode, + OfferedActuation, validate_robot_import, +}; +use records::{EVENT_QUEUE_CAPACITY, IO_TIMEOUT, MAX_ROBOT_SOURCE_BYTES}; + +#[cfg(test)] +mod protocol_boundary_tests; diff --git a/simulators/webots/shared/src/protocol/protocol_boundary_tests.rs b/simulators/webots/shared/src/protocol/protocol_boundary_tests.rs new file mode 100644 index 00000000..66cf7d50 --- /dev/null +++ b/simulators/webots/shared/src/protocol/protocol_boundary_tests.rs @@ -0,0 +1,56 @@ +use super::framing::MAX_FRAME_BYTES; +use super::*; +use std::io::Cursor; + +#[test] +fn private_messages_round_trip_through_the_bounded_frame() { + let request = HostRequest::Event(ControllerEvent::WorldProgress(NativeProgressObservation { + completed_step: 42, + elapsed_ns: 504_000_000, + mode: ObservedNativeMode::RealTime, + })); + let mut bytes = Vec::new(); + write_frame(&mut bytes, &request).expect("the private message encodes"); + let decoded = + read_frame::<_, HostRequest>(&mut Cursor::new(bytes)).expect("the private message decodes"); + assert_eq!(decoded, request); +} + +#[test] +fn an_oversized_incoming_frame_is_refused_before_allocation() { + let bytes = u32::try_from(MAX_FRAME_BYTES + 1) + .expect("the test bound fits") + .to_be_bytes(); + assert!(matches!( + read_frame::<_, HostRequest>(&mut Cursor::new(bytes)), + Err(LinkError::FrameTooLarge { .. }) + )); +} + +#[test] +fn robot_import_budget_admits_real_scene_sizes_before_any_mutation() { + let source = " ".repeat(MAX_ROBOT_SOURCE_BYTES - 5); + validate_robot_import("ROBOT", &source).expect("bounded Robot source"); + assert!(validate_robot_import("ROBOT_", &source).is_err()); + let response = HostResponse::Directive(HostDirective::Mutate(NativeMutation::ImportRobot { + transaction: u64::MAX, + execution: ExecutionId::try_from(0x1000_0000_0000_0000_0000_0000_0000_0001) + .expect("execution"), + definition: "ROBOT".to_owned(), + source, + })); + write_frame(&mut std::io::sink(), &response) + .expect("preflight leaves room for the wire envelope"); +} + +#[test] +fn unsupported_native_modes_stay_typed() { + for observed in [ObservedNativeMode::Run, ObservedNativeMode::Fast] { + let fault = ControllerFault::UnsupportedMode { observed }; + let bytes = rmp_serde::to_vec_named(&fault).expect("the fault encodes"); + assert_eq!( + rmp_serde::from_slice::(&bytes).expect("the fault decodes"), + fault + ); + } +} diff --git a/simulators/webots/shared/src/protocol/records.rs b/simulators/webots/shared/src/protocol/records.rs new file mode 100644 index 00000000..9bd7548e --- /dev/null +++ b/simulators/webots/shared/src/protocol/records.rs @@ -0,0 +1,277 @@ +use super::*; + +// Native indexed geometry for a current robot occupies several MiB. Keep the source +// bounded independently of its small protocol envelope and check it before mutation. +pub(super) const MAX_ROBOT_SOURCE_BYTES: usize = 16 * 1024 * 1024; + +/// Reject an oversized generated robot import before it crosses the native link. +pub fn validate_robot_import(definition: &str, source: &str) -> Result<(), LinkError> { + let bytes = definition.len().saturating_add(source.len()); + if bytes > MAX_ROBOT_SOURCE_BYTES { + return Err(LinkError::FrameTooLarge { + bytes, + maximum: MAX_ROBOT_SOURCE_BYTES, + }); + } + Ok(()) +} +pub(super) const EVENT_QUEUE_CAPACITY: usize = 64; +pub(super) const IO_TIMEOUT: Duration = Duration::from_secs(2); + +/// The native role opening one private host connection. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum ControllerRole { + World, + Robot { execution: ExecutionId }, +} + +/// The native Webots pacing mode observed at a completed boundary. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum ObservedNativeMode { + Paused, + RealTime, + Run, + Fast, +} + +/// The only native motion states a Live host may request. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum NativeMotion { + Paused, + RealTime, +} + +/// One observation of shared native progress. +/// +/// The framework owns the public `WorldProgress` contract. +/// This private record is only the raw Webots observation the host validates before updating that +/// contract. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct NativeProgressObservation { + pub completed_step: u64, + pub elapsed_ns: u64, + pub mode: ObservedNativeMode, +} + +/// Why one native controller can no longer be trusted. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum ControllerFault { + Device { detail: String }, + InvalidProgress { detail: String }, + UnsupportedMode { observed: ObservedNativeMode }, + Protocol { detail: String }, +} + +/// One bounded typed record of command admission and native application. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ActuationEvidence { + pub capability: CapabilityRef, + pub revision: u64, + /// Monotonic Active boundary where command authority was selected before native entry. + pub selected_at: RobotInstant, + /// Last completed world boundary from which this command selection advanced. + pub selected_from: WorldProgress, + /// Completed world transition correlated with `instant`, typed outputs, and `StepEvent`. + pub progress: WorldProgress, + pub instant: RobotInstant, + pub offered: Vec, + pub selected: Option, + pub selection: ActuationSelection, + pub applied: AppliedActuation, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum ActuationSelection { + SelectedNew, + Reused, + None { reason: NoActuationReason }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum NoActuationReason { + Missing, + SourceAbsent, + SourceConflict, + Expired, + ReceiverClosed, + Rejected, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct OfferedActuation { + pub producer: Option, + pub sequence: u64, + pub command: api::component::motor::Command, + pub decision: ActuationDecision, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum ActuationDecision { + Acquired, + Renewed, + WrongParticipant, + ParticipantSource, + ReadyStateOverflow, + SourceAbsent, + SourceConflict, + StaleSequence { + accepted: u64, + observed: u64, + }, + AuthorityHeld { + owner: ProducerId, + }, + NotOwner { + owner: ProducerId, + requested: ProducerId, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub enum AppliedActuation { + Position(f64), + Velocity(f64), + Torque(f64), + Stop, +} + +/// A bounded controller-to-host observation. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum ControllerEvent { + /// Poll the current directive while Webots is paused outside `wb_robot_step`. + Heartbeat, + WorldReady { + time_step_ns: u64, + mode: ObservedNativeMode, + }, + WorldMode { + mode: ObservedNativeMode, + }, + WorldProgress(NativeProgressObservation), + RobotReady { + controller: ProducerId, + }, + /// The controller observed and accepted one exact supervisor Active revision. + RobotActive { + revision: u64, + }, + /// The Robot has completed all work for this boundary and is outside `wb_robot_step`. + RobotBoundary { + progress: WorldProgress, + motion: NativeMotion, + }, + RobotParked, + RobotStopping, + RobotSupervisorLost, + ActuationEvidence(Vec), + /// Native import exists; release its source and start the controller without physics. + RobotImported { + transaction: u64, + }, + MutationCompleted { + transaction: u64, + error: Option, + }, + Stopped, + Fault(ControllerFault), +} + +/// One request on the local private connection. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum HostRequest { + Hello { + framework: FrameworkVersion, + role: ControllerRole, + }, + Event(ControllerEvent), +} + +/// The latest host directive each native controller follows at a transition boundary. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum HostDirective { + Continue { motion: NativeMotion }, + Park, + Mutate(NativeMutation), + Stop { reason: String }, +} + +/// One serialized scene mutation performed by the sole Webots supervisor. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum NativeMutation { + ImportRobot { + transaction: u64, + execution: ExecutionId, + definition: String, + source: String, + }, + StartRobotController { + transaction: u64, + execution: ExecutionId, + ready: bool, + }, + RemoveRobot { + transaction: u64, + definition: String, + }, + /// Idempotent rollback after an import attempt with an uncertain native outcome. + RollbackRobot { + transaction: u64, + definition: String, + }, +} + +impl NativeMutation { + #[must_use] + pub const fn transaction(&self) -> u64 { + match self { + Self::ImportRobot { transaction, .. } + | Self::StartRobotController { transaction, .. } + | Self::RemoveRobot { transaction, .. } + | Self::RollbackRobot { transaction, .. } => *transaction, + } + } +} + +/// One host response. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum HostResponse { + Accepted { + directive: HostDirective, + robot_plan: Option, + }, + Directive(HostDirective), + Rejected { + reason: String, + }, +} + +/// A private controller link failure. +#[derive(Debug, thiserror::Error)] +pub enum LinkError { + #[error("invalid private Webots host endpoint '{endpoint}'")] + InvalidEndpoint { endpoint: String }, + #[error("failed to connect to the private Webots host at {endpoint}: {source}")] + Connect { + endpoint: String, + #[source] + source: std::io::Error, + }, + #[error("private Webots host I/O failed: {0}")] + Io(#[from] std::io::Error), + #[error("private Webots host message encoding failed: {0}")] + Encode(#[from] rmp_serde::encode::Error), + #[error("private Webots host message decoding failed: {0}")] + Decode(#[from] rmp_serde::decode::Error), + #[error("private Webots host message is {bytes} bytes, exceeding the {maximum}-byte bound")] + FrameTooLarge { bytes: usize, maximum: usize }, + #[error("private Webots host refused this controller: {reason}")] + Rejected { reason: String }, + #[error("private Webots host returned an invalid handshake response")] + InvalidHandshake, + #[error("the bounded private Webots host event queue is full")] + WouldBlock, + #[error("the private Webots host link has stopped")] + Closed, + #[error("the private Webots host link failed: {detail}")] + Failed { detail: String }, +} diff --git a/simulators/webots/world-controller/Cargo.toml b/simulators/webots/world-controller/Cargo.toml new file mode 100644 index 00000000..7bfa6a46 --- /dev/null +++ b/simulators/webots/world-controller/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "phoxal-simulator-webots-world-controller" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = ["phoxal"] +description = "Phoxal Webots shared-world controller." +documentation.workspace = true +homepage.workspace = true +repository.workspace = true + +[[bin]] +name = "phoxal-simulator-webots-world-controller" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true } +phoxal-simulator-webots-shared = { path = "../shared", version = "=0.67.1", registry = "phoxal" } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +webots-rs = { workspace = true } + +[lints] +workspace = true diff --git a/simulators/webots/world-controller/src/controller_runtime_tests.rs b/simulators/webots/world-controller/src/controller_runtime_tests.rs new file mode 100644 index 00000000..c8adfb61 --- /dev/null +++ b/simulators/webots/world-controller/src/controller_runtime_tests.rs @@ -0,0 +1,29 @@ +use super::*; + +#[test] +fn basic_time_step_must_be_a_positive_integer() { + assert_eq!(exact_step_ms(12.0).expect("valid step"), 12); + for invalid in [0.0, -1.0, 12.5, f64::NAN] { + assert!(exact_step_ms(invalid).is_err()); + } +} + +#[test] +fn progress_time_is_quantized_to_nanoseconds() { + assert_eq!(observed_elapsed_ns(0.012).expect("valid time"), 12_000_000); + assert!(observed_elapsed_ns(-1.0).is_err()); +} + +#[test] +fn every_post_initialization_failure_forces_native_convergence() { + let mut quit = false; + let result = converge_on_error::<()>(Err(anyhow::anyhow!("host bootstrap failed")), || { + quit = true; + }); + assert!(result.is_err()); + assert!(quit); + + let mut quit = false; + converge_on_error(Ok(()), || quit = true).expect("normal controller exit"); + assert!(!quit); +} diff --git a/simulators/webots/world-controller/src/main.rs b/simulators/webots/world-controller/src/main.rs new file mode 100644 index 00000000..a8de0cd4 --- /dev/null +++ b/simulators/webots/world-controller/src/main.rs @@ -0,0 +1,59 @@ +//! Shared Webots supervisor controller for one Phoxal world session. + +#[cfg(any(target_env = "musl", all(target_os = "linux", target_arch = "aarch64")))] +compile_error!( + "the Webots R2025a controller SDK is dynamically linked and unsupported on musl or Linux aarch64" +); + +use std::time::Duration; + +use anyhow::{Context, Result, bail, ensure}; +use clap::Parser; +use phoxal_simulator_webots_shared::protocol::{ + ControllerEvent, ControllerFault, ControllerLink, ControllerRole, HostDirective, NativeMotion, + NativeMutation, NativeProgressObservation, ObservedNativeMode, +}; +use tracing_subscriber::EnvFilter; +use webots_rs::bindings::{ + WbSimulationMode, WbSimulationMode_WB_SUPERVISOR_SIMULATION_MODE_FAST, + WbSimulationMode_WB_SUPERVISOR_SIMULATION_MODE_PAUSE, + WbSimulationMode_WB_SUPERVISOR_SIMULATION_MODE_REAL_TIME, +}; +use webots_rs::{ + Webots, + supervisor::{Node, Supervisor}, +}; + +mod mode; +mod mutation; +mod runtime; + +use mode::{ + observed_mode, poll_while_paused, set_motion, synchronize_control, validate_native_mode, +}; +use mutation::{apply_mutation, start_imported_controller}; +use runtime::run; + +#[cfg(test)] +use runtime::{converge_on_error, exact_step_ms, observed_elapsed_ns}; + +const PAUSED_POLL: Duration = Duration::from_millis(10); + +#[derive(Debug, Parser)] +#[command(version, about)] +struct Args { + /// Loopback-only endpoint owned by the world-session host. + #[arg(long, value_name = "LOCAL_ENDPOINT")] + host_connect: String, +} + +fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .with_writer(std::io::stderr) + .init(); + run(Args::parse()) +} + +#[cfg(test)] +mod controller_runtime_tests; diff --git a/simulators/webots/world-controller/src/mode.rs b/simulators/webots/world-controller/src/mode.rs new file mode 100644 index 00000000..558ca5fa --- /dev/null +++ b/simulators/webots/world-controller/src/mode.rs @@ -0,0 +1,112 @@ +use super::*; + +pub(super) fn poll_while_paused( + webots: &Webots, + supervisor: &Supervisor, + link: &ControllerLink, +) -> Result<()> { + synchronize_control(webots)?; + validate_paused_mode(supervisor, link)?; + link.exchange(ControllerEvent::Heartbeat)?; + std::thread::sleep(PAUSED_POLL); + Ok(()) +} + +pub(super) fn validate_paused_mode(supervisor: &Supervisor, link: &ControllerLink) -> Result<()> { + let raw = supervisor.simulation_get_mode()?; + match map_mode(raw) { + Some(ObservedNativeMode::Paused) => Ok(()), + Some(observed @ (ObservedNativeMode::Run | ObservedNativeMode::Fast)) => { + link.exchange(ControllerEvent::Fault(ControllerFault::UnsupportedMode { + observed, + }))?; + bail!("Webots entered unsupported native mode {observed:?} while paused") + } + Some(observed) => { + link.exchange(ControllerEvent::Fault(ControllerFault::Protocol { + detail: format!("expected PAUSE outside wb_robot_step, observed {observed:?}"), + }))?; + bail!("Webots left PAUSE without host authority") + } + None => { + link.exchange(ControllerEvent::Fault(ControllerFault::UnsupportedMode { + observed: ObservedNativeMode::Run, + }))?; + bail!("Webots returned unknown simulation mode {raw} while paused") + } + } +} + +pub(super) fn set_motion( + webots: &Webots, + supervisor: &Supervisor, + motion: NativeMotion, +) -> Result<()> { + supervisor + .simulation_set_mode(match motion { + NativeMotion::Paused => WbSimulationMode_WB_SUPERVISOR_SIMULATION_MODE_PAUSE, + NativeMotion::RealTime => WbSimulationMode_WB_SUPERVISOR_SIMULATION_MODE_REAL_TIME, + }) + .context("failed to change Webots simulation mode")?; + synchronize_control(webots) +} + +pub(super) fn synchronize_control(webots: &Webots) -> Result<()> { + // R2025a may return the cached previous mode after simulation_set_mode. + // A zero-duration step refreshes control state without advancing physics, + // including while paused. A positive-duration step would block in PAUSE. + let before = webots.get_time()?; + ensure!( + webots.step(0)?, + "Webots stopped during control synchronization" + ); + ensure!( + webots.get_time()? == before, + "Webots advanced physics during control synchronization" + ); + Ok(()) +} + +pub(super) fn validate_native_mode(supervisor: &Supervisor, link: &ControllerLink) -> Result<()> { + let raw = supervisor.simulation_get_mode()?; + match map_mode(raw) { + Some(ObservedNativeMode::RealTime) => Ok(()), + Some(observed @ (ObservedNativeMode::Run | ObservedNativeMode::Fast)) => { + link.exchange(ControllerEvent::Fault(ControllerFault::UnsupportedMode { + observed, + }))?; + bail!("Webots entered unsupported native mode {observed:?}") + } + Some(observed) => { + link.exchange(ControllerEvent::Fault(ControllerFault::Protocol { + detail: format!("expected REAL_TIME before a native step, observed {observed:?}"), + }))?; + bail!("Webots was not in REAL_TIME before a native step") + } + None => { + link.exchange(ControllerEvent::Fault(ControllerFault::UnsupportedMode { + observed: ObservedNativeMode::Run, + }))?; + bail!("Webots returned unknown simulation mode {raw}") + } + } +} + +pub(super) const fn map_mode(mode: WbSimulationMode) -> Option { + if mode == WbSimulationMode_WB_SUPERVISOR_SIMULATION_MODE_PAUSE { + Some(ObservedNativeMode::Paused) + } else if mode == WbSimulationMode_WB_SUPERVISOR_SIMULATION_MODE_REAL_TIME { + Some(ObservedNativeMode::RealTime) + } else if mode == WbSimulationMode_WB_SUPERVISOR_SIMULATION_MODE_FAST { + Some(ObservedNativeMode::Fast) + } else { + None + } +} + +pub(super) const fn observed_mode(motion: NativeMotion) -> ObservedNativeMode { + match motion { + NativeMotion::Paused => ObservedNativeMode::Paused, + NativeMotion::RealTime => ObservedNativeMode::RealTime, + } +} diff --git a/simulators/webots/world-controller/src/mutation.rs b/simulators/webots/world-controller/src/mutation.rs new file mode 100644 index 00000000..c1fd0f94 --- /dev/null +++ b/simulators/webots/world-controller/src/mutation.rs @@ -0,0 +1,98 @@ +use super::*; + +pub(super) fn apply_mutation(supervisor: &Supervisor, mutation: NativeMutation) -> Result<()> { + ensure!( + supervisor.simulation_get_mode()? == WbSimulationMode_WB_SUPERVISOR_SIMULATION_MODE_PAUSE, + "native scene mutation is permitted only while Webots is paused" + ); + match mutation { + NativeMutation::StartRobotController { .. } => { + bail!("controller bootstrap is not a scene mutation") + } + NativeMutation::ImportRobot { + definition, source, .. + } => { + ensure!( + Node::from_def(&definition).is_err(), + "Robot DEF {definition} already exists" + ); + supervisor + .get_root()? + .field("children")? + .import_mf_node_from_string(-1, &source)?; + let imported = Node::from_def(&definition) + .with_context(|| format!("imported Robot DEF {definition} is not addressable"))?; + ensure!( + imported.base_type_name()? == "Robot", + "imported DEF {definition} is not a Robot" + ); + } + NativeMutation::RemoveRobot { definition, .. } => { + Node::from_def(&definition) + .with_context(|| format!("Robot DEF {definition} is absent during removal"))? + .remove()?; + ensure!( + Node::from_def(&definition).is_err(), + "Robot DEF {definition} remained after removal" + ); + } + NativeMutation::RollbackRobot { definition, .. } => { + if let Ok(node) = Node::from_def(&definition) { + node.remove()?; + } + ensure!( + Node::from_def(&definition).is_err(), + "Robot DEF {definition} remained after rollback" + ); + } + } + Ok(()) +} + +pub(super) fn start_imported_controller( + webots: &Webots, + supervisor: &Supervisor, + link: &ControllerLink, + transaction: u64, +) -> Result<()> { + // R2025a starts imported controllers from its running event loop. Zero-duration + // controller requests do not authorize physics, so bootstrap can preserve this boundary. + // The installed-runtime proof covers startup and return to PAUSE without a time change. + let before = webots.get_time()?; + set_motion(webots, supervisor, NativeMotion::RealTime)?; + let deadline = std::time::Instant::now() + Duration::from_secs(25); + let result = (|| -> Result<()> { + loop { + synchronize_control(webots)?; + ensure!( + webots.get_time()? == before, + "controller bootstrap advanced native physics" + ); + validate_native_mode(supervisor, link)?; + match link.directive()? { + HostDirective::Mutate(NativeMutation::StartRobotController { + transaction: current, + ready, + .. + }) if current == transaction => { + if ready { + return Ok(()); + } + } + directive => bail!("native import bootstrap lost authority: {directive:?}"), + } + ensure!( + std::time::Instant::now() < deadline, + "imported controller bootstrap timed out" + ); + link.exchange(ControllerEvent::Heartbeat)?; + std::thread::sleep(PAUSED_POLL); + } + })(); + set_motion(webots, supervisor, NativeMotion::Paused)?; + ensure!( + webots.get_time()? == before, + "controller bootstrap changed the paused boundary" + ); + result +} diff --git a/simulators/webots/world-controller/src/runtime.rs b/simulators/webots/world-controller/src/runtime.rs new file mode 100644 index 00000000..ac9ce173 --- /dev/null +++ b/simulators/webots/world-controller/src/runtime.rs @@ -0,0 +1,160 @@ +use super::*; + +pub(super) fn run(args: Args) -> Result<()> { + let webots = Webots::new().context("failed to initialize the Webots R2025a controller")?; + let supervisor = webots.get_supervisor(); + let result = run_initialized(args, &webots, &supervisor); + converge_on_error(result, || { + let _ = supervisor.simulation_quit(1); + }) +} + +fn run_initialized(args: Args, webots: &Webots, supervisor: &Supervisor) -> Result<()> { + let step_ms = exact_step_ms(webots.get_basic_time_step()?)?; + let step_ns = u64::try_from(step_ms) + .context("Webots basicTimeStep is negative")? + .checked_mul(1_000_000) + .context("Webots basicTimeStep overflows nanoseconds")?; + let link = ControllerLink::connect(&args.host_connect, ControllerRole::World) + .context("failed to join the private world host")?; + + set_motion(webots, supervisor, NativeMotion::Paused)?; + link.exchange(ControllerEvent::WorldReady { + time_step_ns: step_ns, + mode: ObservedNativeMode::Paused, + })?; + + let mut observed = NativeMotion::Paused; + let mut completed_step = 0_u64; + let mut completed_mutation = None; + loop { + let directive = match link.directive() { + Ok(directive) => directive, + Err(error) => { + return Err(error).context( + "private world-host authority was lost; forced Webots process convergence", + ); + } + }; + match directive { + HostDirective::Continue { motion } => { + if motion != observed { + set_motion(webots, supervisor, motion)?; + observed = motion; + link.exchange(ControllerEvent::WorldMode { + mode: observed_mode(motion), + })?; + } + match motion { + NativeMotion::Paused => poll_while_paused(webots, supervisor, &link)?, + NativeMotion::RealTime => { + validate_native_mode(supervisor, &link)?; + if !webots.step(step_ms)? { + link.exchange(ControllerEvent::Stopped)?; + return Ok(()); + } + completed_step = completed_step + .checked_add(1) + .context("Webots completed-step counter exhausted")?; + link.exchange(ControllerEvent::WorldProgress(NativeProgressObservation { + completed_step, + elapsed_ns: observed_elapsed_ns(webots.get_time()?)?, + mode: ObservedNativeMode::RealTime, + }))?; + } + } + } + HostDirective::Park => { + if observed != NativeMotion::Paused { + set_motion(webots, supervisor, NativeMotion::Paused)?; + observed = NativeMotion::Paused; + link.exchange(ControllerEvent::WorldMode { + mode: ObservedNativeMode::Paused, + })?; + } + poll_while_paused(webots, supervisor, &link)?; + } + HostDirective::Mutate(mutation) => { + if observed != NativeMotion::Paused { + set_motion(webots, supervisor, NativeMotion::Paused)?; + observed = NativeMotion::Paused; + link.exchange(ControllerEvent::WorldMode { + mode: ObservedNativeMode::Paused, + })?; + } + let transaction = mutation.transaction(); + if completed_mutation == Some(transaction) { + poll_while_paused(webots, supervisor, &link)?; + continue; + } + if matches!(mutation, NativeMutation::StartRobotController { .. }) { + start_imported_controller(webots, supervisor, &link, transaction)?; + link.exchange(ControllerEvent::MutationCompleted { + transaction, + error: None, + })?; + completed_mutation = Some(transaction); + continue; + } + let importing = matches!(mutation, NativeMutation::ImportRobot { .. }); + let error = apply_mutation(supervisor, mutation) + .err() + .map(|error| format!("{error:#}")); + if importing && error.is_none() { + link.exchange(ControllerEvent::RobotImported { transaction })?; + continue; + } + link.exchange(ControllerEvent::MutationCompleted { transaction, error })?; + completed_mutation = Some(transaction); + } + HostDirective::Stop { reason } => { + tracing::info!(%reason, "stopping the shared Webots world controller"); + set_motion(webots, supervisor, NativeMotion::Paused)?; + link.exchange(ControllerEvent::Stopped)?; + return Ok(()); + } + } + } +} + +pub(super) fn converge_on_error(result: Result, quit: impl FnOnce()) -> Result { + if let Err(error) = &result { + tracing::error!(error = %format!("{error:#}"), "native world controller failed"); + quit(); + } + result +} + +pub(super) fn exact_step_ms(value: f64) -> Result { + ensure!( + value.is_finite() && value > 0.0, + "Webots basicTimeStep must be finite and positive" + ); + ensure!( + value.fract() == 0.0, + "Webots basicTimeStep must be an exact whole millisecond" + ); + ensure!( + value <= f64::from(i32::MAX), + "Webots basicTimeStep exceeds the controller ABI" + ); + Ok(value as i32) +} + +pub(super) fn observed_elapsed_ns(seconds: f64) -> Result { + ensure!( + seconds.is_finite() && seconds >= 0.0, + "Webots returned invalid simulation time" + ); + let nanoseconds = seconds * 1_000_000_000.0; + let rounded = nanoseconds.round(); + ensure!( + (nanoseconds - rounded).abs() <= 0.25, + "Webots simulation time cannot be represented as whole nanoseconds" + ); + ensure!( + rounded <= u64::MAX as f64, + "Webots simulation time overflows nanoseconds" + ); + Ok(rounded as u64) +} diff --git a/xtask/src/legacy.rs b/xtask/src/legacy.rs index e0c54e75..5ef21786 100644 --- a/xtask/src/legacy.rs +++ b/xtask/src/legacy.rs @@ -8,7 +8,7 @@ //! compared against the last train that predates it, and on that train the //! records this workspace states from one crate were stated from six. //! -//! So a baseline below [`TOPOLOGY_FLOOR`] is read from the packages that +//! So a baseline below [`topology_floor`] is read from the packages that //! actually carried it, at the same version, and their records are unioned. //! Record identity carries no crate name, so a record that moved from //! `phoxal-protocol` into `phoxal` is the same record on both sides and the diff --git a/xtask/src/policy/artifact.rs b/xtask/src/policy/artifact.rs index 5c660c04..1d48bffd 100644 --- a/xtask/src/policy/artifact.rs +++ b/xtask/src/policy/artifact.rs @@ -22,8 +22,8 @@ use cargo_metadata::Target; use super::executable::PHOXAL_PROVIDER; use super::executable::{validate_executable_targets, validate_registry_publish}; use super::{ - FACADE, INTERNAL_CRATE_DIRS, LIBRARY_CRATE_DIRS, LIBRARY_CRATE_ROOT, Subject, Violation, - library_package_name, + ADAPTER_LIBRARY_CRATE_DIRS, FACADE, INTERNAL_CRATE_DIRS, LIBRARY_CRATE_DIRS, + LIBRARY_CRATE_ROOT, Subject, Violation, library_package_name, }; /// The Cargo `package.name` prefix backing [`PHOXAL_PROVIDER`]: the package @@ -322,6 +322,7 @@ impl ManifestClassification { }; let directory = directory.join("/"); if LIBRARY_CRATE_DIRS.contains(&directory.as_str()) + || ADAPTER_LIBRARY_CRATE_DIRS.contains(&directory.as_str()) || INTERNAL_CRATE_DIRS.contains(&directory.as_str()) { return Ok(Self::Excluded); @@ -686,31 +687,32 @@ mod tests { let workspace_dir = tempfile::tempdir().context("failed to create temp workspace dir")?; let root = workspace_dir.path(); - fs::write( - root.join("Cargo.toml"), - r#"[workspace] -resolver = "3" -members = ["components/test", "supervisor"] -"#, - )?; + let specs = super::super::framework_executable::SPECS; + let mut members = vec!["components/test".to_owned()]; + for spec in specs { + let manifest = root.join(spec.manifest_path()); + let directory = manifest + .parent() + .context("executable manifest has no parent")?; + members.push(directory.strip_prefix(root)?.display().to_string()); + fs::create_dir_all(directory.join("src"))?; + fs::write(directory.join("src/main.rs"), "fn main() {}\n")?; + fs::write( + manifest, + format!( + "[package]\nname = \"{}\"\nversion = \"0.1.0\"\nedition = \"2024\"\npublish = [\"phoxal\"]\nautobins = false\nautolib = false\n\n[[bin]]\nname = \"{}\"\npath = \"src/main.rs\"\n", + spec.package_name(), + spec.package_name(), + ), + )?; + } - let supervisor_dir = root.join("supervisor"); - fs::create_dir_all(supervisor_dir.join("src"))?; - fs::write(supervisor_dir.join("src/main.rs"), "fn main() {}\n")?; fs::write( - supervisor_dir.join("Cargo.toml"), - r#"[package] -name = "phoxal-supervisor" -version = "0.1.0" -edition = "2024" -license = "AGPL-3.0-only" -publish = ["phoxal"] -autobins = false - -[[bin]] -name = "phoxal-supervisor" -path = "src/main.rs" -"#, + root.join("Cargo.toml"), + format!( + "[workspace]\nresolver = \"3\"\nmembers = {}\n", + serde_json::to_string(&members)? + ), )?; let package_dir = root.join("components/test"); diff --git a/xtask/src/policy/bus_boundary.rs b/xtask/src/policy/bus_boundary.rs index 0e9dd5dc..19b35e67 100644 --- a/xtask/src/policy/bus_boundary.rs +++ b/xtask/src/policy/bus_boundary.rs @@ -391,7 +391,7 @@ mod tests { for authored in [ "const KEY: &str = \"robot/drive/state\";", " let asset = (\"robot/meshes/base.stl\", \"robot/drive/state\");", - " \"runtime/simulation/clock\"", + " \"runtime/private/diagnostic\"", "pub const PRESENCE_KEY: &str = \"supervisor/presence\";", ] { assert!(authored_topic_root(authored).is_some(), "{authored}"); diff --git a/xtask/src/policy/framework_executable.rs b/xtask/src/policy/framework_executable.rs index f852591f..19a67890 100644 --- a/xtask/src/policy/framework_executable.rs +++ b/xtask/src/policy/framework_executable.rs @@ -10,11 +10,11 @@ use super::artifact::ArtifactKind; use super::executable::{PHOXAL_PROVIDER, validate_executable_targets, validate_registry_publish}; use super::{Subject, Violation}; -/// One permitted framework-owned root executable. +/// One permitted framework-owned executable package. /// /// This is an explicit tuple rather than an extensible kind grammar: the -/// supervisor is framework infrastructure, never a service, component, -/// catalogue entry, or bundle participant. +/// These are framework infrastructure, never services, components, catalogue +/// entries, or bundle participants. #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] pub struct Spec { package_name: &'static str, @@ -78,22 +78,45 @@ impl Spec { } /// The exact framework-owned executables published with the framework train. -pub const SPECS: [Spec; 1] = [Spec { - package_name: "phoxal-supervisor", - manifest_path: "supervisor/Cargo.toml", - bin_name: "phoxal-supervisor", - source_path: "supervisor/src/main.rs", - forbidden_dependencies: &[ - // The supervisor is built from the one framework library, never from - // its former CLI owner. - "phoxal-cli", - // Authored YAML/URDF and their parsers stop at bundle compilation. The - // `authoring` feature that would pull them in is refused separately, by - // the dependency rule that covers every official participant. - "serde_yaml", - "urdf-rs", - ], -}]; +pub const SPECS: [Spec; 4] = [ + Spec { + package_name: "phoxal-supervisor", + manifest_path: "supervisor/Cargo.toml", + bin_name: "phoxal-supervisor", + source_path: "supervisor/src/main.rs", + forbidden_dependencies: &[ + // The supervisor is built from the one framework library, never from + // its former CLI owner. + "phoxal-cli", + // Authored YAML/URDF and their parsers stop at bundle compilation. The + // `authoring` feature that would pull them in is refused separately, by + // the dependency rule that covers every official participant. + "serde_yaml", + "urdf-rs", + ], + }, + Spec { + package_name: "phoxal-simulator-webots-host", + manifest_path: "simulators/webots/host/Cargo.toml", + bin_name: "phoxal-simulator-webots-host", + source_path: "simulators/webots/host/src/main.rs", + forbidden_dependencies: &["phoxal-cli", "serde_yaml", "urdf-rs"], + }, + Spec { + package_name: "phoxal-simulator-webots-world-controller", + manifest_path: "simulators/webots/world-controller/Cargo.toml", + bin_name: "phoxal-simulator-webots-world-controller", + source_path: "simulators/webots/world-controller/src/main.rs", + forbidden_dependencies: &["phoxal-cli", "serde_yaml", "urdf-rs"], + }, + Spec { + package_name: "phoxal-simulator-webots-robot-controller", + manifest_path: "simulators/webots/robot-controller/Cargo.toml", + bin_name: "phoxal-simulator-webots-robot-controller", + source_path: "simulators/webots/robot-controller/src/main.rs", + forbidden_dependencies: &["phoxal-cli", "serde_yaml", "urdf-rs"], + }, +]; pub(crate) fn spec_for_manifest(root: &Path, manifest_path: &Path) -> Option { let relative = manifest_path.strip_prefix(root).ok()?; @@ -110,7 +133,7 @@ pub(crate) fn spec_for_package(package_name: &str) -> Option { .find(|spec| spec.package_name == package_name) } -/// The framework-owned supervisor's place in the workspace: an ordinary +/// The framework-owned executable set's place in the workspace. The supervisor is an ordinary /// default member that plain root cargo commands build, publishing to the /// `phoxal` registry, carrying none of the authoring or parser dependencies its /// spec forbids, and standing outside the artifact catalogue rather than inside @@ -185,18 +208,26 @@ pub(super) fn the_supervisor_is_a_default_member_and_non_catalog_executable( return Ok(violations); } }; - match workspace.framework_executables() { - [only] if *only == SPECS[0] => {} - found => violations.push(Violation::new(format!( - "the workspace must declare exactly the one framework-owned executable; found {:?}", + let found = workspace + .framework_executables() + .iter() + .copied() + .collect::>(); + let expected = SPECS.into_iter().collect::>(); + if found != expected { + violations.push(Violation::new(format!( + "the workspace framework-owned executable set is not exact; found {:?}", found .iter() .map(|spec| spec.package_name()) .collect::>() - ))), + ))); } for artifact in workspace.official_artifacts() { - if artifact.package_name() == SPECS[0].package_name() { + if SPECS + .iter() + .any(|spec| artifact.package_name() == spec.package_name()) + { violations.push(Violation::new(format!( "{} entered the artifact catalogue; it is framework infrastructure and never a \ catalogue entry", @@ -294,15 +325,22 @@ pub(super) fn every_registry_executable_participates_in_the_release_train( let workflow = fs::read_to_string(subject.root.join(".github/workflows/release-plz.yml")) .context("failed to read the release workflow")?; - if workflow.matches("select(.publish == [\"phoxal\"])").count() < 4 { + let order_script = ".github/scripts/registry_package_order.py"; + if !workflow.contains(&format!("python3 {order_script}")) + || !subject.root.join(order_script).is_file() + || workflow + .matches("done < \"$RUNNER_TEMP/registry-package-order.tsv\"") + .count() + != 2 + { violations.push(Violation::new( "registry packaging and verification must remain metadata-driven in \ .github/workflows/release-plz.yml", )); } - if workflow.contains("cargo package -p phoxal-supervisor") { + if registry_packages.iter().any(|name| workflow.contains(name)) { violations.push(Violation::new( - "the supervisor must use the neutral executable batch in \ + "registry executables must use the metadata-derived dependency batch in \ .github/workflows/release-plz.yml", )); } diff --git a/xtask/src/policy/mod.rs b/xtask/src/policy/mod.rs index 8f02e12e..980d0ed4 100644 --- a/xtask/src/policy/mod.rs +++ b/xtask/src/policy/mod.rs @@ -65,6 +65,12 @@ pub(crate) const FACADE: &str = "phoxal"; /// missing entry would silently turn that crate into a grammar violation. pub(crate) const LIBRARY_CRATE_DIRS: [&str; 2] = ["phoxal", "crates/macros"]; +/// Narrow adapter libraries shared only by exact-train simulator executables. +/// +/// They are published through the `phoxal` registry, but do not widen the +/// reusable framework library graph. +pub(crate) const ADAPTER_LIBRARY_CRATE_DIRS: [&str; 1] = ["simulators/webots/shared"]; + /// Library crates that serve this workspace's own tests and reach no /// registry. They carry a library target, so the completeness check below /// still demands they be listed; they are simply listed here rather than in @@ -79,11 +85,9 @@ pub(crate) const INTERNAL_CRATE_DIRS: [&str; 1] = ["crates/fixture"]; /// The package a library crate directory must hold, or `None` for a directory /// that names no library crate location. /// -/// One rule, no exceptions: a library crate is `phoxal-` and lives at -/// `crates/`. The facade is the single crate whose name carries no -/// suffix, so it is the single crate that does not live in the suffix -/// directory; it sits at the workspace root as `phoxal/`. That falls out of -/// the rule rather than carving a hole in it. +/// Framework libraries are `phoxal-` at `crates/`, except for +/// the `phoxal/` facade. Exact-train adapter libraries have one explicit +/// location because they are controller contracts, not framework API. /// /// This is the whole reason the directory can be shortened at all. `crates/` /// already says `phoxal`, so repeating it in every child would be the @@ -92,6 +96,9 @@ pub(crate) fn library_package_name(directory: &str) -> Option { if directory == FACADE { return Some(FACADE.to_owned()); } + if directory == "simulators/webots/shared" { + return Some("phoxal-simulator-webots-shared".to_owned()); + } let suffix = directory .strip_prefix(LIBRARY_CRATE_ROOT)? .strip_prefix('/')?; @@ -115,6 +122,12 @@ pub(crate) fn is_library_package(package_name: &str) -> bool { .any(|directory| library_package_name(directory).as_deref() == Some(package_name)) } +pub(crate) fn is_adapter_library_package(package_name: &str) -> bool { + ADAPTER_LIBRARY_CRATE_DIRS + .iter() + .any(|directory| library_package_name(directory).as_deref() == Some(package_name)) +} + /// The workspace one run of the gate judges. /// /// Cargo is asked for the member metadata once and every rule reads that one @@ -347,10 +360,10 @@ impl fmt::Display for PolicyReport { /// A hand-maintained list that silently skips validation when it goes stale is /// worse than no list, so the workspace itself is the authority: every -/// workspace member carrying a library target must be listed as either -/// published or internal, and every listed directory must still hold one. Both -/// lists obey the naming rule, so being unpublished buys a crate no leniency -/// about where it lives. +/// workspace member carrying a reusable library target must be listed as either +/// published or internal, and every listed directory must still hold one. +/// Exact-train adapter libraries are explicit because they serve only the +/// native controller packages, rather than widening the framework API. fn the_library_crate_list_matches_the_workspace_members( subject: &Subject, ) -> Result> { @@ -388,6 +401,7 @@ fn the_library_crate_list_matches_the_workspace_members( for directory in &discovered { if !LIBRARY_CRATE_DIRS.contains(&directory.as_str()) + && !ADAPTER_LIBRARY_CRATE_DIRS.contains(&directory.as_str()) && !INTERNAL_CRATE_DIRS.contains(&directory.as_str()) { violations.push(Violation::new(format!( @@ -396,7 +410,11 @@ fn the_library_crate_list_matches_the_workspace_members( ))); } } - for directory in LIBRARY_CRATE_DIRS.iter().chain(INTERNAL_CRATE_DIRS.iter()) { + for directory in LIBRARY_CRATE_DIRS + .iter() + .chain(ADAPTER_LIBRARY_CRATE_DIRS.iter()) + .chain(INTERNAL_CRATE_DIRS.iter()) + { if !discovered.iter().any(|found| found == directory) { violations.push(Violation::new(format!( "{directory} is listed as a library crate but no workspace member with a library \ @@ -446,7 +464,11 @@ mod tests { /// become a place to smuggle a crate past it. #[test] fn every_listed_library_crate_directory_obeys_the_rule() { - for directory in LIBRARY_CRATE_DIRS.iter().chain(INTERNAL_CRATE_DIRS.iter()) { + for directory in LIBRARY_CRATE_DIRS + .iter() + .chain(ADAPTER_LIBRARY_CRATE_DIRS.iter()) + .chain(INTERNAL_CRATE_DIRS.iter()) + { assert!( library_package_name(directory).is_some(), "{directory} is listed as a library crate but names no package" diff --git a/xtask/src/policy/registry.rs b/xtask/src/policy/registry.rs index 9213481e..0067e550 100644 --- a/xtask/src/policy/registry.rs +++ b/xtask/src/policy/registry.rs @@ -15,6 +15,7 @@ use super::framework_executable::{SPECS, Spec, spec_for_manifest, spec_for_packa use super::{ artifact::{OfficialArtifact, discover_package}, executable::PHOXAL_PROVIDER, + is_adapter_library_package, }; /// The two disjoint executable sets declared by the workspace. @@ -71,6 +72,9 @@ impl Workspace { official_artifacts.push(artifact); continue; } + if is_adapter_library_package(package.name.as_str()) { + continue; + } if publishes_to_phoxal(package) { bail!( "{} publishes to the {PHOXAL_PROVIDER} registry from {}, but it is neither an \ @@ -122,6 +126,7 @@ impl Workspace { #[cfg(test)] mod tests { use std::fs; + use std::path::Path; use super::*; @@ -164,18 +169,51 @@ autolib = false } #[test] - fn exact_supervisor_is_discovered_outside_the_artifact_catalogue() -> Result<()> { - let workspace = discover_single_package( - "supervisor", - "phoxal-supervisor", - "[\"phoxal\"]", - "[[bin]]\nname = \"phoxal-supervisor\"\npath = \"src/main.rs\"\n", + fn exact_framework_executables_are_discovered_outside_the_artifact_catalogue() -> Result<()> { + let workspace_dir = tempfile::tempdir().context("create executable policy workspace")?; + let root = workspace_dir.path(); + let members = SPECS + .iter() + .map(|spec| { + Path::new(spec.manifest_path()) + .parent() + .expect("spec manifest has a parent") + .display() + .to_string() + }) + .collect::>(); + fs::write( + root.join("Cargo.toml"), + format!( + "[workspace]\nresolver = \"3\"\nmembers = {}\n", + serde_json::to_string(&members)? + ), )?; + for spec in SPECS { + let manifest = root.join(spec.manifest_path()); + let directory = manifest.parent().context("spec manifest has no parent")?; + fs::create_dir_all(directory.join("src"))?; + fs::write(directory.join("src/main.rs"), "fn main() {}\n")?; + fs::write( + manifest, + format!( + "[package]\nname = \"{}\"\nversion = \"0.1.0\"\nedition = \"2024\"\nlicense = \"AGPL-3.0-only\"\npublish = [\"phoxal\"]\nautobins = false\nautolib = false\n\n[[bin]]\nname = \"{}\"\npath = \"src/main.rs\"\n", + spec.package_name(), + spec.package_name() + ), + )?; + } + let workspace = + Workspace::discover(MetadataCommand::new().manifest_path(root.join("Cargo.toml")))?; assert!(workspace.official_artifacts().is_empty()); - let [supervisor] = workspace.framework_executables() else { - bail!("the exact supervisor must be the sole framework executable"); - }; - assert_eq!(*supervisor, SPECS[0]); + assert_eq!( + workspace + .framework_executables() + .iter() + .copied() + .collect::>(), + SPECS.into_iter().collect::>() + ); Ok(()) } diff --git a/xtask/src/policy/retired_surface.rs b/xtask/src/policy/retired_surface.rs index 419180ac..c63d1bff 100644 --- a/xtask/src/policy/retired_surface.rs +++ b/xtask/src/policy/retired_surface.rs @@ -20,8 +20,8 @@ use super::{Subject, Violation}; /// otherwise be its own only violation. /// /// It is one path, not a pattern: naming the exemption as a path means it stops -/// applying the moment this module moves, and -/// [`the_exemptions_name_modules_that_exist`] fails when it does. +/// applying the moment this module moves, and the +/// `the_exemptions_name_modules_that_exist` test fails when it does. const SPELL_THE_VOCABULARY: [&str; 1] = ["xtask/src/policy/retired_surface.rs"]; /// Whether a scanned path is one of them. @@ -80,7 +80,7 @@ struct Retired { /// The rules these replaced differed only in which words they looked for, so /// they are rows rather than functions: a new retirement is a line here, and /// the scan itself is written once. -const RETIRED: [Retired; 32] = [ +const RETIRED: [Retired; 30] = [ // Runtime identity is minted from the compiled participant record, launch // is one clap-only process contract, the bus supplies its clock directly, // and a managed task is either critical or finite. @@ -215,16 +215,6 @@ const RETIRED: [Retired; 32] = [ train: "0.63.0", why: "robot time zero is host boot, so a launcher states no time origin", }, - Retired { - token: Token::Identifier("ParticipantClock"), - train: "0.63.0", - why: "simulation is a launch decision, not a per-participant clock declaration", - }, - Retired { - token: Token::Identifier("WorldAuthoritySurface"), - train: "0.63.0", - why: "the world clock is published by an external bus client, not a privileged role", - }, Retired { token: Token::Identifier("Simulator"), train: "0.63.0", diff --git a/xtask/src/readiness.rs b/xtask/src/readiness.rs index ed558fcc..9d6c32ad 100644 --- a/xtask/src/readiness.rs +++ b/xtask/src/readiness.rs @@ -214,6 +214,17 @@ impl V1Readiness { .any(|suffix| path.ends_with(suffix)) }) .filter(|path| !SEARCH_EXEMPT.contains(path)) + // `git ls-files` intentionally includes a tracked path deleted in + // this working tree. Skip only that known absence. Every other + // metadata outcome stays in the search so the later read reports a + // permission, file-type, or dangling-link failure instead of + // silently shrinking the policy surface. + .filter(|path| { + !matches!( + std::fs::symlink_metadata(self.workspace_root.join(path)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound + ) + }) .map(str::to_owned) .collect()) } @@ -385,6 +396,64 @@ mod tests { } } + /// A deletion left in the worktree remains in `git ls-files`, but the + /// readiness search must not attempt to open that absent source. + #[test] + fn the_search_skips_a_tracked_file_deleted_from_the_worktree() { + let root = tempfile::tempdir().expect("a temporary Git worktree"); + git(root.path(), &["init", "--quiet"]); + git(root.path(), &["config", "user.email", "tests@phoxal.dev"]); + git(root.path(), &["config", "user.name", "Phoxal tests"]); + let deleted = root.path().join("retired.rs"); + std::fs::write(&deleted, "// a tracked source\n").expect("the source is written"); + git(root.path(), &["add", "retired.rs"]); + git(root.path(), &["commit", "--quiet", "-m", "track source"]); + std::fs::remove_file(&deleted).expect("the source is deleted only from the worktree"); + + let files = V1Readiness::new(root.path().to_path_buf(), false) + .searched_files() + .expect("the search ignores the deleted tracked source"); + assert!( + !files.contains(&"retired.rs".to_owned()), + "a tracked worktree deletion must not be returned: {files:?}" + ); + } + + /// A tracked source that is present but unreadable must reach the read + /// boundary. Treating every `is_file() == false` result as a deletion would + /// let a dangling link silently disappear from the readiness policy. + #[cfg(unix)] + #[test] + fn the_search_retains_a_tracked_dangling_source_link() { + let root = tempfile::tempdir().expect("a temporary Git worktree"); + git(root.path(), &["init", "--quiet"]); + std::os::unix::fs::symlink("missing.rs", root.path().join("claimed.rs")) + .expect("the dangling source link is created"); + git(root.path(), &["add", "claimed.rs"]); + + let files = V1Readiness::new(root.path().to_path_buf(), false) + .searched_files() + .expect("listing tracked paths does not read their contents"); + assert!( + files.contains(&"claimed.rs".to_owned()), + "a present tracked path must reach the later read boundary: {files:?}" + ); + } + + fn git(root: &Path, arguments: &[&str]) { + let output = Command::new("git") + .args(arguments) + .current_dir(root) + .output() + .expect("Git is available for the readiness regression"); + assert!( + output.status.success(), + "git {} failed: {}", + arguments.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + } + /// Every forbidden phrase is written in lower case, because the search /// lower-cases what it reads: a capital in the list would never match. #[test]