Skip to content

Default to Zenoh transport on macOS and document replay workflow - #2106

Closed
bogwi wants to merge 41 commits into
mainfrom
feat/integrate-zenoh
Closed

Default to Zenoh transport on macOS and document replay workflow#2106
bogwi wants to merge 41 commits into
mainfrom
feat/integrate-zenoh

Conversation

@bogwi

@bogwi bogwi commented May 16, 2026

Copy link
Copy Markdown
Member

Supersedes #1906 so self-hosted CI can run. Prior review discussion is preserved there.

Supersedes #1787.

This PR carries forward the Zenoh transport integration from #1787 and wraps it into a merge-ready branch that fixes the remaining macOS Big Office replay gap.

Validation

Typical replay on macOS when Zenoh is installed (default is already Zenoh, so no transport flag is required):

dimos --dtop --replay run unitree-go2

The same workload on Linux (default remains lcm until you opt in):

dimos --transport=zenoh --dtop run unitree-go2

Notes

  • this PR is intended as the wrapped successor to Feat/integrate zenoh #1787, not a separate redesign
  • Linux behavior remains unchanged by default: explicit Zenoh still works, and the default transport remains LCM

@greptile-apps

greptile-apps Bot commented May 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR integrates a Zenoh transport backend alongside LCM, defaults to Zenoh on macOS when eclipse-zenoh is installed, and documents the replay workflow for the go2_bigoffice dataset. Linux behavior is unchanged by default.

  • Adds ZenohTransport/pZenohTransport wrappers in transport.py, a singleton ZenohService session manager, and ZenohPubSubBase with LCM/pickle encoder variants; wires GlobalConfig.transport to a platform-aware default factory and gates all new paths behind ZENOH_AVAILABLE.
  • Updates unitree_go2_basic.py to skip the pSHM color_image override on macOS when Zenoh is active, the Rerun bridge to dual-listen on Zenoh+LCM, and agent/MCP conftest fixtures to force transport="lcm" where LCM sidecars are expected.
  • Extends docs (cli.md, osx.md, transports/index.md) with Zenoh quickstart, install, and platform-default tables; adds a new zenoh optional extra to pyproject.toml.

Confidence Score: 4/5

Safe to merge for Linux users; macOS users forcing --transport=lcm may silently lose the pSHM color_image override if the blueprint module was pre-imported.

The module-level _transports_base = _platform_transports() in unitree_go2_basic.py now reads global_config.transport at first import rather than only platform.system(). In the CLI the import is lazy enough that the transport override is already applied, but any pre-import of this module — via a transitive import chain during test collection, or through unitree_go2.py in the smart blueprints — freezes the pSHM decision before the fixture or CLI can update the config. The consequence on macOS with --transport=lcm is that color_image loses its shared-memory path silently.

dimos/robot/unitree/go2/blueprints/basic/unitree_go2_basic.py — module-level transport check should be deferred to blueprint-build time rather than evaluated once at import.

Important Files Changed

Filename Overview
dimos/robot/unitree/go2/blueprints/basic/unitree_go2_basic.py Module-level _transports_base = _platform_transports() now reads global_config.transport at import time; if the module is pre-imported before the CLI/fixture sets transport, the pSHM override for color_image on macOS is silently frozen to the wrong path.
dimos/core/transport.py Adds ZenohTransport and pZenohTransport classes under ZENOH_AVAILABLE guard; _started is a class-level attribute (not instance) but works in practice due to per-instance shadowing on first write.
dimos/protocol/service/zenohservice.py New singleton Zenoh session manager; close_all_zenoh_sessions() correctly holds the lock before closing; the session property reads _sessions without the lock (TOCTOU with concurrent close already flagged in prior review).
dimos/protocol/pubsub/impl/zenohpubsub.py New ZenohPubSubBase with per-topic publisher caching, subscriber cleanup on stop(), and idempotent unsubscribe; topic-to-key-expr encoding is clean.
dimos/visualization/rerun/bridge.py Adds _default_pubsubs and _resolve_pubsubs to drive Zenoh+LCM dual-listen when transport=zenoh; legacy pubsubs=[LCM()] default is treated as the old default and replaced by transport-driven logic.
dimos/core/global_config.py Adds transport: TransportBackend field with platform-aware default factory; validate_assignment=True ensures invalid updates are rejected at runtime.
dimos/protocol/pubsub/test_spec.py Adds Zenoh LCM and pickle test cases; cleanup in both contexts directly manipulates _sessions dict without the lock instead of using close_all_zenoh_sessions().
dimos/core/test_zenoh_transport.py New test module covering GlobalConfig transport field, _get_transport_for() branching, and ZenohTransport/pZenohTransport lifecycle; well structured and uses retry_until for async delivery.
dimos/protocol/pubsub/benchmark/testdata.py Adds Zenoh benchmark case; cleanup in zenoh_pubsub_channel directly clears _sessions without the lock instead of using close_all_zenoh_sessions().

