Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
de664a8
spec: dimos spy task package — universal transport spy
leshdaemon Jul 6, 2026
7c7720c
feat: dimos spy — universal transport spy over LCM + Zenoh
leshdaemon Jul 6, 2026
fae1dc7
chore: drop internal task package file before publishing
leshdaemon Jul 6, 2026
1ed1963
refactor: harden spy lifecycle + transport selection
leshdaemon Jul 6, 2026
e55a1a2
refactor: make lcmspy a thin alias for spy --transport lcm
leshdaemon Jul 6, 2026
d29c740
fix: degrade default spy on missing backends, forward web-mode filters
leshdaemon Jul 6, 2026
22f5442
fix: route lcmspy web through spy web mode
leshdaemon Jul 6, 2026
bf1dac1
revert: keep zenoh subscribe_all conflating; spy stays protocol-agnostic
leshdaemon Jul 6, 2026
851d864
feat: remove spy web mode; fix lcmspy console entry arg handling
leshdaemon Jul 6, 2026
3f4337f
Merge branch 'main' into feat/ivan/spy
leshy Jul 6, 2026
1937f19
fix: spy rejects stray positionals; default_sources degrades on any e…
leshdaemon Jul 6, 2026
2d710f7
fix: spy default path survives per-source start failures
leshdaemon Jul 6, 2026
d517f28
docs: fold dimos spy page into the transports doc
leshdaemon Jul 6, 2026
a2e02c1
comment correction
leshy Jul 6, 2026
00071ad
refactor: move spy core out of protocol/pubsub into the spy tool package
leshdaemon Jul 6, 2026
506a3a0
Merge branch 'main' into feat/ivan/spy
leshy Jul 6, 2026
c97b24f
docs: document dimos spy in cli.md, mark lcmspy as deprecated alias
leshdaemon Jul 6, 2026
3608002
refactor(spy): rename _lcm_only_argv to lcm_only_argv
paul-nechifor Jul 6, 2026
4f7a6f6
refactor(spy): move inline imports to the top of their modules
paul-nechifor Jul 6, 2026
92695be
refactor(spy): share the default history window as one constant
paul-nechifor Jul 6, 2026
7ab39df
refactor(spy): name the gradient saturation magic numbers
paul-nechifor Jul 6, 2026
6cc7b26
fix(spy): resolve type ignores in run_spy with real types
paul-nechifor Jul 6, 2026
9a19f03
refactor(spy): extract duplicated unknown-transport validation
paul-nechifor Jul 6, 2026
2e0df3e
refactor(spy): stop doing work in SpyApp.__init__
paul-nechifor Jul 6, 2026
a01cbe1
fix(spy): emit degrade warnings via the project logger
paul-nechifor Jul 6, 2026
e914ff9
fix(spy): don't abort TransportSpy.stop() on the first error
paul-nechifor Jul 6, 2026
095feec
test: guarantee spy subscription/lifecycle cleanup on assertion failure
leshdaemon Jul 6, 2026
79967db
refactor(spy): remove unused TopicStats.avg_size
paul-nechifor Jul 6, 2026
f705bce
fix(spy): read shared TopicStats fields under the lock, in one pass
paul-nechifor Jul 6, 2026
0329aaf
chore(spy): drop stale TASK.md reference from test docstring
paul-nechifor Jul 6, 2026
510a8ea
refactor(spy): tear down test resources via fixtures
paul-nechifor Jul 6, 2026
fe5859e
fix: dedupe sys import and type SpyApp's App generic in run_spy
leshdaemon Jul 6, 2026
86c3f56
refactor(spy): poll for subscription establishment instead of sleeping
paul-nechifor Jul 6, 2026
bab0fe4
fix: reject root-level --transport before the spy subcommand
leshdaemon Jul 6, 2026
52e6de0
add fix
paul-nechifor Jul 6, 2026
bf819c1
merge: Paul's spy autofixes (PR #2748) into feat/ivan/spy
leshdaemon Jul 6, 2026
9224503
Merge branch 'main' into feat/ivan/spy
leshy Jul 6, 2026
bed7ea2
Merge branch 'main' into feat/ivan/spy
leshy Jul 7, 2026
86fa1b7
Merge remote-tracking branch 'origin/main' into feat/ivan/spy
leshy Jul 8, 2026
6181be2
Merge branch 'main' into feat/ivan/spy
leshy Jul 8, 2026
3ae9461
Merge branch 'main' into feat/ivan/spy
leshy Jul 8, 2026
3aac12e
Merge branch 'main' into feat/ivan/spy
leshy Jul 8, 2026
ade0825
Merge branch 'main' into feat/ivan/spy
leshy Jul 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 18 additions & 48 deletions dimos/protocol/pubsub/impl/zenohpubsub.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,61 +197,31 @@ def unsubscribe() -> None:
return unsubscribe

