From d55d6ac76634b1c1863c383fd2d3a76f655cf112 Mon Sep 17 00:00:00 2001 From: Paul Nechifor Date: Thu, 27 Aug 2026 19:12:49 +0300 Subject: [PATCH 1/2] feat(web): acknowledged shared tx publishing - Protocol v5 grows pub/pub_ack/pub_nack and error.requestId (no version bump: unknown messages drop, nothing misparses). New golden vectors pin the messages and the forwarded tx-frame shape on both sides. - ChannelSpec gains additive publish/requiredScope manifest fields with mirrored domain rules (invalid_publish, invalid_scope). Normalized manifests now always emit both, so the golden manifests changed (normalization idempotence forces it). - Channel(dir="tx", publish="shared") compiles: generated Out port, decoder resolved via the new resolve_decoder (registered @web_decoder or generic json.v1 for scalars/dicts/lists; dataclasses need an explicit decoder). text.json.v1 joins the built-ins. publish="exclusive" stays gated (W8). - The relay validates pub in spec order (watch, dup id, 32 KiB cap, policy, scope bypass for the synthetic local principal, per-viewer + aggregate token buckets at maxHz, pending count/byte caps), stamps provenance, and forwards the JSON as a tx data frame on the robot carrier. The forwarded id is a relay-authored token, translated back to the viewer's own id on ack, so viewer-chosen ids stay untrusted and cannot collide. - Pending settles exactly once: bridge ack/nack routed to the one live sender, publish_timeout via the reap sweep, robot_disconnected on robot death, silent release on viewer death. Counts land in /api/stats. - The bridge dispatches carrier tx frames from the ordered control queue, decodes with the resolved codec (PublishContext when the signature takes it), verifies the declared message type, calls Out.publish(), and only then acks on a robot-opened one-shot @control stream. Failures nack with bounded messages and never recycle the session. - SDK session.publish() -> Promise: stable local rejection codes, bounded pending map, outcome "rejected" for definite failures vs "unknown" for connection loss/timeouts, and no automatic resend ever. Correlated errors bypass the hello handshake slot (they could previously unblock a hello() retry) and never touch the session error banner. - examples/chat-input/ is the end-to-end publish demo, pinned by a new chromium e2e in CI (test_publish_browser.py) plus a Python-viewer e2e. --- .github/workflows/ci.yml | 3 +- dimos/e2e_tests/test_publish_browser.py | 107 +++ dimos/web/cockpit.py | 129 +++- dimos/web/codecs.py | 51 +- dimos/web/relay_bridge/_wt_session.py | 36 +- dimos/web/relay_bridge/builtin_codecs.py | 13 +- dimos/web/relay_bridge/conftest.py | 29 + dimos/web/relay_bridge/e2e_support.py | 2 +- dimos/web/relay_bridge/manifest.py | 38 +- dimos/web/relay_bridge/module_test_support.py | 263 ++++++++ dimos/web/relay_bridge/protocol.py | 104 ++- dimos/web/relay_bridge/relay_bridge_module.py | 269 +++++++- dimos/web/relay_bridge/test_protocol.py | 69 ++ .../test_relay_bridge_authoring.py | 585 +++++++++++++++++ .../relay_bridge/test_relay_bridge_module.py | 612 +----------------- dimos/web/relay_bridge/test_wt_client.py | 35 + dimos/web/relay_bridge/test_wt_session.py | 91 +++ dimos/web/relay_bridge/wt_client.py | 47 +- dimos/web/test_cockpit.py | 127 +++- dimos/web/test_codecs.py | 31 + docs/usage/web_sdk.md | 28 + web/README.md | 20 +- web/cockpit/src/App.test.tsx | 5 + web/cockpit/src/layout/LayoutTree.test.tsx | 13 +- web/cockpit/src/panels/TeleopPanel.test.tsx | 2 + web/cockpit/src/subscriptions.test.ts | 2 + web/examples/chat-input/index.html | 67 ++ web/relay/carrier.ts | 17 +- web/relay/carrier_test.ts | 38 ++ web/relay/forward.ts | 30 + web/relay/forward_test.ts | 26 + web/relay/registry.ts | 313 ++++++++- web/relay/registry_test.ts | 402 +++++++++++- web/relay/server_test.ts | 128 +++- web/relay/session.ts | 41 +- web/sdk/src/errors.ts | 18 + web/sdk/src/index.ts | 19 +- web/sdk/src/internal/teleopMachine.test.ts | 2 + web/sdk/src/session.test.ts | 205 +++++- web/sdk/src/session.ts | 213 +++++- web/sdk/src/testing/fakeRelay.ts | 12 + web/shared/fixtures/control_frames.json | 79 ++- web/shared/fixtures/data_frames.json | 21 +- web/shared/fixtures/datagrams.json | 79 ++- web/shared/fixtures/gen.ts | 79 +++ web/shared/fixtures/manifests.json | 393 ++++++++++- web/shared/manifest.ts | 69 +- web/shared/protocol.ts | 92 ++- web/shared/protocol_test.ts | 35 + 49 files changed, 4310 insertions(+), 779 deletions(-) create mode 100644 dimos/e2e_tests/test_publish_browser.py create mode 100644 dimos/web/relay_bridge/conftest.py create mode 100644 dimos/web/relay_bridge/module_test_support.py create mode 100644 dimos/web/relay_bridge/test_relay_bridge_authoring.py create mode 100644 web/examples/chat-input/index.html 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..cba6df155c 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 +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); From 00bb47a54fffdb54771c6a74e667dabfb5de2814 Mon Sep 17 00:00:00 2001 From: Paul Nechifor Date: Sun, 30 Aug 2026 05:11:13 +0300 Subject: [PATCH 2/2] docs(web): mark the publish channel fragment as a skipped code block The block calls cockpit() without imports, so md-babel failed on it in CI. --- docs/usage/web_sdk.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/usage/web_sdk.md b/docs/usage/web_sdk.md index cba6df155c..490cd98b7f 100644 --- a/docs/usage/web_sdk.md +++ b/docs/usage/web_sdk.md @@ -288,7 +288,7 @@ Decoder notes: 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 +```python skip cockpit(channels=[ Channel("human_input", str, dir="tx", encoding="text.json.v1", publish="shared"), ])