Reviews (4): Last reviewed commit: "codecov: add zenoh" | Re-trigger Greptile

Comment on lines +206 to +210
if "pubsubs" in fields_set and pubsubs is not None:
is_legacy_default = len(pubsubs) == 1 and isinstance(pubsubs[0], LCM)
if not is_legacy_default:
return pubsubs
return _default_pubsubs(getattr(config, "g", config))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Silent override of explicit pubsubs=[LCM()]

_resolve_pubsubs cannot distinguish between the old default pubsubs=[LCM()] and a caller who explicitly sets pubsubs=[LCM()] to opt out of Zenoh. On macOS with Zenoh installed, any explicit pubsubs=[LCM()] will be silently replaced by [Zenoh(), LCM()] — the user's intent to stay on LCM-only is ignored with no warning. The only escape hatch is to also set transport="lcm" globally, but that changes module stream transport as well, not just the Rerun bridge's subscription backend. A narrower fix would be to respect the explicit override if the global transport is also lcm-forced, or to add a dedicated force_pubsubs field so callers can opt out cleanly.

Comment on lines +94 to +100
@property
def session(self) -> zenoh.Session:
"""Get the Zenoh Session instance for this service's config."""
key = self.config.session_key
if key not in _sessions:
raise RuntimeError("Zenoh session not initialized — call start() first")
return _sessions[key]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 session property reads _sessions without holding _sessions_lock — there is a TOCTOU between the if key not in _sessions guard and the return _sessions[key] line. If close_all_zenoh_sessions() runs between those two statements on another thread, the dict will be empty and the return would raise a KeyError rather than the expected RuntimeError. Wrapping the property body in _sessions_lock (or using dict.get) eliminates the race.

Suggested change
@property
def session(self) -> zenoh.Session:
"""Get the Zenoh Session instance for this service's config."""
key = self.config.session_key
if key not in _sessions:
raise RuntimeError("Zenoh session not initialized — call start() first")
return _sessions[key]
@property
def session(self) -> zenoh.Session:
"""Get the Zenoh Session instance for this service's config."""
key = self.config.session_key
with _sessions_lock:
session = _sessions.get(key)
if session is None:
raise RuntimeError("Zenoh session not initialized — call start() first")
return session

Comment thread dimos/core/transport.py
Comment on lines +355 to +363
class ZenohTransport(PubSubTransport[T]):
"""Zenoh transport with LCM encoding for typed DimosMsg."""

_started: bool = False

def __init__(self, topic: str, type: type, **kwargs) -> None: # type: ignore[no-untyped-def]
super().__init__(LCMTopic(topic, type))
self.zenoh = Zenoh(**kwargs)
self._start_lock = threading.RLock()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 _started declared as a class-level attribute_started: bool = False defines a class variable. The first self._started = True assignment silently creates a per-instance attribute that shadows it, which works in CPython but is a code smell: mypy treats the annotation as a class-level field, and pickling via __reduce__ on a freshly reconstructed object relies on the class-level default (which is correct here, but fragile). Initialising _started inside __init__ makes the per-instance ownership explicit and avoids the ambiguity. The same applies to pZenohTransport.