def subscribe_all(self, callback: Callable[[bytes, Topic], Any]) -> Callable[[], None]:
"""Subscribe to all dimos topics, delivering only the latest per topic.
"""Subscribe to every key, delivering every message (non-conflating).

Unlike `subscribe`, this is best effort. If it's done otherwise, rerun lags behind.
"All topics" means everything this session can observe ('**'). Consumers
that only want the newest message per topic use subscribe_latest().
"""
latest: dict[str, tuple[bytes, Topic]] = {}
lock = threading.Lock()
wake = threading.Event()
stop = threading.Event()

def collect(msg: bytes, topic: Topic) -> None:
# Fast path on the Zenoh delivery thread: keep only the newest per topic.
with lock:
latest[str(topic)] = (msg, topic)
wake.set()

def drain() -> None:
while not stop.is_set():
wake.wait()
wake.clear()
with lock:
batch = list(latest.values())
latest.clear()
for msg, topic in batch:
try:
callback(msg, topic)
except Exception:
logger.error("Error in subscribe_all callback", exc_info=True)

thread = threading.Thread(target=drain, name="zenoh-subscribe-all", daemon=True)

def stop_drain() -> None:
stop.set()
wake.set() # unblock the drain so it observes the stop flag
thread.join(timeout=2.0)

# Register the stop callback and launch the drain thread under the lock so
# stop() can't run between them and miss the thread. If stop() already ran,
# bail without starting anything.
return self.subscribe(Topic("**"), callback)

def _register_drain_stop(
self, stop_drain: Callable[[], None], thread: threading.Thread
) -> bool:
# Register + start under the subscriber lock so stop() can't run between
# them and miss the thread. If stop() already ran, bail without starting.
with self._subscriber_lock:
if self._stopped:
return lambda: None
return False
self._drain_stops.append(stop_drain)
thread.start()
return True

inner_unsub = self.subscribe(Topic("dimos/**"), collect)

def unsubscribe() -> None:
with self._subscriber_lock:
if stop_drain not in self._drain_stops:
return # Already removed by stop() or a concurrent unsubscribe
self._drain_stops.remove(stop_drain)
inner_unsub()
stop_drain()

return unsubscribe
def _unregister_drain_stop(self, stop_drain: Callable[[], None]) -> bool:
with self._subscriber_lock:
if stop_drain not in self._drain_stops:
return False # Already removed by stop() or a concurrent unsubscribe
self._drain_stops.remove(stop_drain)
return True

def stop(self) -> None:
with self._subscriber_lock:
Expand Down
116 changes: 112 additions & 4 deletions dimos/protocol/pubsub/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass
import threading
from typing import Any, Generic, Protocol, TypeVar, runtime_checkable

from dimos.utils.logging_config import setup_logger

logger = setup_logger()

MsgT = TypeVar("MsgT")
TopicT = TypeVar("TopicT")
MsgT_co = TypeVar("MsgT_co", covariant=True)
Expand Down Expand Up @@ -115,7 +120,100 @@ def subscribe(
#
# - DiscoveryPubSub: Native support for discovering new topics as they appear.
# Provides a default subscribe_all() by subscribing to each discovered topic.
class AllPubSub(PubSub[TopicT, MsgT], ABC):
class SubscribeLatestMixin(Generic[TopicT, MsgT], ABC):
"""Conflated subscribe-all, built on top of subscribe_all().

Mixed into AllPubSub and DiscoveryPubSub so both expose subscribe_latest().
"""

@abstractmethod
def subscribe_all(self, callback: Callable[[MsgT, TopicT], Any]) -> Callable[[], None]: ...

def _register_drain_stop(
self, stop_drain: Callable[[], None], thread: threading.Thread
) -> bool:
"""Start ``thread`` and record ``stop_drain`` for transport-level shutdown.

