diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6df0e022e8..44da1b5063 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -482,7 +482,8 @@ jobs: run: >- uv run pytest -m web_browser dimos/e2e_tests/test_cockpit_browser.py dimos/e2e_tests/test_sdk_browser.py - dimos/e2e_tests/test_custom_channel_browser.py --no-cov + dimos/e2e_tests/test_custom_channel_browser.py + dimos/e2e_tests/test_publish_browser.py --no-cov tests: if: | diff --git a/dimos/e2e_tests/test_publish_browser.py b/dimos/e2e_tests/test_publish_browser.py new file mode 100644 index 0000000000..c22a7ab2a8 --- /dev/null +++ b/dimos/e2e_tests/test_publish_browser.py @@ -0,0 +1,107 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Shared-publish browser e2e (the W7 acceptance demo, in CI). + +Starts a cockpit(channels=[...]) bridge with +Channel("human_input", str, dir="tx", encoding="text.json.v1", +publish="shared") - a generated RelayBridgeModule subclass with a typed +human_input Out port and the registry-resolved text.json.v1 decoder - serving +web/examples/chat-input from its own local relay. The page calls +session.publish() and renders the settled outcome: an ack proves the bridge +called Out.publish() (so the DimOS consumer already holds the exact string by +the time the browser promise resolves), and a bridge-rejected value renders +its correlated error code without touching anything else. + +The bridge runs in-process (like the relay_bridge e2e) on an ephemeral port, +so it cannot clash with test_sdk_browser's stack on :7780 in the same CI job. + +Marked web_browser: excluded from the default suite (needs the +`browser-tests` dependency group, Playwright browsers, and built web dists). +Locally: +`uv run --group browser-tests pytest -m web_browser dimos/e2e_tests/test_publish_browser.py`. +""" + +from collections.abc import Iterator + +import pytest + +from dimos.web.cockpit import Channel, cockpit +from dimos.web.relay_bridge.e2e_support import stop_module +from dimos.web.relay_bridge.locate import find_web_dir + +pytest.importorskip("playwright") + +from playwright.sync_api import Page, expect, sync_playwright + +pytestmark = pytest.mark.web_browser + +TEXT = "salut din browser β" + + +@pytest.fixture(scope="module") +def chat_bridge() -> Iterator[tuple[str, list[str]]]: + blueprint = cockpit( + channels=[Channel("human_input", str, dir="tx", encoding="text.json.v1", publish="shared")] + ) + (atom,) = blueprint.blueprints + module = atom.module( + local_port=0, + open_browser=False, + robot_id="chat-input-e2e", + serve_dir=str(find_web_dir() / "examples" / "chat-input"), + **atom.kwargs, + ) + received: list[str] = [] + module.human_input.subscribe(received.append) + try: + module.start() # builds web dists if stale, spawns the relay, registers + relay = module._relay + assert relay is not None and relay.info is not None + yield relay.info.open_url, received + finally: + stop_module(module) + + +@pytest.fixture +def chromium_page() -> Iterator[Page]: + with sync_playwright() as p: + browser = p.chromium.launch() + try: + yield browser.new_page() + finally: + browser.close() + + +def test_publish_acks_and_rejections_render( + chat_bridge: tuple[str, list[str]], chromium_page: Page +) -> None: + url, received = chat_bridge + chromium_page.goto(url) + expect(chromium_page.locator("#status")).to_contain_text("connected", timeout=120_000) + + chromium_page.fill("#text", TEXT) + chromium_page.click("#send") + # The ack proves the whole chain: relay validation, the carrier tx frame, + # the registry-resolved decoder, Out.publish(), and the @control ack. + expect(chromium_page.locator("#log")).to_contain_text("acked human_input", timeout=15_000) + # The bridge acks only after Out.publish() returned, so the DimOS + # consumer already held the exact string when the promise resolved. + assert received == [TEXT] + + # A value the decoder refuses settles as a definite rejection with the + # bridge's correlated code, and the accepted publish above is unaffected. + chromium_page.click("#send-bad") + expect(chromium_page.locator("#log")).to_contain_text("rejected decode_failed", timeout=15_000) + assert received == [TEXT] diff --git a/dimos/web/cockpit.py b/dimos/web/cockpit.py index 8a7c674d56..136294fa57 100644 --- a/dimos/web/cockpit.py +++ b/dimos/web/cockpit.py @@ -46,6 +46,7 @@ RESERVED_CHANNEL_PREFIX, Delivery, Dir, + Publish, parse_manifest, ) @@ -136,6 +137,8 @@ class ChannelRequest: max_hz: float params: Mapping[str, Any] = field(default_factory=dict) delivery: Delivery = field(default="reliable", kw_only=True) + publish: Publish = field(default="none", kw_only=True) + required_scope: str | None = field(default=None, kw_only=True) @dataclass(frozen=True) @@ -149,8 +152,16 @@ class Channel: `encoding` names a codec: a registered @web_encoder (dimos.web.codecs) whose message type must match `message_type`, or the generic "json.v1" - for JSON-shaped types and dataclasses. dir/publish/required_scope beyond - the rx defaults arrive with the publish ticket (W7). + for JSON-shaped types and dataclasses (rx) / JSON scalars, lists and + dicts (tx decode). + + A dir="tx" channel is a generic browser publish input: it must declare + publish="shared" (any authorized viewer may publish; the bridge decodes + with the registered @web_decoder and publishes on a generated Out port). + publish="none" tx streams stay reserved for specialized protocol paths + (the teleop panel); publish="exclusive" arrives with the lease ticket + (W8). `required_scope` names the operator scope a remote relay demands + (the local relay never checks scopes). """ stream: str @@ -178,11 +189,6 @@ def __post_init__(self) -> None: raise TypeError(f"message_type must be a class, got {self.message_type!r}") if self.dir not in ("rx", "tx"): raise ValueError(f"dir must be 'rx' or 'tx', got {self.dir!r}") - if self.dir == "tx": - raise ValueError( - 'dir="tx" channels are not available yet: generic browser-to-robot ' - "publish is enabled by the publish ticket (W7)" - ) if not isinstance(self.encoding, str) or not 1 <= len(self.encoding) <= MAX_MANIFEST_ID_LEN: raise ValueError( f"encoding must be 1..{MAX_MANIFEST_ID_LEN} chars, got {self.encoding!r}" @@ -194,12 +200,33 @@ def __post_init__(self) -> None: raise ValueError( f"publish must be 'none', 'shared', or 'exclusive', got {self.publish!r}" ) - if self.publish != "none": - raise ValueError("rx channels must use publish='none' (generic publish is tx-only, W7)") - if self.required_scope is not None: + if self.publish == "exclusive": + raise ValueError( + "publish='exclusive' channels are enabled by the exclusive publisher " + "lease ticket (W8); use publish='shared' for interleavable input" + ) + if self.dir == "rx" and self.publish != "none": + raise ValueError("rx channels must use publish='none'") + if self.dir == "tx" and self.publish == "none": raise ValueError( - "rx channels cannot set required_scope (reserved for tx publish channels, W7)" + 'dir="tx" channels must declare publish="shared": publish="none" tx ' + "streams are reserved for specialized protocol paths (the teleop panel)" ) + if self.publish != "none" and self.delivery != "reliable": + raise ValueError( + f"publish channels must use delivery='reliable', got {self.delivery!r}" + ) + if self.required_scope is not None: + if self.publish == "none": + raise ValueError("required_scope needs a publish policy (publish='shared')") + if ( + not isinstance(self.required_scope, str) + or not 1 <= len(self.required_scope) <= MAX_MANIFEST_ID_LEN + ): + raise ValueError( + f"required_scope must be 1..{MAX_MANIFEST_ID_LEN} chars, " + f"got {self.required_scope!r}" + ) if self.params is not None and not isinstance(self.params, Mapping): raise ValueError(f"params must be a mapping or None, got {self.params!r}") params = {} if self.params is None else dict(self.params) @@ -408,8 +435,9 @@ def build_manifest_data( requested the stream. Advertisement order: rx built-ins in registry order, then rx customs in - first-declaration order, then tx in tx_registry order - so manifests - without custom channels keep their historical channel order. + first-declaration order, then tx in tx_registry order, then declared + publish tx channels in first-declaration order - so manifests without + custom channels keep their historical channel order. """ tx_registry = {} if tx_registry is None else tx_registry merged: dict[str, ChannelRequest] = {} @@ -420,11 +448,20 @@ def merge(request: ChannelRequest) -> None: if previous is None: merged[request.stream] = request return - same = (previous.dir, previous.encoding, previous.delivery, dict(previous.params)) == ( + same = ( + previous.dir, + previous.encoding, + previous.delivery, + dict(previous.params), + previous.publish, + previous.required_scope, + ) == ( request.dir, request.encoding, request.delivery, dict(request.params), + request.publish, + request.required_scope, ) if not same: raise ValueError( @@ -491,6 +528,14 @@ def emit(node: Panel | _Split) -> Any: f"unknown stream {stream!r}; this robot bridge supports: " f"{', '.join(sorted(set(registry) | declared))}" ) + elif request.publish != "none": + # Generic-publish channels are declaration-driven: cockpit() + # generates their Out port and resolves their decoder, so the + # static tx tables have nothing to cross-check. + if stream not in declared: + raise ValueError( + f"unknown publish stream {stream!r}; declare it in cockpit(channels=[...])" + ) else: if stream not in tx_streams: raise ValueError( @@ -524,6 +569,14 @@ def emit(node: Panel | _Split) -> Any: if request.dir == "rx" and stream not in registry ] ordered += [merged[stream] for stream in tx_registry if stream in merged] + ordered += [ + request + for stream, request in merged.items() + if request.dir == "tx" and stream not in tx_registry + ] + # Fully explicit (normalized) emission, publish/requiredScope included: + # parse_manifest(emitted).model_dump() == emitted must hold (idempotence), + # and plain model_dump() always carries every field. return { "version": MANIFEST_VERSION, "channels": [ @@ -534,6 +587,8 @@ def emit(node: Panel | _Split) -> Any: "delivery": request.delivery, "maxHz": request.max_hz, "params": dict(request.params), + "publish": request.publish, + "requiredScope": request.required_scope, } for request in ordered ], @@ -553,13 +608,14 @@ def cockpit( Walks the tree, merges panel requests with the `channels` declarations, compiles the manifest, validates it eagerly, resolves every rx channel's - codec, and returns a relay bridge blueprint carrying it all; compose - onto a robot with `autoconnect(robot_blueprint, cockpit(...))`. Channels - whose stream is not a built-in bridge port get a generated - RelayBridgeModule subclass with matching typed ports (autoconnected by - name + message type). Streams whose producer never publishes are still - advertised: their panels show "waiting for data" (no runtime stream - probing). + encoder and every publish tx channel's decoder, and returns a relay + bridge blueprint carrying it all; compose onto a robot with + `autoconnect(robot_blueprint, cockpit(...))`. Channels whose stream is + not a built-in bridge port get a generated RelayBridgeModule subclass + with matching typed ports (In for rx, Out for publish tx, autoconnected + by name + message type). Streams whose producer never publishes are + still advertised: their panels show "waiting for data" (no runtime + stream probing). The default preset layout applies only when neither a layout nor channels are given; cockpit(channels=[...]) alone compiles with no @@ -585,7 +641,7 @@ def cockpit( "the cockpit blueprint needs the web extra: `uv sync --extra web --inexact`" ) from e from dimos.core.coordination.blueprints import autoconnect - from dimos.web.codecs import resolve_encoder + from dimos.web.codecs import resolve_decoder, resolve_encoder atom = RelayBridgeModule.blueprint().blueprints[0] port_types = {s.name: s.type for s in atom.streams} @@ -606,6 +662,8 @@ def cockpit( # (frozen) authoring record's nested values. _thaw_params(c.params or {}), delivery=c.delivery, + publish=c.publish, + required_scope=c.required_scope, ) for c in declared.values() ), @@ -617,10 +675,33 @@ def cockpit( specs: list[RuntimeChannelSpec] = [] ports: list[DynamicPortSpec] = [] for wire in data["channels"]: - if wire["dir"] != "rx": - continue ch = wire["ch"] explicit = declared.get(ch) + if wire["dir"] != "rx": + if wire["publish"] == "none": + continue # specialized tx (teleop): no runtime spec, no decoder + assert explicit is not None # build_manifest_data rejected the rest + ports.append(DynamicPortSpec(ch, explicit.message_type, "tx")) + try: + decoder = resolve_decoder(wire["encoding"], explicit.message_type) + except ValueError as e: + raise ValueError(f"channel {ch!r}: {e}") from e + specs.append( + RuntimeChannelSpec( + ch=ch, + message_type=explicit.message_type, + dir="tx", + encoding=wire["encoding"], + delivery=wire["delivery"], + max_hz=float(wire["maxHz"]), + params=dict(wire["params"]), + publish=wire["publish"], + required_scope=wire["requiredScope"], + decoder=decoder.decode, + decoder_takes_context=decoder.takes_context, + ) + ) + continue builtin = builtin_by_ch.get(ch) if builtin is not None: message_type = port_types[ch] diff --git a/dimos/web/codecs.py b/dimos/web/codecs.py index c167ce8236..7b0bdbd46a 100644 --- a/dimos/web/codecs.py +++ b/dimos/web/codecs.py @@ -98,12 +98,20 @@ def __post_init__(self) -> None: @dataclass(frozen=True) class PublishContext: - """Relay-authored provenance handed to tx decoders (publish ticket, W7).""" + """Relay-authored provenance handed to tx decoders. + + `request_id` is the relay's forwarding token (not the viewer's request + id), `relay_ts` the relay receive time, `client_ts` the optional + browser-supplied send time, `gen` the exclusive publisher lease + generation (W8; None for shared channels). + """ robot: str ch: str relay_ts: float + request_id: str principal: str | None = None + client_ts: float | None = None gen: int | None = None @@ -358,6 +366,47 @@ def encode_json_v1(msg: Any) -> bytes: return json.dumps(value, separators=(",", ":"), allow_nan=False).encode() +def decode_json_v1(value: Any) -> Any: + """Generic json.v1 decoder: the parsed JSON value passes through and the + bridge's declared-message-type check enforces the channel's type.""" + return value + + +def resolve_decoder(encoding: str, message_type: type[Any]) -> DecoderDef: + """The decoder a publish channel (encoding, message type) compiles to; + ValueError when the pair is unsupported. Runs in the parent at blueprint + authoring time, so this also finishes the pickle-by-reference check + deferred from decoration (mirrors resolve_encoder).""" + definition = _decoders.get(encoding) + if definition is not None: + if message_type is not definition.message_type: + raise ValueError( + f"encoding {encoding!r} decodes to {definition.message_type.__qualname__}, " + f"not {message_type.__qualname__}" + ) + if not _resolves_by_reference(definition.decode): + raise ValueError( + f"decoder {_describe(definition.decode)} for {encoding!r} cannot be " + "pickled by reference; it must be importable under its own module " + "and qualified name" + ) + return definition + if encoding == "json.v1": + # Narrower than the encoder side on purpose: reconstructing a + # dataclass from untrusted browser JSON needs an explicit decoder. + if message_type not in _JSON_V1_TYPES: + raise ValueError( + f"message type {message_type.__module__}.{message_type.__qualname__} is " + "not supported by generic json.v1 decoding (JSON scalars, lists and " + "dicts only); register an explicit decoder with @web_decoder(...)" + ) + return DecoderDef("json.v1", message_type, decode_json_v1, takes_context=False) + raise ValueError( + f"no decoder registered for encoding {encoding!r}; register one with " + f"@web_decoder({encoding!r})" + ) + + def resolve_encoder(encoding: str, message_type: type[Any]) -> EncoderDef: """The encoder a channel (encoding, message type) compiles to; ValueError when the pair is unsupported. Runs in the parent at blueprint authoring diff --git a/dimos/web/relay_bridge/_wt_session.py b/dimos/web/relay_bridge/_wt_session.py index ddaa8d5d60..0437e07804 100644 --- a/dimos/web/relay_bridge/_wt_session.py +++ b/dimos/web/relay_bridge/_wt_session.py @@ -46,6 +46,7 @@ from dimos.web.relay_bridge.protocol import ( CONTROL_CHANNEL, MAX_CONTROL_PAYLOAD_BYTES, + MAX_PUB_DATA_BYTES, DataFrame, DataFrameStreamError, DataFrameStreamReader, @@ -154,7 +155,11 @@ def __init__(self, *args: object, **kwargs: object) -> None: self.relay_error: Error | None = None self.frames = _FrameQueue(_FRAME_QUEUE_MAX, _FRAME_QUEUE_MAX_BYTES) self.frames_oversized = 0 - self.control_msgs: asyncio.Queue[Msg] = asyncio.Queue(maxsize=_CONTROL_QUEUE_MAX) + # Control messages plus, on the robot leg, forwarded publish frames + # (tx DataFrames from the carrier) - one ordered consumer queue. + self.control_msgs: asyncio.Queue[Msg | DataFrame] = asyncio.Queue( + maxsize=_CONTROL_QUEUE_MAX + ) self.control_dropped = 0 self.control_invalid = 0 # Robot role (set by RelayClient.connect): incoming uni streams are @@ -232,6 +237,11 @@ def frames_dropped(self) -> int: def _control_msg_received(self, msg: Msg) -> None: if isinstance(msg, Welcome): self.welcomed.set() + elif isinstance(msg, Error) and msg.requestId is not None: + # Correlated publish failure: addressed to one request, not the + # session, so it joins the consumer queue - it must neither + # overwrite the handshake error slot nor unblock a hello() waiter. + self._queue_control_msg(msg) elif isinstance(msg, Error): logger.warning(f"relay error: {msg.code}: {msg.message}") self.relay_error = msg @@ -260,15 +270,17 @@ def _control_msg_received(self, msg: Msg) -> None: # the consumer queue; see RelayClient.control_messages(). self._queue_control_msg(msg) - def _queue_control_msg(self, msg: Msg) -> None: + def _queue_control_msg(self, msg: Msg | DataFrame) -> None: """Bounded drop-oldest enqueue that never loses subscription state. A subs snapshot is full state with no resend since the carrier: a new one supersedes any queued one (so at most one is ever queued, not counted as a drop), and overflow eviction skips it - evicting the snapshot under a teleop flood would freeze subscriptions until the - next set mutation. All same-loop and await-free, so getters cannot - observe the drain-and-requeue. + next set mutation. Forwarded publish frames (DataFrames) are ordinary + eviction victims: the relay's publish_timeout settles an evicted one. + All same-loop and await-free, so getters cannot observe the + drain-and-requeue. """ if isinstance(msg, Subs): for queued in self._drain_control_msgs(): @@ -284,8 +296,8 @@ def _queue_control_msg(self, msg: Msg) -> None: self.control_msgs.put_nowait(queued) self.control_msgs.put_nowait(msg) - def _drain_control_msgs(self) -> list[Msg]: - msgs: list[Msg] = [] + def _drain_control_msgs(self) -> list[Msg | DataFrame]: + msgs: list[Msg | DataFrame] = [] while not self.control_msgs.empty(): msgs.append(self.control_msgs.get_nowait()) return msgs @@ -321,6 +333,18 @@ def _stream_data_received(self, stream_id: int, data: bytes, ended: bool) -> Non # robot leg). self._control_frame_received(frame) continue + if self._carrier_stream(stream_id): + # A non-control carrier frame is a forwarded publish (tx + # channel data); it joins the same ordered consumer. The + # relay caps serialized publish data, so an over-cap + # payload means a broken control path. + if len(frame.payload) > MAX_PUB_DATA_BYTES: + self._fail_session( + f"carrier tx payload is {len(frame.payload)} B (over the publish cap)" + ) + return + self._queue_control_msg(frame) + continue limit = _MAX_PAYLOAD_BYTES.get(self._encodings.get(frame.header.ch, "")) if limit is not None and len(frame.payload) > limit: self.frames_oversized += 1 diff --git a/dimos/web/relay_bridge/builtin_codecs.py b/dimos/web/relay_bridge/builtin_codecs.py index be4fd5409e..788b6d3dc6 100644 --- a/dimos/web/relay_bridge/builtin_codecs.py +++ b/dimos/web/relay_bridge/builtin_codecs.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Built-in web encoders (jpeg.v1, pose.json.v1, costmap.zlib.v1). +"""Built-in web codecs (jpeg.v1, pose.json.v1, costmap.zlib.v1, text.json.v1). Registered into dimos.web.codecs at import time; relay_bridge_module imports this module so every bridge process (parent and worker) has the built-ins. @@ -30,7 +30,7 @@ from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.nav_msgs.OccupancyGrid import OccupancyGrid, block_max_reduce from dimos.msgs.sensor_msgs.Image import Image -from dimos.web.codecs import EncodedPayload, web_encoder +from dimos.web.codecs import EncodedPayload, web_decoder, web_encoder # Custom jpeg channels authored without a quality param; the built-in # color_image channel never reaches this (main() merges config.jpeg_quality @@ -55,6 +55,15 @@ def encode_jpeg(msg: Image, params: Mapping[str, Any]) -> EncodedPayload: ) +@web_decoder("text.json.v1") +def decode_text(msg: str) -> str: + # The browser value arrives as parsed JSON, so the annotation cannot be + # trusted at runtime; the explicit check gives a clear nack message. + if not isinstance(msg, str): + raise ValueError(f"text.json.v1 wants a string, got {type(msg).__name__}") + return msg + + @web_encoder("pose.json.v1") def encode_pose(msg: PoseStamped) -> bytes: pose = { diff --git a/dimos/web/relay_bridge/conftest.py b/dimos/web/relay_bridge/conftest.py new file mode 100644 index 0000000000..13979e03d3 --- /dev/null +++ b/dimos/web/relay_bridge/conftest.py @@ -0,0 +1,29 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Fixtures shared by the RelayBridgeModule unit-test files.""" + +import pytest + +from dimos.web.relay_bridge.e2e_support import stop_module +from dimos.web.relay_bridge.module_test_support import make_bridge + + +@pytest.fixture +def bridge(monkeypatch): + module, clients = make_bridge(monkeypatch) + try: + yield module, clients + finally: + stop_module(module) diff --git a/dimos/web/relay_bridge/e2e_support.py b/dimos/web/relay_bridge/e2e_support.py index bd625d9226..317ae21172 100644 --- a/dimos/web/relay_bridge/e2e_support.py +++ b/dimos/web/relay_bridge/e2e_support.py @@ -45,7 +45,7 @@ def stop_module(module: RelayBridgeModule) -> None: loop.run_until_complete(loop.shutdown_default_executor()) -async def next_control(client: RelayClient, timeout: float) -> Msg | None: +async def next_control(client: RelayClient, timeout: float) -> Msg | DataFrame | None: try: return await asyncio.wait_for(client._session.control_msgs.get(), timeout) except asyncio.TimeoutError: diff --git a/dimos/web/relay_bridge/manifest.py b/dimos/web/relay_bridge/manifest.py index a7c5173364..dbe0821260 100644 --- a/dimos/web/relay_bridge/manifest.py +++ b/dimos/web/relay_bridge/manifest.py @@ -28,6 +28,7 @@ """ import json +import re import sys from typing import Any, Literal @@ -37,6 +38,12 @@ # Flow direction seen from the viewer: rx = robot->viewer; tx (teleop, chat) # arrives with later tickets. Dir = Literal["rx", "tx"] +# Generic-publish policy for a tx channel: "none" (default; specialized +# protocol paths like teleop only), "shared" (any authorized viewer may +# publish), "exclusive" (requires the per-robot publisher lease, W8). +# Transport delivery and publish authorization are independent: reliable +# does not imply publishable. +Publish = Literal["none", "shared", "exclusive"] # The only manifest version this build understands. MANIFEST_VERSION = 1 @@ -68,7 +75,10 @@ class ChannelSpec(_ManifestModel): Field names and order are the wire names/order, hence the camelCase. `params` carries encoder settings (e.g. jpeg quality); absent fields - normalize to their defaults. + normalize to their defaults. publish/requiredScope gate generic + publishing (W7); requiredScope accepts explicit null so a normalized + manifest parses idempotently, while a null publish is a shape error + (like dir). """ ch: str @@ -77,6 +87,8 @@ class ChannelSpec(_ManifestModel): delivery: Delivery maxHz: int | float params: dict[str, Any] = Field(default_factory=dict) + publish: Publish = "none" + requiredScope: str | None = None class PanelSpec(_ManifestModel): @@ -116,6 +128,12 @@ def _bounded_id(s: str) -> bool: return 1 <= len(s) <= MAX_MANIFEST_ID_LEN +# "Supported JSON encoding" for generic publish is the family-name rule +# (json.v1, text.json.v1, ...): the manifest layer cannot see the codec +# registries, so real decodability is enforced at authoring time. +_JSON_ENCODING_RE = re.compile(r"(^|\.)json\.v[0-9]+$") + + def _validate_layout_node(node: Any, panel_ids: set[str], seen: set[str]) -> Any: """Depth-first layout validation + rebuild (see validateLayout in manifest.ts). A node's own structure (row/col exclusivity, children, @@ -219,6 +237,24 @@ def parse_manifest(data: Any) -> Manifest: # (and isfinite() would raise OverflowError on huge ints). if not 0 < spec.maxHz <= sys.float_info.max: raise ManifestError("invalid_max_hz", f"maxHz for {spec.ch} must be a positive number") + # Generic-publish rules. The manifest layer accepts "exclusive" (only + # authoring and the bridge reject it until the lease ticket, W8). + if spec.dir == "rx" and spec.publish != "none": + raise ManifestError("invalid_publish", f"rx channel {spec.ch} cannot declare publish") + if spec.publish != "none" and spec.delivery != "reliable": + raise ManifestError("invalid_publish", f"publish channel {spec.ch} must be reliable") + if spec.publish != "none" and not _JSON_ENCODING_RE.search(spec.encoding): + raise ManifestError( + "invalid_publish", f"publish channel {spec.ch} needs a JSON encoding" + ) + if spec.requiredScope is not None and spec.publish == "none": + raise ManifestError( + "invalid_scope", f"channel {spec.ch} scope requires a publish policy" + ) + if spec.requiredScope is not None and not _bounded_id(spec.requiredScope): + raise ManifestError( + "invalid_scope", f"scope for {spec.ch} must be 1..{MAX_MANIFEST_ID_LEN} chars" + ) panel_ids: set[str] = set() for panel in manifest.panels: diff --git a/dimos/web/relay_bridge/module_test_support.py b/dimos/web/relay_bridge/module_test_support.py new file mode 100644 index 0000000000..0c267435fe --- /dev/null +++ b/dimos/web/relay_bridge/module_test_support.py @@ -0,0 +1,263 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Shared fakes and helpers for the RelayBridgeModule unit-test files +(test_relay_bridge_module.py, test_relay_bridge_authoring.py): no network, +no Deno, no LCM. A fake relay client is injected under `connect_with_backoff` +and fake transports under the module's `In` streams, so lazy +subscribe/unsubscribe, the encode path, publishing, and reconnect are all +observable directly. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Callable +import threading +import time +from typing import Any + +import pytest + +from dimos.web.relay_bridge import relay_bridge_module +from dimos.web.relay_bridge.protocol import DataFrame, Msg +from dimos.web.relay_bridge.relay_bridge_module import RelayBridgeModule + + +class FakeWriter: + def __init__(self) -> None: + self.offers: list[tuple[bytes, dict[str, Any] | None]] = [] + # ts kept apart so offer-list equality asserts ignore it (a replay + # carries its source arrival time, a live frame None). + self.tss: list[float | None] = [] + + def offer( + self, payload: bytes, meta: dict[str, Any] | None = None, ts: float | None = None + ) -> None: + self.offers.append((payload, meta)) + self.tss.append(ts) + + +class FakeClient: + """Duck-typed RelayClient: everything the module touches, nothing else.""" + + def __init__(self, hello_error: Exception | None = None) -> None: + self.hello_args: tuple[Any, Any] | None = None + self.hello_error = hello_error + self.control_msgs: asyncio.Queue[Msg | DataFrame] = asyncio.Queue() + self.closed = asyncio.Event() + self.writers: dict[str, FakeWriter] = {} + self.frames: list[tuple[str, bytes, str, dict[str, Any] | None]] = [] + # Robot-opened one-shot @control frames (publish acks/nacks). + self.control_frames: list[Msg] = [] + self.close_count = 0 + + async def hello(self, timeout: float = 5.0, *, robot: Any = None, manifest: Any = None) -> None: + self.hello_args = (robot, manifest) + if self.hello_error is not None: + raise self.hello_error + + def latest_writer(self, ch: str, *, stale_after: float = 0.5) -> FakeWriter: + writer = FakeWriter() + self.writers[ch] = writer + return writer + + def send_frame( + self, + ch: str, + payload: bytes, + *, + delivery: str = "reliable", + meta: dict[str, Any] | None = None, + ts: float | None = None, + ) -> int: + self.frames.append((ch, bytes(payload), delivery, meta)) + return 1 + + def send_control_frame(self, msg: Msg) -> int: + self.control_frames.append(msg) + return 1 + + async def control_messages(self) -> AsyncIterator[Msg | DataFrame]: + while True: + get = asyncio.ensure_future(self.control_msgs.get()) + closed = asyncio.ensure_future(self.closed.wait()) + try: + done, _ = await asyncio.wait({get, closed}, return_when=asyncio.FIRST_COMPLETED) + finally: + closed.cancel() + if not get.done(): + get.cancel() + if get in done: + yield get.result() + continue + return + + async def close(self) -> None: + self.close_count += 1 + self.closed.set() + + +class FakeTransport: + """In-stream transport stub: counts subscribers, publishes synchronously + (the test thread plays the LCM callback thread).""" + + def __init__(self) -> None: + self.subscribers: list[Callable[[Any], Any]] = [] + self.unsubscribed = 0 + self.unsubscribe_attempts = 0 + self.unsubscribe_error: Exception | None = None + + def subscribe(self, cb: Callable[[Any], Any], stream: Any = None) -> Callable[[], None]: + self.subscribers.append(cb) + + def unsubscribe() -> None: + self.unsubscribe_attempts += 1 + if self.unsubscribe_error is not None: + raise self.unsubscribe_error + self.subscribers.remove(cb) + self.unsubscribed += 1 + + return unsubscribe + + def publish(self, msg: Any) -> None: + for cb in list(self.subscribers): + cb(msg) + + def stop(self) -> None: # called by In.stop() during module close + self.subscribers.clear() + + +class FakeRelay: + """RelayProcess stand-in for the respawn/teardown paths.""" + + def __init__(self, running: bool) -> None: + self.running = running + self.stops = 0 + + def is_running(self) -> bool: + return self.running + + def poll(self) -> int | None: + return None # what a FAILED start reads: no process at all + + def stop(self) -> None: + self.stops += 1 + self.running = False + + +def wait_until(cond: Callable[[], bool], timeout: float = 5.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if cond(): + return True + time.sleep(0.01) + return cond() + + +def flush_loop(module: RelayBridgeModule) -> None: + """Wait until all callbacks already queued on the module loop have run.""" + loop = module._loop + assert loop is not None + flushed = threading.Event() + loop.call_soon_threadsafe(flushed.set) + assert flushed.wait(timeout=5.0) + + +def make_bridge( + monkeypatch: pytest.MonkeyPatch, + *, + wire: tuple[str, ...] = ("color_image", "odom"), + available_channels: tuple[str, ...] | None = None, + manifest: dict[str, Any] | None = None, + hello_errors: tuple[Exception | None, ...] = (), + relay: FakeRelay | None = None, +) -> tuple[RelayBridgeModule, list[FakeClient]]: + clients: list[FakeClient] = [] + + async def fake_connect(url: str, role: str, **kwargs: Any) -> FakeClient: + error = hello_errors[len(clients)] if len(clients) < len(hello_errors) else None + clients.append(FakeClient(hello_error=error)) + return clients[-1] + + monkeypatch.setattr(relay_bridge_module, "connect_with_backoff", fake_connect) + module = RelayBridgeModule( + relay_url="https://127.0.0.1:1", + open_browser=False, + robot_id="unit-bot", + available_channels=available_channels, + manifest=manifest, + ) + module._relay = relay # type: ignore[assignment] # duck-typed RelayProcess stand-in + for ch in wire: + getattr(module, ch).transport = FakeTransport() + module.start() + return module, clients + + +def push(module: RelayBridgeModule, client: FakeClient, msg: Msg | DataFrame) -> None: + """Deliver a relay push onto the module's own event loop (queue affinity).""" + assert module._loop is not None + module._loop.call_soon_threadsafe(client.control_msgs.put_nowait, msg) + + +def kill_session(module: RelayBridgeModule, client: FakeClient) -> None: + assert module._loop is not None + module._loop.call_soon_threadsafe(client.closed.set) + + +def image_transport(module: RelayBridgeModule) -> FakeTransport: + transport = module.color_image.transport + assert isinstance(transport, FakeTransport) + return transport + + +def odom_transport(module: RelayBridgeModule) -> FakeTransport: + transport = module.odom.transport + assert isinstance(transport, FakeTransport) + return transport + + +def costmap_transport(module: RelayBridgeModule) -> FakeTransport: + transport = module.global_costmap.transport + assert isinstance(transport, FakeTransport) + return transport + + +def start_authored( + monkeypatch: pytest.MonkeyPatch, blueprint: Any, wire: tuple[str, ...] +) -> tuple[RelayBridgeModule, list[FakeClient]]: + """Start a cockpit()-compiled bridge atom (possibly a generated class) + against the fake relay client, mirroring make_bridge.""" + (atom,) = blueprint.blueprints + clients: list[FakeClient] = [] + + async def fake_connect(url: str, role: str, **kwargs: Any) -> FakeClient: + clients.append(FakeClient()) + return clients[-1] + + monkeypatch.setattr(relay_bridge_module, "connect_with_backoff", fake_connect) + module = atom.module( + relay_url="https://127.0.0.1:1", open_browser=False, robot_id="unit-bot", **atom.kwargs + ) + for ch in wire: + getattr(module, ch).transport = FakeTransport() + module.start() + return module, clients + + +def transport_of(module: RelayBridgeModule, ch: str) -> FakeTransport: + transport = getattr(module, ch).transport + assert isinstance(transport, FakeTransport) + return transport diff --git a/dimos/web/relay_bridge/protocol.py b/dimos/web/relay_bridge/protocol.py index 6e73fb6c26..d57f05db0d 100644 --- a/dimos/web/relay_bridge/protocol.py +++ b/dimos/web/relay_bridge/protocol.py @@ -50,6 +50,7 @@ ValidationError, ValidationInfo, field_validator, + model_serializer, ) from dimos.utils.logging_config import setup_logger @@ -64,6 +65,7 @@ Delivery as Delivery, Dir as Dir, PanelSpec as PanelSpec, + Publish as Publish, ) logger = setup_logger() @@ -71,7 +73,10 @@ # v5: the robot hello leaves datagrams (and their ~1100 B budget) and rides # an @control data frame on a robot-opened one-shot bidi stream; channel ids # beginning with "@" are reserved for protocol control; a robot datagram -# hello is rejected. v4: the twist datagram gains vy (strafe) and the teleop +# hello is rejected. Generic publish (amended into v5 pre-release): +# pub/pub_ack/pub_nack and the error requestId correlation; an older v5 peer +# drops the unknown messages, so a publish times out instead of misparsing. +# v4: the twist datagram gains vy (strafe) and the teleop # lease messages (teleop_start/teleop_started/teleop_stop) enter the control # plane; robot-bound twist/stop/teleop_start/teleop_stop carry the # relay-stamped lease generation `gen` (amended into v4 pre-release: an @@ -94,6 +99,15 @@ # beyond it. MAX_CONTROL_PAYLOAD_BYTES = 64 * 1024 +# Cap for a pub message's serialized `data` JSON. The SDK checks it before +# sending; the relay enforces it independently (callers can bypass the SDK). +MAX_PUB_DATA_BYTES = 32 * 1024 + +# Bound for pub request ids (and the error requestId correlating a failure +# to its publish). Ids are opaque: the SDK sends random-prefix + counter, +# the relay forwards its own per-robot token robot-ward. +MAX_REQUEST_ID_LEN = 64 + # Reject absurd header lengths before allocating (mirrors protocol.ts). MAX_HEADER_LEN = 65536 @@ -144,6 +158,23 @@ class RobotInfo(_WireModel): _WIRE_CTX: dict[str, Any] = {} +def _optional_reject_wire_null(value: Any, info: ValidationInfo) -> Any: + if value is None and info.context is _WIRE_CTX: + raise ValueError("explicit null (absent optional fields are omitted)") + return value + + +# Optional wire scalars (teleop gen, pub clientTs, error requestId): absent is +# fine, never null on the wire (mirrors the absent-or-typed validators in +# protocol.ts). +_WireOptNumber = Annotated[int | float | None, BeforeValidator(_optional_reject_wire_null)] +_WireOptRequestId = Annotated[ + str | None, + BeforeValidator(_optional_reject_wire_null), + Field(min_length=1, max_length=MAX_REQUEST_ID_LEN), +] + + class Hello(_WireModel): t: Literal["hello"] = "hello" v: int | float @@ -181,6 +212,9 @@ class Error(_WireModel): t: Literal["error"] = "error" code: str message: str + # Correlates a publish failure to its request (the viewer's own pub id); + # absent on session-level errors. + requestId: _WireOptRequestId = None # Session messages (T2): robot registration, viewer watch + per-channel @@ -246,17 +280,8 @@ class Subs(_WireModel): # robot after a stop. Viewer-authored messages never carry gen. -def _gen_reject_wire_null(value: Any, info: ValidationInfo) -> Any: - if value is None and info.context is _WIRE_CTX: - raise ValueError("explicit null (absent optional fields are omitted)") - return value - - -# The relay-stamped lease generation: optional (viewer-authored messages omit -# it), never null on the wire (mirrors genAbsentOrNumber in protocol.ts). -_WireGen = Annotated[int | float | None, BeforeValidator(_gen_reject_wire_null)] - - +# `gen` is the relay-stamped lease generation: optional because +# viewer-authored messages omit it. class Twist(_WireModel): t: Literal["twist"] = "twist" vx: int | float @@ -264,19 +289,19 @@ class Twist(_WireModel): wz: int | float seq: int | float ts: int | float - gen: _WireGen = None + gen: _WireOptNumber = None class Stop(_WireModel): t: Literal["stop"] = "stop" seq: int | float ts: int | float - gen: _WireGen = None + gen: _WireOptNumber = None class TeleopStart(_WireModel): t: Literal["teleop_start"] = "teleop_start" - gen: _WireGen = None + gen: _WireOptNumber = None class TeleopStarted(_WireModel): @@ -285,7 +310,51 @@ class TeleopStarted(_WireModel): class TeleopStop(_WireModel): t: Literal["teleop_stop"] = "teleop_stop" - gen: _WireGen = None + gen: _WireOptNumber = None + + +# Generic publish (W7), for tx channels declared publish="shared". A viewer's +# pub rides its control stream; the relay validates it, then forwards the +# JSON `data` as a tx-channel data frame on the robot control carrier with +# provenance in the frame meta (id there is a relay-authored token, never the +# viewer's request id). The bridge acknowledges on a robot-opened one-shot +# @control stream -- pub_ack after Out.publish() returned, pub_nack for a +# decode/publish failure -- and the relay routes pub_ack (or a correlated +# error carrying requestId) to the originating viewer. +class Pub(_WireModel): + t: Literal["pub"] = "pub" + id: str = Field(min_length=1, max_length=MAX_REQUEST_ID_LEN) + ch: str + # Required, spanning all of JSON -- null included (mirrors JsonValue). + data: Any + clientTs: _WireOptNumber = None + + @model_serializer(mode="plain") + def _serialize(self) -> dict[str, Any]: + # Hand-rolled so the encoders' exclude_none cannot eat a null data + # value; clientTs keeps the absent-optional convention. + out: dict[str, Any] = {"t": self.t, "id": self.id, "ch": self.ch, "data": self.data} + if self.clientTs is not None: + out["clientTs"] = self.clientTs + return out + + +class PubAck(_WireModel): + t: Literal["pub_ack"] = "pub_ack" + # Robot leg: the relay token; viewer leg: the viewer's pub id. + id: str = Field(min_length=1, max_length=MAX_REQUEST_ID_LEN) + ch: str + relayTs: int | float + bridgeTs: int | float + + +class PubNack(_WireModel): + """Robot->relay only; the relay maps it to a correlated viewer error.""" + + t: Literal["pub_nack"] = "pub_nack" + id: str = Field(min_length=1, max_length=MAX_REQUEST_ID_LEN) + code: str + message: str Msg = ( @@ -305,6 +374,9 @@ class TeleopStop(_WireModel): | TeleopStart | TeleopStarted | TeleopStop + | Pub + | PubAck + | PubNack ) # One pydantic-core pass takes raw peer bytes to a validated message: UTF-8 diff --git a/dimos/web/relay_bridge/relay_bridge_module.py b/dimos/web/relay_bridge/relay_bridge_module.py index 1b38a4ba70..a605a51674 100644 --- a/dimos/web/relay_bridge/relay_bridge_module.py +++ b/dimos/web/relay_bridge/relay_bridge_module.py @@ -39,6 +39,7 @@ from collections.abc import AsyncIterator, Callable, Collection from dataclasses import dataclass, field, replace import functools +import json import math from pathlib import Path import socket @@ -72,7 +73,7 @@ Video, build_manifest_data, ) -from dimos.web.codecs import EncodedPayload, encoder_definition +from dimos.web.codecs import EncodedPayload, PublishContext, encoder_definition # Imported for its registration side effect: the built-in encoders must be in # the codec registry wherever this module runs (parent and worker). @@ -80,8 +81,13 @@ from dimos.web.relay_bridge.locate import find_web_dir from dimos.web.relay_bridge.manifest import Dir, parse_manifest from dimos.web.relay_bridge.protocol import ( + MAX_REQUEST_ID_LEN, ChannelSpec, + DataFrame, Delivery, + Msg, + PubAck, + PubNack, RobotInfo, RobotManifest, Stop as WireStop, @@ -197,11 +203,11 @@ async def _cancel_task(task: asyncio.Task[None] | None, name: str) -> None: class RuntimeChannelSpec: """One channel's immutable runtime contract. - Compiled by cockpit() in the parent (encoder resolved from the codec - registry, ready to pickle by reference into the worker) or resolved from - the manifest against BUILTIN_CHANNELS at module start. The bridge's rx - behavior is driven entirely by these specs; publish/required_scope are - carried for the tx publish ticket (W7). + Compiled by cockpit() in the parent (codecs resolved from the registry, + ready to pickle by reference into the worker) or resolved from the + manifest against BUILTIN_CHANNELS at module start. The bridge's rx + encode behavior and its tx publish decode behavior are driven entirely + by these specs. """ ch: str @@ -216,6 +222,10 @@ class RuntimeChannelSpec: # bytes | EncodedPayload | None (None skips the sample); None for tx. encoder: Callable[..., Any] | None = None encoder_takes_params: bool = False + # browser JSON value (+ optional PublishContext) -> message; publish tx + # channels only, None otherwise. + decoder: Callable[..., Any] | None = None + decoder_takes_context: bool = False # Keep an always-on raw-input cache (decode only, no encode) and replay # the newest message when the channel goes from zero viewers to some # viewer: a new session must not wait for the next publish (the producer @@ -278,6 +288,74 @@ def _passes_rate_gate( return True +def _matches_message_type(value: Any, message_type: type[Any]) -> bool: + """Decoded-result check with JSON's number/bool subtleties: bool is exact + (Python bool subclasses int), int excludes bool, float accepts int (JSON + has one number type) but not bool.""" + if message_type is bool: + return isinstance(value, bool) + if message_type is int: + return isinstance(value, int) and not isinstance(value, bool) + if message_type is float: + return isinstance(value, (int, float)) and not isinstance(value, bool) + return isinstance(value, message_type) + + +# Publish values nesting deeper than this are rejected before json.loads; the +# SDK enforces the same cap, so both ends agree on what "too deep" means. +_MAX_PUB_DEPTH = 100 + + +def _pub_depth_ok(payload: bytes) -> bool: + """True when the JSON payload's bracket nesting stays within + _MAX_PUB_DEPTH (string contents are skipped, so braces in text never + count). Keeps deep-but-valid JSON from reaching json.loads, whose own + depth bound is a RecursionError near the interpreter limit.""" + depth = 0 + in_string = False + escaped = False + for byte in payload: + if in_string: + if escaped: + escaped = False + elif byte == 0x5C: # backslash + escaped = True + elif byte == 0x22: # quote + in_string = False + elif byte == 0x22: # quote + in_string = True + elif byte in (0x5B, 0x7B): # [ { + depth += 1 + if depth > _MAX_PUB_DEPTH: + return False + elif byte in (0x5D, 0x7D): # ] } + depth -= 1 + return True + + +def _parse_pub_meta(meta: dict[str, Any] | None) -> tuple[str, str, float, float | None] | None: + """(request id, principal, relayTs, clientTs) from a publish frame's + relay-stamped meta; None when the shape is unusable (a compliant relay + never produces one).""" + if not isinstance(meta, dict): + return None + request_id = meta.get("id") + principal = meta.get("principal") + relay_ts = meta.get("relayTs") + client_ts = meta.get("clientTs") + if not isinstance(request_id, str) or not 1 <= len(request_id) <= MAX_REQUEST_ID_LEN: + return None + if not isinstance(principal, str): + return None + if isinstance(relay_ts, bool) or not isinstance(relay_ts, (int, float)): + return None + if client_ts is not None and ( + isinstance(client_ts, bool) or not isinstance(client_ts, (int, float)) + ): + return None + return request_id, principal, float(relay_ts), None if client_ts is None else float(client_ts) + + @dataclass(slots=True) class _Session: client: RelayClient @@ -418,6 +496,9 @@ def __init__(self, **kwargs: Any) -> None: self._robot_info: RobotInfo | None = None self._manifest: RobotManifest | None = None self._channel_specs: tuple[RuntimeChannelSpec, ...] = () + # Generic-publish tx specs by channel id (decoder resolved); the rx + # machinery (_reconcile, rate gates, encode counters) never sees them. + self._pub_specs: dict[str, RuntimeChannelSpec] = {} self._min_interval: dict[str, float] = {} self._last_input: dict[str, float] = {} # Last "encoder failed" log time per channel: a broken encoder on a @@ -445,6 +526,9 @@ def __init__(self, **kwargs: Any) -> None: # Live-path encode counters, keyed by the advertised rx channels at # start (main() fills it once the manifest resolves). self.encoded: dict[str, int] = {} + # Publish frames dropped for an unusable meta shape (no correlatable + # request id to nack with); a compliant relay never produces one. + self._pub_invalid = 0 async def main(self) -> AsyncIterator[None]: supervisor: asyncio.Task[None] | None = None @@ -479,14 +563,17 @@ async def main(self) -> AsyncIterator[None]: manifest = parse_manifest(manifest_data) rx_wire = [spec for spec in manifest.channels if spec.dir == "rx"] if self.config.channels is not None: - self._channel_specs = self._adopt_authored_specs(rx_wire) + self._channel_specs, self._pub_specs = self._adopt_authored_specs(manifest.channels) else: self._channel_specs = self._resolve_builtin_specs(rx_wire) self._min_interval = {s.ch: 1.0 / s.max_hz for s in self._channel_specs} self.encoded = {s.ch: 0 for s in self._channel_specs} by_tx = {ch: (encoding, delivery) for ch, encoding, delivery in TX_CHANNELS} for spec in manifest.channels: - if spec.dir != "tx": + if spec.dir != "tx" or spec.publish != "none": + # Publish tx channels are declaration-driven (adopted + # above); the static tx table covers only the + # specialized protocol paths. continue if by_tx.get(spec.ch) != (spec.encoding, spec.delivery): raise RuntimeError( @@ -601,52 +688,93 @@ def _close_module(self) -> None: cancel.set() super()._close_module() - def _adopt_authored_specs(self, rx_wire: list[ChannelSpec]) -> tuple[RuntimeChannelSpec, ...]: + def _adopt_authored_specs( + self, wire_channels: list[ChannelSpec] + ) -> tuple[tuple[RuntimeChannelSpec, ...], dict[str, RuntimeChannelSpec]]: """cockpit() compiled config.channels together with the manifest; cross-check the pair and finish config-dependent params. Every advertised field that drives runtime behavior must agree, so a stale or independently overridden half fails start instead of gating - and encoding differently from what viewers were told. The encoder - callable and the internal resend flag are runtime-only and not - advertised, so they have no wire side to compare against. + and encoding differently from what viewers were told. The codec + callables and the internal resend flag are runtime-only and not + advertised, so they have no wire side to compare against. Returns + (rx specs, publish tx specs by channel); publish="none" tx channels + (teleop) stay with the static tx table. """ assert self.config.channels is not None by_ch = {s.ch: s for s in self.config.channels} - if {w.ch for w in rx_wire} != set(by_ch): + covered = [w for w in wire_channels if w.dir == "rx" or w.publish != "none"] + if {w.ch for w in covered} != set(by_ch): raise RuntimeError( - f"manifest rx channels {sorted(w.ch for w in rx_wire)} do not match the " - f"compiled runtime specs {sorted(by_ch)}; author both through cockpit()" + f"manifest rx/publish channels {sorted(w.ch for w in covered)} do not match " + f"the compiled runtime specs {sorted(by_ch)}; author both through cockpit()" ) - specs = [] - for wire in rx_wire: + rx_specs = [] + pub_specs: dict[str, RuntimeChannelSpec] = {} + for wire in covered: spec = by_ch[wire.ch] - advertised = (wire.dir, wire.encoding, wire.delivery, float(wire.maxHz), wire.params) - compiled = (spec.dir, spec.encoding, spec.delivery, spec.max_hz, spec.params) + advertised = ( + wire.dir, + wire.encoding, + wire.delivery, + float(wire.maxHz), + wire.params, + wire.publish, + wire.requiredScope, + ) + compiled = ( + spec.dir, + spec.encoding, + spec.delivery, + spec.max_hz, + spec.params, + spec.publish, + spec.required_scope, + ) if advertised != compiled: raise RuntimeError( f"manifest channel {wire.ch!r} does not match its compiled runtime " "spec; author both through cockpit()" ) - if spec.publish != "none" or spec.required_scope is not None: + if spec.publish == "exclusive": + raise RuntimeError( + f"runtime spec {wire.ch!r} sets publish='exclusive'; exclusive " + "publishers arrive with the lease ticket (W8)" + ) + if spec.dir == "rx": + if wire.ch not in self.inputs: + raise RuntimeError( + f"runtime spec {wire.ch!r} has no matching input port " + f"on {type(self).__name__}" + ) + port_type = self.inputs[wire.ch].type + if port_type is not spec.message_type: + raise RuntimeError( + f"runtime spec {wire.ch!r} message type " + f"{spec.message_type.__qualname__} does not match the " + f"{type(self).__name__} port type {port_type.__qualname__}" + ) + rx_specs.append(self._finalize_spec(spec)) + continue + if spec.decoder is None: raise RuntimeError( - f"runtime spec {wire.ch!r} sets publish={spec.publish!r}/" - f"required_scope={spec.required_scope!r}; generic publish arrives " - "with the publish ticket (W7)" + f"publish spec {wire.ch!r} has no resolved decoder; author " + "both through cockpit()" ) - if wire.ch not in self.inputs: + if wire.ch not in self.outputs: raise RuntimeError( - f"runtime spec {wire.ch!r} has no matching input port on {type(self).__name__}" + f"runtime spec {wire.ch!r} has no matching output port on {type(self).__name__}" ) - port_type = self.inputs[wire.ch].type - if port_type is not spec.message_type: + out_type = self.outputs[wire.ch].type + if out_type is not spec.message_type: raise RuntimeError( f"runtime spec {wire.ch!r} message type " f"{spec.message_type.__qualname__} does not match the " - f"{type(self).__name__} port type {port_type.__qualname__}" + f"{type(self).__name__} port type {out_type.__qualname__}" ) - specs.append(self._finalize_spec(spec)) - return tuple(specs) + pub_specs[wire.ch] = spec + return tuple(rx_specs), pub_specs def _resolve_builtin_specs(self, rx_wire: list[ChannelSpec]) -> tuple[RuntimeChannelSpec, ...]: """Runtime specs for a hand-written or auto (default_manifest) @@ -787,6 +915,10 @@ async def _supervise(self, session: _Session) -> None: if isinstance(msg, Subs) and msg.n > session.last_n: session.last_n = msg.n self._reconcile(session, set(msg.chs)) + elif isinstance(msg, DataFrame): + # A forwarded viewer publish (tx channel data on + # the carrier); never raises out of the loop. + self._on_pub_frame(session, msg) elif isinstance(msg, WireTwist): self._on_wire_twist(msg) elif isinstance(msg, WireStop): @@ -817,6 +949,83 @@ async def _supervise(self, session: _Session) -> None: await _cancel_task(watchdog, "watchdog") await self._disconnect(session) + def _on_pub_frame(self, session: _Session, frame: DataFrame) -> None: + """One forwarded viewer publish from the carrier: decode the JSON + value, publish on the channel's Out port, then acknowledge on a + robot-opened one-shot @control stream - pub_ack only after + Out.publish() returned, pub_nack for decode/publish failures (bounded + messages, no tracebacks). Failures never raise (an exception would + recycle the whole relay session) and never touch other channels. + """ + ch = frame.header.ch + parsed = _parse_pub_meta(frame.header.meta) + if parsed is None: + # No trustworthy request id to nack with; the relay's publish + # timeout settles the viewer side. + self._pub_invalid += 1 + logger.warning(f"dropping publish frame on {ch!r}: unusable meta") + return + request_id, principal, relay_ts, client_ts = parsed + + def nack(code: str, error: Exception | str) -> None: + message = error if isinstance(error, str) else f"{type(error).__name__}: {error}" + self._send_pub_result(session, PubNack(id=request_id, code=code, message=message[:200])) + + spec = self._pub_specs.get(ch) + if spec is None: + nack("unknown_channel", f"no publishable channel {ch!r}") + return + if not _pub_depth_ok(frame.payload): + nack("decode_failed", f"value nests deeper than {_MAX_PUB_DEPTH} levels") + return + try: + value = json.loads(frame.payload) + except (ValueError, RecursionError) as e: + # RecursionError as backstop: escaping here would recycle the + # whole relay session over one request's payload. + nack("decode_failed", e) + return + assert self._robot_info is not None # set before the supervisor starts + assert spec.decoder is not None # _adopt_authored_specs required it + context = PublishContext( + robot=self._robot_info.id, + ch=ch, + relay_ts=relay_ts, + request_id=request_id, + principal=principal, + client_ts=client_ts, + ) + try: + if spec.decoder_takes_context: + result = spec.decoder(value, context) + else: + result = spec.decoder(value) + except Exception as e: + nack("decode_failed", e) + return + if not _matches_message_type(result, spec.message_type): + nack( + "decode_failed", + f"decoder returned {type(result).__name__}, not {spec.message_type.__name__}", + ) + return + try: + self.outputs[ch].publish(result) + except Exception as e: + nack("publish_failed", e) + return + self._send_pub_result( + session, PubAck(id=request_id, ch=ch, relayTs=relay_ts, bridgeTs=time.time()) + ) + + def _send_pub_result(self, session: _Session, msg: Msg) -> None: + try: + session.client.send_control_frame(msg) + except Exception as e: + # A session death racing the result is routine; the relay's + # publish timeout covers the viewer side. + logger.warning(f"relay bridge: sending a publish result failed: {e}") + async def _watch_child(self) -> None: """Close the session promptly when the local relay child dies (a kill sends no CONNECTION_CLOSE; waiting for QUIC idle timeout is too slow). diff --git a/dimos/web/relay_bridge/test_protocol.py b/dimos/web/relay_bridge/test_protocol.py index 86499fcdce..eb109a520f 100644 --- a/dimos/web/relay_bridge/test_protocol.py +++ b/dimos/web/relay_bridge/test_protocol.py @@ -26,16 +26,20 @@ MAX_CONTROL_PAYLOAD_BYTES, MAX_DATA_FRAME_BYTES, MAX_HEADER_LEN, + MAX_PUB_DATA_BYTES, + MAX_REQUEST_ID_LEN, PROTOCOL_VERSION, RESERVED_CHANNEL_PREFIX, ControlFrameReader, DataFrameStreamError, DataFrameStreamReader, + Error, FrameHeader, Hello, Manifest, Ping, ProtocolError, + Pub, RobotInfo, Robots, TeleopStop, @@ -98,6 +102,25 @@ def test_control_subs_payload_is_the_datagram_encoding(): assert decode_datagram(payload) == msg_from_dict(subs["message"]) +def test_pub_tx_frame_payload_is_the_data_json(): + # A forwarded publish is a tx-channel data frame whose payload is exactly + # the JSON of the pub's data, with relay provenance in the header meta + # (meta.id is the relay token, not the viewer's request id). Also pins + # the pub bounds against the mirror. + assert MAX_PUB_DATA_BYTES == 32 * 1024 + assert MAX_REQUEST_ID_LEN == 64 + frame = next(v for v in DATA if v["name"] == "pub_tx_frame") + pub = next(v for v in DATAGRAMS if v["name"] == "pub") + assert frame["header"]["ch"] == pub["message"]["ch"] + assert frame["header"]["delivery"] == "reliable" + meta = frame["header"]["meta"] + assert set(meta) == {"id", "principal", "relayTs", "clientTs"} + assert meta["id"] != pub["message"]["id"] + payload = base64.b64decode(frame["payload_b64"]) + assert len(payload) <= MAX_PUB_DATA_BYTES + assert json.loads(payload) == pub["message"]["data"] + + @pytest.mark.parametrize("vector", CONTROL, ids=[v["name"] for v in CONTROL]) def test_control_frame_encode_matches_golden(vector): msg = msg_from_dict(vector["message"]) @@ -335,6 +358,52 @@ def test_msg_from_dict_validates_nested_session_shapes(): msg_from_dict(data) +def test_pub_shape_validation(): + ok = {"t": "pub", "id": "a", "ch": "chat", "data": {"x": 1.5}} + assert isinstance(msg_from_dict(ok), Pub) + # data is required and spans all of JSON, null included; only an absent + # data field is invalid. + bad = [ + {"t": "pub", "id": "a", "ch": "chat"}, + {"t": "pub", "id": "", "ch": "chat", "data": 1.5}, + {"t": "pub", "id": "x" * (MAX_REQUEST_ID_LEN + 1), "ch": "chat", "data": 1.5}, + {"t": "pub", "id": 7, "ch": "chat", "data": 1.5}, + {**ok, "clientTs": None}, + {**ok, "clientTs": "1"}, + {**ok, "clientTs": True}, + {"t": "pub_ack", "id": "", "ch": "chat", "relayTs": 1.5, "bridgeTs": 2.5}, + {"t": "pub_nack", "id": "x" * (MAX_REQUEST_ID_LEN + 1), "code": "c", "message": "m"}, + ] + for data in bad: + with pytest.raises(ProtocolError): + msg_from_dict(data) + # A top-level null data value survives encoding (Pub serializes itself so + # the encoders' exclude_none cannot eat it) and the round trip. + raw = encode_datagram(Pub(id="a", ch="chat", data=None)) + assert raw == b'{"t":"pub","id":"a","ch":"chat","data":null}' + decoded = decode_datagram(raw) + assert isinstance(decoded, Pub) and decoded.data is None + # Nested nulls inside data are ordinary JSON and survive the round trip. + nested = {"t": "pub", "id": "a", "ch": "chat", "data": {"x": None, "y": 1.5}} + raw = encode_datagram(msg_from_dict(nested)) + assert b'"x":null' in raw + decoded = decode_datagram(raw) + assert isinstance(decoded, Pub) and decoded.data == {"x": None, "y": 1.5} + + +def test_error_request_id_validation(): + # requestId correlates a publish failure to its request: absent for + # session-level errors, bounded and never null on the wire. + plain = msg_from_dict({"t": "error", "code": "c", "message": "m"}) + assert isinstance(plain, Error) and plain.requestId is None + assert encode_datagram(plain) == b'{"t":"error","code":"c","message":"m"}' + tagged = msg_from_dict({"t": "error", "code": "c", "message": "m", "requestId": "r-1"}) + assert isinstance(tagged, Error) and tagged.requestId == "r-1" + for request_id in [None, "", "x" * (MAX_REQUEST_ID_LEN + 1), 7]: + with pytest.raises(ProtocolError): + msg_from_dict({"t": "error", "code": "c", "message": "m", "requestId": request_id}) + + def test_manifest_dict_roundtrips_verbatim(): # The opaque manifest is carried untouched: exclude_none must not strip # None values inside it (a layout-less manifest legitimately carries diff --git a/dimos/web/relay_bridge/test_relay_bridge_authoring.py b/dimos/web/relay_bridge/test_relay_bridge_authoring.py new file mode 100644 index 0000000000..90d06e79d8 --- /dev/null +++ b/dimos/web/relay_bridge/test_relay_bridge_authoring.py @@ -0,0 +1,585 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""cockpit()-authored RelayBridgeModule tests: blueprint composition, runtime +channel specs with custom codecs, and the generic publish path (W7). Same +no-network harness as test_relay_bridge_module.py (see module_test_support). +""" + +from __future__ import annotations + +from dataclasses import replace +import json +import pickle +import struct +from typing import Any + +import numpy as np +import pytest + +from dimos.core.coordination.blueprints import autoconnect +from dimos.core.module import Module, ModuleConfig +from dimos.core.stream import In, Out +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.geometry_msgs.Twist import Twist +from dimos.msgs.nav_msgs.OccupancyGrid import OccupancyGrid +from dimos.msgs.nav_msgs.Path import Path as NavPath +from dimos.msgs.sensor_msgs.Image import Image +from dimos.web.cockpit import Channel, Video, cockpit +from dimos.web.codecs import EncodedPayload, PublishContext, web_decoder, web_encoder +from dimos.web.relay_bridge import builtin_codecs, relay_bridge_module +from dimos.web.relay_bridge.e2e_support import stop_module +from dimos.web.relay_bridge.module_test_support import ( + FakeClient, + FakeTransport, + flush_loop, + push, + start_authored, + transport_of, + wait_until, +) +from dimos.web.relay_bridge.protocol import ( + DataFrame, + FrameHeader, + PubAck, + PubNack, + Subs, +) +from dimos.web.relay_bridge.relay_bridge_module import ( + RelayBridgeConfig, + RelayBridgeModule, + RuntimeChannelSpec, + with_relay_bridge, +) + + +# Composition helpers live at module level: under PEP 563 (`from __future__ +# import annotations`) a Module class defined inside a function loses its +# streams, because its annotations cannot be resolved from module globals. +class _EmptyConfig(ModuleConfig): + pass + + +class _ImageProducer(Module): + config: _EmptyConfig + color_image: Out[Image] + + +class _CostmapProducer(Module): + config: _EmptyConfig + global_costmap: Out[OccupancyGrid] + + +class _TwistConsumer(Module): + config: _EmptyConfig + tele_cmd_vel: In[Twist] + + +class _BareModule(Module): + config: _EmptyConfig + + +def test_composition_adds_relay_to_non_visual_blueprint() -> None: + blueprint = with_relay_bridge(_ImageProducer.blueprint()) + relay_atoms = [atom for atom in blueprint.blueprints if atom.module is RelayBridgeModule] + + assert len(relay_atoms) == 1 + assert relay_atoms[0].kwargs["available_channels"] == ("color_image",) + + +def test_composition_includes_costmap_producer() -> None: + blueprint = with_relay_bridge( + autoconnect(_ImageProducer.blueprint(), _CostmapProducer.blueprint()) + ) + relay_atom = next(atom for atom in blueprint.blueprints if atom.module is RelayBridgeModule) + + assert relay_atom.kwargs["available_channels"] == ("color_image", "global_costmap") + + +def test_composition_derives_tx_channels_from_consumers() -> None: + # A MovementManager-like consumer of tele_cmd_vel makes the tx channel + # available (Out transports exist regardless of wiring, so consumers are + # the availability signal). + blueprint = with_relay_bridge( + autoconnect(_ImageProducer.blueprint(), _TwistConsumer.blueprint()) + ) + relay_atom = next(atom for atom in blueprint.blueprints if atom.module is RelayBridgeModule) + + assert relay_atom.kwargs["available_channels"] == ("color_image", "tele_cmd_vel") + + +def test_composition_ignores_disabled_producers() -> None: + source = _ImageProducer.blueprint().disabled_modules(_ImageProducer) + blueprint = with_relay_bridge(source) + relay_atom = next(atom for atom in blueprint.blueprints if atom.module is RelayBridgeModule) + + assert relay_atom.kwargs["available_channels"] == () + + +def test_composition_preserves_existing_relay() -> None: + existing = RelayBridgeModule.blueprint(local_port=8899, available_channels=("odom",)) + blueprint = with_relay_bridge(autoconnect(_BareModule.blueprint(), existing)) + relay_atoms = [atom for atom in blueprint.blueprints if atom.module is RelayBridgeModule] + + assert len(relay_atoms) == 1 + assert relay_atoms[0].kwargs == { + "local_port": 8899, + "available_channels": ("odom",), + } + + +@web_encoder("path.rbm.v1") +def _encode_path_points(msg: NavPath) -> EncodedPayload: + payload = b"".join(struct.pack(" bytes: + raise RuntimeError("boom") + + +_NAV_PATH = NavPath( + ts=1.0, + frame_id="world", + poses=[PoseStamped(ts=1.0, position=[1.5, -2.5, 0.0], orientation=[0.0, 0.0, 0.0, 1.0])], +) +_NAV_PATH_PAYLOAD = struct.pack(" None: + spec = RuntimeChannelSpec( + ch="odom", + message_type=PoseStamped, + dir="rx", + encoding="pose.json.v1", + delivery="reliable", + max_hz=20.0, + params={"a": 1}, + encoder=builtin_codecs.encode_pose, + ) + config = RelayBridgeConfig(channels=(spec,)) + # Pydantic must pass the frozen dataclass through untouched; a rebuilt + # copy would break the is-checks blueprints rely on. + assert config.channels is not None and config.channels[0] is spec + restored = pickle.loads(pickle.dumps(config)) + assert restored.channels[0].encoder is builtin_codecs.encode_pose + assert restored.channels[0].params == {"a": 1} + + +def test_authored_specs_drive_generated_port(monkeypatch) -> None: + blueprint = cockpit( + channels=[Channel("nav_path", NavPath, encoding="path.rbm.v1", max_hz=1000.0)] + ) + module, clients = start_authored(monkeypatch, blueprint, wire=("nav_path",)) + try: + nav = transport_of(module, "nav_path") + nav.publish(_NAV_PATH) + flush_loop(module) + assert clients[0].frames == [] # no viewers: no encode, no send + assert module.encoded == {"nav_path": 0} + + push(module, clients[0], Subs(chs=["nav_path"], n=1)) + assert wait_until(lambda: nav.subscribers) + nav.publish(_NAV_PATH) + assert wait_until(lambda: clients[0].frames) + ch, payload, delivery, meta = clients[0].frames[0] + assert (ch, delivery) == ("nav_path", "reliable") + assert payload == _NAV_PATH_PAYLOAD + assert meta == {"n": 1} + assert module.encoded["nav_path"] == 1 + + push(module, clients[0], Subs(chs=[], n=2)) + assert wait_until(lambda: not nav.subscribers) + finally: + stop_module(module) + + +def test_two_jpeg_channels_use_independent_quality(monkeypatch) -> None: + # The second camera the old single-int _jpeg_quality could not express. + blueprint = cockpit( + layout=Video("color_image", quality=33, max_hz=1000.0), + channels=[ + Channel( + "rear_cam", + Image, + encoding="jpeg.v1", + delivery="latest", + max_hz=1000.0, + params={"quality": 90}, + ) + ], + ) + module, clients = start_authored(monkeypatch, blueprint, wire=("color_image", "rear_cam")) + try: + qualities: list[int] = [] + real = Image.to_jpeg_bytes + + def spy(self: Image, quality: int = 75) -> bytes: + qualities.append(quality) + return real(self, quality=quality) + + monkeypatch.setattr(Image, "to_jpeg_bytes", spy) + push(module, clients[0], Subs(chs=["color_image", "rear_cam"], n=1)) + front, rear = transport_of(module, "color_image"), transport_of(module, "rear_cam") + assert wait_until(lambda: front.subscribers and rear.subscribers) + image = Image.from_numpy(np.zeros((8, 12, 3), dtype=np.uint8)) + front.publish(image) + rear.publish(image) + assert wait_until(lambda: sorted(qualities) == [33, 90]) + finally: + stop_module(module) + + +def test_resend_flag_replays_cache_on_generated_port(monkeypatch) -> None: + # The replay mechanism is spec-driven now; prove it on a generated input + # (the flag is internal - cockpit() sets it only for the built-in costmap). + blueprint = cockpit( + channels=[Channel("nav_path", NavPath, encoding="path.rbm.v1", max_hz=1000.0)] + ) + (atom,) = blueprint.blueprints + atom.kwargs["channels"] = tuple( + replace(spec, resend_on_subscribe=True) for spec in atom.kwargs["channels"] + ) + module, clients = start_authored(monkeypatch, blueprint, wire=("nav_path",)) + try: + nav = transport_of(module, "nav_path") + assert len(nav.subscribers) == 1 # the always-on raw cache + nav.publish(_NAV_PATH) # nobody watching; only the cache sees it + flush_loop(module) + assert clients[0].frames == [] + + push(module, clients[0], Subs(chs=["nav_path"], n=1)) + # The cached message replays without a new publish. + assert wait_until(lambda: clients[0].frames) + assert clients[0].frames[0][1] == _NAV_PATH_PAYLOAD + assert module.encoded["nav_path"] == 0 # replays do not count + finally: + stop_module(module) + + +def test_encoder_failure_is_isolated_and_rate_limited(monkeypatch) -> None: + blueprint = cockpit( + channels=[ + Channel("bad_path", NavPath, encoding="boom.rbm.v1", max_hz=1000.0), + Channel("target_pose", PoseStamped, encoding="pose.json.v1", max_hz=1000.0), + ] + ) + module, clients = start_authored(monkeypatch, blueprint, wire=("bad_path", "target_pose")) + try: + exceptions: list[str] = [] + monkeypatch.setattr( + relay_bridge_module.logger, + "exception", + lambda msg, *args, **kwargs: exceptions.append(msg), + ) + push(module, clients[0], Subs(chs=["bad_path", "target_pose"], n=1)) + bad, pose = transport_of(module, "bad_path"), transport_of(module, "target_pose") + assert wait_until(lambda: bad.subscribers and pose.subscribers) + # Drop the input rate gates so every publish reaches the encoder. + module._min_interval = {"bad_path": 0.0, "target_pose": 0.0} + for _ in range(3): + bad.publish(_NAV_PATH) + pose.publish(PoseStamped(ts=2.0, position=[1.0, 2.0, 0.0], orientation=[0, 0, 0, 1])) + assert wait_until(lambda: clients[0].frames) + # The healthy channel flows; the broken one drops every sample. + assert all(frame[0] == "target_pose" for frame in clients[0].frames) + assert module.encoded == {"bad_path": 0, "target_pose": 1} + # Three failures inside the log window produce one exception log. + assert len(exceptions) == 1 + assert "bad_path" in exceptions[0] + finally: + stop_module(module) + + +def test_channels_without_manifest_fails(monkeypatch) -> None: + async def fake_connect(url: str, role: str, **kwargs: Any) -> FakeClient: + raise AssertionError("must not reach the relay without a manifest") + + monkeypatch.setattr(relay_bridge_module, "connect_with_backoff", fake_connect) + spec = RuntimeChannelSpec( + ch="odom", + message_type=PoseStamped, + dir="rx", + encoding="pose.json.v1", + delivery="reliable", + max_hz=20.0, + params={}, + encoder=builtin_codecs.encode_pose, + ) + module = RelayBridgeModule( + relay_url="https://127.0.0.1:1", robot_id="unit-bot", channels=(spec,) + ) + with pytest.raises(RuntimeError, match="require a manifest"): + try: + module.start() + finally: + stop_module(module) + + +def test_spec_manifest_mismatch_fails(monkeypatch) -> None: + async def fake_connect(url: str, role: str, **kwargs: Any) -> FakeClient: + raise AssertionError("must not reach the relay with mismatched specs") + + monkeypatch.setattr(relay_bridge_module, "connect_with_backoff", fake_connect) + manifest = { + "version": 1, + "channels": [ + {"ch": "odom", "encoding": "pose.json.v1", "delivery": "reliable", "maxHz": 5.0} + ], + } + + def start_with(spec: RuntimeChannelSpec, match: str) -> None: + module = RelayBridgeModule( + relay_url="https://127.0.0.1:1", + robot_id="unit-bot", + manifest=manifest, + channels=(spec,), + ) + module.odom.transport = FakeTransport() + with pytest.raises(RuntimeError, match=match): + try: + module.start() + finally: + stop_module(module) + + good = RuntimeChannelSpec( + ch="odom", + message_type=PoseStamped, + dir="rx", + encoding="pose.json.v1", + delivery="reliable", + max_hz=5.0, + params={}, + encoder=builtin_codecs.encode_pose, + ) + start_with(replace(good, ch="target_pose"), "do not match the compiled runtime specs") + # Every advertised field that drives behavior is cross-checked: a spec + # gating at 500 Hz while the manifest promises 5 Hz (or drifted params, + # encoding, delivery, direction) must fail start. + start_with(replace(good, encoding="json.v1"), "does not match its compiled runtime spec") + start_with(replace(good, delivery="latest"), "does not match its compiled runtime spec") + start_with(replace(good, max_hz=500.0), "does not match its compiled runtime spec") + start_with(replace(good, params={"q": 1}), "does not match its compiled runtime spec") + start_with(replace(good, dir="tx"), "does not match its compiled runtime spec") + start_with( + replace(good, message_type=Twist), + "message type Twist does not match the RelayBridgeModule port type PoseStamped", + ) + + +def test_composition_preserves_generated_bridge() -> None: + blueprint = cockpit(channels=[Channel("target_pose", PoseStamped, encoding="pose.json.v1")]) + assert with_relay_bridge(blueprint) is blueprint + + +# Generic publish (W7): forwarded viewer publishes arriving as tx DataFrames +# on the carrier -> decode -> Out.publish -> @control ack. + + +_PUB_CONTEXTS: list[PublishContext] = [] + + +@web_decoder("ctx.probe.json.v1") +def _decode_ctx_probe(value: dict[str, Any], context: PublishContext) -> dict: + _PUB_CONTEXTS.append(context) + return dict(value) + + +@web_decoder("wrongtype.json.v1") +def _decode_wrong_type(value: Any) -> str: + return 42 # type: ignore[return-value] # deliberately violates the annotation + + +@web_decoder("boom.json.v1") +def _decode_boom(value: Any) -> str: + raise ValueError("nope β") + + +def _pub_meta(**over: Any) -> dict[str, Any]: + return {"id": "p1", "principal": "local", "relayTs": 41.5, **over} + + +_DEFAULT_META = object() # sentinel: meta=None must mean a header WITHOUT meta + + +def _pub_frame( + payload: bytes, meta: Any = _DEFAULT_META, ch: str = "human_input", seq: int = 1 +) -> DataFrame: + if meta is _DEFAULT_META: + meta = _pub_meta() + return DataFrame( + header=FrameHeader(ch=ch, seq=seq, ts=41.5, delivery="reliable", meta=meta), + payload=payload, + ) + + +def _start_pub_bridge(monkeypatch, *channels: Channel): + blueprint = cockpit( + channels=[ + Channel("human_input", str, dir="tx", encoding="text.json.v1", publish="shared"), + Channel("target_pose", PoseStamped, encoding="pose.json.v1", max_hz=1000.0), + *channels, + ] + ) + return start_authored(monkeypatch, blueprint, wire=("target_pose",)) + + +def test_publish_frame_decodes_publishes_then_acks(monkeypatch) -> None: + module, clients = _start_pub_bridge(monkeypatch) + try: + seen: list[tuple[str, int]] = [] # (value, acks already sent when it arrived) + module.human_input.subscribe( + lambda value: seen.append((value, len(clients[0].control_frames))) + ) + push(module, clients[0], _pub_frame(json.dumps("salut β").encode())) + assert wait_until(lambda: clients[0].control_frames) + # The consumer received the exact string BEFORE the ack went out. + assert seen == [("salut β", 0)] + (ack,) = clients[0].control_frames + assert isinstance(ack, PubAck) + assert ack.id == "p1" and ack.ch == "human_input" + assert ack.relayTs == 41.5 and ack.bridgeTs > 0 + # The reliable carrier cannot duplicate, so the bridge is dedup-free + # by design: a replayed frame publishes (and acks) again. + push(module, clients[0], _pub_frame(json.dumps("salut β").encode(), seq=2)) + assert wait_until(lambda: len(clients[0].control_frames) == 2) + assert [value for value, _ in seen] == ["salut β", "salut β"] + finally: + stop_module(module) + + +def test_publish_decoder_context_and_no_context_paths(monkeypatch) -> None: + module, clients = _start_pub_bridge( + monkeypatch, + Channel("probe", dict, dir="tx", encoding="ctx.probe.json.v1", publish="shared"), + Channel("counter", int, dir="tx", publish="shared"), # generic json.v1 + ) + try: + _PUB_CONTEXTS.clear() + counts: list[int] = [] + module.counter.subscribe(counts.append) + push( + module, + clients[0], + _pub_frame(b'{"x":1.5}', _pub_meta(id="p7", clientTs=40.25), ch="probe"), + ) + assert wait_until(lambda: clients[0].control_frames) + (context,) = _PUB_CONTEXTS + assert context == PublishContext( + robot="unit-bot", + ch="probe", + relay_ts=41.5, + request_id="p7", + principal="local", + client_ts=40.25, + ) + # Generic json.v1 decode: the identity value passes the declared-type + # check; a JSON bool is not an int (Python bool subclasses int). + push(module, clients[0], _pub_frame(b"7", _pub_meta(id="p8"), ch="counter")) + assert wait_until(lambda: counts == [7]) + push(module, clients[0], _pub_frame(b"true", _pub_meta(id="p9"), ch="counter")) + assert wait_until(lambda: len(clients[0].control_frames) == 3) + nack = clients[0].control_frames[-1] + assert isinstance(nack, PubNack) and nack.id == "p9" + assert nack.code == "decode_failed" and "bool" in nack.message + assert counts == [7] + finally: + stop_module(module) + + +def test_publish_failures_nack_without_recycling_the_session(monkeypatch) -> None: + module, clients = _start_pub_bridge( + monkeypatch, + Channel("boom", str, dir="tx", encoding="boom.json.v1", publish="shared"), + Channel("wrong", str, dir="tx", encoding="wrongtype.json.v1", publish="shared"), + ) + try: + seen: list[str] = [] + module.human_input.subscribe(seen.append) + + def nacked(request_id: str, code: str, needle: str) -> bool: + last = clients[0].control_frames[-1] if clients[0].control_frames else None + return ( + isinstance(last, PubNack) + and last.id == request_id + and last.code == code + and needle in last.message + ) + + push(module, clients[0], _pub_frame(b"{not json", _pub_meta(id="a"))) + assert wait_until(lambda: nacked("a", "decode_failed", "JSONDecodeError")) + # Deep-but-valid JSON is a request-local decode failure, never a + # RecursionError escaping into the supervisor. + deep = b"[" * 10_000 + b"]" * 10_000 + push(module, clients[0], _pub_frame(deep, _pub_meta(id="deep"))) + assert wait_until(lambda: nacked("deep", "decode_failed", "nests deeper than 100")) + push(module, clients[0], _pub_frame(b'"x"', _pub_meta(id="b"), ch="ghost")) + assert wait_until(lambda: nacked("b", "unknown_channel", "ghost")) + push(module, clients[0], _pub_frame(b'"x"', _pub_meta(id="c"), ch="boom")) + assert wait_until(lambda: nacked("c", "decode_failed", "ValueError: nope")) + push(module, clients[0], _pub_frame(b'"x"', _pub_meta(id="d"), ch="wrong")) + assert wait_until(lambda: nacked("d", "decode_failed", "int, not str")) + + def raising_publish(msg: Any) -> None: + raise RuntimeError("transport down β") + + monkeypatch.setattr(module.human_input, "publish", raising_publish) + push(module, clients[0], _pub_frame(b'"x"', _pub_meta(id="e"))) + assert wait_until(lambda: nacked("e", "publish_failed", "RuntimeError")) + monkeypatch.undo() + + # No traceback ever reaches the wire, messages stay bounded, and the + # session was never recycled by any of the failures. + assert all( + len(m.message) <= 200 and "Traceback" not in m.message + for m in clients[0].control_frames + if isinstance(m, PubNack) + ) + assert len(clients) == 1 + + # Channel isolation: the healthy publish channel and the rx path + # still work after every failure above. + push(module, clients[0], _pub_frame(json.dumps("încă merge").encode(), _pub_meta(id="f"))) + assert wait_until(lambda: seen == ["încă merge"]) + push(module, clients[0], Subs(chs=["target_pose"], n=1)) + assert wait_until(lambda: transport_of(module, "target_pose").subscribers) + finally: + stop_module(module) + + +def test_publish_frame_with_unusable_meta_is_dropped(monkeypatch) -> None: + module, clients = _start_pub_bridge(monkeypatch) + try: + seen: list[str] = [] + module.human_input.subscribe(seen.append) + for meta in [ + None, + {"principal": "local", "relayTs": 41.5}, # no id + _pub_meta(id=""), + _pub_meta(id="x" * 65), + _pub_meta(principal=5), + _pub_meta(relayTs="soon"), + _pub_meta(relayTs=True), + _pub_meta(clientTs="now"), + ]: + push(module, clients[0], _pub_frame(b'"x"', meta)) + # A later valid publish proves all of the above were processed + # (ordered queue) and dropped without an ack or a publish. + push(module, clients[0], _pub_frame(json.dumps("ok").encode(), _pub_meta(id="z"))) + assert wait_until(lambda: seen == ["ok"]) + assert [m.id for m in clients[0].control_frames if isinstance(m, PubAck)] == ["z"] + assert module._pub_invalid == 8 + finally: + stop_module(module) diff --git a/dimos/web/relay_bridge/test_relay_bridge_module.py b/dimos/web/relay_bridge/test_relay_bridge_module.py index 0811e1759c..1cf39ec6e4 100644 --- a/dimos/web/relay_bridge/test_relay_bridge_module.py +++ b/dimos/web/relay_bridge/test_relay_bridge_module.py @@ -15,20 +15,19 @@ """RelayBridgeModule unit tests: no network, no Deno, no LCM. A fake relay client is injected under `connect_with_backoff` and fake -transports under the module's `In` streams, so lazy subscribe/unsubscribe, -the maxHz gate, the encode path, and reconnect are all observable directly. +transports under the module's `In` streams (module_test_support.py), so lazy +subscribe/unsubscribe, the maxHz gate, the encode path, and reconnect are all +observable directly. cockpit()-authored channels and the publish path are +covered in test_relay_bridge_authoring.py. """ from __future__ import annotations import asyncio -from collections.abc import AsyncIterator, Callable from dataclasses import replace import json from pathlib import Path -import pickle import socket -import struct import subprocess import sys import threading @@ -40,24 +39,30 @@ from pydantic import ValidationError import pytest -from dimos.core.coordination.blueprints import autoconnect -from dimos.core.module import Module, ModuleConfig -from dimos.core.stream import In, Out from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Twist import Twist from dimos.msgs.geometry_msgs.Vector3 import Vector3 from dimos.msgs.nav_msgs.OccupancyGrid import OccupancyGrid -from dimos.msgs.nav_msgs.Path import Path as NavPath from dimos.msgs.sensor_msgs.Image import Image from dimos.simulation.mujoco.constants import VIDEO_FPS -from dimos.web.cockpit import Channel, Video, cockpit -from dimos.web.codecs import EncodedPayload, web_encoder from dimos.web.relay_bridge import builtin_codecs, relay_bridge_module from dimos.web.relay_bridge.e2e_support import stop_module from dimos.web.relay_bridge.manifest import ManifestError, parse_manifest +from dimos.web.relay_bridge.module_test_support import ( + FakeClient, + FakeRelay, + FakeTransport, + costmap_transport, + flush_loop, + image_transport, + kill_session, + make_bridge, + odom_transport, + push, + wait_until, +) from dimos.web.relay_bridge.protocol import ( - Msg, Stop as WireStop, Subs, TeleopStart as WireTeleopStart, @@ -67,217 +72,12 @@ from dimos.web.relay_bridge.relay_bridge_module import ( RelayBridgeConfig, RelayBridgeModule, - RuntimeChannelSpec, default_manifest, resolve_robot_info, - with_relay_bridge, ) from dimos.web.relay_bridge.wt_client import RelayRejectedError -class FakeWriter: - def __init__(self) -> None: - self.offers: list[tuple[bytes, dict[str, Any] | None]] = [] - # ts kept apart so offer-list equality asserts ignore it (a replay - # carries its source arrival time, a live frame None). - self.tss: list[float | None] = [] - - def offer( - self, payload: bytes, meta: dict[str, Any] | None = None, ts: float | None = None - ) -> None: - self.offers.append((payload, meta)) - self.tss.append(ts) - - -class FakeClient: - """Duck-typed RelayClient: everything the module touches, nothing else.""" - - def __init__(self, hello_error: Exception | None = None) -> None: - self.hello_args: tuple[Any, Any] | None = None - self.hello_error = hello_error - self.control_msgs: asyncio.Queue[Msg] = asyncio.Queue() - self.closed = asyncio.Event() - self.writers: dict[str, FakeWriter] = {} - self.frames: list[tuple[str, bytes, str, dict[str, Any] | None]] = [] - self.close_count = 0 - - async def hello(self, timeout: float = 5.0, *, robot: Any = None, manifest: Any = None) -> None: - self.hello_args = (robot, manifest) - if self.hello_error is not None: - raise self.hello_error - - def latest_writer(self, ch: str, *, stale_after: float = 0.5) -> FakeWriter: - writer = FakeWriter() - self.writers[ch] = writer - return writer - - def send_frame( - self, - ch: str, - payload: bytes, - *, - delivery: str = "reliable", - meta: dict[str, Any] | None = None, - ts: float | None = None, - ) -> int: - self.frames.append((ch, bytes(payload), delivery, meta)) - return 1 - - async def control_messages(self) -> AsyncIterator[Msg]: - while True: - get = asyncio.ensure_future(self.control_msgs.get()) - closed = asyncio.ensure_future(self.closed.wait()) - try: - done, _ = await asyncio.wait({get, closed}, return_when=asyncio.FIRST_COMPLETED) - finally: - closed.cancel() - if not get.done(): - get.cancel() - if get in done: - yield get.result() - continue - return - - async def close(self) -> None: - self.close_count += 1 - self.closed.set() - - -class FakeTransport: - """In-stream transport stub: counts subscribers, publishes synchronously - (the test thread plays the LCM callback thread).""" - - def __init__(self) -> None: - self.subscribers: list[Callable[[Any], Any]] = [] - self.unsubscribed = 0 - self.unsubscribe_attempts = 0 - self.unsubscribe_error: Exception | None = None - - def subscribe(self, cb: Callable[[Any], Any], stream: Any = None) -> Callable[[], None]: - self.subscribers.append(cb) - - def unsubscribe() -> None: - self.unsubscribe_attempts += 1 - if self.unsubscribe_error is not None: - raise self.unsubscribe_error - self.subscribers.remove(cb) - self.unsubscribed += 1 - - return unsubscribe - - def publish(self, msg: Any) -> None: - for cb in list(self.subscribers): - cb(msg) - - def stop(self) -> None: # called by In.stop() during module close - self.subscribers.clear() - - -class FakeRelay: - """RelayProcess stand-in for the respawn/teardown paths.""" - - def __init__(self, running: bool) -> None: - self.running = running - self.stops = 0 - - def is_running(self) -> bool: - return self.running - - def poll(self) -> int | None: - return None # what a FAILED start reads: no process at all - - def stop(self) -> None: - self.stops += 1 - self.running = False - - -def wait_until(cond: Callable[[], bool], timeout: float = 5.0) -> bool: - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if cond(): - return True - time.sleep(0.01) - return cond() - - -def flush_loop(module: RelayBridgeModule) -> None: - """Wait until all callbacks already queued on the module loop have run.""" - loop = module._loop - assert loop is not None - flushed = threading.Event() - loop.call_soon_threadsafe(flushed.set) - assert flushed.wait(timeout=5.0) - - -def _make_bridge( - monkeypatch, - *, - wire: tuple[str, ...] = ("color_image", "odom"), - available_channels: tuple[str, ...] | None = None, - manifest: dict[str, Any] | None = None, - hello_errors: tuple[Exception | None, ...] = (), - relay: FakeRelay | None = None, -) -> tuple[RelayBridgeModule, list[FakeClient]]: - clients: list[FakeClient] = [] - - async def fake_connect(url: str, role: str, **kwargs: Any) -> FakeClient: - error = hello_errors[len(clients)] if len(clients) < len(hello_errors) else None - clients.append(FakeClient(hello_error=error)) - return clients[-1] - - monkeypatch.setattr(relay_bridge_module, "connect_with_backoff", fake_connect) - module = RelayBridgeModule( - relay_url="https://127.0.0.1:1", - open_browser=False, - robot_id="unit-bot", - available_channels=available_channels, - manifest=manifest, - ) - module._relay = relay - for ch in wire: - getattr(module, ch).transport = FakeTransport() - module.start() - return module, clients - - -@pytest.fixture -def bridge(monkeypatch): - module, clients = _make_bridge(monkeypatch) - try: - yield module, clients - finally: - stop_module(module) - - -def push(module: RelayBridgeModule, client: FakeClient, msg: Msg) -> None: - """Deliver a relay push onto the module's own event loop (queue affinity).""" - assert module._loop is not None - module._loop.call_soon_threadsafe(client.control_msgs.put_nowait, msg) - - -def kill_session(module: RelayBridgeModule, client: FakeClient) -> None: - assert module._loop is not None - module._loop.call_soon_threadsafe(client.closed.set) - - -def image_transport(module: RelayBridgeModule) -> FakeTransport: - transport = module.color_image.transport - assert isinstance(transport, FakeTransport) - return transport - - -def odom_transport(module: RelayBridgeModule) -> FakeTransport: - transport = module.odom.transport - assert isinstance(transport, FakeTransport) - return transport - - -def costmap_transport(module: RelayBridgeModule) -> FakeTransport: - transport = module.global_costmap.transport - assert isinstance(transport, FakeTransport) - return transport - - def test_manifest_and_robot_info_content() -> None: config = RelayBridgeConfig(robot_id="go2-lab", robot_name="Lab", image_max_hz=12.0) # A video panel for the camera and a map2d panel binding costmap + pose; @@ -292,6 +92,8 @@ def test_manifest_and_robot_info_content() -> None: "delivery": "latest", "maxHz": 12.0, "params": {"quality": 75}, + "publish": "none", + "requiredScope": None, }, { "ch": "odom", @@ -300,6 +102,8 @@ def test_manifest_and_robot_info_content() -> None: "delivery": "reliable", "maxHz": 20.0, "params": {}, + "publish": "none", + "requiredScope": None, }, { "ch": "global_costmap", @@ -308,6 +112,8 @@ def test_manifest_and_robot_info_content() -> None: "delivery": "latest", "maxHz": 5.0, "params": {}, + "publish": "none", + "requiredScope": None, }, ], "panels": [ @@ -479,7 +285,7 @@ def spy(self: Image, quality: int = 75) -> bytes: @pytest.fixture def costmap_bridge(monkeypatch): - module, clients = _make_bridge(monkeypatch, wire=("color_image", "odom", "global_costmap")) + module, clients = make_bridge(monkeypatch, wire=("color_image", "odom", "global_costmap")) try: yield module, clients finally: @@ -639,7 +445,7 @@ def test_costmap_empty_cached_grid_is_not_replayed(costmap_bridge) -> None: def test_stop_disposes_costmap_cache_subscription(monkeypatch) -> None: - module, _clients = _make_bridge(monkeypatch, wire=("color_image", "odom", "global_costmap")) + module, _clients = make_bridge(monkeypatch, wire=("color_image", "odom", "global_costmap")) transport = costmap_transport(module) assert len(transport.subscribers) == 1 # the always-on raw cache stop_module(module) @@ -657,7 +463,7 @@ def test_bridge_import_does_not_pull_matplotlib() -> None: def test_manifest_omits_pose_binding_when_odom_unwired(monkeypatch) -> None: - module, clients = _make_bridge(monkeypatch, wire=("color_image", "global_costmap")) + module, clients = make_bridge(monkeypatch, wire=("color_image", "global_costmap")) try: _, manifest = clients[0].hello_args assert isinstance(manifest, dict) @@ -698,7 +504,7 @@ def blocking_encode(msg: PoseStamped) -> bytes: assert release_encode.wait(timeout=5.0) return b"old-session-frame" - module, clients = _make_bridge(monkeypatch) + module, clients = make_bridge(monkeypatch) # Swap the resolved odom spec's encoder for the blocking one before any # viewer subscribes (the runtime specs are the encoder source now). module._channel_specs = tuple( @@ -778,7 +584,7 @@ def fake_spawn(open_browser: bool, serve_dir: Path | None) -> str: def test_stop_waits_for_in_flight_respawn_and_stops_spawned_child(monkeypatch) -> None: - module, clients = _make_bridge(monkeypatch) + module, clients = make_bridge(monkeypatch) loop = module._loop assert loop is not None dead_relay = FakeRelay(running=False) @@ -851,7 +657,7 @@ async def crash(module: RelayBridgeModule) -> None: monkeypatch.setattr(RelayBridgeModule, "_watch_child", crash) relay = FakeRelay(running=True) - module, clients = _make_bridge(monkeypatch, relay=relay) + module, clients = make_bridge(monkeypatch, relay=relay) try: assert crashed.wait(timeout=5.0) push(module, clients[0], Subs(chs=["color_image", "odom"], n=1)) @@ -891,7 +697,7 @@ def test_unsubscribe_failure_does_not_skip_other_cleanup_or_leak_into_new_sessio def test_unwired_input_is_not_advertised_or_subscribed(monkeypatch) -> None: - module, clients = _make_bridge(monkeypatch, wire=("odom",)) + module, clients = make_bridge(monkeypatch, wire=("odom",)) try: _, manifest = clients[0].hello_args assert isinstance(manifest, dict) @@ -911,7 +717,7 @@ def test_unwired_input_is_not_advertised_or_subscribed(monkeypatch) -> None: def test_composition_channel_allowlist_filters_bound_inputs(monkeypatch) -> None: - module, clients = _make_bridge(monkeypatch, available_channels=("odom",)) + module, clients = make_bridge(monkeypatch, available_channels=("odom",)) try: _, manifest = clients[0].hello_args assert isinstance(manifest, dict) @@ -939,7 +745,7 @@ def test_start_with_authored_manifest_rates_and_quality(monkeypatch) -> None: "panels": [{"id": "p0", "kind": "video", "channels": ["color_image"]}], "layout": "p0", } - module, clients = _make_bridge(monkeypatch, wire=("color_image",), manifest=manifest) + module, clients = make_bridge(monkeypatch, wire=("color_image",), manifest=manifest) try: _, hello_manifest = clients[0].hello_args # The hello carries the normalized form (defaults made explicit). @@ -1024,7 +830,7 @@ def start_with(manifest: dict[str, Any]) -> RelayBridgeModule: def test_advertised_unwired_channel_is_not_probed(monkeypatch) -> None: manifest = default_manifest(RelayBridgeConfig(), ("color_image", "odom", "global_costmap")) - module, clients = _make_bridge(monkeypatch, wire=("odom",), manifest=manifest) + module, clients = make_bridge(monkeypatch, wire=("odom",), manifest=manifest) try: _, hello_manifest = clients[0].hello_args # No runtime stream probing: everything the manifest declares is @@ -1060,7 +866,7 @@ def test_default_manifest_matches_cockpit_default_preset() -> None: def test_relay_hello_rejection_stops_reconnect_attempts(monkeypatch) -> None: conflict = RelayRejectedError("robot_id_conflict", "already connected") - module, clients = _make_bridge(monkeypatch, hello_errors=(None, conflict)) + module, clients = make_bridge(monkeypatch, hello_errors=(None, conflict)) try: kill_session(module, clients[0]) assert wait_until(lambda: len(clients) == 2) @@ -1248,7 +1054,7 @@ def wire_twist(vx: float, vy: float, wz: float, seq: float, gen: int | None = 1) @pytest.fixture def teleop_bridge(monkeypatch): - module, clients = _make_bridge(monkeypatch, manifest=teleop_manifest()) + module, clients = make_bridge(monkeypatch, manifest=teleop_manifest()) twists: list[Twist] = [] module.tele_cmd_vel.subscribe(twists.append) try: @@ -1538,351 +1344,3 @@ def test_default_manifest_teleop_degradations() -> None: with_video = default_manifest(config, ("color_image", "tele_cmd_vel")) assert [p["kind"] for p in with_video["panels"]] == ["video", "teleop"] assert with_video["layout"] == {"row": ["p0", "p1"], "shares": [2, 1]} - - -# Composition helpers live at module level: under PEP 563 (`from __future__ -# import annotations`) a Module class defined inside a function loses its -# streams, because its annotations cannot be resolved from module globals. -class _EmptyConfig(ModuleConfig): - pass - - -class _ImageProducer(Module): - config: _EmptyConfig - color_image: Out[Image] - - -class _CostmapProducer(Module): - config: _EmptyConfig - global_costmap: Out[OccupancyGrid] - - -class _TwistConsumer(Module): - config: _EmptyConfig - tele_cmd_vel: In[Twist] - - -class _BareModule(Module): - config: _EmptyConfig - - -def test_composition_adds_relay_to_non_visual_blueprint() -> None: - blueprint = with_relay_bridge(_ImageProducer.blueprint()) - relay_atoms = [atom for atom in blueprint.blueprints if atom.module is RelayBridgeModule] - - assert len(relay_atoms) == 1 - assert relay_atoms[0].kwargs["available_channels"] == ("color_image",) - - -def test_composition_includes_costmap_producer() -> None: - blueprint = with_relay_bridge( - autoconnect(_ImageProducer.blueprint(), _CostmapProducer.blueprint()) - ) - relay_atom = next(atom for atom in blueprint.blueprints if atom.module is RelayBridgeModule) - - assert relay_atom.kwargs["available_channels"] == ("color_image", "global_costmap") - - -def test_composition_derives_tx_channels_from_consumers() -> None: - # A MovementManager-like consumer of tele_cmd_vel makes the tx channel - # available (Out transports exist regardless of wiring, so consumers are - # the availability signal). - blueprint = with_relay_bridge( - autoconnect(_ImageProducer.blueprint(), _TwistConsumer.blueprint()) - ) - relay_atom = next(atom for atom in blueprint.blueprints if atom.module is RelayBridgeModule) - - assert relay_atom.kwargs["available_channels"] == ("color_image", "tele_cmd_vel") - - -def test_composition_ignores_disabled_producers() -> None: - source = _ImageProducer.blueprint().disabled_modules(_ImageProducer) - blueprint = with_relay_bridge(source) - relay_atom = next(atom for atom in blueprint.blueprints if atom.module is RelayBridgeModule) - - assert relay_atom.kwargs["available_channels"] == () - - -def test_composition_preserves_existing_relay() -> None: - existing = RelayBridgeModule.blueprint(local_port=8899, available_channels=("odom",)) - blueprint = with_relay_bridge(autoconnect(_BareModule.blueprint(), existing)) - relay_atoms = [atom for atom in blueprint.blueprints if atom.module is RelayBridgeModule] - - assert len(relay_atoms) == 1 - assert relay_atoms[0].kwargs == { - "local_port": 8899, - "available_channels": ("odom",), - } - - -@web_encoder("path.rbm.v1") -def _encode_path_points(msg: NavPath) -> EncodedPayload: - payload = b"".join(struct.pack(" bytes: - raise RuntimeError("boom") - - -_NAV_PATH = NavPath( - ts=1.0, - frame_id="world", - poses=[PoseStamped(ts=1.0, position=[1.5, -2.5, 0.0], orientation=[0.0, 0.0, 0.0, 1.0])], -) -_NAV_PATH_PAYLOAD = struct.pack(" FakeClient: - clients.append(FakeClient()) - return clients[-1] - - monkeypatch.setattr(relay_bridge_module, "connect_with_backoff", fake_connect) - module = atom.module( - relay_url="https://127.0.0.1:1", open_browser=False, robot_id="unit-bot", **atom.kwargs - ) - for ch in wire: - getattr(module, ch).transport = FakeTransport() - module.start() - return module, clients - - -def _transport_of(module: RelayBridgeModule, ch: str) -> FakeTransport: - transport = getattr(module, ch).transport - assert isinstance(transport, FakeTransport) - return transport - - -def test_config_accepts_runtime_specs_by_identity_and_pickles() -> None: - spec = RuntimeChannelSpec( - ch="odom", - message_type=PoseStamped, - dir="rx", - encoding="pose.json.v1", - delivery="reliable", - max_hz=20.0, - params={"a": 1}, - encoder=builtin_codecs.encode_pose, - ) - config = RelayBridgeConfig(channels=(spec,)) - # Pydantic must pass the frozen dataclass through untouched; a rebuilt - # copy would break the is-checks blueprints rely on. - assert config.channels is not None and config.channels[0] is spec - restored = pickle.loads(pickle.dumps(config)) - assert restored.channels[0].encoder is builtin_codecs.encode_pose - assert restored.channels[0].params == {"a": 1} - - -def test_authored_specs_drive_generated_port(monkeypatch) -> None: - blueprint = cockpit( - channels=[Channel("nav_path", NavPath, encoding="path.rbm.v1", max_hz=1000.0)] - ) - module, clients = _start_authored(monkeypatch, blueprint, wire=("nav_path",)) - try: - nav = _transport_of(module, "nav_path") - nav.publish(_NAV_PATH) - flush_loop(module) - assert clients[0].frames == [] # no viewers: no encode, no send - assert module.encoded == {"nav_path": 0} - - push(module, clients[0], Subs(chs=["nav_path"], n=1)) - assert wait_until(lambda: nav.subscribers) - nav.publish(_NAV_PATH) - assert wait_until(lambda: clients[0].frames) - ch, payload, delivery, meta = clients[0].frames[0] - assert (ch, delivery) == ("nav_path", "reliable") - assert payload == _NAV_PATH_PAYLOAD - assert meta == {"n": 1} - assert module.encoded["nav_path"] == 1 - - push(module, clients[0], Subs(chs=[], n=2)) - assert wait_until(lambda: not nav.subscribers) - finally: - stop_module(module) - - -def test_two_jpeg_channels_use_independent_quality(monkeypatch) -> None: - # The second camera the old single-int _jpeg_quality could not express. - blueprint = cockpit( - layout=Video("color_image", quality=33, max_hz=1000.0), - channels=[ - Channel( - "rear_cam", - Image, - encoding="jpeg.v1", - delivery="latest", - max_hz=1000.0, - params={"quality": 90}, - ) - ], - ) - module, clients = _start_authored(monkeypatch, blueprint, wire=("color_image", "rear_cam")) - try: - qualities: list[int] = [] - real = Image.to_jpeg_bytes - - def spy(self: Image, quality: int = 75) -> bytes: - qualities.append(quality) - return real(self, quality=quality) - - monkeypatch.setattr(Image, "to_jpeg_bytes", spy) - push(module, clients[0], Subs(chs=["color_image", "rear_cam"], n=1)) - front, rear = _transport_of(module, "color_image"), _transport_of(module, "rear_cam") - assert wait_until(lambda: front.subscribers and rear.subscribers) - image = Image.from_numpy(np.zeros((8, 12, 3), dtype=np.uint8)) - front.publish(image) - rear.publish(image) - assert wait_until(lambda: sorted(qualities) == [33, 90]) - finally: - stop_module(module) - - -def test_resend_flag_replays_cache_on_generated_port(monkeypatch) -> None: - # The replay mechanism is spec-driven now; prove it on a generated input - # (the flag is internal - cockpit() sets it only for the built-in costmap). - blueprint = cockpit( - channels=[Channel("nav_path", NavPath, encoding="path.rbm.v1", max_hz=1000.0)] - ) - (atom,) = blueprint.blueprints - atom.kwargs["channels"] = tuple( - replace(spec, resend_on_subscribe=True) for spec in atom.kwargs["channels"] - ) - module, clients = _start_authored(monkeypatch, blueprint, wire=("nav_path",)) - try: - nav = _transport_of(module, "nav_path") - assert len(nav.subscribers) == 1 # the always-on raw cache - nav.publish(_NAV_PATH) # nobody watching; only the cache sees it - flush_loop(module) - assert clients[0].frames == [] - - push(module, clients[0], Subs(chs=["nav_path"], n=1)) - # The cached message replays without a new publish. - assert wait_until(lambda: clients[0].frames) - assert clients[0].frames[0][1] == _NAV_PATH_PAYLOAD - assert module.encoded["nav_path"] == 0 # replays do not count - finally: - stop_module(module) - - -def test_encoder_failure_is_isolated_and_rate_limited(monkeypatch) -> None: - blueprint = cockpit( - channels=[ - Channel("bad_path", NavPath, encoding="boom.rbm.v1", max_hz=1000.0), - Channel("target_pose", PoseStamped, encoding="pose.json.v1", max_hz=1000.0), - ] - ) - module, clients = _start_authored(monkeypatch, blueprint, wire=("bad_path", "target_pose")) - try: - exceptions: list[str] = [] - monkeypatch.setattr( - relay_bridge_module.logger, - "exception", - lambda msg, *args, **kwargs: exceptions.append(msg), - ) - push(module, clients[0], Subs(chs=["bad_path", "target_pose"], n=1)) - bad, pose = _transport_of(module, "bad_path"), _transport_of(module, "target_pose") - assert wait_until(lambda: bad.subscribers and pose.subscribers) - # Drop the input rate gates so every publish reaches the encoder. - module._min_interval = {"bad_path": 0.0, "target_pose": 0.0} - for _ in range(3): - bad.publish(_NAV_PATH) - pose.publish(PoseStamped(ts=2.0, position=[1.0, 2.0, 0.0], orientation=[0, 0, 0, 1])) - assert wait_until(lambda: clients[0].frames) - # The healthy channel flows; the broken one drops every sample. - assert all(frame[0] == "target_pose" for frame in clients[0].frames) - assert module.encoded == {"bad_path": 0, "target_pose": 1} - # Three failures inside the log window produce one exception log. - assert len(exceptions) == 1 - assert "bad_path" in exceptions[0] - finally: - stop_module(module) - - -def test_channels_without_manifest_fails(monkeypatch) -> None: - async def fake_connect(url: str, role: str, **kwargs: Any) -> FakeClient: - raise AssertionError("must not reach the relay without a manifest") - - monkeypatch.setattr(relay_bridge_module, "connect_with_backoff", fake_connect) - spec = RuntimeChannelSpec( - ch="odom", - message_type=PoseStamped, - dir="rx", - encoding="pose.json.v1", - delivery="reliable", - max_hz=20.0, - params={}, - encoder=builtin_codecs.encode_pose, - ) - module = RelayBridgeModule( - relay_url="https://127.0.0.1:1", robot_id="unit-bot", channels=(spec,) - ) - with pytest.raises(RuntimeError, match="require a manifest"): - try: - module.start() - finally: - stop_module(module) - - -def test_spec_manifest_mismatch_fails(monkeypatch) -> None: - async def fake_connect(url: str, role: str, **kwargs: Any) -> FakeClient: - raise AssertionError("must not reach the relay with mismatched specs") - - monkeypatch.setattr(relay_bridge_module, "connect_with_backoff", fake_connect) - manifest = { - "version": 1, - "channels": [ - {"ch": "odom", "encoding": "pose.json.v1", "delivery": "reliable", "maxHz": 5.0} - ], - } - - def start_with(spec: RuntimeChannelSpec, match: str) -> None: - module = RelayBridgeModule( - relay_url="https://127.0.0.1:1", - robot_id="unit-bot", - manifest=manifest, - channels=(spec,), - ) - module.odom.transport = FakeTransport() - with pytest.raises(RuntimeError, match=match): - try: - module.start() - finally: - stop_module(module) - - good = RuntimeChannelSpec( - ch="odom", - message_type=PoseStamped, - dir="rx", - encoding="pose.json.v1", - delivery="reliable", - max_hz=5.0, - params={}, - encoder=builtin_codecs.encode_pose, - ) - start_with(replace(good, ch="target_pose"), "do not match the compiled runtime specs") - # Every advertised field that drives behavior is cross-checked: a spec - # gating at 500 Hz while the manifest promises 5 Hz (or drifted params, - # encoding, delivery, direction) must fail start. - start_with(replace(good, encoding="json.v1"), "does not match its compiled runtime spec") - start_with(replace(good, delivery="latest"), "does not match its compiled runtime spec") - start_with(replace(good, max_hz=500.0), "does not match its compiled runtime spec") - start_with(replace(good, params={"q": 1}), "does not match its compiled runtime spec") - start_with(replace(good, dir="tx"), "does not match its compiled runtime spec") - start_with(replace(good, publish="shared"), r"publish ticket \(W7\)") - start_with(replace(good, required_scope="goal:write"), r"publish ticket \(W7\)") - start_with( - replace(good, message_type=Twist), - "message type Twist does not match the RelayBridgeModule port type PoseStamped", - ) - - -def test_composition_preserves_generated_bridge() -> None: - blueprint = cockpit(channels=[Channel("target_pose", PoseStamped, encoding="pose.json.v1")]) - assert with_relay_bridge(blueprint) is blueprint diff --git a/dimos/web/relay_bridge/test_wt_client.py b/dimos/web/relay_bridge/test_wt_client.py index 8db89f5816..3d0ee6e443 100644 --- a/dimos/web/relay_bridge/test_wt_client.py +++ b/dimos/web/relay_bridge/test_wt_client.py @@ -32,9 +32,13 @@ Hello, Msg, ProtocolError, + Pub, + PubAck, + PubNack, RobotInfo, RobotManifest, Role, + Sub, Subs, decode_datagram, encode_data_frame, @@ -331,6 +335,37 @@ async def test_robot_hello_control_payload_boundary_is_exact() -> None: assert over_cap.sent_frames == [] +async def test_send_control_frame_uses_the_hello_framing() -> None: + # Publish acks ride the same robot-opened one-shot @control path as + # hello: a datagram-encoded payload in an @control data frame. + session = StubSession() + client = _client(session) + ack = PubAck(id="p1", ch="human_input", relayTs=1.5, bridgeTs=2.5) + stream_id = client.send_control_frame(ack) + assert stream_id > 0 + ((header, payload),) = session.sent_frames + assert header.ch == CONTROL_CHANNEL + assert decode_datagram(payload) == ack + assert session.sent_msgs == [] # nothing rode datagrams + with pytest.raises(ProtocolError, match=str(MAX_CONTROL_PAYLOAD_BYTES)): + client.send_control_frame( + PubNack(id="p2", code="decode_failed", message="x" * MAX_CONTROL_PAYLOAD_BYTES) + ) + assert len(session.sent_frames) == 1 + + +async def test_send_control_refuses_an_unsendable_datagram() -> None: + # aioquic retries an oversize datagram forever, wedging the whole queue; + # the test viewer's control plane must refuse it locally instead. + session = StubSession() + client = _client(session) + client.send_control(Sub(ch="odom")) + assert len(session.sent_msgs) == 1 + with pytest.raises(ProtocolError, match="wedges aioquic"): + client.send_control(Pub(id="a", ch="chat", data="x" * 2048)) + assert len(session.sent_msgs) == 1 + + async def test_robot_hello_cancellation_is_prompt_and_retires_stream() -> None: session = StubSession() real_send = session.send_frame diff --git a/dimos/web/relay_bridge/test_wt_session.py b/dimos/web/relay_bridge/test_wt_session.py index b289011f21..24e83de7c6 100644 --- a/dimos/web/relay_bridge/test_wt_session.py +++ b/dimos/web/relay_bridge/test_wt_session.py @@ -29,7 +29,9 @@ from dimos.web.relay_bridge.protocol import ( CONTROL_CHANNEL, MAX_CONTROL_PAYLOAD_BYTES, + MAX_PUB_DATA_BYTES, DataFrame, + Error, FrameHeader, Manifest, Stop, @@ -251,6 +253,95 @@ async def test_subs_snapshot_survives_a_teleop_flood(): assert [m.seq for m in msgs[1:] if isinstance(m, Stop)] == list(range(1, _CONTROL_QUEUE_MAX)) +def _tx_bytes(payload: bytes, seq: int = 1) -> bytes: + header = FrameHeader( + ch="human_input", + seq=seq, + ts=0.5, + delivery="reliable", + meta={"id": f"p{seq}", "principal": "local", "relayTs": 0.5}, + ) + return encode_data_frame(header, payload) + + +async def test_carrier_tx_frames_join_the_control_queue_in_order(): + # A forwarded publish (non-control carrier frame) rides the same ordered + # consumer as control; nothing lands in the undrained data-frame queue. + session = _session() + session.incoming_is_carrier = True + chunk = _control_bytes(encode_datagram(Subs(chs=["odom"], n=1)), seq=1) + _tx_bytes( + b'{"text":"salut"}', seq=2 + ) + session._stream_data_received(3, chunk, False) + assert session.frames.qsize() == 0 + assert session.control_msgs.get_nowait() == Subs(chs=["odom"], n=1) + frame = session.control_msgs.get_nowait() + assert isinstance(frame, DataFrame) + assert frame.header.ch == "human_input" + assert frame.header.meta == {"id": "p2", "principal": "local", "relayTs": 0.5} + assert frame.payload == b'{"text":"salut"}' + assert not session.closed.is_set() + + +async def test_carrier_tx_oversize_payload_fails_the_session(): + # The relay caps serialized publish data; past the cap the control path + # is broken. The exact cap still passes. + session = _session() + session.incoming_is_carrier = True + session._stream_data_received(3, _tx_bytes(b"x" * MAX_PUB_DATA_BYTES), False) + assert session.control_msgs.qsize() == 1 + assert not session.closed.is_set() + session._stream_data_received(3, _tx_bytes(b"x" * (MAX_PUB_DATA_BYTES + 1), seq=2), False) + assert session.closed.is_set() + assert session.control_msgs.qsize() == 1 # the oversize frame never queued + + +async def test_viewer_leg_tx_channel_frames_stay_data_frames(): + # Only the robot leg's carrier reroutes non-control frames; a viewer + # session keeps every data frame in the data-frame queue. + session = _session() + session._stream_data_received(4, _tx_bytes(b"{}"), False) + assert session.frames.qsize() == 1 + assert session.control_msgs.qsize() == 0 + + +async def test_subs_snapshot_survives_a_publish_flood(): + # Publish frames are ordinary eviction victims; the one queued snapshot + # is not (an evicted publish is settled by the relay's timeout, an + # evicted snapshot would freeze subscriptions). + session = _session() + session.incoming_is_carrier = True + session._stream_data_received( + 3, _control_bytes(encode_datagram(Subs(chs=["odom"], n=1))), False + ) + for seq in range(_CONTROL_QUEUE_MAX): + session._stream_data_received(3, _tx_bytes(b"{}", seq=seq + 2), False) + assert session.control_msgs.qsize() == _CONTROL_QUEUE_MAX + assert session.control_dropped == 1 + first = session.control_msgs.get_nowait() + assert first == Subs(chs=["odom"], n=1) + # The oldest publish frame (seq 2) was the eviction victim. + second = session.control_msgs.get_nowait() + assert isinstance(second, DataFrame) and second.header.seq == 3 + + +async def test_correlated_errors_join_the_consumer_queue_not_the_handshake_slot(): + # An error carrying requestId is addressed to one publish, not the + # session: it must reach the consumer and must never unblock a hello() + # waiter or overwrite the handshake error slot. + session = _session() + correlated = Error(code="not_publishable", message="nu", requestId="r-1") + session._control_msg_received(correlated) + assert session.control_msgs.get_nowait() == correlated + assert session.relay_error is None + assert not session.welcomed.is_set() + # A session-level error keeps the handshake behavior. + session._control_msg_received(Error(code="version_mismatch", message="v")) + assert session.relay_error is not None and session.relay_error.code == "version_mismatch" + assert session.welcomed.is_set() + assert session.control_msgs.qsize() == 0 + + async def test_newer_subs_snapshot_supersedes_the_queued_one(): # Snapshots are full state: a queued older one is replaced (not counted # as a drop) while other control keeps its order. diff --git a/dimos/web/relay_bridge/wt_client.py b/dimos/web/relay_bridge/wt_client.py index 162a854343..103fe3bf20 100644 --- a/dimos/web/relay_bridge/wt_client.py +++ b/dimos/web/relay_bridge/wt_client.py @@ -55,8 +55,8 @@ # forever, so a datagram that cannot fit one packet (~1165 B encoded) wedges # every datagram queued behind it - hello resends, pings, all send_control. # Refuse to queue one; 1100 keeps margin under the real cliff. Robot hellos -# left datagrams in v5; this guards the (tiny) viewer hello. -_HELLO_DATAGRAM_MAX_BYTES = 1100 +# left datagrams in v5; this guards the viewer hello and send_control. +_DATAGRAM_MAX_BYTES = 1100 class RelayRejectedError(ProtocolError): @@ -197,9 +197,9 @@ async def hello( ) else: size = len(encode_datagram(msg)) - if size > _HELLO_DATAGRAM_MAX_BYTES: + if size > _DATAGRAM_MAX_BYTES: raise ProtocolError( - f"hello datagram is {size} B (limit {_HELLO_DATAGRAM_MAX_BYTES}); an " + f"hello datagram is {size} B (limit {_DATAGRAM_MAX_BYTES}); an " "oversized datagram wedges aioquic's whole datagram queue" ) deadline = time.monotonic() + timeout @@ -241,9 +241,33 @@ def retire_hello_stream() -> None: retire_hello_stream() def send_control(self, msg: Msg) -> None: - """Send one control message to the relay (datagram: lossy, ordered-less).""" + """Send one control message to the relay (datagram: lossy, ordered-less). + + Refuses a datagram over the packet-size cliff: aioquic would retry it + forever and wedge every datagram queued behind it (the hello guard's + rule, applied to the test viewer's whole control plane). + """ + size = len(encode_datagram(msg)) + if size > _DATAGRAM_MAX_BYTES: + raise ProtocolError( + f"control datagram is {size} B (limit {_DATAGRAM_MAX_BYTES}); an " + "oversized datagram wedges aioquic's whole datagram queue" + ) self._session.send_msg(msg) + def send_control_frame(self, msg: Msg) -> int: + """Send one @control frame on a fresh one-shot bidi stream: the + reliable robot->relay control path (publish acks; hello wraps the + same framing in its own retry/retire loop). Returns the stream id; + raises ProtocolError past the control payload cap. + """ + payload = encode_datagram(msg) + if len(payload) > MAX_CONTROL_PAYLOAD_BYTES: + raise ProtocolError( + f"@control payload is {len(payload)} B (limit {MAX_CONTROL_PAYLOAD_BYTES})" + ) + return self.send_frame(CONTROL_CHANNEL, payload) + async def ping(self, timeout: float = 5.0) -> float: """Datagram ping; returns the round-trip time in seconds.""" n = next(self._ping_n) @@ -320,14 +344,15 @@ async def frames(self) -> AsyncIterator[DataFrame]: finally: closed.cancel() - async def control_messages(self) -> AsyncIterator[Msg]: + async def control_messages(self) -> AsyncIterator[Msg | DataFrame]: """Control messages pushed by the relay (subs snapshots, robots, ...). - Fed by relay datagrams and, on the robot leg, by @control frames from - the relay-opened control carrier. Same contract as :meth:`frames`: - buffered messages drain before the close is honored, and cancelling - the consumer never orphans the queue getter. Ends when the session - closes. + Fed by relay datagrams and, on the robot leg, by the relay-opened + control carrier: @control frames arrive decoded as messages, and + forwarded publishes (tx channel data) arrive as raw DataFrames in the + same order. Same contract as :meth:`frames`: buffered messages drain + before the close is honored, and cancelling the consumer never + orphans the queue getter. Ends when the session closes. """ closed = asyncio.ensure_future(self._session.wait_closed()) try: diff --git a/dimos/web/test_cockpit.py b/dimos/web/test_cockpit.py index f967a0f193..3efc74caa1 100644 --- a/dimos/web/test_cockpit.py +++ b/dimos/web/test_cockpit.py @@ -37,8 +37,9 @@ Video, cockpit, ) -from dimos.web.codecs import EncodedPayload, encode_json_v1, web_encoder -from dimos.web.relay_bridge.manifest import parse_manifest +from dimos.web.codecs import EncodedPayload, decode_json_v1, encode_json_v1, web_encoder +from dimos.web.relay_bridge.builtin_codecs import decode_text +from dimos.web.relay_bridge.manifest import ManifestError, parse_manifest from dimos.web.relay_bridge.protocol import ( MAX_CONTROL_PAYLOAD_BYTES, PROTOCOL_VERSION, @@ -67,6 +68,8 @@ "delivery": "latest", "maxHz": 30.0, "params": {"quality": 75}, + "publish": "none", + "requiredScope": None, }, { "ch": "odom", @@ -75,6 +78,8 @@ "delivery": "reliable", "maxHz": 20.0, "params": {}, + "publish": "none", + "requiredScope": None, }, { "ch": "global_costmap", @@ -83,6 +88,8 @@ "delivery": "latest", "maxHz": 5.0, "params": {}, + "publish": "none", + "requiredScope": None, }, { "ch": "tele_cmd_vel", @@ -91,6 +98,8 @@ "delivery": "latest", "maxHz": 15.0, "params": {"maxLinear": 0.8, "maxAngular": 1.0, "boost": 2.0, "watchdogMs": 300.0}, + "publish": "none", + "requiredScope": None, }, ], "panels": [ @@ -272,18 +281,41 @@ def _encode_path_xy(msg: Path) -> EncodedPayload: return EncodedPayload(payload, {"n": len(msg.poses)}) -def test_channel_tx_rejected_until_publish_ticket() -> None: - with pytest.raises(ValueError, match="publish ticket"): - Channel("goal", dict, dir="tx") +def test_channel_shared_tx_accepted() -> None: + channel = Channel( + "human_input", + str, + dir="tx", + encoding="text.json.v1", + publish="shared", + required_scope="chat:send", + ) + assert channel.publish == "shared" + assert channel.required_scope == "chat:send" + # required_scope stays optional. + assert Channel("goal", dict, dir="tx", publish="shared").required_scope is None -def test_channel_rx_publish_policy() -> None: +def test_channel_publish_policy_rules() -> None: + # rx channels never declare a policy or scope. with pytest.raises(ValueError, match="publish='none'"): Channel("note", dict, publish="shared") - with pytest.raises(ValueError, match="publish='none'"): - Channel("note", dict, publish="exclusive") - with pytest.raises(ValueError, match="required_scope"): + with pytest.raises(ValueError, match="required_scope needs a publish policy"): Channel("note", dict, required_scope="chat:send") + # Exclusive arrives with the lease ticket. + with pytest.raises(ValueError, match=r"\(W8\)"): + Channel("goal", dict, dir="tx", publish="exclusive") + # publish="none" tx streams are the specialized protocol paths (teleop). + with pytest.raises(ValueError, match="specialized protocol paths"): + Channel("goal", dict, dir="tx") + # Generic publish is reliable-only. + with pytest.raises(ValueError, match="delivery='reliable'"): + Channel("goal", dict, dir="tx", publish="shared", delivery="latest") + # Scope uses the manifest id bound. + with pytest.raises(ValueError, match="required_scope must be 1..64"): + Channel("goal", dict, dir="tx", publish="shared", required_scope="") + with pytest.raises(ValueError, match="required_scope must be 1..64"): + Channel("goal", dict, dir="tx", publish="shared", required_scope="x" * 65) def test_channel_message_type_must_be_a_class() -> None: @@ -412,6 +444,79 @@ def test_channels_only_blueprint() -> None: assert ratom.kwargs["manifest"] == manifest +def test_publish_tx_channel_blueprint() -> None: + blueprint = cockpit( + channels=[ + Channel( + "human_input", + str, + dir="tx", + encoding="text.json.v1", + publish="shared", + required_scope="chat:send", + max_hz=2.0, + ), + ] + ) + (atom,) = blueprint.blueprints + manifest = atom.kwargs["manifest"] + (channel,) = manifest["channels"] + assert channel["dir"] == "tx" and channel["delivery"] == "reliable" + assert channel["publish"] == "shared" and channel["requiredScope"] == "chat:send" + assert parse_manifest(manifest).model_dump() == manifest + # Publish streams ride a generated subclass with a typed Out port. + assert issubclass(atom.module, RelayBridgeModule) and atom.module is not RelayBridgeModule + assert any( + s.name == "human_input" and s.type is str and s.direction == "out" for s in atom.streams + ) + (spec,) = atom.kwargs["channels"] + assert spec.dir == "tx" and spec.publish == "shared" and spec.required_scope == "chat:send" + assert spec.decoder is decode_text and spec.decoder_takes_context is False + assert spec.encoder is None + # Blueprint kwargs cross the forkserver Pipe: the by-reference decoder + # must survive pickling. + restored = pickle.loads(pickle.dumps(blueprint)) + (ratom,) = restored.blueprints + assert ratom.module is atom.module + assert ratom.kwargs["channels"][0].decoder is decode_text + + +def test_publish_tx_generic_json_and_dataclass_rejection() -> None: + (atom,) = cockpit(channels=[Channel("counter", int, dir="tx", publish="shared")]).blueprints + (spec,) = atom.kwargs["channels"] + assert spec.decoder is decode_json_v1 and spec.decoder_takes_context is False + # Reconstructing a dataclass from untrusted browser JSON needs an + # explicit decoder (narrower than the encoder side). + with pytest.raises(ValueError, match="register an explicit decoder"): + cockpit(channels=[Channel("ops_note", _OpsNote, dir="tx", publish="shared")]) + + +def test_publish_tx_codec_errors() -> None: + with pytest.raises(ValueError, match=r"@web_decoder\('goal.json.v1'\)"): + cockpit( + channels=[Channel("goal", dict, dir="tx", encoding="goal.json.v1", publish="shared")] + ) + with pytest.raises(ValueError, match="decodes to str, not int"): + cockpit( + channels=[ + Channel("human_input", int, dir="tx", encoding="text.json.v1", publish="shared") + ] + ) + # Non-JSON-family encodings fail the manifest's invalid_publish rule. + with pytest.raises(ManifestError, match="invalid_publish"): + cockpit(channels=[Channel("blob", bytes, dir="tx", encoding="blob.v1", publish="shared")]) + + +def test_publish_channel_conflicts_with_teleop_panel() -> None: + with pytest.raises(ValueError, match="conflicting requirements for stream 'tele_cmd_vel'"): + cockpit( + layout=Teleop(), + channels=[ + Channel("tele_cmd_vel", Twist, dir="tx", encoding="twist.json.v1", publish="shared") + ], + ) + + def test_default_path_channels_kwarg_matches_manifest() -> None: (atom,) = cockpit().blueprints assert atom.module is RelayBridgeModule @@ -519,10 +624,11 @@ def test_reserved_stream_names_rejected(stream: str) -> None: cockpit(channels=[Channel(stream, dict)]) -def test_channel_ordering_builtins_then_customs() -> None: +def test_channel_ordering_builtins_then_customs_then_publish() -> None: blueprint = cockpit( layout=GO2_LAYOUT, channels=[ + Channel("human_input", str, dir="tx", encoding="text.json.v1", publish="shared"), Channel("target_pose", PoseStamped, encoding="pose.json.v1", max_hz=5.0), Channel("ops_note", _OpsNote), ], @@ -535,6 +641,7 @@ def test_channel_ordering_builtins_then_customs() -> None: "target_pose", "ops_note", "tele_cmd_vel", + "human_input", ] diff --git a/dimos/web/test_codecs.py b/dimos/web/test_codecs.py index 1d181155b8..3488808d5a 100644 --- a/dimos/web/test_codecs.py +++ b/dimos/web/test_codecs.py @@ -32,9 +32,11 @@ EncodedPayload, EncoderDef, PublishContext, + decode_json_v1, decoder_definition, encode_json_v1, encoder_definition, + resolve_decoder, resolve_encoder, web_decoder, web_encoder, @@ -333,6 +335,35 @@ def test_resolve_rechecks_pickle_by_reference(monkeypatch: pytest.MonkeyPatch) - resolve_encoder("t.enc.unref.v1", _Point) +def test_resolve_decoder_registered_and_generic() -> None: + web_decoder("t.dec.res.v1")(_dec_ok) + definition = resolve_decoder("t.dec.res.v1", _Point) + assert definition.decode is _dec_ok and definition.takes_context is False + for message_type in (dict, list, str, int, float, bool): + generic = resolve_decoder("json.v1", message_type) + assert generic.decode is decode_json_v1 and generic.takes_context is False + assert decode_json_v1({"a": [1, None]}) == {"a": [1, None]} + + +def test_resolve_decoder_rejections() -> None: + web_decoder("t.dec.resbad.v1")(_dec_ok) + with pytest.raises(ValueError, match="decodes to _Point, not dict"): + resolve_decoder("t.dec.resbad.v1", dict) + with pytest.raises(ValueError, match="no decoder registered for encoding 'nope.dec.v1'"): + resolve_decoder("nope.dec.v1", dict) + # Dataclasses are excluded from generic decode on purpose: reconstructing + # one from untrusted browser JSON needs an explicit decoder. + with pytest.raises(ValueError, match="register an explicit decoder"): + resolve_decoder("json.v1", _Point) + + +def test_resolve_decoder_rechecks_pickle_by_reference(monkeypatch: pytest.MonkeyPatch) -> None: + web_decoder("t.dec.unref.v1")(_dec_ok) + monkeypatch.delattr(sys.modules[__name__], "_dec_ok") + with pytest.raises(ValueError, match="cannot be pickled by reference"): + resolve_decoder("t.dec.unref.v1", _Point) + + def test_encoded_payload_normalizes_bytes_like() -> None: assert EncodedPayload(bytearray(b"ab")).payload == b"ab" assert EncodedPayload(memoryview(b"cd"), {"n": 2}).meta == {"n": 2} diff --git a/docs/usage/web_sdk.md b/docs/usage/web_sdk.md index 1f06da9a7c..490cd98b7f 100644 --- a/docs/usage/web_sdk.md +++ b/docs/usage/web_sdk.md @@ -283,3 +283,31 @@ Decoder notes: - Each session owns its registry (`connect({decoders})`), so two apps on one page cannot clobber each other. Registering a taken encoding throws unless you pass `{replace: true}`. - An encoding with no decoder is not an error. The channel still counts frames and renders as unsupported. A throwing decoder bumps `decodeErrors` and keeps the last good value. - Decoders run on the ingest path, so keep them synchronous and cheap. Heavy work (inflate, draw) belongs in the consumer. + +## Publishing to the robot + +A `dir="tx"` channel with `publish="shared"` is a browser input: any viewer may publish, the bridge decodes the JSON value with the matching `@web_decoder` and publishes it on a typed `Out` port, and your modules consume it like any other stream. + +```python skip +cockpit(channels=[ + Channel("human_input", str, dir="tx", encoding="text.json.v1", publish="shared"), +]) +``` + +```js +try { + const receipt = await session.publish("human_input", "hello robot"); + // The bridge decoded the value and published it on the dimOS stream. +} catch (e) { + // e.outcome === "rejected": it definitively did not happen (e.code says why). + // e.outcome === "unknown": the connection died before the ack - it MAY have + // been published. Never auto-resend an "unknown" command. +} +``` + +Publish notes: + +- Only reliable JSON-encoded tx channels declared `publish="shared"` accept `publish()`; everything else (rx channels, teleop) rejects locally with a stable code. Values are any JSON value (`null` included), at most 32 KiB serialized and at most 100 nesting levels deep. +- The relay rate-limits per viewer and per robot at the channel's `max_hz`, so extra open tabs never multiply the accepted rate. +- `text.json.v1` (strings) is built in; other encodings need an `@web_decoder` whose return annotation matches the channel's `message_type`. An optional second `PublishContext` parameter carries provenance (request id, principal, relay/client timestamps). +- `web/examples/chat-input/` in the repository is a minimal publish page. diff --git a/web/README.md b/web/README.md index 8d078e0bbc..59f9dc2c72 100644 --- a/web/README.md +++ b/web/README.md @@ -157,16 +157,16 @@ bytes, so it is generated by `uv run python -m dimos.web.relay_bridge.gen_costma The transport per leg is deliberately asymmetric (the numbered workarounds below explain why): -| Leg | What | Transport | -| --------------- | ----------------------------- | ------------------------------------------------------------------------------------------------ | -| robot -> relay | hello | `@control` data frame on a one-shot bidi stream, resent until welcomed | -| robot -> relay | channel data | one-shot bidi stream per frame | -| robot -> relay | ping | datagram | -| relay -> robot | welcome, errors, pong, teleop | datagrams (lossy; teleop is loss-tolerant by design) | -| relay -> robot | subs snapshots | `@control` frames on the robot control carrier: ONE relay-opened reliable uni stream per session | -| viewer -> relay | control | viewer-opened bidi control stream (browser/SDK) or datagrams (Python test viewer) | -| relay -> viewer | control replies + pushes | the same control stream, or datagrams | -| relay -> viewer | channel data | relay-opened uni streams: per-frame for latest, one persistent per reliable channel | +| Leg | What | Transport | +| --------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| robot -> relay | hello, publish acks | `@control` data frame on a one-shot bidi stream (hello resent until welcomed) | +| robot -> relay | channel data | one-shot bidi stream per frame | +| robot -> relay | ping | datagram | +| relay -> robot | welcome, errors, pong, teleop | datagrams (lossy; teleop is loss-tolerant by design) | +| relay -> robot | subs snapshots, publishes | `@control` (subs) and tx data frames (forwarded publishes) on the robot control carrier: ONE relay-opened reliable uni stream per session | +| viewer -> relay | control | viewer-opened bidi control stream (browser/SDK) or datagrams (Python test viewer) | +| relay -> viewer | control replies + pushes | the same control stream, or datagrams | +| relay -> viewer | channel data | relay-opened uni streams: per-frame for latest, one persistent per reliable channel | Relay-opened uni streams are the proven direction on both legs: Deno's server->client uni delivery works to browsers, Deno's own client, and aioquic alike; only client->Deno-server uni receive is diff --git a/web/cockpit/src/App.test.tsx b/web/cockpit/src/App.test.tsx index 1185d2ba0f..28e0437742 100644 --- a/web/cockpit/src/App.test.tsx +++ b/web/cockpit/src/App.test.tsx @@ -19,6 +19,8 @@ const ODOM: ChannelSpec = { delivery: "reliable", maxHz: 20, params: {}, + publish: "none", + requiredScope: null, }; const IMAGE: ChannelSpec = { ch: "color_image", @@ -27,6 +29,8 @@ const IMAGE: ChannelSpec = { delivery: "latest", maxHz: 15, params: {}, + publish: "none", + requiredScope: null, }; function mf(channels: ChannelSpec[], panels: PanelSpec[] = []): Manifest { @@ -51,6 +55,7 @@ describe("App session states", () => { store: channels, watch: () => new Promise(() => {}), subscribe: () => () => {}, + publish: () => new Promise(() => {}), close: () => {}, }; registerTeleopHooks(session, { diff --git a/web/cockpit/src/layout/LayoutTree.test.tsx b/web/cockpit/src/layout/LayoutTree.test.tsx index 7acd2f84ab..67b7308110 100644 --- a/web/cockpit/src/layout/LayoutTree.test.tsx +++ b/web/cockpit/src/layout/LayoutTree.test.tsx @@ -10,7 +10,16 @@ import { LayoutTree } from "./LayoutTree.tsx"; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; function spec(ch: string): ChannelSpec { - return { ch, dir: "rx", encoding: "pose.json.v1", delivery: "reliable", maxHz: 20, params: {} }; + return { + ch, + dir: "rx", + encoding: "pose.json.v1", + delivery: "reliable", + maxHz: 20, + params: {}, + publish: "none", + requiredScope: null, + }; } function panel(id: string, kind: string, channels: string[] = []): PanelSpec { @@ -122,6 +131,8 @@ describe("LayoutTree", () => { delivery: "latest", maxHz: 15, params: {}, + publish: "none", + requiredScope: null, }, ], panels: [video], diff --git a/web/cockpit/src/panels/TeleopPanel.test.tsx b/web/cockpit/src/panels/TeleopPanel.test.tsx index 40258f49a8..9d38cce515 100644 --- a/web/cockpit/src/panels/TeleopPanel.test.tsx +++ b/web/cockpit/src/panels/TeleopPanel.test.tsx @@ -22,6 +22,8 @@ const MANIFEST: Manifest = { delivery: "latest", maxHz: 15, params: { maxLinear: 0.8, maxAngular: 1.0, boost: 2.0, watchdogMs: 300 }, + publish: "none", + requiredScope: null, }, ], panels: [SPEC], diff --git a/web/cockpit/src/subscriptions.test.ts b/web/cockpit/src/subscriptions.test.ts index ff35e71114..a1886b0829 100644 --- a/web/cockpit/src/subscriptions.test.ts +++ b/web/cockpit/src/subscriptions.test.ts @@ -20,6 +20,8 @@ function spec(over: Partial = {}): ChannelSpec { delivery: "reliable", maxHz: 20, params: {}, + publish: "none", + requiredScope: null, ...over, }; } diff --git a/web/examples/chat-input/index.html b/web/examples/chat-input/index.html new file mode 100644 index 0000000000..7fd29e1581 --- /dev/null +++ b/web/examples/chat-input/index.html @@ -0,0 +1,67 @@ + + + + + + DimOS chat input + + + +

DimOS chat input

+
status: connecting
+

+ + + +

+
+ + + diff --git a/web/relay/carrier.ts b/web/relay/carrier.ts index 091e9f4cc3..0b19c427c1 100644 --- a/web/relay/carrier.ts +++ b/web/relay/carrier.ts @@ -66,11 +66,26 @@ export class RobotCarrier { this.dispose(); return; } + this.#push(CONTROL_CHANNEL, payload, undefined); + } + + /** Queue one robot-bound tx data frame (a forwarded viewer publish, its + * relay provenance in `meta`). Same FIFO as control: a publish can never + * bypass queued subscription state, and a frame that cannot be delivered + * fails the session. The registry bounds payload size and pending volume + * before queueing. */ + sendFrame(ch: string, payload: Uint8Array, meta: Record): void { + if (this.#disposed) return; + this.#push(ch, payload, meta); + } + + #push(ch: string, payload: Uint8Array, meta: Record | undefined): void { const header: FrameHeader = { - ch: CONTROL_CHANNEL, + ch, seq: ++this.#seq, ts: Date.now() / 1000, delivery: "reliable", + ...(meta !== undefined ? { meta } : {}), }; const frame = encodeDataFrame(header, payload); this.#fifo.push(frame); diff --git a/web/relay/carrier_test.ts b/web/relay/carrier_test.ts index 91cd733641..246dc956a3 100644 --- a/web/relay/carrier_test.ts +++ b/web/relay/carrier_test.ts @@ -154,3 +154,41 @@ Deno.test("carrier: an over-cap control payload fails the session, nothing queue assertEquals(sink.streamsOpened, 0); assertEquals(carrier.stats().sent, 0); }); + +Deno.test("carrier: publishes share the FIFO with control, in order, meta intact", async () => { + const sink = new FakeCarrierSink(); + const carrier = new RobotCarrier(sink); + const payload = new TextEncoder().encode('{"text":"salut β"}'); + carrier.sendControl(subs(1, "odom")); + carrier.sendFrame("human_input", payload, { id: "p1", principal: "local", relayTs: 1.5 }); + carrier.sendControl(subs(2)); + await tick(); + const reader = new DataFrameStreamReader(); + const frames = sink.written.flatMap((chunk) => reader.push(chunk)); + // One stream, one seq space: a publish can never bypass queued control. + assertEquals(frames.map((f) => [f.header.ch, f.header.seq]), [ + [CONTROL_CHANNEL, 1], + ["human_input", 2], + [CONTROL_CHANNEL, 3], + ]); + assertEquals(frames[1].header.delivery, "reliable"); + assertEquals(frames[1].header.meta, { id: "p1", principal: "local", relayTs: 1.5 }); + assertEquals(frames[1].payload, payload); + assertEquals(carrier.stats().sent, 3); +}); + +Deno.test("carrier: publish frames count toward the overflow caps", async () => { + const sink = new FakeCarrierSink(false); + const carrier = new RobotCarrier(sink); + // 32 KiB payloads: the byte cap (4 MiB) trips long before the frame cap. + const payload = new Uint8Array(32 * 1024); + for (let i = 0; i < 200 && sink.fails === 0; i++) { + carrier.sendFrame("human_input", payload, { id: `p${i}` }); + } + await tick(); + assertEquals(sink.fails, 1); + assertEquals(sink.failed, "carrier overflow"); + carrier.sendFrame("human_input", payload, { id: "late" }); + await tick(); + assertEquals(sink.fails, 1); // disposed: later sends are no-ops +}); diff --git a/web/relay/forward.ts b/web/relay/forward.ts index d77d3dc874..d605554eb6 100644 --- a/web/relay/forward.ts +++ b/web/relay/forward.ts @@ -150,6 +150,36 @@ export class Rate { } } +/** + * Publish-rate limiter: `capacity` tokens refilled continuously at + * `ratePerSec`, so a short burst up to the capacity passes and the sustained + * rate converges on ratePerSec. take() consumes one token when available. + * No clock inside: callers pass nowMs (tests fabricate time). + */ +export class TokenBucket { + #tokens: number; + #lastMs: number | null = null; + + constructor( + readonly ratePerSec: number, + readonly capacity: number = Math.max(1, Math.ceil(ratePerSec)), + ) { + this.#tokens = this.capacity; + } + + take(nowMs: number): boolean { + if (this.#lastMs !== null) { + // max(0, ...): clock wobble must not drain tokens. + const refill = (Math.max(0, nowMs - this.#lastMs) / 1000) * this.ratePerSec; + this.#tokens = Math.min(this.capacity, this.#tokens + refill); + } + this.#lastMs = nowMs; + if (this.#tokens < 1) return false; + this.#tokens -= 1; + return true; + } +} + interface OutstandingSend { send: FrameSend; at: number; diff --git a/web/relay/forward_test.ts b/web/relay/forward_test.ts index b8d4e759c0..cd3c15905d 100644 --- a/web/relay/forward_test.ts +++ b/web/relay/forward_test.ts @@ -15,6 +15,7 @@ import { readRobotFrame, readWebTransportPreamble, ReliableChannel, + TokenBucket, type ViewerSink, } from "./forward.ts"; @@ -527,6 +528,31 @@ Deno.test("rate: bucketed trailing window with idle decay and wraparound", () => assertEquals(rate.snapshot(t0 + 30_000), { fps: 0.2, bps: 100 }); }); +Deno.test("token bucket: burst to capacity, continuous refill, no wobble drain", () => { + const bucket = new TokenBucket(2); // capacity max(1, ceil(2)) = 2 + const t0 = 1_000_000; + assertEquals(bucket.take(t0), true); + assertEquals(bucket.take(t0), true); + assertEquals(bucket.take(t0), false); // burst spent + // 2/s: half a second buys exactly one token. + assertEquals(bucket.take(t0 + 499), false); + assertEquals(bucket.take(t0 + 500), true); + // A backwards clock must not drain tokens (the failed take at t0+500ms+1 + // refilled nothing; going back 400ms keeps the balance). + assertEquals(bucket.take(t0 + 100), false); + // Idle refill clamps at capacity: a long pause buys 2 tokens, not 20. + assertEquals(bucket.take(t0 + 60_000), true); + assertEquals(bucket.take(t0 + 60_000), true); + assertEquals(bucket.take(t0 + 60_000), false); + + // Fractional rates keep at least one token of capacity. + const slow = new TokenBucket(0.5); + assertEquals(slow.capacity, 1); + assertEquals(slow.take(t0), true); + assertEquals(slow.take(t0 + 1999), false); + assertEquals(slow.take(t0 + 2000), true); +}); + Deno.test("parseRobotFrameHeader accepts valid frames and rejects junk", () => { const good = dataFrame("odom", 4, "reliable"); assertEquals(parseRobotFrameHeader(good), { ch: "odom", seq: 4, ts: 4.5, delivery: "reliable" }); diff --git a/web/relay/registry.ts b/web/relay/registry.ts index 0a60c68031..a96cd13495 100644 --- a/web/relay/registry.ts +++ b/web/relay/registry.ts @@ -14,8 +14,11 @@ import { type ChannelSpec, type Delivery, type FrameHeader, + MAX_PUB_DATA_BYTES, type Msg, PROTOCOL_VERSION, + type PubAckMsg, + type PubNackMsg, RESERVED_CHANNEL_PREFIX, type RobotInfo, type RobotManifest, @@ -29,9 +32,22 @@ import { parseRobotFrameHeader, Rate, ReliableChannel, + TokenBucket, type ViewerSink, } from "./forward.ts"; +// Publish forwarding bounds. The timeout settles pending routing state when +// a bridge never answers (SDK outcome "unknown": the bridge may still have +// published); the caps bound relay memory per viewer and per robot, and keep +// worst-case queued publish bytes far under the carrier's fail-fast caps. +export const PUBLISH_TIMEOUT_MS = 10_000; +const MAX_PENDING_PER_VIEWER = 16; +const MAX_PENDING_BYTES_PER_VIEWER = 256 * 1024; +const MAX_PENDING_PER_ROBOT = 64; +const MAX_PENDING_BYTES_PER_ROBOT = 1024 * 1024; + +const enc = new TextEncoder(); + /** What the registry needs from a robot session. */ export interface RobotPeer { /** Set by the session once a valid robot hello arrived. */ @@ -50,6 +66,9 @@ export interface RobotPeer { * a write failure fails the whole robot session). Subs snapshots and future * robot-bound control. */ sendControl(msg: Msg): void; + /** One forwarded viewer publish, as a tx data frame on the carrier (meta + * carries the relay provenance: token id, principal, relayTs, clientTs). */ + sendPub(ch: string, payload: Uint8Array, meta: Record): void; /** Live carrier queue counters for stats(). */ carrierStats(): CarrierStats; } @@ -82,10 +101,38 @@ interface ChannelInStats { rate: Rate; } +/** One forwarded publish awaiting its bridge ack. Indexed twice: by relay + * token on the robot entry (ack routing; dies with the robot) and by the + * viewer's own request id in the per-viewer state (dup detection, caps, + * disconnect release). */ +interface PendingPub { + viewer: ViewerPeer; + /** The viewer's own request id, restored in the routed pub_ack/error. */ + viewerId: string; + token: string; + robotId: string; + ch: string; + bytes: number; + deadlineMs: number; +} + +/** Per-viewer publish state, keyed by the viewer object (viewer-supplied + * request ids are untrusted and never namespace anything globally). */ +interface ViewerPubState { + pending: Map; + pendingBytes: number; + /** Per-viewer accepted-rate buckets, cleared on watch switch and rebuilt + * per channel when the spec's maxHz stops matching (a same-id robot + * restart may change the manifest under a live viewer; the rebuild grants + * one bounded fresh burst up to ceil(maxHz)). */ + buckets: Map; +} + interface RobotEntry { peer: RobotPeer; - /** Manifest delivery per channel; frame-header delivery is the fallback. */ - delivery: Map; + /** Normalized manifest spec per channel (delivery, publish policy, rate); + * frame-header delivery is the undeclared-channel fallback. */ + specs: Map; /** Viewer holding the exclusive teleop lease, or null. Dies with the * entry: a re-registered robot starts lease-free. */ teleop: ViewerPeer | null; @@ -104,16 +151,36 @@ interface RobotEntry { * header ch strings must not grow channelsIn. A manifest-less robot's * traffic lands entirely here. */ undeclared: { framesIn: number; bytesIn: number; rate: Rate }; + /** Relay-authored pub token counter (the id forwarded robot-ward). */ + pubN: number; + /** Pending publishes by token; settled by ack/nack, timeout, or death. */ + pubPending: Map; + pubPendingBytes: number; + /** Aggregate accepted-publish rate per channel (the manifest maxHz). */ + pubBuckets: Map; } export class Registry { #robots = new Map(); #viewers = new Set(); + #pubViewers = new Map(); #framesDropped = 0; #framesFromUnregistered = 0; #teleopForwarded = 0; #teleopDropped = 0; #carrierFailures = 0; + #pubAccepted = 0; + #pubAcked = 0; + #pubTimedOut = 0; + #pubLateAcks = 0; + #pubRejected: Record = {}; + readonly #now: () => number; + + /** Clock injected so tests fabricate time (buckets and pending deadlines + * are the only time-dependent state beyond stats). */ + constructor(now: () => number = Date.now) { + this.#now = now; + } /** A robot control carrier overflowed or failed a write; the session is * being closed. Registry-level because the per-robot entry dies with it. */ @@ -128,6 +195,18 @@ export class Registry { viewerClosed(viewer: ViewerPeer): void { if (!this.#viewers.delete(viewer)) return; disposePolicies(viewer); + // Release the viewer's pending publishes silently (there is nobody left + // to route the ack to); the robot-side entries go with them. + const state = this.#pubViewers.get(viewer); + if (state !== undefined) { + for (const pending of state.pending.values()) { + const entry = this.#robots.get(pending.robotId); + if (entry !== undefined && entry.pubPending.delete(pending.token)) { + entry.pubPendingBytes -= pending.bytes; + } + } + this.#pubViewers.delete(viewer); + } if (viewer.watched !== null) { this.#releaseTeleop(viewer.watched, viewer); this.#syncSubs(viewer.watched); @@ -148,16 +227,19 @@ export class Registry { console.log(`[relay] rejecting duplicate live robot id ${info.id}`); return false; } - const delivery = new Map(peer.channels.map((c) => [c.ch, c.delivery])); this.#robots.set(info.id, { peer, - delivery, + specs: new Map(peer.channels.map((c) => [c.ch, c])), teleop: null, teleopGen: 0, n: 0, lastChs: [], channelsIn: new Map(), undeclared: { framesIn: 0, bytesIn: 0, rate: new Rate() }, + pubN: 0, + pubPending: new Map(), + pubPendingBytes: 0, + pubBuckets: new Map(), }); this.#pushRobots(); // Forced: gives a fresh bridge its baseline and reattaches surviving @@ -173,6 +255,20 @@ export class Registry { const entry = this.#robots.get(id); if (entry === undefined || entry.peer !== peer) return; this.#robots.delete(id); + // Pending publishes cannot be acked any more; their viewers get an + // outcome-"unknown" error (the bridge may have published before dying). + for (const pending of entry.pubPending.values()) { + this.#settlePub(entry, pending); + if (this.#viewers.has(pending.viewer)) { + this.#countRejected("robot_disconnected"); + pending.viewer.sendMsg({ + t: "error", + code: "robot_disconnected", + message: `robot ${id} disconnected before acknowledging the publish`, + requestId: pending.viewerId, + }); + } + } console.log(`[relay] robot ${id} disconnected`); // Viewers keep watched/subs: a returning robot reattaches seamlessly. this.#pushRobots(); @@ -227,6 +323,9 @@ export class Registry { viewer.subs.clear(); disposePolicies(viewer); this.#releaseTeleop(previous, viewer); + // Publish buckets are per robot+channel; pending publishes stay + // (the viewer is live, acks from the old robot still route). + this.#pubViewers.get(viewer)?.buckets.clear(); } viewer.watched = msg.robotId; if (previous !== null && previous !== msg.robotId) this.#syncSubs(previous); @@ -277,7 +376,7 @@ export class Registry { reply({ t: "error", code: "unknown_robot", message: `no robot ${viewer.watched}` }); break; } - if (entry.delivery.size > 0 && !entry.delivery.has(msg.ch)) { + if (entry.specs.size > 0 && !entry.specs.has(msg.ch)) { reply({ t: "error", code: "unknown_channel", @@ -348,10 +447,175 @@ export class Registry { this.#teleopForwarded++; break; } + case "pub": { + // Every failure settles the request with a correlated error, never a + // silent drop. Identity and shape first, then policy, then pending + // caps, then the token buckets last: a rejected request never spends + // rate tokens. + const fail = (code: string, message: string) => { + this.#countRejected(code); + reply({ t: "error", code, message, requestId: msg.id }); + }; + const entry = viewer.watched === null ? undefined : this.#robots.get(viewer.watched); + if (viewer.watched === null) { + fail("no_watch", "watch a robot before publishing"); + break; + } + if (entry === undefined) { + fail("unknown_robot", `no robot ${viewer.watched}`); + break; + } + const state = this.#pubViewers.get(viewer) ?? { + pending: new Map(), + pendingBytes: 0, + buckets: new Map(), + }; + this.#pubViewers.set(viewer, state); + if (state.pending.has(msg.id)) { + fail("duplicate_request", `request id ${msg.id} is already pending`); + break; + } + // Independent of the SDK's own check: callers can bypass the SDK. + const payload = enc.encode(JSON.stringify(msg.data)); + if (payload.byteLength > MAX_PUB_DATA_BYTES) { + fail( + "publish_too_large", + `serialized data is ${payload.byteLength} B (limit ${MAX_PUB_DATA_BYTES})`, + ); + break; + } + const spec = entry.specs.get(msg.ch); + if (spec === undefined) { + // A manifest-less robot (transport tests) declared no publishable + // channels either: publishing requires a declared policy, + // deliberately stricter than sub. + fail("unknown_channel", `no channel ${msg.ch.slice(0, 64)} on ${viewer.watched}`); + break; + } + if (spec.publish === "exclusive") { + fail( + "not_publishable", + `channel ${msg.ch} requires the exclusive publisher lease (W8)`, + ); + break; + } + if (spec.dir !== "tx" || spec.publish !== "shared" || spec.delivery !== "reliable") { + fail("not_publishable", `channel ${msg.ch} does not accept generic publish`); + break; + } + // Scope check: the local relay has no operator auth (W10); its + // synthetic principal bypasses requiredScope by design. A remote + // relay must check the authenticated principal's scopes here. + const principal = "local"; + if ( + state.pending.size >= MAX_PENDING_PER_VIEWER || + state.pendingBytes + payload.byteLength > MAX_PENDING_BYTES_PER_VIEWER || + entry.pubPending.size >= MAX_PENDING_PER_ROBOT || + entry.pubPendingBytes + payload.byteLength > MAX_PENDING_BYTES_PER_ROBOT + ) { + fail("pending_limit", "too many unacknowledged publishes"); + break; + } + const now = this.#now(); + // Per-viewer bucket first (a rate-rejected attempt still costs its + // viewer), then the aggregate at the advertised maxHz: more viewer + // sessions must never multiply the accepted robot/channel rate. A + // viewer bucket whose rate stopped matching the spec belongs to a + // dead registration (a same-id robot restart may change maxHz) and + // is rebuilt at the current rate. + let viewerBucket = state.buckets.get(msg.ch); + if (viewerBucket === undefined || viewerBucket.ratePerSec !== spec.maxHz) { + viewerBucket = new TokenBucket(spec.maxHz); + state.buckets.set(msg.ch, viewerBucket); + } + let aggregate = entry.pubBuckets.get(msg.ch); + if (aggregate === undefined) { + aggregate = new TokenBucket(spec.maxHz); + entry.pubBuckets.set(msg.ch, aggregate); + } + if (!viewerBucket.take(now) || !aggregate.take(now)) { + fail("rate_limited", `channel ${msg.ch} accepts at most ${spec.maxHz} publishes/s`); + break; + } + const pending: PendingPub = { + viewer, + viewerId: msg.id, + token: `p${++entry.pubN}`, + robotId: viewer.watched, + ch: msg.ch, + bytes: payload.byteLength, + deadlineMs: now + PUBLISH_TIMEOUT_MS, + }; + entry.pubPending.set(pending.token, pending); + entry.pubPendingBytes += payload.byteLength; + state.pending.set(msg.id, pending); + state.pendingBytes += payload.byteLength; + entry.peer.sendPub(msg.ch, payload, { + id: pending.token, + principal, + relayTs: now / 1000, + ...(msg.clientTs !== undefined ? { clientTs: msg.clientTs } : {}), + }); + this.#pubAccepted++; + break; + } } return true; } + /** Route one bridge publish result to the originating viewer: settle the + * pending entry (restoring the viewer's own request id) and forward the + * ack, or map a nack to a correlated error. Late/duplicate results are + * counted and dropped without growing state. */ + onRobotPubResult(peer: RobotPeer, msg: PubAckMsg | PubNackMsg): void { + const id = peer.info?.id; + const entry = id === undefined ? undefined : this.#robots.get(id); + if (entry === undefined || entry.peer !== peer) { + this.#pubLateAcks++; + return; + } + const pending = entry.pubPending.get(msg.id); + if (pending === undefined) { + this.#pubLateAcks++; + console.log(`[relay] dropping late/duplicate pub result ${msg.id.slice(0, 64)} from ${id}`); + return; + } + this.#settlePub(entry, pending); + if (!this.#viewers.has(pending.viewer)) return; // viewer left; nothing to route + if (msg.t === "pub_ack") { + this.#pubAcked++; + pending.viewer.sendMsg({ + t: "pub_ack", + id: pending.viewerId, + ch: pending.ch, + relayTs: msg.relayTs, + bridgeTs: msg.bridgeTs, + }); + } else { + this.#countRejected(msg.code); + pending.viewer.sendMsg({ + t: "error", + code: msg.code.slice(0, 64), + message: msg.message.slice(0, 256), + requestId: pending.viewerId, + }); + } + } + + /** Remove one pending publish from both indexes (robot token map and the + * viewer's request-id map), with byte accounting. */ + #settlePub(entry: RobotEntry, pending: PendingPub): void { + if (entry.pubPending.delete(pending.token)) entry.pubPendingBytes -= pending.bytes; + const state = this.#pubViewers.get(pending.viewer); + if (state !== undefined && state.pending.delete(pending.viewerId)) { + state.pendingBytes -= pending.bytes; + } + } + + #countRejected(code: string): void { + this.#pubRejected[code] = (this.#pubRejected[code] ?? 0) + 1; + } + /** Release `viewer`'s teleop lease on `robotId`, if held: the bridge gets * a gen-stamped teleop_stop that permanently voids this lease's datagrams * (its deadman watchdog covers a lost stop) and the next teleop_start @@ -390,7 +654,7 @@ export class Registry { return; } const ch = header.ch; - const declared = entry.delivery.get(ch); + const declared = entry.specs.get(ch)?.delivery; // Manifest delivery wins; the header's is the undeclared-channel fallback. const delivery = declared ?? header.delivery; @@ -426,13 +690,30 @@ export class Registry { } } - /** Reap stale accepted latest streams on every viewer. Offers reap + /** Reap stale accepted latest streams on every viewer, and settle expired + * pending publishes with a `publish_timeout` error (outcome "unknown" at + * the SDK: the bridge may still have published). Offers reap * opportunistically, but an idle input stops offering; server.ts drives * this on an interval. Clock passed in so tests fabricate time. */ reapAll(nowMs: number): void { for (const viewer of this.#viewers) { for (const policy of viewer.policies.values()) policy.reap(nowMs); } + for (const entry of this.#robots.values()) { + for (const pending of entry.pubPending.values()) { + if (nowMs < pending.deadlineMs) continue; + this.#settlePub(entry, pending); + this.#pubTimedOut++; + if (this.#viewers.has(pending.viewer)) { + pending.viewer.sendMsg({ + t: "error", + code: "publish_timeout", + message: `no bridge acknowledgement within ${PUBLISH_TIMEOUT_MS} ms`, + requestId: pending.viewerId, + }); + } + } + } } robotsMsg(): Msg { @@ -448,7 +729,9 @@ export class Registry { } stats(): unknown { - const now = Date.now(); + const now = this.#now(); + let pubPending = 0; + for (const entry of this.#robots.values()) pubPending += entry.pubPending.size; return { robots: this.#robotInfos(), viewers: this.#viewers.size, @@ -457,6 +740,14 @@ export class Registry { teleopForwarded: this.#teleopForwarded, teleopDropped: this.#teleopDropped, carrierFailures: this.#carrierFailures, + pub: { + accepted: this.#pubAccepted, + acked: this.#pubAcked, + timedOut: this.#pubTimedOut, + lateAcks: this.#pubLateAcks, + pending: pubPending, + rejected: { ...this.#pubRejected }, + }, perRobot: Object.fromEntries( [...this.#robots].map(([id, e]) => [id, { subs: e.lastChs, @@ -501,12 +792,12 @@ export class Registry { /** Sorted union of the subs of every viewer watching `robotId`, kept to * the current manifest when one was declared (a reconnect can shrink the * manifest under surviving subs). */ - #activeChs(robotId: string, delivery: Map): string[] { + #activeChs(robotId: string, specs: Map): string[] { const chs = new Set(); for (const viewer of this.#viewers) { if (viewer.watched !== robotId) continue; for (const ch of viewer.subs) { - if (delivery.size === 0 || delivery.has(ch)) chs.add(ch); + if (specs.size === 0 || specs.has(ch)) chs.add(ch); } } return [...chs].sort(); @@ -520,7 +811,7 @@ export class Registry { #syncSubs(robotId: string, force = false): void { const entry = this.#robots.get(robotId); if (entry === undefined) return; - const chs = this.#activeChs(robotId, entry.delivery); + const chs = this.#activeChs(robotId, entry.specs); if (!force && chs.join("\n") === entry.lastChs.join("\n")) return; entry.lastChs = chs; entry.peer.sendControl({ t: "subs", chs, n: ++entry.n }); diff --git a/web/relay/registry_test.ts b/web/relay/registry_test.ts index 0d22f12dca..d0e01e0697 100644 --- a/web/relay/registry_test.ts +++ b/web/relay/registry_test.ts @@ -6,6 +6,7 @@ import { type ChannelSpec, encodeDataFrame, type FrameHeader, + type JsonValue, type ManifestMsg, type Msg, PROTOCOL_VERSION, @@ -23,7 +24,7 @@ import { ReliableChannel, type ViewerSink, } from "./forward.ts"; -import { Registry, type RobotPeer, type ViewerPeer } from "./registry.ts"; +import { PUBLISH_TIMEOUT_MS, Registry, type RobotPeer, type ViewerPeer } from "./registry.ts"; class FakeSink implements ViewerSink { sent: Uint8Array[] = []; @@ -91,6 +92,8 @@ class FakeRobot implements RobotPeer { msgs: Msg[] = []; /** Robot control carrier messages (subs snapshots). */ control: Msg[] = []; + /** Forwarded publishes (tx data frames on the carrier). */ + pubs: { ch: string; payload: Uint8Array; meta: Record }[] = []; closed: string | null = null; constructor(id: string, channels: ChannelSpec[] = [], manifest: RobotManifest | null = null) { @@ -107,6 +110,10 @@ class FakeRobot implements RobotPeer { this.control.push(msg); } + sendPub(ch: string, payload: Uint8Array, meta: Record): void { + this.pubs.push({ ch, payload, meta }); + } + carrierStats(): CarrierStats { return { queued: 0, queuedBytes: 0, sent: this.control.length, bytesOut: 0 }; } @@ -161,9 +168,22 @@ function tick(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } +function rxSpec(ch: string, encoding: string, delivery: "latest" | "reliable"): ChannelSpec { + return { + ch, + dir: "rx", + encoding, + delivery, + maxHz: 15, + params: {}, + publish: "none", + requiredScope: null, + }; +} + const SPECS: ChannelSpec[] = [ - { ch: "color_image", dir: "rx", encoding: "jpeg.v1", delivery: "latest", maxHz: 15, params: {} }, - { ch: "odom", dir: "rx", encoding: "pose.json.v1", delivery: "reliable", maxHz: 20, params: {} }, + rxSpec("color_image", "jpeg.v1", "latest"), + rxSpec("odom", "pose.json.v1", "reliable"), ]; Deno.test("snapshots fire on 0->1 and ->0, not on redundant subs", () => { @@ -233,18 +253,26 @@ Deno.test("re-watching the same robot keeps subscriptions", () => { }); Deno.test("duplicate live robot id is rejected; reconnect works after close", () => { - const reg = new Registry(); - const first = new FakeRobot("r1", SPECS); + const reg = new Registry(() => 0); + const first = new FakeRobot("r1", [...SPECS, pubSpec("chat", 1)]); assertEquals(reg.registerRobot(first), true); const watcher = attach(reg, "r1", ["odom"]); - const second = new FakeRobot("r1", SPECS); + const second = new FakeRobot("r1", [...SPECS, pubSpec("chat", 100)]); assertEquals(reg.registerRobot(second), false); assertEquals(first.closed, null); assertEquals(second.subs(), []); assertEquals(watcher.pushed, []); assertEquals((reg.robotsMsg() as { robots: RobotInfo[] }).robots, [first.info!]); + // A publisher empties its 1 Hz chat bucket against the first registration + // (capacity 1, zero refill under the frozen clock). + const publisher = attach(reg, "r1", []); + pub(reg, publisher, "p-1", "chat", 1.5); + assertEquals(first.pubs.length, 1); + pub(reg, publisher, "p-2", "chat", 1.5); + assertEquals(lastError(publisher).code, "rate_limited"); + reg.robotClosed(first); assertEquals(reg.registerRobot(second), true); // The returning robot reattaches the surviving viewer state and announces @@ -254,6 +282,13 @@ Deno.test("duplicate live robot id is rejected; reconnect works after close", () { t: "robots", robots: [second.info!] }, ]); assertEquals(second.lastSubs(), { t: "subs", chs: ["odom"], n: 1 }); + + // The restart raised chat's maxHz 1 -> 100: the surviving viewer's stale + // (empty) bucket must be rebuilt at the new rate, not keep limiting at the + // dead registration's 1 Hz. + for (let i = 0; i < 5; i++) pub(reg, publisher, `q${i}`, "chat", 1.5); + assertEquals(second.pubs.length, 5); + assertEquals(pubStats(reg).rejected.rate_limited, 1); // only the p-2 probe }); Deno.test("hello replies welcome + robots; register/close push robots", () => { @@ -496,10 +531,7 @@ Deno.test("sub while the watched robot is offline is rejected", () => { Deno.test("reconnect-stale sub is filtered from snapshots, frames fall back to header delivery", () => { const reg = new Registry(); - const withMystery: ChannelSpec[] = [ - ...SPECS, - { ch: "mystery", dir: "rx", encoding: "x", delivery: "latest", maxHz: 1, params: {} }, - ]; + const withMystery: ChannelSpec[] = [...SPECS, rxSpec("mystery", "x", "latest")]; const first = new FakeRobot("r1", withMystery); reg.registerRobot(first); const viewer = attach(reg, "r1", ["mystery"]); @@ -827,3 +859,353 @@ Deno.test("stats expose the lease holder", () => { stats = reg.stats() as typeof stats; assertEquals(stats.perRobot.r1.teleop, holder.id); }); + +// ---------- generic publish (W7) ---------- + +function pubSpec(ch: string, maxHz = 5): ChannelSpec { + return { + ch, + dir: "tx", + encoding: "text.json.v1", + delivery: "reliable", + maxHz, + params: {}, + publish: "shared", + requiredScope: null, + }; +} + +function pub(reg: Registry, viewer: FakeViewer, id: string, ch: string, data: JsonValue): void { + send(reg, viewer, { t: "pub", id, ch, data }); +} + +function lastError(viewer: FakeViewer): { code: string; requestId?: string } { + const last = viewer.replies.at(-1) as { t: string; code: string; requestId?: string }; + assertEquals(last.t, "error"); + return last; +} + +function pubStats(reg: Registry): { + accepted: number; + acked: number; + timedOut: number; + lateAcks: number; + pending: number; + rejected: Record; +} { + return (reg.stats() as { pub: ReturnType }).pub; +} + +Deno.test("pub forwards with stamped meta and routes exactly one ack", () => { + const now = 10_000; + const reg = new Registry(() => now); + const robot = new FakeRobot("r1", [...SPECS, pubSpec("human_input")]); + reg.registerRobot(robot); + const sender = attach(reg, "r1", []); + const bystander = attach(reg, "r1", []); + send(reg, sender, { + t: "pub", + id: "v-1", + ch: "human_input", + data: { text: "salut β" }, + clientTs: 9.5, + }); + assertEquals(robot.pubs.length, 1); + const { ch, payload, meta } = robot.pubs[0]; + assertEquals(ch, "human_input"); + assertEquals(JSON.parse(new TextDecoder().decode(payload)), { text: "salut β" }); + // Relay-authored token, never the viewer's id; the synthetic local + // principal marks the no-auth relay. + assertEquals(meta, { id: "p1", principal: "local", relayTs: now / 1000, clientTs: 9.5 }); + assertEquals(pubStats(reg).pending, 1); + + reg.onRobotPubResult(robot, { + t: "pub_ack", + id: "p1", + ch: "human_input", + relayTs: 10, + bridgeTs: 10.5, + }); + // The ack restores the viewer's own request id and reaches only its sender. + assertEquals(sender.pushed.at(-1), { + t: "pub_ack", + id: "v-1", + ch: "human_input", + relayTs: 10, + bridgeTs: 10.5, + }); + assertEquals(bystander.pushed.filter((m) => m.t === "pub_ack"), []); + const stats = pubStats(reg); + assertEquals([stats.accepted, stats.acked, stats.pending], [1, 1, 0]); + // clientTs is optional and never stamped as undefined/null. + pub(reg, sender, "v-2", "human_input", 1.5); + assertEquals("clientTs" in robot.pubs[1].meta, false); + assertEquals(robot.pubs[1].meta.id, "p2"); + // null is a publishable value: it forwards as the literal JSON "null". + pub(reg, sender, "v-3", "human_input", null); + assertEquals(new TextDecoder().decode(robot.pubs[2].payload), "null"); +}); + +Deno.test("pub validation failures reply correlated errors", () => { + const reg = new Registry(() => 0); + const robot = new FakeRobot("r1", [ + ...SPECS, + pubSpec("human_input"), + { ...pubSpec("goal"), publish: "exclusive" }, + // The teleop shape: tx but publish="none". + { ...pubSpec("tele_cmd_vel"), encoding: "twist.json.v1", delivery: "latest", publish: "none" }, + ]); + reg.registerRobot(robot); + + const unwatched = new FakeViewer(); + reg.addViewer(unwatched); + send(reg, unwatched, { t: "hello", v: PROTOCOL_VERSION, role: "viewer" }); + pub(reg, unwatched, "a", "human_input", 1.5); + assertEquals( + lastError(unwatched), + { + t: "error", + code: "no_watch", + message: "watch a robot before publishing", + requestId: "a", + } as never, + ); + + const viewer = attach(reg, "r1", []); + pub(reg, viewer, "b", "nope", 1.5); + assertEquals(lastError(viewer).code, "unknown_channel"); + pub(reg, viewer, "c", "odom", 1.5); // rx channel + assertEquals(lastError(viewer).code, "not_publishable"); + pub(reg, viewer, "d", "goal", 1.5); // exclusive until W8 + assertEquals(lastError(viewer).code, "not_publishable"); + pub(reg, viewer, "e", "tele_cmd_vel", 1.5); // specialized tx + assertEquals(lastError(viewer).code, "not_publishable"); + pub(reg, viewer, "f", "human_input", "x".repeat(33 * 1024)); + assertEquals(lastError(viewer).code, "publish_too_large"); + + // Duplicate ids are per-viewer and only while pending: settling frees the id. + pub(reg, viewer, "g", "human_input", 1.5); + pub(reg, viewer, "g", "human_input", 2.5); + assertEquals(lastError(viewer).code, "duplicate_request"); + assertEquals(lastError(viewer).requestId, "g"); + reg.onRobotPubResult(robot, { + t: "pub_ack", + id: "p1", + ch: "human_input", + relayTs: 1, + bridgeTs: 2, + }); + pub(reg, viewer, "g", "human_input", 2.5); + assertEquals(robot.pubs.length, 2); + + // A manifest-less robot declared no publishable channels either. + const bare = new FakeRobot("r2", []); + reg.registerRobot(bare); + const bareViewer = attach(reg, "r2", []); + pub(reg, bareViewer, "h", "human_input", 1.5); + assertEquals(lastError(bareViewer).code, "unknown_channel"); + + const rejected = pubStats(reg).rejected; + assertEquals(rejected.no_watch, 1); + assertEquals(rejected.unknown_channel, 2); + assertEquals(rejected.not_publishable, 3); + assertEquals(rejected.publish_too_large, 1); + assertEquals(rejected.duplicate_request, 1); +}); + +Deno.test("pub rate limits: per-viewer bucket, then the aggregate across viewers", () => { + let now = 0; + const reg = new Registry(() => now); + const robot = new FakeRobot("r1", [pubSpec("human_input", 2)]); // capacity 2 + reg.registerRobot(robot); + const a = attach(reg, "r1", []); + const b = attach(reg, "r1", []); + + pub(reg, a, "a1", "human_input", 1.5); + pub(reg, a, "a2", "human_input", 1.5); + pub(reg, a, "a3", "human_input", 1.5); + assertEquals(lastError(a).code, "rate_limited"); + assertEquals(robot.pubs.length, 2); + + // Viewer B has fresh per-viewer tokens, but the aggregate is exhausted: + // more sessions cannot multiply the accepted robot/channel rate. + pub(reg, b, "b1", "human_input", 1.5); + assertEquals(lastError(b).code, "rate_limited"); + assertEquals(robot.pubs.length, 2); + + // Refill at 2/s: half a second buys one token (viewer and aggregate). + now += 500; + pub(reg, a, "a4", "human_input", 1.5); + assertEquals(robot.pubs.length, 3); +}); + +Deno.test("pub pending caps bound per-viewer and per-robot state", () => { + const reg = new Registry(() => 0); + const robot = new FakeRobot("r1", [pubSpec("human_input", 10_000)]); + reg.registerRobot(robot); + const first = attach(reg, "r1", []); + for (let i = 0; i < 16; i++) pub(reg, first, `f${i}`, "human_input", 1.5); + assertEquals(robot.pubs.length, 16); + pub(reg, first, "f16", "human_input", 1.5); + assertEquals(lastError(first).code, "pending_limit"); + + // Three more viewers reach the 64-entry robot cap; a fresh fifth viewer + // then fails on the robot budget, not its own. + for (let v = 0; v < 3; v++) { + const viewer = attach(reg, "r1", []); + for (let i = 0; i < 16; i++) pub(reg, viewer, `x${i}`, "human_input", 1.5); + } + assertEquals(robot.pubs.length, 64); + const fresh = attach(reg, "r1", []); + pub(reg, fresh, "y0", "human_input", 1.5); + assertEquals(lastError(fresh).code, "pending_limit"); + assertEquals(pubStats(reg).pending, 64); +}); + +Deno.test("pending-capped publishes do not drain the aggregate pub bucket", () => { + const reg = new Registry(() => 0); + // Capacity ceil(32) with zero refill under the frozen clock: if rejected + // requests spent tokens, 16 accepted + 16 pending_limit rejections would + // empty the aggregate and starve the second viewer. + const robot = new FakeRobot("r1", [pubSpec("human_input", 32)]); + reg.registerRobot(robot); + const capped = attach(reg, "r1", []); + for (let i = 0; i < 16; i++) pub(reg, capped, `a${i}`, "human_input", 1.5); + assertEquals(robot.pubs.length, 16); + for (let i = 16; i < 32; i++) { + pub(reg, capped, `a${i}`, "human_input", 1.5); + assertEquals(lastError(capped).code, "pending_limit"); + } + const other = attach(reg, "r1", []); + pub(reg, other, "b0", "human_input", 1.5); + assertEquals(robot.pubs.length, 17); + assertEquals(pubStats(reg).rejected.rate_limited, undefined); +}); + +Deno.test("pub byte budget bounds pending payload volume per viewer", () => { + const reg = new Registry(() => 0); + const robot = new FakeRobot("r1", [pubSpec("human_input", 10_000)]); + reg.registerRobot(robot); + const viewer = attach(reg, "r1", []); + // 30 KiB serialized each: the 9th crosses the 256 KiB viewer byte budget + // well before the 16-entry count cap. + const big = "x".repeat(30 * 1024); + for (let i = 0; i < 8; i++) pub(reg, viewer, `b${i}`, "human_input", big); + assertEquals(robot.pubs.length, 8); + pub(reg, viewer, "b8", "human_input", big); + assertEquals(lastError(viewer).code, "pending_limit"); +}); + +Deno.test("pub timeout settles pending with publish_timeout; a late ack is dropped", () => { + let now = 0; + const reg = new Registry(() => now); + const robot = new FakeRobot("r1", [pubSpec("human_input")]); + reg.registerRobot(robot); + const viewer = attach(reg, "r1", []); + pub(reg, viewer, "v-1", "human_input", 1.5); + now += PUBLISH_TIMEOUT_MS - 1; + reg.reapAll(now); + assertEquals(pubStats(reg).pending, 1); + now += 1; + reg.reapAll(now); + assertEquals(viewer.pushed.at(-1), { + t: "error", + code: "publish_timeout", + message: `no bridge acknowledgement within ${PUBLISH_TIMEOUT_MS} ms`, + requestId: "v-1", + }); + const stats = pubStats(reg); + assertEquals([stats.timedOut, stats.pending], [1, 0]); + // The bridge's ack arriving after the timeout is counted, not routed. + reg.onRobotPubResult(robot, { + t: "pub_ack", + id: "p1", + ch: "human_input", + relayTs: 1, + bridgeTs: 2, + }); + assertEquals(pubStats(reg).lateAcks, 1); + assertEquals(viewer.pushed.filter((m) => m.t === "pub_ack"), []); +}); + +Deno.test("viewer disconnect releases its pending publishes", () => { + const reg = new Registry(() => 0); + const robot = new FakeRobot("r1", [pubSpec("human_input")]); + reg.registerRobot(robot); + const viewer = attach(reg, "r1", []); + pub(reg, viewer, "v-1", "human_input", 1.5); + reg.viewerClosed(viewer); + assertEquals(pubStats(reg).pending, 0); + reg.onRobotPubResult(robot, { + t: "pub_ack", + id: "p1", + ch: "human_input", + relayTs: 1, + bridgeTs: 2, + }); + assertEquals(pubStats(reg).lateAcks, 1); +}); + +Deno.test("robot disconnect fails pending publishes to live viewers", () => { + const reg = new Registry(() => 0); + const robot = new FakeRobot("r1", [pubSpec("human_input")]); + reg.registerRobot(robot); + const viewer = attach(reg, "r1", []); + pub(reg, viewer, "v-1", "human_input", 1.5); + reg.robotClosed(robot); + // The error precedes the robots-update push that closes out robotClosed. + assertEquals(viewer.pushed.filter((m) => m.t === "error"), [{ + t: "error", + code: "robot_disconnected", + message: "robot r1 disconnected before acknowledging the publish", + requestId: "v-1", + }]); + assertEquals(pubStats(reg).pending, 0); +}); + +Deno.test("pub_nack maps to a correlated error with a clamped message", () => { + const reg = new Registry(() => 0); + const robot = new FakeRobot("r1", [pubSpec("human_input")]); + reg.registerRobot(robot); + const viewer = attach(reg, "r1", []); + pub(reg, viewer, "v-1", "human_input", 1.5); + reg.onRobotPubResult(robot, { + t: "pub_nack", + id: "p1", + code: "decode_failed", + message: "x".repeat(400), + }); + const err = viewer.pushed.at(-1) as { code: string; message: string; requestId: string }; + assertEquals(err.code, "decode_failed"); + assertEquals(err.message.length, 256); + assertEquals(err.requestId, "v-1"); + assertEquals(pubStats(reg).rejected.decode_failed, 1); +}); + +Deno.test("watch switch clears pub buckets but keeps pending routable", () => { + const reg = new Registry(() => 0); + const robotA = new FakeRobot("ra", [pubSpec("human_input", 1)]); // capacity 1 + const robotB = new FakeRobot("rb", [pubSpec("human_input", 1)]); + reg.registerRobot(robotA); + reg.registerRobot(robotB); + const viewer = attach(reg, "ra", []); + pub(reg, viewer, "v-1", "human_input", 1.5); // pending on A; bucket empty + pub(reg, viewer, "v-2", "human_input", 1.5); + assertEquals(lastError(viewer).code, "rate_limited"); + send(reg, viewer, { t: "watch", robotId: "rb" }); + // Fresh per-viewer bucket for the new robot (the aggregate is per robot). + pub(reg, viewer, "v-3", "human_input", 1.5); + assertEquals(robotB.pubs.length, 1); + // The pending publish on A still routes its ack to the live viewer. + reg.onRobotPubResult(robotA, { + t: "pub_ack", + id: "p1", + ch: "human_input", + relayTs: 1, + bridgeTs: 2, + }); + assertEquals( + viewer.pushed.filter((m) => m.t === "pub_ack").map((m) => (m as { id: string }).id), + ["v-1"], + ); +}); diff --git a/web/relay/server_test.ts b/web/relay/server_test.ts index 992fc4a922..ed805deaf3 100644 --- a/web/relay/server_test.ts +++ b/web/relay/server_test.ts @@ -26,6 +26,14 @@ const ROBOT: RobotInfo = { id: "deno-bot", name: "Deno Bot", model: "test" }; const CHANNELS = [ { ch: "color_image", encoding: "jpeg.v1", delivery: "latest", maxHz: 15.5 }, { ch: "odom", encoding: "pose.json.v1", delivery: "reliable", maxHz: 20.5 }, + { + ch: "human_input", + dir: "tx", + encoding: "text.json.v1", + delivery: "reliable", + maxHz: 50.5, + publish: "shared", + }, ]; const MANIFEST: RobotManifest = { version: 1, @@ -147,6 +155,40 @@ function robotControl(wt: WebTransport): () => Promise { }; } +/** Like robotControl, but splits the carrier into @control messages and + * forwarded publish (tx data) frames. Serial use only: nextMsg/nextPub share + * one frame cursor. */ +function robotCarrier(wt: WebTransport): { + nextMsg: () => Promise; + nextPub: () => Promise<{ header: FrameHeader; payload: Uint8Array }>; +} { + const nextFrame = frameQueue(wt); + const msgs: Msg[] = []; + const pubs: { header: FrameHeader; payload: Uint8Array }[] = []; + const pump = async (want: "msg" | "pub") => { + while ((want === "msg" ? msgs : pubs).length === 0) { + const { header, payload } = await nextFrame(); + if (header.ch === CONTROL_CHANNEL) { + const msg = decodeDatagram(payload); + assert(msg !== null, "undecodable @control payload"); + msgs.push(msg); + } else { + pubs.push({ header, payload }); + } + } + }; + return { + nextMsg: async () => { + await pump("msg"); + return msgs.shift()!; + }, + nextPub: async () => { + await pump("pub"); + return pubs.shift()!; + }, + }; +} + async function sendRobotFrame(robot: WebTransport, header: FrameHeader, payload: Uint8Array) { const stream = await robot.createBidirectionalStream(); const writer = stream.writable.getWriter(); @@ -418,7 +460,8 @@ Deno.test({ const robot = new WebTransport(`${relay.wtUrl}/robot`, certOpts(relay.certHash)); await within(robot.ready, "robot connect"); const robotDatagrams = datagramQueue(robot.datagrams.readable); - const robotCtrl = robotControl(robot); + const carrier = robotCarrier(robot); + const robotCtrl = carrier.nextMsg; await t.step( "robot hello (@control stream frame) -> welcome + carrier baseline subs", @@ -579,6 +622,80 @@ Deno.test({ assertEquals(got.header.seq, 5); }); + await t.step( + "publish: pub -> stamped carrier tx frame -> bridge ack -> exactly one pub_ack", + async () => { + await controlWriter.write( + encodeControlFrame({ + t: "pub", + id: "v-1", + ch: "human_input", + data: { text: "salut β" }, + clientTs: 42.5, + }), + ); + const { header, payload } = await within(carrier.nextPub(), "carrier tx frame"); + assertEquals(header.ch, "human_input"); + assertEquals(header.delivery, "reliable"); + const meta = header.meta as Record; + // Relay-authored token + synthetic local principal, never the viewer id. + assertEquals(meta.id, "p1"); + assertEquals(meta.principal, "local"); + assertEquals(meta.clientTs, 42.5); + assert(typeof meta.relayTs === "number"); + assertEquals(JSON.parse(new TextDecoder().decode(payload)), { text: "salut β" }); + + // The bridge acks on a robot-opened one-shot @control stream after + // publishing; the relay routes it back with the viewer's own id. + await sendRobotFrame( + robot, + { ch: CONTROL_CHANNEL, seq: 0, ts: 43.5, delivery: "reliable" }, + encodeDatagram({ + t: "pub_ack", + id: "p1", + ch: "human_input", + relayTs: 42.5, + bridgeTs: 43.5, + }), + ); + assertEquals(await nextOfType(nextControl, "pub_ack", "viewer pub_ack"), { + t: "pub_ack", + id: "v-1", + ch: "human_input", + relayTs: 42.5, + bridgeTs: 43.5, + }); + + // A duplicate ack is dropped: the ordered control stream shows nothing + // between here and the next pong. + await sendRobotFrame( + robot, + { ch: CONTROL_CHANNEL, seq: 0, ts: 44.5, delivery: "reliable" }, + encodeDatagram({ + t: "pub_ack", + id: "p1", + ch: "human_input", + relayTs: 42.5, + bridgeTs: 44.5, + }), + ); + await controlWriter.write(encodeControlFrame({ t: "ping", n: 77, ts: 77.5 })); + assertEquals(await within(nextControl(), "pong after duplicate ack"), { + t: "pong", + n: 77, + ts: 77.5, + }); + }, + ); + + await t.step("publish: a rejected pub settles with a correlated error", async () => { + await controlWriter.write(encodeControlFrame({ t: "pub", id: "v-2", ch: "odom", data: 1.5 })); + const err = await nextOfType(nextControl, "error", "correlated error"); + assert(err.t === "error"); + assertEquals(err.code, "not_publishable"); + assertEquals(err.requestId, "v-2"); + }); + await t.step("/api/stats reflects sessions and traffic", async () => { // The idle viewer's close is asynchronous on the relay side; poll it out. let stats = await (await fetch(`${httpBase}/api/stats`)).json(); @@ -720,6 +837,15 @@ Deno.test({ )); }); + await t.step("pub_ack before registration -> invalid_control + close", async () => { + await expectRobotReject("preregistration-ack", "invalid_control", (wt) => + sendRobotFrame( + wt, + { ch: CONTROL_CHANNEL, seq: 0, ts: 0.5, delivery: "reliable" }, + encodeDatagram({ t: "pub_ack", id: "p1", ch: "chat", relayTs: 1.5, bridgeTs: 2.5 }), + )); + }); + await t.step("oversized @control payload -> control_too_large + close", async () => { await expectRobotReject("oversized-control", "control_too_large", (wt) => sendRobotFrame( diff --git a/web/relay/session.ts b/web/relay/session.ts index b33dc43ce1..5fa15f3b49 100644 --- a/web/relay/session.ts +++ b/web/relay/session.ts @@ -112,6 +112,10 @@ export class RobotSession implements RobotPeer { this.#carrier.sendControl(msg); } + sendPub(ch: string, payload: Uint8Array, meta: Record): void { + this.#carrier.sendFrame(ch, payload, meta); + } + carrierStats(): CarrierStats { return this.#carrier.stats(); } @@ -176,28 +180,35 @@ export class RobotSession implements RobotPeer { /** One @-channel frame (header null = the raw header named a reserved * channel but failed validation). Only a well-formed @control hello is * legal before registration; violations reject the session (error - * datagram + close). After registration, unknown control is dropped - a - * registered peer's bytes must not kill its live session. */ + * datagram + close). After registration, publish acks route to the + * registry and other unknown control is dropped - a registered peer's + * bytes must not kill its live session. */ #onControlFrame(header: FrameHeader | null, bytes: Uint8Array): void { // Control payloads reuse the datagram encoding; bytes are a complete // frame, so the lengths peek cannot fail. const lens = peekDataFrameLengths(bytes)!; const msg = header === null ? null : decodeDatagram(bytes.subarray(8 + lens.headerLen)); - if (header === null || header.ch !== CONTROL_CHANNEL || msg === null || msg.t !== "hello") { - if (this.info === null) { - this.#reject( - "invalid_control", - "only a hello @control frame is accepted before registration", - "invalid control frame", - ); - } else { - console.log( - `[relay] dropping unknown robot control frame (ch ${header?.ch ?? "invalid header"})`, - ); + if (header !== null && header.ch === CONTROL_CHANNEL && msg !== null) { + if (msg.t === "hello") { + this.#onHello(msg); + return; } - return; + if (this.info !== null && (msg.t === "pub_ack" || msg.t === "pub_nack")) { + this.#registry.onRobotPubResult(this, msg); + return; + } + } + if (this.info === null) { + this.#reject( + "invalid_control", + "only a hello @control frame is accepted before registration", + "invalid control frame", + ); + } else { + console.log( + `[relay] dropping unknown robot control frame (ch ${header?.ch ?? "invalid header"})`, + ); } - this.#onHello(msg); } /** Hello validation, identical to the v4 datagram path, plus the v5 diff --git a/web/sdk/src/errors.ts b/web/sdk/src/errors.ts index ef8d5a9329..007fa2d150 100644 --- a/web/sdk/src/errors.ts +++ b/web/sdk/src/errors.ts @@ -20,3 +20,21 @@ export class WatchRejectedError extends Error { this.reason = reason; } } + +/** "rejected": the publish definitively did not happen. "unknown": it may + * have (connection loss, timeout - the bridge can publish right before its + * ack is lost); never assume either way, and never auto-resend. */ +export type PublishOutcome = "rejected" | "unknown"; + +export class PublishError extends Error { + constructor( + readonly outcome: PublishOutcome, + /** Stable machine-readable reason: a local validation code, a relay + * rejection code, or a bridge nack code. */ + readonly code: string, + message: string, + ) { + super(`${code}: ${message}`); + this.name = "PublishError"; + } +} diff --git a/web/sdk/src/index.ts b/web/sdk/src/index.ts index 2aa4e2b461..699338d248 100644 --- a/web/sdk/src/index.ts +++ b/web/sdk/src/index.ts @@ -1,13 +1,26 @@ // Public root of @dimos/sdk. React-free by design: the React bindings live // in the ./react subpath so a non-React consumer never pulls React in. -export { connect, type ConnectOptions, EMPTY_MANIFEST, type Session } from "./session.ts"; +export { + connect, + type ConnectOptions, + EMPTY_MANIFEST, + type PublishOptions, + type PublishReceipt, + type Session, +} from "./session.ts"; export { ChannelStore, StatusStore } from "./store.ts"; export type { ChannelSnapshot, ChannelStats, SessionStatus, Slot } from "./store.ts"; export { createDecoderRegistry, DecoderRegistry } from "./decoders/index.ts"; export type { Decoded, Decoder } from "./decoders/index.ts"; export { type CostmapValue, inflateCostmap } from "./decoders/costmap.ts"; -export { type SessionError, type SessionErrorCode, WatchRejectedError } from "./errors.ts"; +export { + PublishError, + type PublishOutcome, + type SessionError, + type SessionErrorCode, + WatchRejectedError, +} from "./errors.ts"; export type { RelayInfo, TransportDeps, TransportPhase } from "./transport.ts"; -export type { ChannelSpec, FrameHeader, PanelSpec, RobotInfo } from "@dimos/shared"; +export type { ChannelSpec, FrameHeader, JsonValue, PanelSpec, RobotInfo } from "@dimos/shared"; export type { Manifest } from "@dimos/shared/manifest"; diff --git a/web/sdk/src/internal/teleopMachine.test.ts b/web/sdk/src/internal/teleopMachine.test.ts index 40c8620cab..139dadb5ca 100644 --- a/web/sdk/src/internal/teleopMachine.test.ts +++ b/web/sdk/src/internal/teleopMachine.test.ts @@ -192,6 +192,8 @@ describe("teleopConfigFromChannel", () => { delivery: "latest", maxHz: 10, params: { maxLinear: 0.5, maxAngular: 0.9, boost: 3 }, + publish: "none", + requiredScope: null, }; it("reads params and maxHz from the channel spec", () => { diff --git a/web/sdk/src/session.test.ts b/web/sdk/src/session.test.ts index cf8e39b51c..8bbb569852 100644 --- a/web/sdk/src/session.test.ts +++ b/web/sdk/src/session.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { type Msg, PROTOCOL_VERSION, type RobotInfo } from "@dimos/shared"; +import { type JsonValue, type Msg, PROTOCOL_VERSION, type RobotInfo } from "@dimos/shared"; import type { CostmapValue } from "./decoders/costmap.ts"; import { createDecoderRegistry } from "./decoders/index.ts"; import { teleopHooks } from "./internal/teleopMachine.ts"; @@ -10,6 +10,7 @@ import { pickAutoWatch, type Session, } from "./session.ts"; +import { PublishError } from "./errors.ts"; import { FakeRelayEnd, INFO, @@ -45,6 +46,17 @@ describe("manifestsEqual", () => { expect(manifestsEqual(manifest([]), manifest([]))).toBe(true); }); + it("detects publish policy and scope changes (a publish-only edit must remount)", () => { + const chat = () => spec({ ch: "chat", dir: "tx", encoding: "text.json.v1", publish: "shared" }); + expect(manifestsEqual(manifest([chat()]), manifest([chat()]))).toBe(true); + expect(manifestsEqual(manifest([chat()]), manifest([{ ...chat(), publish: "none" }]))).toBe( + false, + ); + expect( + manifestsEqual(manifest([chat()]), manifest([{ ...chat(), requiredScope: "chat:send" }])), + ).toBe(false); + }); + it("detects panel changes, including display order", () => { const video = panel({ id: "cam", kind: "video", channels: ["odom"] }); const readout = panel({ id: "pose", kind: "readout", channels: ["odom"] }); @@ -350,6 +362,197 @@ describe("Session over a fake WebTransport", () => { expect(relay.subs()).toEqual([]); }); + const chatSpec = () => + spec({ ch: "chat", dir: "tx", encoding: "text.json.v1", publish: "shared" }); + + async function goLiveChat(relay: FakeRelayEnd, handle: Session): Promise { + await goLive(relay, handle, ROBOT_A, [spec(), chatSpec()]); + } + + function expectPublishError( + promise: Promise, + outcome: "rejected" | "unknown", + code: string, + ): Promise { + return promise.then( + () => { + throw new Error(`expected a PublishError ${code}, got a resolution`); + }, + (e: unknown) => { + expect(e).toBeInstanceOf(PublishError); + expect((e as PublishError).outcome).toBe(outcome); + expect((e as PublishError).code).toBe(code); + }, + ); + } + + it("publish resolves on pub_ack with the receipt fields", async () => { + const { relay, handle } = start(); + await goLiveChat(relay, handle); + const promise = handle.publish("chat", { text: "salut β" }, { clientTs: 9.5 }); + await until(() => relay.pubs().length === 1, "pub sent"); + const pub = relay.pubs()[0]; + expect(pub.t === "pub" && pub.ch).toBe("chat"); + expect(pub.t === "pub" && pub.data).toEqual({ text: "salut β" }); + expect(pub.t === "pub" && pub.clientTs).toBe(9.5); + const id = pub.t === "pub" ? pub.id : ""; + expect(id.length).toBeGreaterThan(4); + relay.push({ t: "pub_ack", id, ch: "chat", relayTs: 10.5, bridgeTs: 11.5 }); + await expect(promise).resolves.toEqual({ ch: "chat", relayTs: 10.5, bridgeTs: 11.5 }); + }); + + it("publish rejects locally with stable codes before anything is sent", async () => { + const { relay, handle } = start(); + // Before the manifest is adopted nothing may go out. + await expectPublishError(handle.publish("chat", 1.5), "rejected", "not_connected"); + await goLive(relay, handle, ROBOT_A, [ + spec(), + chatSpec(), + spec({ ch: "goal", dir: "tx", encoding: "goal.json.v1", publish: "exclusive" }), + ]); + await expectPublishError(handle.publish("ghost", 1.5), "rejected", "unknown_channel"); + await expectPublishError(handle.publish("odom", 1.5), "rejected", "not_publishable"); + await expectPublishError(handle.publish("goal", 1.5), "rejected", "exclusive_unsupported"); + // Runtime callers can bypass the JsonValue type; anything JSON.stringify + // would silently drop or rewrite rejects instead of mutating in flight. + await expectPublishError( + handle.publish("chat", undefined as unknown as JsonValue), + "rejected", + "not_serializable", + ); + await expectPublishError(handle.publish("chat", NaN), "rejected", "not_serializable"); + await expectPublishError(handle.publish("chat", Infinity), "rejected", "not_serializable"); + await expectPublishError( + handle.publish("chat", { a: undefined } as unknown as JsonValue), + "rejected", + "not_serializable", + ); + await expectPublishError( + handle.publish("chat", new Date() as unknown as JsonValue), + "rejected", + "not_serializable", + ); + let deep: JsonValue = 1.5; + for (let i = 0; i < 101; i++) deep = [deep]; + await expectPublishError(handle.publish("chat", deep), "rejected", "not_serializable"); + await expectPublishError( + handle.publish("chat", 1.5, { clientTs: NaN }), + "rejected", + "not_serializable", + ); + await expectPublishError( + handle.publish("chat", "x".repeat(33 * 1024)), + "rejected", + "too_large", + ); + await settle(); + expect(relay.pubs()).toEqual([]); + // Local rejections never touch the session error banner. + expect(handle.status.get().lastError).toBeNull(); + + handle.close(); + await expectPublishError(handle.publish("chat", 1.5), "rejected", "closed"); + }); + + it("publish accepts null and sends it as JSON null", async () => { + const { relay, handle } = start(); + await goLiveChat(relay, handle); + const promise = handle.publish("chat", null); + await until(() => relay.pubs().length === 1, "pub sent"); + const pub = relay.pubs()[0]; + expect(pub.t === "pub" && pub.data).toBeNull(); + const id = pub.t === "pub" ? pub.id : ""; + relay.push({ t: "pub_ack", id, ch: "chat", relayTs: 1.5, bridgeTs: 2.5 }); + await expect(promise).resolves.toEqual({ ch: "chat", relayTs: 1.5, bridgeTs: 2.5 }); + }); + + it("a correlated error rejects the publish and skips the error banner", async () => { + const { relay, handle } = start(); + await goLiveChat(relay, handle); + const promise = handle.publish("chat", 1.5); + await until(() => relay.pubs().length === 1, "pub sent"); + const pub = relay.pubs()[0]; + const id = pub.t === "pub" ? pub.id : ""; + relay.push({ t: "error", code: "rate_limited", message: "prea rapid", requestId: id }); + await expectPublishError(promise, "rejected", "rate_limited"); + expect(handle.status.get().lastError).toBeNull(); + // Codes that cannot prove non-delivery map to outcome "unknown". + const second = handle.publish("chat", 2.5); + await until(() => relay.pubs().length === 2, "second pub"); + const pub2 = relay.pubs()[1]; + relay.push({ + t: "error", + code: "publish_timeout", + message: "no ack", + requestId: pub2.t === "pub" ? pub2.id : "", + }); + await expectPublishError(second, "unknown", "publish_timeout"); + // An uncorrelated error still banners, and one for an unknown id is + // dropped without touching anything. + relay.push({ t: "error", code: "some_error", message: "x", requestId: "nope-1" }); + relay.push({ t: "error", code: "other_error", message: "y" }); + await until(() => handle.status.get().lastError !== null, "banner"); + expect(handle.status.get().lastError?.message).toBe("other_error: y"); + }); + + it("connection loss rejects in-flight publishes as unknown and never resends", async () => { + const { relays, handle } = startReconnecting(); + await until(() => relays.length === 1, "first connection"); + const relay = relays[0]; + await goLiveChat(relay, handle); + const promise = handle.publish("chat", 1.5); + await until(() => relay.pubs().length === 1, "pub sent"); + relay.endControl(); + await expectPublishError(promise, "unknown", "connection_lost"); + // The next connection comes up empty: no automatic resend, ever. + await until(() => relays.length >= 2, "reconnect"); + await settle(); + expect(relays[0].pubs().length).toBe(1); + for (const later of relays.slice(1)) expect(later.pubs()).toEqual([]); + }); + + it("close() settles every pending publish and clears its timer", async () => { + const { relay, handle } = start(); + await goLiveChat(relay, handle); + const first = handle.publish("chat", 1.5); + const second = handle.publish("chat", 2.5); + await until(() => relay.pubs().length === 2, "pubs sent"); + handle.close(); + await expectPublishError(first, "unknown", "closed"); + await expectPublishError(second, "unknown", "closed"); + }); + + it("the local safety timer settles a never-answered publish as unknown", async () => { + const { relay, handle } = start(); + await goLiveChat(relay, handle); + // The publish must run under fake timers so its safety timer is fake too. + vi.useFakeTimers(); + try { + const promise = handle.publish("chat", 1.5); + promise.catch(() => {}); // settled by fake time below; no unhandled noise + await vi.advanceTimersByTimeAsync(20_000); + await expectPublishError(promise, "unknown", "publish_timeout"); + } finally { + vi.useRealTimers(); + } + await settle(); + expect(relay.pubs().length).toBe(1); // it really went out; nothing answered + }); + + it("bounds the pending publish map", async () => { + const { relay, handle } = start(); + await goLiveChat(relay, handle); + const pending: Promise[] = []; + for (let i = 0; i < 64; i++) { + const p = handle.publish("chat", i + 0.5); + p.catch(() => {}); + pending.push(p); + } + await expectPublishError(handle.publish("chat", 65.5), "rejected", "pending_limit"); + handle.close(); + await Promise.allSettled(pending); + }); + it("keeps notifying other subscribers when one callback throws", async () => { const { relay, handle } = start({ uiTickMs: 3_600_000 }); await goLive(relay, handle); diff --git a/web/sdk/src/session.ts b/web/sdk/src/session.ts index c1b41a1b0c..8c739679b9 100644 --- a/web/sdk/src/session.ts +++ b/web/sdk/src/session.ts @@ -11,13 +11,15 @@ import { DataFrameStreamReader, encodeControlFrame, encodeDatagram, + type JsonValue, + MAX_PUB_DATA_BYTES, type Msg, PROTOCOL_VERSION, type RobotInfo, } from "@dimos/shared"; import { type Manifest, ManifestError, parseManifest } from "@dimos/shared/manifest"; import { createDecoderRegistry, type DecoderRegistry } from "./decoders/index.ts"; -import { WatchRejectedError } from "./errors.ts"; +import { PublishError, WatchRejectedError } from "./errors.ts"; import { registerTeleopHooks, type TeleopHooks } from "./internal/teleopMachine.ts"; import { type ChannelSnapshot, ChannelStore, StatusStore } from "./store.ts"; import { @@ -30,6 +32,19 @@ import { const DEFAULT_UI_TICK_MS = 500; +// Local safety net over the relay's own 10 s publish timeout: a wedged-but- +// alive transport must not leak pending promises forever. +const PUBLISH_LOCAL_TIMEOUT_MS = 20_000; +// Local pending cap; the relay enforces its own (stricter) budgets. +const MAX_PENDING_PUBLISHES = 64; +// Publish values nesting deeper than this are rejected locally; the bridge +// enforces the same cap, so both ends agree on what "too deep" means. +const MAX_PUBLISH_DEPTH = 100; + +// Correlated relay codes whose outcome is genuinely unknown: the bridge may +// have published even though no ack made it back. +const UNKNOWN_OUTCOME_CODES = new Set(["publish_timeout", "robot_disconnected"]); + export interface ConnectOptions { /** HTTP relay base to resolve /api/info against; default: same origin. */ url?: string; @@ -41,6 +56,20 @@ export interface ConnectOptions { uiTickMs?: number; } +/** A resolved publish: the bridge decoded the value and called the DimOS + * output stream. Timestamps are seconds since epoch (relay receive time and + * bridge publish time). */ +export interface PublishReceipt { + ch: string; + relayTs: number; + bridgeTs: number; +} + +export interface PublishOptions { + /** Browser send time forwarded to the bridge's PublishContext. */ + clientTs?: number; +} + export interface Session { readonly status: StatusStore; readonly store: ChannelStore; @@ -59,6 +88,15 @@ export interface Session { * staying desired for a later manifest. */ subscribe(ch: string, cb: (snapshot: ChannelSnapshot) => void): () => void; + /** + * Publish one JSON value on a tx channel declared publish="shared". + * Resolves once the bridge decoded the value and called the DimOS output + * stream; rejects with PublishError - outcome "rejected" for definite + * validation failures, "unknown" when the connection died (or nothing + * answered) after the send. Never auto-resends: an "unknown" command may + * well have been published. + */ + publish(ch: string, value: JsonValue, options?: PublishOptions): Promise; close(): void; } @@ -67,6 +105,49 @@ export function pickAutoWatch(robots: RobotInfo[]): RobotInfo | null { return robots.length === 1 ? robots[0] : null; } +/** Random per-session publish-id prefix, so ids from independent sessions + * (or a reloaded page) cannot collide at the relay's per-viewer dup check. */ +function randomIdPrefix(): string { + const bytes = new Uint8Array(6); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** First reason `v` is not a publishable JSON value, or null if it is one. + * Stricter than JSON.stringify on purpose: anything stringify would silently + * rewrite (non-finite numbers, undefined members, array holes, non-plain + * objects) is rejected instead. The depth cap bounds recursion (cycles + * included); messages are short constants that never echo the value. */ +function findNonJson(v: unknown, depth: number): string | null { + if (depth > MAX_PUBLISH_DEPTH) { + return `value nests deeper than ${MAX_PUBLISH_DEPTH} levels`; + } + if (v === null || typeof v === "boolean" || typeof v === "string") return null; + if (typeof v === "number") { + return Number.isFinite(v) ? null : "value contains a non-finite number"; + } + if (Array.isArray(v)) { + for (const item of v) { + const bad = findNonJson(item, depth + 1); + if (bad !== null) return bad; + } + return null; + } + if (typeof v === "object") { + const proto = Object.getPrototypeOf(v); + if (proto !== Object.prototype && proto !== null) { + return "value contains a non-plain object"; + } + // Own enumerable string-keyed values: exactly what JSON.stringify emits. + for (const item of Object.values(v)) { + const bad = findNonJson(item, depth + 1); + if (bad !== null) return bad; + } + return null; + } + return `value contains a non-JSON ${typeof v}`; +} + /** Structural equality over plain-JSON values (params, layout trees). Key * order is ignored so a re-serialized but identical manifest does not churn * the epoch. */ @@ -112,7 +193,9 @@ export function manifestsEqual(a: Manifest, b: Manifest): boolean { c.encoding === other.encoding && c.delivery === other.delivery && c.maxHz === other.maxHz && - jsonEqual(c.params, other.params) + jsonEqual(c.params, other.params) && + c.publish === other.publish && + c.requiredScope === other.requiredScope ); }); const panelsEqual = a.panels.every((p, i) => { @@ -165,6 +248,19 @@ class SessionImpl implements Session { | { id: string; resolve: (m: Manifest) => void; reject: (e: Error) => void } | null = null; + // Pending publishes by request id; each settles exactly once - by pub_ack, + // a correlated error, the local safety timer, its connection's death (the + // run-loop sweep), or close(). Ids are session-random + counter, bounded. + #pending = new Map void; + reject: (error: PublishError) => void; + timer: ReturnType; + }>(); + #pubPrefix = randomIdPrefix(); + #pubN = 0; + // Subscription refcounts. #desired counts live subscribe() consumers per // channel and survives reconnects and manifest changes. #wire mirrors the // relay-side viewer subscription set: cleared on a new connection and on a @@ -224,6 +320,8 @@ class SessionImpl implements Session { if (this.#closed) return; this.#closed = true; this.#rejectWaiter(new WatchRejectedError("closed")); + // A publish already sent may have been delivered; close cannot know. + this.#sweepPendingPublishes(null, "closed", "the session was closed"); clearInterval(this.#ticker); this.transport.stop(); } @@ -269,6 +367,92 @@ class SessionImpl implements Session { } } + publish(ch: string, value: JsonValue, options?: PublishOptions): Promise { + const rejectLocal = (code: string, message: string) => + Promise.reject(new PublishError("rejected", code, message)); + if (this.#closed) return rejectLocal("closed", "the session is closed"); + // The subscription gate: a live connection whose manifest is adopted. + if (this.#wireRunId !== this.#runId || this.#manifest === null || this.#send === null) { + return rejectLocal("not_connected", "no adopted manifest on a live connection"); + } + // Validate, then serialize exactly once: the bytes checked below are the + // bytes sent (the message carries their parse, so a getter re-reading + // differently cannot smuggle an unchecked value onto the wire). + let data: string; + try { + const bad = findNonJson(value, 0); + if (bad !== null) return rejectLocal("not_serializable", bad); + data = JSON.stringify(value); + } catch { + // A property getter threw mid-traversal or mid-stringify. + return rejectLocal("not_serializable", "value threw while serializing"); + } + const clientTs = options?.clientTs; + if (clientTs !== undefined && !Number.isFinite(clientTs)) { + return rejectLocal("not_serializable", "clientTs must be a finite number"); + } + const bytes = new TextEncoder().encode(data).byteLength; + if (bytes > MAX_PUB_DATA_BYTES) { + return rejectLocal( + "too_large", + `serialized data is ${bytes} B (limit ${MAX_PUB_DATA_BYTES})`, + ); + } + const spec = this.#manifest.channels.find((c) => c.ch === ch); + if (spec === undefined) { + return rejectLocal("unknown_channel", `no channel ${ch} in the adopted manifest`); + } + if (spec.publish === "exclusive") { + return rejectLocal( + "exclusive_unsupported", + `channel ${ch} requires the exclusive publisher lease (not supported yet)`, + ); + } + if (spec.dir !== "tx" || spec.publish !== "shared") { + return rejectLocal("not_publishable", `channel ${ch} does not accept generic publish`); + } + if (this.#pending.size >= MAX_PENDING_PUBLISHES) { + return rejectLocal("pending_limit", "too many unacknowledged publishes"); + } + const id = `${this.#pubPrefix}-${++this.#pubN}`; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + // The relay's 10 s publish_timeout normally lands first; this only + // fires on a wedged-but-alive transport, where nothing is provable. + this.#settlePub(id)?.reject( + new PublishError("unknown", "publish_timeout", "no acknowledgement (local timeout)"), + ); + }, PUBLISH_LOCAL_TIMEOUT_MS); + this.#pending.set(id, { runId: this.#runId, ch, resolve, reject, timer }); + this.#send!({ + t: "pub", + id, + ch, + data: JSON.parse(data) as JsonValue, + ...(clientTs !== undefined ? { clientTs } : {}), + }); + }); + } + + /** Remove one pending publish, its timer cleared; null if already settled. */ + #settlePub(id: string) { + const pending = this.#pending.get(id); + if (pending === undefined) return null; + this.#pending.delete(id); + clearTimeout(pending.timer); + return pending; + } + + /** Reject pending publishes with outcome "unknown" (a sent command may + * have been delivered): all of them (runId null, close) or one dead + * connection's (its run-loop exit). Never resends anything. */ + #sweepPendingPublishes(runId: number | null, code: string, message: string): void { + for (const [id, pending] of [...this.#pending]) { + if (runId !== null && pending.runId !== runId) continue; + this.#settlePub(id)?.reject(new PublishError("unknown", code, message)); + } + } + #rejectWaiter(error: WatchRejectedError): void { const waiter = this.#watchWaiter; this.#watchWaiter = null; @@ -442,7 +626,27 @@ class SessionImpl implements Session { case "teleop_started": for (const cb of this.#teleopCbs) cb(msg); break; + case "pub_ack": + this.#settlePub(msg.id)?.resolve({ + ch: msg.ch, + relayTs: msg.relayTs, + bridgeTs: msg.bridgeTs, + }); + break; case "error": { + if (msg.requestId !== undefined) { + // A correlated publish failure settles its promise and never + // reaches the session error banner; an unknown id (already + // settled locally) is dropped. + this.#settlePub(msg.requestId)?.reject( + new PublishError( + UNKNOWN_OUTCOME_CODES.has(msg.code) ? "unknown" : "rejected", + msg.code, + msg.message, + ), + ); + break; + } if (msg.code === "version_mismatch") { this.transport.fail(msg.message); } else if (msg.code === "teleop_held") { @@ -464,6 +668,11 @@ class SessionImpl implements Session { } } catch { // control stream died with the connection; the transport reconnects + } finally { + // This connection can never answer its publishes; the commands may + // have been delivered before it died, so the outcome is unknown and + // nothing is ever resent. + this.#sweepPendingPublishes(runId, "connection_lost", "the relay connection died"); } } diff --git a/web/sdk/src/testing/fakeRelay.ts b/web/sdk/src/testing/fakeRelay.ts index 3e7b23b8fd..c3553bf187 100644 --- a/web/sdk/src/testing/fakeRelay.ts +++ b/web/sdk/src/testing/fakeRelay.ts @@ -26,6 +26,8 @@ export function spec(over: Partial = {}): ChannelSpec { delivery: "reliable", maxHz: 20, params: {}, + publish: "none", + requiredScope: null, ...over, }; } @@ -151,10 +153,20 @@ export class FakeRelayEnd { ); } + /** End the control stream (the session's read loop sees done and treats + * the connection as dead; the transport then reconnects). */ + endControl(): void { + this.#control.close(); + } + watches(id: string): number { return this.sent.filter((m) => m.t === "watch" && m.robotId === id).length; } + pubs(): Msg[] { + return this.sent.filter((m) => m.t === "pub"); + } + subs(): string[] { return this.sent.flatMap((m) => (m.t === "sub" ? [m.ch] : [])); } diff --git a/web/shared/fixtures/control_frames.json b/web/shared/fixtures/control_frames.json index f15ff1b091..dc2da4e39e 100644 --- a/web/shared/fixtures/control_frames.json +++ b/web/shared/fixtures/control_frames.json @@ -22,7 +22,9 @@ "maxHz": 15.5, "params": { "quality": 75.5 - } + }, + "publish": "none", + "requiredScope": null }, { "ch": "odom", @@ -30,7 +32,9 @@ "encoding": "pose.json.v1", "delivery": "reliable", "maxHz": 20.5, - "params": {} + "params": {}, + "publish": "none", + "requiredScope": null } ], "panels": [ @@ -52,7 +56,7 @@ "pages": [] } }, - "b64": "5wEAAHsidCI6ImhlbGxvIiwidiI6NSwicm9sZSI6InJvYm90Iiwicm9ib3QiOnsiaWQiOiJnbzItbGFiIiwibmFtZSI6IkdvMiBMYWJvcmF0b3IgzrIiLCJtb2RlbCI6InVuaXRyZWUtZ28yIn0sIm1hbmlmZXN0Ijp7InZlcnNpb24iOjEsImNoYW5uZWxzIjpbeyJjaCI6ImNvbG9yX2ltYWdlIiwiZGlyIjoicngiLCJlbmNvZGluZyI6ImpwZWcudjEiLCJkZWxpdmVyeSI6ImxhdGVzdCIsIm1heEh6IjoxNS41LCJwYXJhbXMiOnsicXVhbGl0eSI6NzUuNX19LHsiY2giOiJvZG9tIiwiZGlyIjoicngiLCJlbmNvZGluZyI6InBvc2UuanNvbi52MSIsImRlbGl2ZXJ5IjoicmVsaWFibGUiLCJtYXhIeiI6MjAuNSwicGFyYW1zIjp7fX1dLCJwYW5lbHMiOlt7ImlkIjoicDAiLCJraW5kIjoidmlkZW8iLCJ0aXRsZSI6IkNhbWVyYSDOsiIsImNoYW5uZWxzIjpbImNvbG9yX2ltYWdlIl0sInBhcmFtcyI6e319XSwibGF5b3V0Ijp7InJvdyI6WyJwMCJdfSwicGFnZXMiOltdfX0=" + "b64": "MwIAAHsidCI6ImhlbGxvIiwidiI6NSwicm9sZSI6InJvYm90Iiwicm9ib3QiOnsiaWQiOiJnbzItbGFiIiwibmFtZSI6IkdvMiBMYWJvcmF0b3IgzrIiLCJtb2RlbCI6InVuaXRyZWUtZ28yIn0sIm1hbmlmZXN0Ijp7InZlcnNpb24iOjEsImNoYW5uZWxzIjpbeyJjaCI6ImNvbG9yX2ltYWdlIiwiZGlyIjoicngiLCJlbmNvZGluZyI6ImpwZWcudjEiLCJkZWxpdmVyeSI6ImxhdGVzdCIsIm1heEh6IjoxNS41LCJwYXJhbXMiOnsicXVhbGl0eSI6NzUuNX0sInB1Ymxpc2giOiJub25lIiwicmVxdWlyZWRTY29wZSI6bnVsbH0seyJjaCI6Im9kb20iLCJkaXIiOiJyeCIsImVuY29kaW5nIjoicG9zZS5qc29uLnYxIiwiZGVsaXZlcnkiOiJyZWxpYWJsZSIsIm1heEh6IjoyMC41LCJwYXJhbXMiOnt9LCJwdWJsaXNoIjoibm9uZSIsInJlcXVpcmVkU2NvcGUiOm51bGx9XSwicGFuZWxzIjpbeyJpZCI6InAwIiwia2luZCI6InZpZGVvIiwidGl0bGUiOiJDYW1lcmEgzrIiLCJjaGFubmVscyI6WyJjb2xvcl9pbWFnZSJdLCJwYXJhbXMiOnt9fV0sImxheW91dCI6eyJyb3ciOlsicDAiXX0sInBhZ2VzIjpbXX19" }, { "name": "hello_viewer", @@ -228,6 +232,75 @@ "t": "teleop_stop" }, "b64": "EwAAAHsidCI6InRlbGVvcF9zdG9wIn0=" + }, + { + "name": "pub", + "message": { + "t": "pub", + "id": "s7k2β-1", + "ch": "human_input", + "data": { + "text": "Salut, robotule! β", + "urgency": 0.75 + }, + "clientTs": 1752576000.25 + }, + "b64": "fAAAAHsidCI6InB1YiIsImlkIjoiczdrMs6yLTEiLCJjaCI6Imh1bWFuX2lucHV0IiwiZGF0YSI6eyJ0ZXh0IjoiU2FsdXQsIHJvYm90dWxlISDOsiIsInVyZ2VuY3kiOjAuNzV9LCJjbGllbnRUcyI6MTc1MjU3NjAwMC4yNX0=" + }, + { + "name": "pub_no_client_ts", + "message": { + "t": "pub", + "id": "s7k2β-2", + "ch": "human_input", + "data": [ + 1.5, + "β", + true + ] + }, + "b64": "RQAAAHsidCI6InB1YiIsImlkIjoiczdrMs6yLTIiLCJjaCI6Imh1bWFuX2lucHV0IiwiZGF0YSI6WzEuNSwizrIiLHRydWVdfQ==" + }, + { + "name": "pub_null_data", + "message": { + "t": "pub", + "id": "s7k2β-3", + "ch": "human_input", + "data": null + }, + "b64": "OgAAAHsidCI6InB1YiIsImlkIjoiczdrMs6yLTMiLCJjaCI6Imh1bWFuX2lucHV0IiwiZGF0YSI6bnVsbH0=" + }, + { + "name": "pub_ack", + "message": { + "t": "pub_ack", + "id": "s7k2β-1", + "ch": "human_input", + "relayTs": 1752576000.5, + "bridgeTs": 1752576000.75 + }, + "b64": "YgAAAHsidCI6InB1Yl9hY2siLCJpZCI6InM3azLOsi0xIiwiY2giOiJodW1hbl9pbnB1dCIsInJlbGF5VHMiOjE3NTI1NzYwMDAuNSwiYnJpZGdlVHMiOjE3NTI1NzYwMDAuNzV9" + }, + { + "name": "pub_nack", + "message": { + "t": "pub_nack", + "id": "p3", + "code": "decode_failed", + "message": "text prea lung β" + }, + "b64": "TwAAAHsidCI6InB1Yl9uYWNrIiwiaWQiOiJwMyIsImNvZGUiOiJkZWNvZGVfZmFpbGVkIiwibWVzc2FnZSI6InRleHQgcHJlYSBsdW5nIM6yIn0=" + }, + { + "name": "error_with_request_id", + "message": { + "t": "error", + "code": "rate_limited", + "message": "publish peste limită β", + "requestId": "s7k2β-1" + }, + "b64": "XwAAAHsidCI6ImVycm9yIiwiY29kZSI6InJhdGVfbGltaXRlZCIsIm1lc3NhZ2UiOiJwdWJsaXNoIHBlc3RlIGxpbWl0xIMgzrIiLCJyZXF1ZXN0SWQiOiJzN2syzrItMSJ9" } ] } diff --git a/web/shared/fixtures/data_frames.json b/web/shared/fixtures/data_frames.json index 59646bb408..f87c51adf9 100644 --- a/web/shared/fixtures/data_frames.json +++ b/web/shared/fixtures/data_frames.json @@ -45,8 +45,8 @@ "ts": 1752576000.5, "delivery": "reliable" }, - "payload_b64": "eyJ0IjoiaGVsbG8iLCJ2Ijo1LCJyb2xlIjoicm9ib3QiLCJyb2JvdCI6eyJpZCI6ImdvMi1sYWIiLCJuYW1lIjoiR28yIExhYm9yYXRvciDOsiIsIm1vZGVsIjoidW5pdHJlZS1nbzIifSwibWFuaWZlc3QiOnsidmVyc2lvbiI6MSwiY2hhbm5lbHMiOlt7ImNoIjoiY29sb3JfaW1hZ2UiLCJkaXIiOiJyeCIsImVuY29kaW5nIjoianBlZy52MSIsImRlbGl2ZXJ5IjoibGF0ZXN0IiwibWF4SHoiOjE1LjUsInBhcmFtcyI6eyJxdWFsaXR5Ijo3NS41fX0seyJjaCI6Im9kb20iLCJkaXIiOiJyeCIsImVuY29kaW5nIjoicG9zZS5qc29uLnYxIiwiZGVsaXZlcnkiOiJyZWxpYWJsZSIsIm1heEh6IjoyMC41LCJwYXJhbXMiOnt9fV0sInBhbmVscyI6W3siaWQiOiJwMCIsImtpbmQiOiJ2aWRlbyIsInRpdGxlIjoiQ2FtZXJhIM6yIiwiY2hhbm5lbHMiOlsiY29sb3JfaW1hZ2UiXSwicGFyYW1zIjp7fX1dLCJsYXlvdXQiOnsicm93IjpbInAwIl19LCJwYWdlcyI6W119fQ==", - "frame_b64": "QQAAAOcBAAB7ImNoIjoiQGNvbnRyb2wiLCJzZXEiOjEsInRzIjoxNzUyNTc2MDAwLjUsImRlbGl2ZXJ5IjoicmVsaWFibGUifXsidCI6ImhlbGxvIiwidiI6NSwicm9sZSI6InJvYm90Iiwicm9ib3QiOnsiaWQiOiJnbzItbGFiIiwibmFtZSI6IkdvMiBMYWJvcmF0b3IgzrIiLCJtb2RlbCI6InVuaXRyZWUtZ28yIn0sIm1hbmlmZXN0Ijp7InZlcnNpb24iOjEsImNoYW5uZWxzIjpbeyJjaCI6ImNvbG9yX2ltYWdlIiwiZGlyIjoicngiLCJlbmNvZGluZyI6ImpwZWcudjEiLCJkZWxpdmVyeSI6ImxhdGVzdCIsIm1heEh6IjoxNS41LCJwYXJhbXMiOnsicXVhbGl0eSI6NzUuNX19LHsiY2giOiJvZG9tIiwiZGlyIjoicngiLCJlbmNvZGluZyI6InBvc2UuanNvbi52MSIsImRlbGl2ZXJ5IjoicmVsaWFibGUiLCJtYXhIeiI6MjAuNSwicGFyYW1zIjp7fX1dLCJwYW5lbHMiOlt7ImlkIjoicDAiLCJraW5kIjoidmlkZW8iLCJ0aXRsZSI6IkNhbWVyYSDOsiIsImNoYW5uZWxzIjpbImNvbG9yX2ltYWdlIl0sInBhcmFtcyI6e319XSwibGF5b3V0Ijp7InJvdyI6WyJwMCJdfSwicGFnZXMiOltdfX0=" + "payload_b64": "eyJ0IjoiaGVsbG8iLCJ2Ijo1LCJyb2xlIjoicm9ib3QiLCJyb2JvdCI6eyJpZCI6ImdvMi1sYWIiLCJuYW1lIjoiR28yIExhYm9yYXRvciDOsiIsIm1vZGVsIjoidW5pdHJlZS1nbzIifSwibWFuaWZlc3QiOnsidmVyc2lvbiI6MSwiY2hhbm5lbHMiOlt7ImNoIjoiY29sb3JfaW1hZ2UiLCJkaXIiOiJyeCIsImVuY29kaW5nIjoianBlZy52MSIsImRlbGl2ZXJ5IjoibGF0ZXN0IiwibWF4SHoiOjE1LjUsInBhcmFtcyI6eyJxdWFsaXR5Ijo3NS41fSwicHVibGlzaCI6Im5vbmUiLCJyZXF1aXJlZFNjb3BlIjpudWxsfSx7ImNoIjoib2RvbSIsImRpciI6InJ4IiwiZW5jb2RpbmciOiJwb3NlLmpzb24udjEiLCJkZWxpdmVyeSI6InJlbGlhYmxlIiwibWF4SHoiOjIwLjUsInBhcmFtcyI6e30sInB1Ymxpc2giOiJub25lIiwicmVxdWlyZWRTY29wZSI6bnVsbH1dLCJwYW5lbHMiOlt7ImlkIjoicDAiLCJraW5kIjoidmlkZW8iLCJ0aXRsZSI6IkNhbWVyYSDOsiIsImNoYW5uZWxzIjpbImNvbG9yX2ltYWdlIl0sInBhcmFtcyI6e319XSwibGF5b3V0Ijp7InJvdyI6WyJwMCJdfSwicGFnZXMiOltdfX0=", + "frame_b64": "QQAAADMCAAB7ImNoIjoiQGNvbnRyb2wiLCJzZXEiOjEsInRzIjoxNzUyNTc2MDAwLjUsImRlbGl2ZXJ5IjoicmVsaWFibGUifXsidCI6ImhlbGxvIiwidiI6NSwicm9sZSI6InJvYm90Iiwicm9ib3QiOnsiaWQiOiJnbzItbGFiIiwibmFtZSI6IkdvMiBMYWJvcmF0b3IgzrIiLCJtb2RlbCI6InVuaXRyZWUtZ28yIn0sIm1hbmlmZXN0Ijp7InZlcnNpb24iOjEsImNoYW5uZWxzIjpbeyJjaCI6ImNvbG9yX2ltYWdlIiwiZGlyIjoicngiLCJlbmNvZGluZyI6ImpwZWcudjEiLCJkZWxpdmVyeSI6ImxhdGVzdCIsIm1heEh6IjoxNS41LCJwYXJhbXMiOnsicXVhbGl0eSI6NzUuNX0sInB1Ymxpc2giOiJub25lIiwicmVxdWlyZWRTY29wZSI6bnVsbH0seyJjaCI6Im9kb20iLCJkaXIiOiJyeCIsImVuY29kaW5nIjoicG9zZS5qc29uLnYxIiwiZGVsaXZlcnkiOiJyZWxpYWJsZSIsIm1heEh6IjoyMC41LCJwYXJhbXMiOnt9LCJwdWJsaXNoIjoibm9uZSIsInJlcXVpcmVkU2NvcGUiOm51bGx9XSwicGFuZWxzIjpbeyJpZCI6InAwIiwia2luZCI6InZpZGVvIiwidGl0bGUiOiJDYW1lcmEgzrIiLCJjaGFubmVscyI6WyJjb2xvcl9pbWFnZSJdLCJwYXJhbXMiOnt9fV0sImxheW91dCI6eyJyb3ciOlsicDAiXX0sInBhZ2VzIjpbXX19" }, { "name": "control_subs", @@ -58,6 +58,23 @@ }, "payload_b64": "eyJ0Ijoic3VicyIsImNocyI6WyJjb2xvcl9pbWFnZSIsIm9kb20iXSwibiI6M30=", "frame_b64": "QgAAAC8AAAB7ImNoIjoiQGNvbnRyb2wiLCJzZXEiOjIsInRzIjoxNzUyNTc2MDAwLjc1LCJkZWxpdmVyeSI6InJlbGlhYmxlIn17InQiOiJzdWJzIiwiY2hzIjpbImNvbG9yX2ltYWdlIiwib2RvbSJdLCJuIjozfQ==" + }, + { + "name": "pub_tx_frame", + "header": { + "ch": "human_input", + "seq": 3, + "ts": 1752576000.5, + "delivery": "reliable", + "meta": { + "id": "p1", + "principal": "local", + "relayTs": 1752576000.5, + "clientTs": 1752576000.25 + } + }, + "payload_b64": "eyJ0ZXh0IjoiU2FsdXQsIHJvYm90dWxlISDOsiIsInVyZ2VuY3kiOjAuNzV9", + "frame_b64": "mwAAAC0AAAB7ImNoIjoiaHVtYW5faW5wdXQiLCJzZXEiOjMsInRzIjoxNzUyNTc2MDAwLjUsImRlbGl2ZXJ5IjoicmVsaWFibGUiLCJtZXRhIjp7ImlkIjoicDEiLCJwcmluY2lwYWwiOiJsb2NhbCIsInJlbGF5VHMiOjE3NTI1NzYwMDAuNSwiY2xpZW50VHMiOjE3NTI1NzYwMDAuMjV9fXsidGV4dCI6IlNhbHV0LCByb2JvdHVsZSEgzrIiLCJ1cmdlbmN5IjowLjc1fQ==" } ] } diff --git a/web/shared/fixtures/datagrams.json b/web/shared/fixtures/datagrams.json index 68b0c97059..ee92f9053c 100644 --- a/web/shared/fixtures/datagrams.json +++ b/web/shared/fixtures/datagrams.json @@ -22,7 +22,9 @@ "maxHz": 15.5, "params": { "quality": 75.5 - } + }, + "publish": "none", + "requiredScope": null }, { "ch": "odom", @@ -30,7 +32,9 @@ "encoding": "pose.json.v1", "delivery": "reliable", "maxHz": 20.5, - "params": {} + "params": {}, + "publish": "none", + "requiredScope": null } ], "panels": [ @@ -52,7 +56,7 @@ "pages": [] } }, - "b64": "eyJ0IjoiaGVsbG8iLCJ2Ijo1LCJyb2xlIjoicm9ib3QiLCJyb2JvdCI6eyJpZCI6ImdvMi1sYWIiLCJuYW1lIjoiR28yIExhYm9yYXRvciDOsiIsIm1vZGVsIjoidW5pdHJlZS1nbzIifSwibWFuaWZlc3QiOnsidmVyc2lvbiI6MSwiY2hhbm5lbHMiOlt7ImNoIjoiY29sb3JfaW1hZ2UiLCJkaXIiOiJyeCIsImVuY29kaW5nIjoianBlZy52MSIsImRlbGl2ZXJ5IjoibGF0ZXN0IiwibWF4SHoiOjE1LjUsInBhcmFtcyI6eyJxdWFsaXR5Ijo3NS41fX0seyJjaCI6Im9kb20iLCJkaXIiOiJyeCIsImVuY29kaW5nIjoicG9zZS5qc29uLnYxIiwiZGVsaXZlcnkiOiJyZWxpYWJsZSIsIm1heEh6IjoyMC41LCJwYXJhbXMiOnt9fV0sInBhbmVscyI6W3siaWQiOiJwMCIsImtpbmQiOiJ2aWRlbyIsInRpdGxlIjoiQ2FtZXJhIM6yIiwiY2hhbm5lbHMiOlsiY29sb3JfaW1hZ2UiXSwicGFyYW1zIjp7fX1dLCJsYXlvdXQiOnsicm93IjpbInAwIl19LCJwYWdlcyI6W119fQ==" + "b64": "eyJ0IjoiaGVsbG8iLCJ2Ijo1LCJyb2xlIjoicm9ib3QiLCJyb2JvdCI6eyJpZCI6ImdvMi1sYWIiLCJuYW1lIjoiR28yIExhYm9yYXRvciDOsiIsIm1vZGVsIjoidW5pdHJlZS1nbzIifSwibWFuaWZlc3QiOnsidmVyc2lvbiI6MSwiY2hhbm5lbHMiOlt7ImNoIjoiY29sb3JfaW1hZ2UiLCJkaXIiOiJyeCIsImVuY29kaW5nIjoianBlZy52MSIsImRlbGl2ZXJ5IjoibGF0ZXN0IiwibWF4SHoiOjE1LjUsInBhcmFtcyI6eyJxdWFsaXR5Ijo3NS41fSwicHVibGlzaCI6Im5vbmUiLCJyZXF1aXJlZFNjb3BlIjpudWxsfSx7ImNoIjoib2RvbSIsImRpciI6InJ4IiwiZW5jb2RpbmciOiJwb3NlLmpzb24udjEiLCJkZWxpdmVyeSI6InJlbGlhYmxlIiwibWF4SHoiOjIwLjUsInBhcmFtcyI6e30sInB1Ymxpc2giOiJub25lIiwicmVxdWlyZWRTY29wZSI6bnVsbH1dLCJwYW5lbHMiOlt7ImlkIjoicDAiLCJraW5kIjoidmlkZW8iLCJ0aXRsZSI6IkNhbWVyYSDOsiIsImNoYW5uZWxzIjpbImNvbG9yX2ltYWdlIl0sInBhcmFtcyI6e319XSwibGF5b3V0Ijp7InJvdyI6WyJwMCJdfSwicGFnZXMiOltdfX0=" }, { "name": "hello_viewer", @@ -229,6 +233,75 @@ }, "b64": "eyJ0IjoidGVsZW9wX3N0b3AifQ==" }, + { + "name": "pub", + "message": { + "t": "pub", + "id": "s7k2β-1", + "ch": "human_input", + "data": { + "text": "Salut, robotule! β", + "urgency": 0.75 + }, + "clientTs": 1752576000.25 + }, + "b64": "eyJ0IjoicHViIiwiaWQiOiJzN2syzrItMSIsImNoIjoiaHVtYW5faW5wdXQiLCJkYXRhIjp7InRleHQiOiJTYWx1dCwgcm9ib3R1bGUhIM6yIiwidXJnZW5jeSI6MC43NX0sImNsaWVudFRzIjoxNzUyNTc2MDAwLjI1fQ==" + }, + { + "name": "pub_no_client_ts", + "message": { + "t": "pub", + "id": "s7k2β-2", + "ch": "human_input", + "data": [ + 1.5, + "β", + true + ] + }, + "b64": "eyJ0IjoicHViIiwiaWQiOiJzN2syzrItMiIsImNoIjoiaHVtYW5faW5wdXQiLCJkYXRhIjpbMS41LCLOsiIsdHJ1ZV19" + }, + { + "name": "pub_null_data", + "message": { + "t": "pub", + "id": "s7k2β-3", + "ch": "human_input", + "data": null + }, + "b64": "eyJ0IjoicHViIiwiaWQiOiJzN2syzrItMyIsImNoIjoiaHVtYW5faW5wdXQiLCJkYXRhIjpudWxsfQ==" + }, + { + "name": "pub_ack", + "message": { + "t": "pub_ack", + "id": "s7k2β-1", + "ch": "human_input", + "relayTs": 1752576000.5, + "bridgeTs": 1752576000.75 + }, + "b64": "eyJ0IjoicHViX2FjayIsImlkIjoiczdrMs6yLTEiLCJjaCI6Imh1bWFuX2lucHV0IiwicmVsYXlUcyI6MTc1MjU3NjAwMC41LCJicmlkZ2VUcyI6MTc1MjU3NjAwMC43NX0=" + }, + { + "name": "pub_nack", + "message": { + "t": "pub_nack", + "id": "p3", + "code": "decode_failed", + "message": "text prea lung β" + }, + "b64": "eyJ0IjoicHViX25hY2siLCJpZCI6InAzIiwiY29kZSI6ImRlY29kZV9mYWlsZWQiLCJtZXNzYWdlIjoidGV4dCBwcmVhIGx1bmcgzrIifQ==" + }, + { + "name": "error_with_request_id", + "message": { + "t": "error", + "code": "rate_limited", + "message": "publish peste limită β", + "requestId": "s7k2β-1" + }, + "b64": "eyJ0IjoiZXJyb3IiLCJjb2RlIjoicmF0ZV9saW1pdGVkIiwibWVzc2FnZSI6InB1Ymxpc2ggcGVzdGUgbGltaXTEgyDOsiIsInJlcXVlc3RJZCI6InM3azLOsi0xIn0=" + }, { "name": "twist", "message": { diff --git a/web/shared/fixtures/gen.ts b/web/shared/fixtures/gen.ts index 96e6a31434..942c150917 100644 --- a/web/shared/fixtures/gen.ts +++ b/web/shared/fixtures/gen.ts @@ -30,6 +30,10 @@ function b64(bytes: Uint8Array): string { return btoa(s); } +// Shared by the pub vector and pub_tx_frame: the frame payload must be +// exactly the JSON.stringify of the pub's data (the relay's forwarding rule). +const pubData = { text: "Salut, robotule! β", urgency: 0.75 }; + const controlMsgs: Record = { hello_robot: { t: "hello", @@ -47,6 +51,8 @@ const controlMsgs: Record = { delivery: "latest", maxHz: 15.5, params: { quality: 75.5 }, + publish: "none", + requiredScope: null, }, { ch: "odom", @@ -55,6 +61,8 @@ const controlMsgs: Record = { delivery: "reliable", maxHz: 20.5, params: {}, + publish: "none", + requiredScope: null, }, ], panels: [ @@ -105,6 +113,35 @@ const controlMsgs: Record = { teleop_start: { t: "teleop_start" }, teleop_started: { t: "teleop_started" }, teleop_stop: { t: "teleop_stop" }, + // Generic publish (W7): pub and pub_ack ride the viewer control stream + // (pub_ack.id is the viewer's request id there). pub_nack is robot->relay + // on a one-shot @control stream (id = the relay token); the relay maps it + // to a correlated error like error_with_request_id. + pub: { + t: "pub", + id: "s7k2β-1", + ch: "human_input", + data: pubData, + clientTs: 1752576000.25, + }, + pub_no_client_ts: { t: "pub", id: "s7k2β-2", ch: "human_input", data: [1.5, "β", true] }, + // data spans all of JSON: a top-level null must survive both mirrors' + // encode paths (pins Python against exclude_none eating it). + pub_null_data: { t: "pub", id: "s7k2β-3", ch: "human_input", data: null }, + pub_ack: { + t: "pub_ack", + id: "s7k2β-1", + ch: "human_input", + relayTs: 1752576000.5, + bridgeTs: 1752576000.75, + }, + pub_nack: { t: "pub_nack", id: "p3", code: "decode_failed", message: "text prea lung β" }, + error_with_request_id: { + t: "error", + code: "rate_limited", + message: "publish peste limită β", + requestId: "s7k2β-1", + }, }; const teleopMsgs: Record = { @@ -149,6 +186,19 @@ const dataFrames: Record = header: { ch: CONTROL_CHANNEL, seq: 2, ts: 1752576000.75, delivery: "reliable" }, payload: encodeDatagram(controlMsgs.subs_snapshot), }, + // Generic publish forwarded relay->robot (W7): the pub's JSON data as a + // tx-channel data frame on the robot control carrier, provenance in meta + // (meta.id is the relay-authored token, never the viewer's request id). + pub_tx_frame: { + header: { + ch: "human_input", + seq: 3, + ts: 1752576000.5, + delivery: "reliable", + meta: { id: "p1", principal: "local", relayTs: 1752576000.5, clientTs: 1752576000.25 }, + }, + payload: new TextEncoder().encode(JSON.stringify(pubData)), + }, }; // Manifest vectors: valid cases pin normalization, invalid cases pin the @@ -180,6 +230,15 @@ const chTwist = { maxHz: 15.5, params: { maxLinear: 0.85, maxAngular: 1.5, boost: 2.5, watchdogMs: 300.5 }, }; +const chChat = { + ch: "human_input", + dir: "tx", + encoding: "text.json.v1", + delivery: "reliable", + maxHz: 2.5, + publish: "shared", + requiredScope: "chat:send", +}; const pCamera = { id: "camera", kind: "video", channels: ["color_image"] }; const pPose = { id: "pose", kind: "readout", channels: ["odom"] }; const longId = "x".repeat(65); @@ -230,6 +289,26 @@ const manifestCases: Record = { dir_tx_channel: { version: 1, channels: [{ ...chOdom, dir: "tx" }] }, dir_invalid: { version: 1, channels: [{ ...chOdom, dir: "both" }] }, dir_null: { version: 1, channels: [{ ...chOdom, dir: null }] }, + // Generic-publish rules (W7). The manifest layer accepts "exclusive" + // (authoring and the bridge reject it until W8). + publish_shared_tx: { version: 1, channels: [chChat] }, + publish_exclusive_tx: { version: 1, channels: [{ ...chChat, publish: "exclusive" }] }, + publish_none_roundtrip: { version: 1, channels: [{ ...chOdom, publish: "none" }] }, + required_scope_null_roundtrip: { version: 1, channels: [{ ...chChat, requiredScope: null }] }, + publish_on_rx: { version: 1, channels: [{ ...chOdom, publish: "shared" }] }, + publish_bad_value: { version: 1, channels: [{ ...chOdom, publish: "always" }] }, + publish_null: { version: 1, channels: [{ ...chOdom, publish: null }] }, + publish_shared_latest: { version: 1, channels: [{ ...chChat, delivery: "latest" }] }, + publish_shared_binary_encoding: { version: 1, channels: [{ ...chChat, encoding: "jpeg.v1" }] }, + scope_without_publish: { version: 1, channels: [{ ...chOdom, requiredScope: "chat:send" }] }, + scope_empty: { version: 1, channels: [{ ...chChat, requiredScope: "" }] }, + scope_too_long: { version: 1, channels: [{ ...chChat, requiredScope: longId }] }, + scope_not_string: { version: 1, channels: [{ ...chChat, requiredScope: 1.5 }] }, + // The rx-publish rule fires before the scope rules (document order). + publish_rx_before_scope: { + version: 1, + channels: [{ ...chOdom, publish: "shared", requiredScope: "" }], + }, channel_params_roundtrip: { version: 1, channels: [chImageFull] }, channel_params_not_object: { version: 1, channels: [{ ...chOdom, params: 1.5 }] }, panels_not_list: { version: 1, channels: [chOdom], panels: {} }, diff --git a/web/shared/fixtures/manifests.json b/web/shared/fixtures/manifests.json index 63cf89c333..f9b36913db 100644 --- a/web/shared/fixtures/manifests.json +++ b/web/shared/fixtures/manifests.json @@ -28,7 +28,9 @@ "encoding": "jpeg.v1", "delivery": "latest", "maxHz": 15.5, - "params": {} + "params": {}, + "publish": "none", + "requiredScope": null }, { "ch": "odom", @@ -36,7 +38,9 @@ "encoding": "pose.json.v1", "delivery": "reliable", "maxHz": 20.5, - "params": {} + "params": {}, + "publish": "none", + "requiredScope": null } ], "panels": [], @@ -133,7 +137,9 @@ "encoding": "jpeg.v1", "delivery": "latest", "maxHz": 15.5, - "params": {} + "params": {}, + "publish": "none", + "requiredScope": null }, { "ch": "odom", @@ -141,7 +147,9 @@ "encoding": "pose.json.v1", "delivery": "reliable", "maxHz": 20.5, - "params": {} + "params": {}, + "publish": "none", + "requiredScope": null } ], "panels": [ @@ -242,7 +250,9 @@ "encoding": "pose.json.v1", "delivery": "reliable", "maxHz": 20.5, - "params": {} + "params": {}, + "publish": "none", + "requiredScope": null } ], "panels": [ @@ -534,7 +544,9 @@ "encoding": "pose.json.v1", "delivery": "reliable", "maxHz": 20.5, - "params": {} + "params": {}, + "publish": "none", + "requiredScope": null } ], "panels": [], @@ -574,6 +586,315 @@ }, "error": "invalid_shape" }, + { + "name": "publish_shared_tx", + "data": { + "version": 1, + "channels": [ + { + "ch": "human_input", + "dir": "tx", + "encoding": "text.json.v1", + "delivery": "reliable", + "maxHz": 2.5, + "publish": "shared", + "requiredScope": "chat:send" + } + ] + }, + "manifest": { + "version": 1, + "channels": [ + { + "ch": "human_input", + "dir": "tx", + "encoding": "text.json.v1", + "delivery": "reliable", + "maxHz": 2.5, + "params": {}, + "publish": "shared", + "requiredScope": "chat:send" + } + ], + "panels": [], + "layout": null, + "pages": [] + } + }, + { + "name": "publish_exclusive_tx", + "data": { + "version": 1, + "channels": [ + { + "ch": "human_input", + "dir": "tx", + "encoding": "text.json.v1", + "delivery": "reliable", + "maxHz": 2.5, + "publish": "exclusive", + "requiredScope": "chat:send" + } + ] + }, + "manifest": { + "version": 1, + "channels": [ + { + "ch": "human_input", + "dir": "tx", + "encoding": "text.json.v1", + "delivery": "reliable", + "maxHz": 2.5, + "params": {}, + "publish": "exclusive", + "requiredScope": "chat:send" + } + ], + "panels": [], + "layout": null, + "pages": [] + } + }, + { + "name": "publish_none_roundtrip", + "data": { + "version": 1, + "channels": [ + { + "ch": "odom", + "encoding": "pose.json.v1", + "delivery": "reliable", + "maxHz": 20.5, + "publish": "none" + } + ] + }, + "manifest": { + "version": 1, + "channels": [ + { + "ch": "odom", + "dir": "rx", + "encoding": "pose.json.v1", + "delivery": "reliable", + "maxHz": 20.5, + "params": {}, + "publish": "none", + "requiredScope": null + } + ], + "panels": [], + "layout": null, + "pages": [] + } + }, + { + "name": "required_scope_null_roundtrip", + "data": { + "version": 1, + "channels": [ + { + "ch": "human_input", + "dir": "tx", + "encoding": "text.json.v1", + "delivery": "reliable", + "maxHz": 2.5, + "publish": "shared", + "requiredScope": null + } + ] + }, + "manifest": { + "version": 1, + "channels": [ + { + "ch": "human_input", + "dir": "tx", + "encoding": "text.json.v1", + "delivery": "reliable", + "maxHz": 2.5, + "params": {}, + "publish": "shared", + "requiredScope": null + } + ], + "panels": [], + "layout": null, + "pages": [] + } + }, + { + "name": "publish_on_rx", + "data": { + "version": 1, + "channels": [ + { + "ch": "odom", + "encoding": "pose.json.v1", + "delivery": "reliable", + "maxHz": 20.5, + "publish": "shared" + } + ] + }, + "error": "invalid_publish" + }, + { + "name": "publish_bad_value", + "data": { + "version": 1, + "channels": [ + { + "ch": "odom", + "encoding": "pose.json.v1", + "delivery": "reliable", + "maxHz": 20.5, + "publish": "always" + } + ] + }, + "error": "invalid_shape" + }, + { + "name": "publish_null", + "data": { + "version": 1, + "channels": [ + { + "ch": "odom", + "encoding": "pose.json.v1", + "delivery": "reliable", + "maxHz": 20.5, + "publish": null + } + ] + }, + "error": "invalid_shape" + }, + { + "name": "publish_shared_latest", + "data": { + "version": 1, + "channels": [ + { + "ch": "human_input", + "dir": "tx", + "encoding": "text.json.v1", + "delivery": "latest", + "maxHz": 2.5, + "publish": "shared", + "requiredScope": "chat:send" + } + ] + }, + "error": "invalid_publish" + }, + { + "name": "publish_shared_binary_encoding", + "data": { + "version": 1, + "channels": [ + { + "ch": "human_input", + "dir": "tx", + "encoding": "jpeg.v1", + "delivery": "reliable", + "maxHz": 2.5, + "publish": "shared", + "requiredScope": "chat:send" + } + ] + }, + "error": "invalid_publish" + }, + { + "name": "scope_without_publish", + "data": { + "version": 1, + "channels": [ + { + "ch": "odom", + "encoding": "pose.json.v1", + "delivery": "reliable", + "maxHz": 20.5, + "requiredScope": "chat:send" + } + ] + }, + "error": "invalid_scope" + }, + { + "name": "scope_empty", + "data": { + "version": 1, + "channels": [ + { + "ch": "human_input", + "dir": "tx", + "encoding": "text.json.v1", + "delivery": "reliable", + "maxHz": 2.5, + "publish": "shared", + "requiredScope": "" + } + ] + }, + "error": "invalid_scope" + }, + { + "name": "scope_too_long", + "data": { + "version": 1, + "channels": [ + { + "ch": "human_input", + "dir": "tx", + "encoding": "text.json.v1", + "delivery": "reliable", + "maxHz": 2.5, + "publish": "shared", + "requiredScope": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + ] + }, + "error": "invalid_scope" + }, + { + "name": "scope_not_string", + "data": { + "version": 1, + "channels": [ + { + "ch": "human_input", + "dir": "tx", + "encoding": "text.json.v1", + "delivery": "reliable", + "maxHz": 2.5, + "publish": "shared", + "requiredScope": 1.5 + } + ] + }, + "error": "invalid_shape" + }, + { + "name": "publish_rx_before_scope", + "data": { + "version": 1, + "channels": [ + { + "ch": "odom", + "encoding": "pose.json.v1", + "delivery": "reliable", + "maxHz": 20.5, + "publish": "shared", + "requiredScope": "" + } + ] + }, + "error": "invalid_publish" + }, { "name": "channel_params_roundtrip", "data": { @@ -602,7 +923,9 @@ "maxHz": 15.5, "params": { "quality": 75.5 - } + }, + "publish": "none", + "requiredScope": null } ], "panels": [], @@ -734,7 +1057,9 @@ "encoding": "pose.json.v1", "delivery": "reliable", "maxHz": 20.5, - "params": {} + "params": {}, + "publish": "none", + "requiredScope": null } ], "panels": [ @@ -1061,7 +1386,9 @@ "encoding": "costmap.zlib.v1", "delivery": "latest", "maxHz": 5.5, - "params": {} + "params": {}, + "publish": "none", + "requiredScope": null }, { "ch": "odom", @@ -1069,7 +1396,9 @@ "encoding": "pose.json.v1", "delivery": "reliable", "maxHz": 20.5, - "params": {} + "params": {}, + "publish": "none", + "requiredScope": null } ], "panels": [ @@ -1119,7 +1448,9 @@ "encoding": "costmap.zlib.v1", "delivery": "latest", "maxHz": 5.5, - "params": {} + "params": {}, + "publish": "none", + "requiredScope": null } ], "panels": [ @@ -1376,7 +1707,9 @@ "maxAngular": 1.5, "boost": 2.5, "watchdogMs": 300.5 - } + }, + "publish": "none", + "requiredScope": null } ], "panels": [ @@ -1580,7 +1913,9 @@ "encoding": "pose.json.v1", "delivery": "reliable", "maxHz": 20.5, - "params": {} + "params": {}, + "publish": "none", + "requiredScope": null } ], "panels": [ @@ -1652,7 +1987,9 @@ "encoding": "jpeg.v1", "delivery": "latest", "maxHz": 15.5, - "params": {} + "params": {}, + "publish": "none", + "requiredScope": null }, { "ch": "odom", @@ -1660,7 +1997,9 @@ "encoding": "pose.json.v1", "delivery": "reliable", "maxHz": 20.5, - "params": {} + "params": {}, + "publish": "none", + "requiredScope": null } ], "panels": [ @@ -1760,7 +2099,9 @@ "encoding": "jpeg.v1", "delivery": "latest", "maxHz": 15.5, - "params": {} + "params": {}, + "publish": "none", + "requiredScope": null }, { "ch": "odom", @@ -1768,7 +2109,9 @@ "encoding": "pose.json.v1", "delivery": "reliable", "maxHz": 20.5, - "params": {} + "params": {}, + "publish": "none", + "requiredScope": null } ], "panels": [ @@ -1848,7 +2191,9 @@ "encoding": "pose.json.v1", "delivery": "reliable", "maxHz": 20.5, - "params": {} + "params": {}, + "publish": "none", + "requiredScope": null } ], "panels": [ @@ -2493,7 +2838,9 @@ "encoding": "pose.json.v1", "delivery": "reliable", "maxHz": 20.5, - "params": {} + "params": {}, + "publish": "none", + "requiredScope": null } ], "panels": [ @@ -2561,7 +2908,9 @@ "encoding": "jpeg.v1", "delivery": "latest", "maxHz": 15.5, - "params": {} + "params": {}, + "publish": "none", + "requiredScope": null }, { "ch": "odom", @@ -2569,7 +2918,9 @@ "encoding": "pose.json.v1", "delivery": "reliable", "maxHz": 20.5, - "params": {} + "params": {}, + "publish": "none", + "requiredScope": null } ], "panels": [ diff --git a/web/shared/manifest.ts b/web/shared/manifest.ts index bfcc5b9c74..742bf110b1 100644 --- a/web/shared/manifest.ts +++ b/web/shared/manifest.ts @@ -15,12 +15,21 @@ export type Delivery = "latest" | "reliable"; export type Dir = "rx" | "tx"; +// Generic-publish policy for a tx channel: "none" (default; specialized +// protocol paths like teleop only), "shared" (any authorized viewer may +// publish), "exclusive" (requires the per-robot publisher lease, W8). +// Transport delivery and publish authorization are independent: reliable +// does not imply publishable. +export type Publish = "none" | "shared" | "exclusive"; // One robot<->viewer stream: dir is the flow direction seen from the viewer // (rx = robot->viewer; tx arrives with teleop/chat), encoding names the // payload format (e.g. jpeg.v1, pose.json.v1), delivery picks the relay's -// forwarding policy, maxHz is the bridge's advertised send cap, params are -// encoder settings (informational for viewers). +// forwarding policy, maxHz is the bridge's advertised send cap (for a +// publish channel: the aggregate accepted publish rate), params are encoder +// settings (informational for viewers). publish/requiredScope gate generic +// publishing; requiredScope names the operator scope a remote relay demands +// (null = none; the local relay never checks scopes). export interface ChannelSpec { ch: string; dir: Dir; @@ -28,6 +37,8 @@ export interface ChannelSpec { delivery: Delivery; maxHz: number; params: Record; + publish: Publish; + requiredScope: string | null; } // kind is the panel-component registry key; channels lists the channel ids @@ -87,6 +98,9 @@ function isRecord(value: unknown): value is Record { } // Raw (pre-normalization) spec shapes: dir/params/title may be absent. +// requiredScope also accepts explicit null so a normalized manifest parses +// idempotently (parse(parse(m)) == parse(m)); publish does not (null is a +// shape error, like dir). interface RawChannelSpec { ch: string; dir?: Dir; @@ -94,6 +108,8 @@ interface RawChannelSpec { delivery: Delivery; maxHz: number; params?: Record; + publish?: Publish; + requiredScope?: string | null; } interface RawPanelSpec { @@ -112,7 +128,11 @@ function isChannelSpec(value: unknown): value is RawChannelSpec { typeof value.encoding === "string" && (value.delivery === "latest" || value.delivery === "reliable") && typeof value.maxHz === "number" && - (value.params === undefined || isRecord(value.params)) + (value.params === undefined || isRecord(value.params)) && + (value.publish === undefined || value.publish === "none" || value.publish === "shared" || + value.publish === "exclusive") && + (value.requiredScope === undefined || value.requiredScope === null || + typeof value.requiredScope === "string") ); } @@ -136,6 +156,19 @@ function dirOf(spec: RawChannelSpec): Dir { return spec.dir ?? "rx"; } +function publishOf(spec: RawChannelSpec): Publish { + return spec.publish ?? "none"; +} + +function scopeOf(spec: RawChannelSpec): string | null { + return spec.requiredScope ?? null; +} + +// "Supported JSON encoding" for generic publish is the family-name rule +// (json.v1, text.json.v1, ...): the manifest layer cannot see the codec +// registries, so real decodability is enforced at authoring time. +const JSON_ENCODING_RE = /(^|\.)json\.v[0-9]+$/; + /** * Depth-first layout validation + rebuild. A node's own structure (row/col * exclusivity, children, shares) is checked before its children; string @@ -248,6 +281,34 @@ export function parseManifest(value: unknown): Manifest { if (!Number.isFinite(spec.maxHz) || spec.maxHz <= 0) { throw new ManifestError("invalid_max_hz", `maxHz for ${spec.ch} must be a positive number`); } + // Generic-publish rules. The manifest layer accepts "exclusive" (only + // authoring and the bridge reject it until the lease ticket, W8). + const publish = publishOf(spec); + const scope = scopeOf(spec); + if (dirOf(spec) === "rx" && publish !== "none") { + throw new ManifestError("invalid_publish", `rx channel ${spec.ch} cannot declare publish`); + } + if (publish !== "none" && spec.delivery !== "reliable") { + throw new ManifestError("invalid_publish", `publish channel ${spec.ch} must be reliable`); + } + if (publish !== "none" && !JSON_ENCODING_RE.test(spec.encoding)) { + throw new ManifestError( + "invalid_publish", + `publish channel ${spec.ch} needs a JSON encoding`, + ); + } + if (scope !== null && publish === "none") { + throw new ManifestError( + "invalid_scope", + `channel ${spec.ch} scope requires a publish policy`, + ); + } + if (scope !== null && !boundedId(scope)) { + throw new ManifestError( + "invalid_scope", + `scope for ${spec.ch} must be 1..${MAX_MANIFEST_ID_LEN} chars`, + ); + } } const panelIds = new Set(); @@ -363,6 +424,8 @@ export function parseManifest(value: unknown): Manifest { delivery: spec.delivery, maxHz: spec.maxHz, params: { ...(spec.params ?? {}) }, + publish: publishOf(spec), + requiredScope: scopeOf(spec), })), panels: panels.map((panel) => ({ id: panel.id, diff --git a/web/shared/protocol.ts b/web/shared/protocol.ts index c07164472d..6a86595378 100644 --- a/web/shared/protocol.ts +++ b/web/shared/protocol.ts @@ -23,7 +23,7 @@ import { type Delivery, MAX_MANIFEST_ID_LEN } from "./manifest.ts"; // Channel/manifest domain types live in manifest.ts; re-exported so protocol // consumers keep a single import surface. -export type { ChannelSpec, Delivery, Dir, PanelSpec } from "./manifest.ts"; +export type { ChannelSpec, Delivery, Dir, PanelSpec, Publish } from "./manifest.ts"; export { RESERVED_CHANNEL_PREFIX } from "./manifest.ts"; // v5: the robot hello leaves datagrams (and their ~1100 B budget) and rides @@ -31,7 +31,10 @@ export { RESERVED_CHANNEL_PREFIX } from "./manifest.ts"; // beginning with "@" are reserved for protocol control; a robot datagram // hello is rejected; subs snapshots ride @control frames on the reliable // robot control carrier (one relay-opened uni stream per robot session) -// instead of datagrams. v4: the twist datagram gains vy (strafe) and the teleop +// instead of datagrams. Generic publish (amended into v5 pre-release): +// pub/pub_ack/pub_nack and the error requestId correlation; an older v5 peer +// drops the unknown messages, so a publish times out instead of misparsing. +// v4: the twist datagram gains vy (strafe) and the teleop // lease messages (teleop_start/teleop_started/teleop_stop) enter the control // plane; robot-bound twist/stop/teleop_start/teleop_stop carry the // relay-stamped lease generation `gen` (amended into v4 pre-release: an @@ -55,6 +58,15 @@ export const CONTROL_CHANNEL = "@control"; // beyond it. export const MAX_CONTROL_PAYLOAD_BYTES = 64 * 1024; +// Cap for a pub message's serialized `data` JSON. The SDK checks it before +// sending; the relay enforces it independently (callers can bypass the SDK). +export const MAX_PUB_DATA_BYTES = 32 * 1024; + +// Bound for pub request ids (and the error requestId correlating a failure +// to its publish). Ids are opaque: the SDK sends random-prefix + counter, +// the relay forwards its own per-robot token robot-ward. +export const MAX_REQUEST_ID_LEN = 64; + // Reject absurd header lengths before allocating. export const MAX_HEADER_LEN = 65536; @@ -108,6 +120,9 @@ export interface ErrorMsg { t: "error"; code: string; message: string; + // Correlates a publish failure to its request (the viewer's own pub id); + // absent on session-level errors. + requestId?: string; } // Session messages (T2): robot registration, viewer watch + per-channel @@ -191,10 +206,53 @@ export interface TeleopStopMsg { gen?: number; } +/** What Session.publish accepts and `pub.data` carries: any JSON value. */ +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +// Generic publish (W7), for tx channels declared publish="shared". A viewer's +// pub rides its control stream; the relay validates it, then forwards the +// JSON `data` as a tx-channel data frame on the robot control carrier with +// provenance in the frame meta (id there is a relay-authored token, never the +// viewer's request id). The bridge acknowledges on a robot-opened one-shot +// @control stream -- pub_ack after Out.publish() returned, pub_nack for a +// decode/publish failure -- and the relay routes pub_ack (or a correlated +// error carrying requestId) to the originating viewer. `data` is required and +// spans all of JSON, null included; only an absent data field is invalid. +export interface PubMsg { + t: "pub"; + id: string; + ch: string; + data: JsonValue; + clientTs?: number; +} + +export interface PubAckMsg { + t: "pub_ack"; + id: string; // robot leg: the relay token; viewer leg: the viewer's pub id + ch: string; + relayTs: number; + bridgeTs: number; +} + +// Robot->relay only; the relay maps it to a correlated viewer error. +export interface PubNackMsg { + t: "pub_nack"; + id: string; + code: string; + message: string; +} + export type ControlMsg = HelloMsg | WelcomeMsg | PingMsg | PongMsg | ErrorMsg; export type SessionMsg = RobotsMsg | WatchMsg | ManifestMsg | SubMsg | UnsubMsg | SubsMsg; export type TeleopMsg = TwistMsg | StopMsg | TeleopStartMsg | TeleopStartedMsg | TeleopStopMsg; -export type Msg = ControlMsg | SessionMsg | TeleopMsg; +export type PublishMsg = PubMsg | PubAckMsg | PubNackMsg; +export type Msg = ControlMsg | SessionMsg | TeleopMsg | PublishMsg; // Data-plane frame header. `delivery` tells the relay how to forward frames // on channels the robot's manifest does not declare (the manifest's delivery @@ -239,6 +297,9 @@ const MSG_FIELDS: Record> = { teleop_start: {}, teleop_started: {}, teleop_stop: {}, + pub: { id: "string", ch: "string" }, + pub_ack: { id: "string", ch: "string", relayTs: "number", bridgeTs: "number" }, + pub_nack: { id: "string", code: "string", message: "string" }, }; function isRecord(value: unknown): value is Record { @@ -255,23 +316,30 @@ function isRobotInfo(value: unknown): value is RobotInfo { } // Structural checks for nested fields, run after the flat MSG_FIELDS pass. -// Optional fields (hello.robot, hello/manifest.manifest, teleop gen) accept -// absent but reject null: JSON encoders on both sides omit absent fields and -// never emit null. The manifest is only checked for record-ness here -- its +// Optional fields (hello.robot, hello/manifest.manifest, teleop gen, pub +// clientTs, error requestId) accept absent but reject null: JSON encoders on +// both sides omit absent fields and never emit null. Required pub.data is +// different: it spans all of JSON (null included), so only absence is +// invalid. The manifest is only checked for record-ness here -- its // structure belongs to parseManifest (see RobotManifest above). -const genAbsentOrNumber = (v: Record) => - v.gen === undefined || typeof v.gen === "number"; +const absentOrNumber = (v: unknown) => v === undefined || typeof v === "number"; +const requestIdOk = (v: unknown) => + typeof v === "string" && v.length >= 1 && v.length <= MAX_REQUEST_ID_LEN; const MSG_VALIDATORS: Record) => boolean> = { hello: (v) => (v.robot === undefined || isRobotInfo(v.robot)) && (v.manifest === undefined || isRecord(v.manifest)), + error: (v) => v.requestId === undefined || requestIdOk(v.requestId), robots: (v) => Array.isArray(v.robots) && v.robots.every(isRobotInfo), manifest: (v) => v.manifest === undefined || isRecord(v.manifest), subs: (v) => Array.isArray(v.chs) && v.chs.every((c) => typeof c === "string"), - twist: genAbsentOrNumber, - stop: genAbsentOrNumber, - teleop_start: genAbsentOrNumber, - teleop_stop: genAbsentOrNumber, + twist: (v) => absentOrNumber(v.gen), + stop: (v) => absentOrNumber(v.gen), + teleop_start: (v) => absentOrNumber(v.gen), + teleop_stop: (v) => absentOrNumber(v.gen), + pub: (v) => requestIdOk(v.id) && v.data !== undefined && absentOrNumber(v.clientTs), + pub_ack: (v) => requestIdOk(v.id), + pub_nack: (v) => requestIdOk(v.id), }; /** Validated message from parsed JSON; null for unknown or malformed ones. */ diff --git a/web/shared/protocol_test.ts b/web/shared/protocol_test.ts index 5f4d757096..70e3c9c325 100644 --- a/web/shared/protocol_test.ts +++ b/web/shared/protocol_test.ts @@ -14,6 +14,8 @@ import { frameHeaderFromUnknown, MAX_DATA_FRAME_BYTES, MAX_HEADER_LEN, + MAX_PUB_DATA_BYTES, + MAX_REQUEST_ID_LEN, type Msg, msgFromUnknown, peekDataFrameLengths, @@ -293,6 +295,39 @@ Deno.test("msgFromUnknown validates nested session-message shapes", () => { assertEquals(msgFromUnknown({ t: "teleop_stop", gen: null }), null); }); +Deno.test("msgFromUnknown validates publish-message shapes", () => { + // Also pins the pub bounds against the Python mirror (test_protocol.py). + assertEquals(MAX_PUB_DATA_BYTES, 32 * 1024); + assertEquals(MAX_REQUEST_ID_LEN, 64); + const ok = { t: "pub", id: "a", ch: "chat", data: { x: 1.5 } }; + assertEquals(msgFromUnknown(ok) !== null, true); + // data is required and spans all of JSON, null included; only an absent + // data field is invalid. + assertEquals(msgFromUnknown({ t: "pub", id: "a", ch: "chat" }), null); + assertEquals(msgFromUnknown({ t: "pub", id: "a", ch: "chat", data: null }) !== null, true); + assertEquals(msgFromUnknown({ t: "pub", id: "", ch: "chat", data: 1.5 }), null); + const longId = "x".repeat(MAX_REQUEST_ID_LEN + 1); + assertEquals(msgFromUnknown({ t: "pub", id: longId, ch: "chat", data: 1.5 }), null); + assertEquals(msgFromUnknown({ t: "pub", id: 7, ch: "chat", data: 1.5 }), null); + assertEquals(msgFromUnknown({ ...ok, clientTs: null }), null); + assertEquals(msgFromUnknown({ ...ok, clientTs: "1" }), null); + assertEquals(msgFromUnknown({ ...ok, clientTs: true }), null); + const ack = { t: "pub_ack", id: "a", ch: "chat", relayTs: 1.5, bridgeTs: 2.5 }; + assertEquals(msgFromUnknown(ack) !== null, true); + assertEquals(msgFromUnknown({ ...ack, id: "" }), null); + assertEquals(msgFromUnknown({ t: "pub_nack", id: "p1", code: "c", message: "m" }) !== null, true); + assertEquals(msgFromUnknown({ t: "pub_nack", id: longId, code: "c", message: "m" }), null); + // error.requestId: absent for session-level errors, bounded, never null. + assertEquals(msgFromUnknown({ t: "error", code: "c", message: "m" }) !== null, true); + assertEquals( + msgFromUnknown({ t: "error", code: "c", message: "m", requestId: "r-1" }) !== null, + true, + ); + assertEquals(msgFromUnknown({ t: "error", code: "c", message: "m", requestId: null }), null); + assertEquals(msgFromUnknown({ t: "error", code: "c", message: "m", requestId: "" }), null); + assertEquals(msgFromUnknown({ t: "error", code: "c", message: "m", requestId: longId }), null); +}); + Deno.test("frameHeaderFromUnknown validates the header shape", () => { const ok = { ch: "cam", seq: 1, ts: 2.5, delivery: "latest" }; assertEquals(frameHeaderFromUnknown(ok), ok as FrameHeader);