- Volatile frames are held in memory with a byte-bounded queue instead of the /dev/shm ring scanned on every put; workers keep writing to the directory, which is trimmed on a timer. A volatile upload no longer spools to disk. ARTIFACT_VOLATILE_BYTES=0 turns the ring off. - A device sends camera frames as binary messages on its socket, held and published in one step and credited to its device node; refused frames are logged and dropped. Its snapshot carries demand and the uplink (binary, or http through the portal). - One demand registry for every socket's wanted media, the tunnel's included, plus what running flows read, mapped onto on-demand ports and published as device_demand; at most 8 media names per socket. - Per-message deflate off in serve and dev; a device may fetch the last 8 digests of a message; published dashboards cached per store revision; a revoked device socket is closed with 1008. - Media tiles want bytes only while on screen; the Devices tab previews a wired camera; the device node warns when a flow reads an on-demand port. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138N7k5PT4dcR1iEqXaaQ9k
125 lines
5.1 KiB
Python
125 lines
5.1 KiB
Python
"""Event bus bridging the engine's worker threads to async subscribers.
|
|
|
|
Nodes execute in a thread pool; websocket clients live on the event loop.
|
|
Publishers are therefore thread-safe and never block: a subscriber that cannot
|
|
keep up loses its oldest queued events rather than stalling the engine.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import threading
|
|
from collections import deque
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
QUEUE_SIZE = 256
|
|
LOG_HISTORY = 400
|
|
#: How many of a message's last artifact digests are remembered.
|
|
RECENT_DIGESTS = 8
|
|
|
|
|
|
class EventBus:
|
|
"""Fan-out of engine events to any number of async subscribers."""
|
|
|
|
def __init__(self) -> None:
|
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set()
|
|
# Events published since the last flush was scheduled. A cascade
|
|
# publishes 13-16 of them and each used to cost its own
|
|
# `call_soon_threadsafe`; they now cost one between two turns of the
|
|
# loop, which is what a wave is.
|
|
self._pending: list[dict[str, Any]] = []
|
|
self._pending_lock = threading.Lock()
|
|
self._flush_scheduled = False
|
|
# Kept whether or not anyone is listening, so opening the log panel
|
|
# shows what just happened rather than an empty box.
|
|
self.recent_logs: deque[dict[str, Any]] = deque(maxlen=LOG_HISTORY)
|
|
# How often each qualified node has emitted, for the same reason: a
|
|
# client that reconnects between two pages would otherwise start from
|
|
# zero and draw a busy graph as idle. A session count, not a metric —
|
|
# the rollups in `metrics.py` are what survives a restart.
|
|
self.emits: dict[str, int] = {}
|
|
#: The last few artifacts each message pointed at, newest last. A
|
|
#: screen fetching a camera frame by frame asks for one the message
|
|
#: may already have moved past, and a device's credential reaches
|
|
#: these as well as the current one.
|
|
self.digests: dict[str, deque[str]] = {}
|
|
|
|
def bind(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
"""Attach the bus to the running event loop (called once at startup)."""
|
|
self._loop = loop
|
|
|
|
def publish(self, event: dict[str, Any]) -> None:
|
|
"""Publish an event from any thread."""
|
|
kind = event.get("type")
|
|
if kind == "node_log":
|
|
# deque.append is atomic, so worker threads need no lock here.
|
|
self.recent_logs.append(event)
|
|
elif kind == "node_executed" and event.get("outputs"):
|
|
# The condition the canvas animates on, so both ends agree on what
|
|
# counts as an emission. Two worker threads racing here can lose an
|
|
# increment, which is a count nobody is going to miss.
|
|
node = str(event.get("node") or "")
|
|
self.emits[node] = self.emits.get(node, 0) + 1
|
|
elif kind == "message_value":
|
|
value = event.get("value")
|
|
if isinstance(value, dict) and isinstance(value.get("digest"), str):
|
|
name = str(event.get("name") or "")
|
|
recent = self.digests.get(name)
|
|
if recent is None:
|
|
recent = self.digests[name] = deque(maxlen=RECENT_DIGESTS)
|
|
recent.append(value["digest"])
|
|
loop = self._loop
|
|
if loop is None or not self._subscribers:
|
|
return
|
|
with self._pending_lock:
|
|
self._pending.append(event)
|
|
if self._flush_scheduled:
|
|
# A flush is already on its way and has not run yet, so it
|
|
# will find this event too.
|
|
return
|
|
self._flush_scheduled = True
|
|
try:
|
|
loop.call_soon_threadsafe(self._dispatch)
|
|
except RuntimeError:
|
|
# Loop already closed — shutting down.
|
|
with self._pending_lock:
|
|
self._pending.clear()
|
|
self._flush_scheduled = False
|
|
|
|
def _dispatch(self) -> None:
|
|
"""Hand everything published since the last turn to every subscriber."""
|
|
with self._pending_lock:
|
|
batch = self._pending
|
|
self._pending = []
|
|
self._flush_scheduled = False
|
|
if not batch:
|
|
return
|
|
for queue in self._subscribers:
|
|
for event in batch:
|
|
if queue.full():
|
|
# Drop the oldest so a slow client never blocks the engine.
|
|
try:
|
|
queue.get_nowait()
|
|
except asyncio.QueueEmpty:
|
|
pass
|
|
queue.put_nowait(event)
|
|
|
|
@asynccontextmanager
|
|
async def subscribe(self) -> AsyncIterator[asyncio.Queue[dict[str, Any]]]:
|
|
"""Yield a queue receiving every event published while subscribed."""
|
|
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=QUEUE_SIZE)
|
|
self._subscribers.add(queue)
|
|
try:
|
|
yield queue
|
|
finally:
|
|
self._subscribers.discard(queue)
|
|
|
|
|
|
event_bus = EventBus()
|