Default: just start the thread. Transports whose stop() must join live
drains (e.g. zenoh) override this to register under their own lock and
return False if already stopped (subscribe_latest then bails).
"""
thread.start()
return True

def _unregister_drain_stop(self, stop_drain: Callable[[], None]) -> bool:
"""Forget a drain stopper. Returns False if already removed."""
return True

def subscribe_latest(self, callback: Callable[[MsgT, TopicT], Any]) -> Callable[[], None]:
"""Subscribe to all topics, conflated: newest message per topic wins.

For consumers that only need the current value per topic and must not
lag behind a fast producer (e.g. the rerun bridge). When the callback
is slower than the message flow, intermediate messages on a topic are
dropped and only the latest is delivered.

Contract:
- The callback runs on a dedicated drain thread, never on the
transport's delivery thread.
- Per topic, the newest pending message is always eventually delivered
(no starvation); intermediate ones may be skipped.
- The returned callable unsubscribes the underlying subscribe_all and
stops+joins the drain thread.
"""
latest: dict[str, tuple[MsgT, TopicT]] = {}
lock = threading.Lock()
wake = threading.Event()
stop = threading.Event()

def collect(msg: MsgT, topic: TopicT) -> None:
# Fast path on the transport's delivery thread: keep only newest per topic.
with lock:
latest[str(topic)] = (msg, topic)
wake.set()

def drain() -> None:
while not stop.is_set():
wake.wait()
wake.clear()
with lock:
batch = list(latest.values())
latest.clear()
for msg, topic in batch:
try:
callback(msg, topic)
except Exception:
logger.error("Error in subscribe_latest callback", exc_info=True)

thread = threading.Thread(target=drain, name="subscribe-latest-drain", daemon=True)

def stop_drain() -> None:
stop.set()
wake.set() # unblock the drain so it observes the stop flag
thread.join(timeout=2.0)

# Register + start the drain atomically w.r.t. transport shutdown, then
# subscribe. If the transport already stopped, bail without subscribing.
if not self._register_drain_stop(stop_drain, thread):
return lambda: None
try:
inner_unsub = self.subscribe_all(collect)
except BaseException:
# Don't leak the drain thread if the underlying subscribe fails.
if self._unregister_drain_stop(stop_drain):
stop_drain()
raise

def unsubscribe() -> None:
if not self._unregister_drain_stop(stop_drain):
return # Already removed by stop() or a concurrent unsubscribe
inner_unsub()
stop_drain()

return unsubscribe


class AllPubSub(SubscribeLatestMixin[TopicT, MsgT], PubSub[TopicT, MsgT], ABC):
"""Mixin for PubSub that supports subscribing to all topics.

Subclass from this if you support native subscribe-all (e.g. MQTT #, Redis *).
Expand All @@ -124,7 +222,13 @@ class AllPubSub(PubSub[TopicT, MsgT], ABC):

@abstractmethod
def subscribe_all(self, callback: Callable[[MsgT, TopicT], Any]) -> Callable[[], None]:
"""Subscribe to all topics."""
"""Subscribe to all topics.

Contract: delivers EVERY message on every topic this transport can
observe — implementations must not conflate, sample, or drop beyond
the transport's own delivery semantics. Consumers that only want the
newest message per topic use subscribe_latest() instead.
"""
...

def subscribe_new_topics(self, callback: Callable[[TopicT], Any]) -> Callable[[], None]:
Expand All @@ -144,7 +248,7 @@ def on_msg(msg: MsgT, topic: TopicT) -> None:


# This is for ros for now
class DiscoveryPubSub(PubSub[TopicT, MsgT], ABC):
class DiscoveryPubSub(SubscribeLatestMixin[TopicT, MsgT], PubSub[TopicT, MsgT], ABC):
"""Mixin for PubSub that supports discovery of topics.

Subclass from this if you support topic discovery (e.g. MQTT, Redis, NATS, RabbitMQ).
Expand Down Expand Up @@ -187,5 +291,9 @@ class SubscribeAllCapable(Protocol[MsgT_co, TopicT_co]):
"""

def subscribe_all(self, callback: Callable[[Any, Any], Any]) -> Callable[[], None]:
"""Subscribe to all messages on all topics."""
"""Subscribe to all messages on all topics (every message, no conflation)."""
...

def subscribe_latest(self, callback: Callable[[Any, Any], Any]) -> Callable[[], None]:
"""Subscribe to all topics, conflated to the newest message per topic."""
...
Loading
Loading