Suggested change
class ZenohTransport(PubSubTransport[T]):
"""Zenoh transport with LCM encoding for typed DimosMsg."""
_started: bool = False
def __init__(self, topic: str, type: type, **kwargs) -> None: # type: ignore[no-untyped-def]
super().__init__(LCMTopic(topic, type))
self.zenoh = Zenoh(**kwargs)
self._start_lock = threading.RLock()
class ZenohTransport(PubSubTransport[T]):
"""Zenoh transport with LCM encoding for typed DimosMsg."""
def __init__(self, topic: str, type: type, **kwargs) -> None: # type: ignore[no-untyped-def]
super().__init__(LCMTopic(topic, type))
self.zenoh = Zenoh(**kwargs)
self._start_lock = threading.RLock()
self._started: bool = False

vrinek and others added 27 commits May 25, 2026 02:12
Prepare the codebase for Zenoh integration without changing behavior.
All existing tests pass (1401 passed, 3 xfailed for Phase 2 stubs).

- Add `transport` field to GlobalConfig (default: "lcm")
- Add ZENOH_AVAILABLE guard in transport.py
- Branch _get_transport_for() on global_config.transport
- Gate LCM configurators to only run when transport is "lcm"
- Add ZenohTransport/pZenohTransport behind ZENOH_AVAILABLE guard
- Add zenohpubsub.py stub (raises NotImplementedError)
- Add `zenoh` optional dependency group in pyproject.toml
- Add test_zenoh_transport.py covering all new conditional branches
TDD: tests written first, then implementation.
Follows DDSService pattern — module-level session dict with lock.

- ZenohConfig with mode/connect/listen fields and session_key
- ZenohService.start() opens session if not exists for config
- ZenohService.stop() does NOT close shared session
- session property raises RuntimeError if not started
- Two services with same config share one session (8 tests pass)
TDD: tests written first, then implementation.

- ZenohPubSubBase(ZenohService, AllPubSub[Topic, bytes])
- Publisher caching per key expression (avoids re-declaring)
- Subscriber tracking for cleanup on stop()
- Idempotent unsubscribe (guards against Zenoh ZError)
- subscribe_all() via dimos/** wildcard
- Zenoh and PickleZenoh composed classes (encoder mixins)
- 7 unit tests pass
Both encoder-composed variants pass all spec conformance tests:
- test_store, test_multiple_subscribers, test_unsubscribe
- test_multiple_messages, test_async_iterator
- 25 total tests pass (10 new Zenoh tests)
Remove xfail markers — Phase 2 stubs are now real implementations.
Add transport wrapper integration tests for broadcast/subscribe.

- ZenohTransport wraps Zenoh (LCM-encoded) with DDSTransport pattern
- pZenohTransport wraps PickleZenoh with Topic wrapping for pubsub layer
- Auto-start on first broadcast, stop/restart lifecycle
- 16 tests pass (4 new wrapper tests)
Zenoh appears alongside LCM, SHM in benchmark heatmaps.
Results: competitive with LCM for localhost — 82-149k msgs/sec
for small messages, 0% message loss, <1ms latency.
Transport-level errors (session closed, invalid key expression) are
logged but not raised. Delivery guarantees are handled by Zenoh's
reliability protocol, not by exception propagation.
- Fix #3: unsubscribe() now only calls undeclare() if it successfully
  removed the subscriber from the list. If stop() already cleared the
  list, unsubscribe() returns without double-undeclaring.
- Fix #5: on_sample callback wraps payload.to_bytes() in try/except
  to prevent malformed payloads from crashing Zenoh's internal thread.
Check membership before removing instead of catching ValueError.
Reads more clearly and avoids using exceptions for control flow.
Two issues prevented the Rerun bridge from showing data over Zenoh:

1. The bridge hardcoded LCM() as its pubsub. Now resolves lazily at
   start() using self.config.g.transport from the worker's GlobalConfig.

2. Zenoh key expressions cannot contain '#' (forbidden character).
   Type info is now embedded as a '/' segment in the key expression
   (e.g., dimos/pointcloud/sensor_msgs.PointCloud2). _key_expr_to_topic
   reconstructs the Topic with lcm_type for subscribe_all decoding.

Also fixes entity path mapping to strip the dimos/ prefix so Zenoh
entity paths match LCM paths in the Rerun viewer.
- typed_out/untyped_out → typed_data/untyped_data
- Use TypedMsg instead of Image for blueprint integration tests
- Image still used in transport wrapper test (real LCM round-trip)
Replace raw time.sleep() calls with named helpers that document intent.
wait_for_subscribers() explains Zenoh has no "subscriber ready" signal.
Replace manual if-both-received check with threading.Barrier(2).
The previous approach could miss the event if both callbacks ran
concurrently and checked the other's list before it was populated.
Review findings #2 and #4:
- Remove Config.pubsubs from RerunBridgeModule — pubsubs are resolved
  lazily at start() from global_config.transport
- Remove _zenoh_topic field from pZenohTransport — construct on demand
  like pLCMTransport does, avoiding dual state
8 new tests covering:
- Typed/untyped topic → key expression conversion
- Key expression → topic with known/unknown/missing type
- Default lcm_type fallback
- Round-trip typed and untyped

Also documents known limitation: if a topic's base path ends with a
segment matching a registered DimosMsg type name, _key_expr_to_topic
will incorrectly split it. In practice this doesn't happen because
stream names (cmd_vel, lidar) don't match type names.
Existing blueprints pass pubsubs=[LCM()] to RerunBridgeModule.
Removing the field caused a Pydantic ValidationError (extra_forbidden).
Keep the field but document that it's ignored — start() resolves
the pubsub backend from global_config.transport instead.
TF (transform frames) is hardcoded to LCM in the Module base class.
When transport=zenoh, module streams use Zenoh but TF stays on LCM.
The bridge now listens on both so the robot pose updates in the viewer.
Zenoh tests used time.sleep() to wait for subscriber propagation,
which is either too slow or too flaky in CI. Replace with _retry_until()
that re-publishes in a tight loop until the subscriber's Event fires.
Calls zenoh.init_log_from_env_or("warn") at module load so that
RUST_LOG=debug surfaces Zenoh's Rust-side transport logs (including
SHM negotiation). Defaults to warn to avoid noise.
uv sync --extra zenoh would resolve dimos[dev] from PyPI instead of
the local project, uninstalling other dependencies. The zenoh extra
only needs eclipse-zenoh — base deps are already installed.
… missing

Align with module_coordinator._get_transport_for: raise RuntimeError instead
of silently falling back to LCM when transport is zenoh and eclipse-zenoh is
not installed.
@bogwi
bogwi force-pushed the feat/integrate-zenoh branch from f7959b2 to 056ddcd Compare May 24, 2026 17:23
@codecov

codecov Bot commented May 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.08470% with 60 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
dimos/visualization/rerun/bridge.py 41.93% 16 Missing and 2 partials ⚠️
dimos/core/transport.py 76.56% 7 Missing and 8 partials ⚠️
dimos/protocol/pubsub/benchmark/testdata.py 43.75% 8 Missing and 1 partial ⚠️
dimos/protocol/pubsub/impl/zenohpubsub.py 91.56% 6 Missing and 1 partial ⚠️
dimos/protocol/service/zenohservice.py 89.09% 4 Missing and 2 partials ⚠️
.../unitree/go2/blueprints/basic/unitree_go2_basic.py 71.42% 1 Missing and 1 partial ⚠️
dimos/core/test_zenoh_transport.py 99.35% 1 Missing ⚠️
dimos/protocol/pubsub/test_spec.py 95.83% 0 Missing and 1 partial ⚠️
...mos/visualization/rerun/test_viewer_integration.py 94.44% 1 Missing ⚠️
Flag Coverage Δ
OS-ubuntu 62.95% <90.78%> (+0.28%) ⬆️
Py-3.10 62.95% <90.78%> (+0.27%) ⬆️
Py-3.11 62.95% <90.78%> (?)
Py-3.12 62.95% <90.78%> (?)
Py-3.13 62.95% <90.78%> (+0.28%) ⬆️
Py-3.14 62.95% <90.78%> (+0.27%) ⬆️
Py-3.14t 62.95% <90.78%> (+0.28%) ⬆️
SelfHosted-Linux 39.96% <43.68%> (+0.06%) ⬆️
SelfHosted-macos 39.09% <33.13%> (-0.06%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
dimos/agents/conftest.py 88.00% <100.00%> (ø)
dimos/agents/mcp/conftest.py 88.00% <100.00%> (ø)
dimos/constants.py 80.00% <100.00%> (+1.05%) ⬆️
dimos/core/coordination/module_coordinator.py 82.00% <100.00%> (+0.29%) ⬆️
dimos/core/coordination/test_module_reloading.py 100.00% <ø> (ø)
dimos/core/global_config.py 83.11% <100.00%> (+2.51%) ⬆️
dimos/core/test_utils.py 100.00% <100.00%> (ø)
dimos/protocol/pubsub/impl/test_zenohpubsub.py 100.00% <100.00%> (ø)
dimos/protocol/service/test_zenohservice.py 100.00% <100.00%> (ø)
dimos/core/test_zenoh_transport.py 99.35% <99.35%> (ø)
... and 8 more

... and 4 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

**Two ways to override for one run or for your shell:**

1. **CLI:** `dimos --transport=zenoh ...` or `dimos --transport=lcm ...` (see [CLI](/docs/usage/cli.md) for precedence with `.env` and blueprints).
2. **Environment:** `DIMOS_TRANSPORT=zenoh` or `DIMOS_TRANSPORT=lcm`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 DIMOS_TRANSPORT env var is not recognized — should be TRANSPORT

GlobalConfig is a pydantic_settings.BaseSettings with no env_prefix configured in its model_config. Without env_prefix = "DIMOS_", pydantic-settings resolves each field from the uppercased field name directly — so transport reads from TRANSPORT, not DIMOS_TRANSPORT. A user who follows the docs and sets export DIMOS_TRANSPORT=zenoh will silently get no effect; their stack will still start with the platform default transport. The same issue applies to the DIMOS_TRANSPORT reference in docs/installation/osx.md. The correct env var to document is TRANSPORT=zenoh.

@bogwi
bogwi force-pushed the feat/integrate-zenoh branch from 056ddcd to 91c3af2 Compare May 24, 2026 17:54
return autoconnect()


_transports_base = _platform_transports()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Module-level transport check baked in at first import

_transports_base = _platform_transports() is evaluated exactly once — when this module is first imported — and its global_config.transport read is frozen at that point. If the module is imported before the CLI (or a test fixture) calls global_config.update(transport="lcm"), the pSHM override for color_image is silently skipped and never re-applied for the lifetime of the process.

The original line only called platform.system() (constant), so it was safe to evaluate at import time. The new code adds a dependency on global_config.transport, which can change after import. Any pre-import of this module — e.g., via dimos.robot.unitree.go2.blueprints.smart.unitree_go2, which is itself discoverable by pytest collection — will freeze the pSHM decision before the fixture's global_config.update(transport="lcm") runs.

@leshy leshy mentioned this pull request May 25, 2026
@bogwi
bogwi marked this pull request as draft May 25, 2026 06:02
@sharkqwy sharkqwy mentioned this pull request May 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This pull request has been automatically marked as stale because it has not had recent activity. It will be closed in 7 days if no further activity occurs.

@github-actions github-actions Bot added the stale label Jun 19, 2026
@paul-nechifor

Copy link
Copy Markdown
Contributor

Closed this old PR due to inactivity. Can be reopened later.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants