refactor: Backend core rewrite - WsHandler + WsManager + handler migration
- Add WsHandler base class, WsManager (connection tracking / event routing / buffering), WsResult - Extract network.py (is_loopback_address) and context_utils.py (use_context) to eliminate duplication - Migrate three handlers to api/ following the ws_* py naming convention - Simplify run_ui.py WebSocket init from ~170 lines to ~10 - Update import paths in api.py, plugins.py, state_monitor.py
keyboardstaff committed
Mar 26, 2026 at 00:58 UTC
1d81f72a31931534d7154a2df242dbac6e439720
11 files changed
+1957
-290
api/ws_dev_test.py
new
+72
@@ -0,0 +1,72 @@
1
+import asyncio
2
+from typing import Any
3
+
4
+from helpers.ws import WsHandler
5
+from helpers.print_style import PrintStyle
6
+from helpers import runtime
7
+
8
+
9
+class WsDevTest(WsHandler):
10
+ """Developer-only WebSocket test harness handler."""
11
+
12
+ async def process(self, event: str, data: dict, sid: str) -> dict[str, Any] | None:
13
+ if event == "ws_event_console_subscribe":
14
+ if not runtime.is_development():
15
+ return {"_error": True, "code": "NOT_AVAILABLE",
16
+ "message": "Event console is available only in development mode"}
17
+ registered = self.manager.register_diagnostic_watcher(self.namespace, sid)
18
+ if not registered:
19
+ return {"_error": True, "code": "SUBSCRIBE_FAILED",
20
+ "message": "Unable to subscribe to diagnostics"}
21
+ return {"status": "subscribed", "timestamp": data.get("requestedAt")}
22
+
23
+ if event == "ws_event_console_unsubscribe":
24
+ self.manager.unregister_diagnostic_watcher(self.namespace, sid)
25
+ return {"status": "unsubscribed"}
26
+
27
+ if event == "ws_tester_emit":
28
+ message = data.get("message", "emit")
29
+ payload = {"message": message, "echo": True, "timestamp": data.get("timestamp")}
30
+ await self.broadcast("ws_tester_broadcast", payload)
31
+ PrintStyle.info(f"Harness emit broadcasted message='{message}'")
32
+ return None
33
+
34
+ if event == "ws_tester_request":
35
+ value = data.get("value")
36
+ PrintStyle.debug("Harness request responded with echo %s", value)
37
+ return {"echo": value, "handler": self.identifier, "status": "ok"}
38
+
39
+ if event == "ws_tester_request_delayed":
40
+ delay_ms = int(data.get("delay_ms", 0))
41
+ await asyncio.sleep(delay_ms / 1000)
42
+ PrintStyle.warning("Harness delayed request finished after %s ms", delay_ms)
43
+ return {"status": "delayed", "delay_ms": delay_ms, "handler": self.identifier}
44
+
45
+ if event == "ws_tester_trigger_persistence":
46
+ phase = data.get("phase", "unknown")
47
+ payload = {"phase": phase, "handler": self.identifier}
48
+ await self.emit_to(sid, "ws_tester_persistence", payload)
49
+ PrintStyle.info(f"Harness persistence event phase='{phase}' -> {sid}")
50
+ return None
51
+
52
+ if event == "ws_tester_broadcast_demo_trigger":
53
+ payload = {"demo": True, "requested_at": data.get("requested_at")}
54
+ await self.broadcast("ws_tester_broadcast_demo", payload)
55
+ PrintStyle.info("Harness broadcast demo event dispatched")
56
+ return None
57
+
58
+ if event == "ws_tester_request_all":
59
+ correlation_id = data.get("correlationId")
60
+ aggregated = await self.dispatch_to_all_sids(
61
+ "ws_tester_request",
62
+ {"value": data.get("marker", "aggregate")},
63
+ correlation_id=correlation_id,
64
+ )
65
+ return {"results": aggregated}
66
+
67
+ # Ignore events not targeted at this handler (other activated handlers
68
+ # may process them). Only warn for events that look like dev-harness
69
+ # traffic so we don't spam logs with unrelated events.
70
+ if event.startswith("ws_tester_"):
71
+ PrintStyle.warning(f"Harness received unknown event '{event}'")
72
+ return None
api/ws_hello.py
new
+13
@@ -0,0 +1,13 @@
1
+from helpers.ws import WsHandler
2
+from helpers.print_style import PrintStyle
3
+
4
+
5
+class WsHello(WsHandler):
6
+ """Simple echo handler used for foundational testing."""
7
+
8
+ async def process(self, event: str, data: dict, sid: str) -> dict | None:
9
+ if event != "hello_request":
10
+ return None
11
+ name = data.get("name") or "stranger"
12
+ PrintStyle.info(f"hello_request from {sid} ({name})")
13
+ return {"message": f"Hello, {name}!", "handler": self.identifier}
api/ws_webui.py
new
+32
@@ -0,0 +1,32 @@
1
+from helpers.ws import WsHandler
2
+from helpers import extension
3
+
4
+
5
+class WsWebui(WsHandler):
6
+ """State synchronisation handler — the primary WebSocket endpoint for the UI."""
7
+
8
+ async def on_connect(self, sid: str) -> None:
9
+ await extension.call_extensions_async(
10
+ "webui_ws_connect", agent=None, instance=self, sid=sid
11
+ )
12
+
13
+ async def on_disconnect(self, sid: str) -> None:
14
+ await extension.call_extensions_async(
15
+ "webui_ws_disconnect", agent=None, instance=self, sid=sid
16
+ )
17
+
18
+ async def process(self, event: str, data: dict, sid: str) -> dict | None:
19
+ response_data: dict = {}
20
+
21
+ await extension.call_extensions_async(
22
+ "webui_ws_event",
23
+ agent=None,
24
+ instance=self,
25
+ sid=sid,
26
+ event_type=event,
27
+ data=data,
28
+ response_data=response_data,
29
+ )
30
+
31
+ # Return None (fire-and-forget) when no extension populated the response.
32
+ return response_data if response_data else None
helpers/api.py
+24
-58
@@ -1,11 +1,9 @@
1
from abc import abstractmethod
2
import json
3
-import socket
4
-import struct
3
import threading
4
from functools import wraps
5
from pathlib import Path
8
-from typing import Union, TypedDict, Dict, Any
6
+from typing import Union, Dict, Any
7
from flask import (
8
Request,
9
Response,
@@ -19,7 +17,6 @@ from flask import (
17
)
18
from werkzeug.wrappers.response import Response as BaseResponse
19
from agent import AgentContext
22
-from initialize import initialize_agent
20
from helpers.print_style import PrintStyle
21
from helpers.errors import format_error
22
from helpers import files, cache
@@ -30,7 +27,7 @@ CACHE_AREA = "api_handlers(api)"
27
cache.toggle_area(CACHE_AREA, False) # cache off for now
28
29
Input = dict
33
-Output = Union[Dict[str, Any], Response, TypedDict] # type: ignore
30
+Output = Union[Dict[str, Any], Response]
31
32
33
class ApiHandler:
@@ -99,59 +96,11 @@ class ApiHandler:
96
97
# get context to run agent zero in
98
def use_context(self, ctxid: str, create_if_not_exists: bool = True):
102
- with self.thread_lock:
103
- if not ctxid:
104
- first = AgentContext.first()
105
- if first:
106
- AgentContext.use(first.id)
107
- return first
108
- context = AgentContext(config=initialize_agent(), set_current=True)
109
- return context
110
- got = AgentContext.use(ctxid)
111
- if got:
112
- return got
113
- if create_if_not_exists:
114
- context = AgentContext(
115
- config=initialize_agent(), id=ctxid, set_current=True
116
- )
117
- return context
118
- else:
119
- raise Exception(f"Context {ctxid} not found")
120
-
121
-
122
-def is_loopback_address(address: str) -> bool:
123
- loopback_checker = {
124
- socket.AF_INET: lambda x: (
125
- struct.unpack("!I", socket.inet_aton(x))[0] >> (32 - 8)
126
- )
127
- == 127,
128
- socket.AF_INET6: lambda x: x == "::1",
129
- }
130
- address_type = "hostname"
131
- try:
132
- socket.inet_pton(socket.AF_INET6, address)
133
- address_type = "ipv6"
134
- except socket.error:
135
- try:
136
- socket.inet_pton(socket.AF_INET, address)
137
- address_type = "ipv4"
138
- except socket.error:
139
- address_type = "hostname"
140
-
141
- if address_type == "ipv4":
142
- return loopback_checker[socket.AF_INET](address)
143
- elif address_type == "ipv6":
144
- return loopback_checker[socket.AF_INET6](address)
145
- else:
146
- for family in (socket.AF_INET, socket.AF_INET6):
147
- try:
148
- r = socket.getaddrinfo(address, None, family, socket.SOCK_STREAM)
149
- except socket.gaierror:
150
- return False
151
- for family, _, _, _, sockaddr in r:
152
- if not loopback_checker[family](sockaddr[0]):
153
- return False
154
- return True
99
+ from helpers.context_utils import use_context as _use_context
100
+ return _use_context(self.thread_lock, ctxid, create_if_not_exists)
101
+
102
+
103
+from helpers.network import is_loopback_address
104
105
106
def requires_api_key(f):
@@ -301,3 +250,20 @@ def register_watchdogs():
250
patterns=["*.py"],
251
handler=on_api_change,
252
)
253
+
254
+ # WS handler cache shares the same watched directories (api/, usr/api/)
255
+ from helpers.ws import CACHE_AREA as WS_CACHE_AREA
256
+
257
+ def on_ws_change(items: list[watchdog.WatchItem]):
258
+ PrintStyle.debug("WS handler watchdog triggered:", items)
259
+ cache.clear(WS_CACHE_AREA)
260
+
261
+ watchdog.add_watchdog(
262
+ "ws_handlers",
263
+ roots=[
264
+ files.get_abs_path(files.API_DIR),
265
+ files.get_abs_path(files.USER_DIR, files.API_DIR),
266
+ ],
267
+ patterns=["ws_*.py"],
268
+ handler=on_ws_change,
269
+ )
helpers/context_utils.py
new
+30
@@ -0,0 +1,30 @@
1
+"""Shared context helper used by both ApiHandler and WsHandler."""
2
+
3
+import threading
4
+from typing import Union
5
+
6
+ThreadLockType = Union[threading.Lock, threading.RLock]
7
+
8
+
9
+def use_context(lock: ThreadLockType, ctxid: str, create_if_not_exists: bool = True):
10
+ from agent import AgentContext
11
+ from initialize import initialize_agent
12
+
13
+ with lock:
14
+ if not ctxid:
15
+ first = AgentContext.first()
16
+ if first:
17
+ AgentContext.use(first.id)
18
+ return first
19
+ context = AgentContext(config=initialize_agent(), set_current=True)
20
+ return context
21
+ got = AgentContext.use(ctxid)
22
+ if got:
23
+ return got
24
+ if create_if_not_exists:
25
+ context = AgentContext(
26
+ config=initialize_agent(), id=ctxid, set_current=True
27
+ )
28
+ return context
29
+ else:
30
+ raise Exception(f"Context {ctxid} not found")
helpers/network.py
new
+31
@@ -0,0 +1,31 @@
1
+import socket
2
+import struct
3
+
4
+
5
+def is_loopback_address(address: str) -> bool:
6
+ """Check whether *address* resolves to a loopback interface."""
7
+ _checkers = {
8
+ socket.AF_INET: lambda x: (
9
+ struct.unpack("!I", socket.inet_aton(x))[0] >> (32 - 8)
10
+ ) == 127,
11
+ socket.AF_INET6: lambda x: x == "::1",
12
+ }
13
+ try:
14
+ socket.inet_pton(socket.AF_INET6, address)
15
+ return _checkers[socket.AF_INET6](address)
16
+ except socket.error:
17
+ pass
18
+ try:
19
+ socket.inet_pton(socket.AF_INET, address)
20
+ return _checkers[socket.AF_INET](address)
21
+ except socket.error:
22
+ pass
23
+ for family in (socket.AF_INET, socket.AF_INET6):
24
+ try:
25
+ r = socket.getaddrinfo(address, None, family, socket.SOCK_STREAM)
26
+ except socket.gaierror:
27
+ return False
28
+ for fam, _, _, _, sockaddr in r:
29
+ if not _checkers[fam](sockaddr[0]):
30
+ return False
31
+ return True
helpers/plugins.py
+1
-1
@@ -202,7 +202,7 @@ def clear_plugin_cache(plugin_names: list[str] | None = None):
202
for area in areas:
203
cache.clear(area)
204
205
- from helpers.websocket_manager import send_data
205
+ from helpers.ws_manager import send_data
206
207
DeferredTask().start_task(
208
send_data,
helpers/state_monitor.py
+4
-4
@@ -14,10 +14,10 @@ from helpers.state_snapshot import (
14
advance_state_request_after_snapshot,
15
build_snapshot_from_request,
16
)
17
-from helpers.websocket import ConnectionNotFoundError
17
+from helpers.ws import ConnectionNotFoundError
18
19
if TYPE_CHECKING: # pragma: no cover - hints only
20
- from helpers.websocket_manager import WebSocketManager
20
+ from helpers.ws_manager import WsManager
21
22
23
ConnectionIdentity = tuple[str, str] # (namespace, sid)
@@ -60,12 +60,12 @@ class StateMonitor:
60
self._projections: dict[ConnectionIdentity, ConnectionProjection] = {}
61
self._debounce_handles: dict[ConnectionIdentity, asyncio.TimerHandle] = {}
62
self._push_tasks: dict[ConnectionIdentity, asyncio.Task[None]] = {}
63
- self._manager: WebSocketManager | None = None
63
+ self._manager: WsManager | None = None
64
self._emit_handler_id: str | None = None
65
self._dispatcher_loop: asyncio.AbstractEventLoop | None = None
66
self._dirty_wave_seq: int = 0
67
68
- def bind_manager(self, manager: "WebSocketManager", *, handler_id: str | None = None) -> None:
68
+ def bind_manager(self, manager: "WsManager", *, handler_id: str | None = None) -> None:
69
with self._lock:
70
self._manager = manager
71
if handler_id:
helpers/ws.py
+392
-55
@@ -1,22 +1,148 @@
1
import threading
2
+import uuid
3
from abc import abstractmethod
4
from dataclasses import dataclass
5
from pathlib import Path
5
-from typing import Any, Union
6
+from typing import Any, Iterable, Union, TYPE_CHECKING
7
+from urllib.parse import urlparse
8
9
import socketio
10
from flask import Flask, session, request
11
10
-from agent import AgentContext
11
-from initialize import initialize_agent
12
from helpers import files, cache
13
-from helpers.api import is_loopback_address
13
from helpers.print_style import PrintStyle
14
from helpers.errors import format_error
16
-from helpers.websocket import validate_ws_origin
15
+
16
+if TYPE_CHECKING:
17
+ from helpers.ws_manager import WsManager
18
+
19
+
20
+# Utilities
21
+
22
+from helpers.network import is_loopback_address
23
+
24
+
25
+class ConnectionNotFoundError(RuntimeError):
26
+ """Raised when attempting to emit to a non-existent WebSocket connection."""
27
+
28
+ def __init__(self, sid: str, *, namespace: str | None = None) -> None:
29
+ self.sid = sid
30
+ self.namespace = namespace
31
+ if namespace:
32
+ super().__init__(f"Connection not found: namespace={namespace} sid={sid}")
33
+ else:
34
+ super().__init__(f"Connection not found: {sid}")
35
+
36
+
37
+def _default_port_for_scheme(scheme: str) -> int | None:
38
+ if scheme == "http":
39
+ return 80
40
+ if scheme == "https":
41
+ return 443
42
+ return None
43
+
44
+
45
+def normalize_origin(value: Any) -> str | None:
46
+ """Normalize an Origin/Referer header value to scheme://host[:port]."""
47
+ if not isinstance(value, str) or not value.strip():
48
+ return None
49
+ parsed = urlparse(value.strip())
50
+ if not parsed.scheme or not parsed.hostname:
51
+ return None
52
+ origin = f"{parsed.scheme}://{parsed.hostname}"
53
+ if parsed.port:
54
+ origin += f":{parsed.port}"
55
+ return origin
56
+
57
+
58
+def _parse_host_header(value: Any) -> tuple[str | None, int | None]:
59
+ if not isinstance(value, str) or not value.strip():
60
+ return None, None
61
+ parsed = urlparse(f"http://{value.strip()}")
62
+ return parsed.hostname, parsed.port
63
+
64
+
65
+def validate_ws_origin(environ: dict[str, Any]) -> tuple[bool, str | None]:
66
+ """Validate the browser Origin during the Socket.IO handshake.
67
+
68
+ This is the minimum baseline recommended by RFC 6455 (Origin considerations)
69
+ and OWASP (CSWSH mitigation): reject cross-origin WebSocket handshakes when
70
+ the server is intended for a specific web UI origin.
71
+ """
72
+
73
+ raw_origin = environ.get("HTTP_ORIGIN") or environ.get("HTTP_REFERER")
74
+ origin = normalize_origin(raw_origin)
75
+ if origin is None:
76
+ return False, "missing_origin"
77
+
78
+ origin_parsed = urlparse(origin)
79
+ origin_host = origin_parsed.hostname.lower() if origin_parsed.hostname else None
80
+ origin_port = origin_parsed.port or _default_port_for_scheme(origin_parsed.scheme)
81
+ if origin_host is None or origin_port is None:
82
+ return False, "invalid_origin"
83
+
84
+ raw_host = environ.get("HTTP_HOST")
85
+ req_host, req_port = _parse_host_header(raw_host)
86
+ if not req_host:
87
+ req_host = environ.get("SERVER_NAME")
88
+
89
+ if req_port is None:
90
+ server_port_raw = environ.get("SERVER_PORT")
91
+ try:
92
+ server_port = int(server_port_raw) if server_port_raw is not None else None
93
+ except (TypeError, ValueError):
94
+ server_port = None
95
+ if server_port is not None and server_port > 0:
96
+ req_port = server_port
97
+
98
+ if req_host:
99
+ req_host = req_host.lower()
100
+ if req_port is None:
101
+ req_port = origin_port
102
+
103
+ forwarded_host_raw = environ.get("HTTP_X_FORWARDED_HOST")
104
+ forwarded_host = None
105
+ forwarded_port = None
106
+ if isinstance(forwarded_host_raw, str) and forwarded_host_raw.strip():
107
+ first = forwarded_host_raw.split(",")[0].strip()
108
+ forwarded_host, forwarded_port = _parse_host_header(first)
109
+ if forwarded_host:
110
+ forwarded_host = forwarded_host.lower()
111
+
112
+ forwarded_proto_raw = environ.get("HTTP_X_FORWARDED_PROTO")
113
+ forwarded_scheme = None
114
+ if isinstance(forwarded_proto_raw, str) and forwarded_proto_raw.strip():
115
+ forwarded_scheme = forwarded_proto_raw.split(",")[0].strip().lower()
116
+ forwarded_scheme = forwarded_scheme or origin_parsed.scheme
117
+ forwarded_port = (
118
+ forwarded_port
119
+ if forwarded_port is not None
120
+ else _default_port_for_scheme(forwarded_scheme) or origin_port
121
+ )
122
+
123
+ candidates: list[tuple[str, int]] = []
124
+ if req_host:
125
+ candidates.append((req_host, int(req_port)))
126
+ if forwarded_host:
127
+ candidates.append((forwarded_host, int(forwarded_port)))
128
+
129
+ if not candidates:
130
+ return False, "missing_host"
131
+
132
+ for host, port in candidates:
133
+ if origin_host == host and origin_port == port:
134
+ return True, None
135
+
136
+ if origin_host not in {host for host, _ in candidates}:
137
+ return False, "origin_host_mismatch"
138
+ return False, "origin_port_mismatch"
139
+
140
+
141
+# Constants
142
143
ThreadLockType = Union[threading.Lock, threading.RLock]
144
145
+NAMESPACE = "/ws"
146
CACHE_AREA = "ws_handlers(api)(plugins)"
147
cache.toggle_area(CACHE_AREA, False) # cache off for now
148
@@ -37,9 +163,52 @@ _contexts_lock = threading.Lock()
163
164
165
class WsHandler:
40
- def __init__(self, socketio_server: socketio.AsyncServer, lock: ThreadLockType):
166
+ """Base class for WebSocket handlers loaded from api/ directories.
167
+
168
+ Mirrors ApiHandler conventions: declarative security flags, dynamic file-
169
+ based loading, and a ``process(event, data, sid)`` entry point. Handlers
170
+ are activated per-connection based on the ``auth.handlers`` list sent by the
171
+ client during the Socket.IO connect handshake.
172
+ """
173
+
174
+ def __init__(
175
+ self,
176
+ socketio_server: socketio.AsyncServer,
177
+ lock: ThreadLockType,
178
+ *,
179
+ manager: "WsManager | None" = None,
180
+ namespace: str = NAMESPACE,
181
+ ):
182
self.socketio = socketio_server
183
self.lock = lock
184
+ self._manager = manager
185
+ self._namespace = namespace
186
+
187
+ # Properties
188
+
189
+ @property
190
+ def namespace(self) -> str:
191
+ return self._namespace
192
+
193
+ @property
194
+ def manager(self) -> "WsManager":
195
+ if self._manager is None:
196
+ raise RuntimeError("WsHandler has no WsManager bound")
197
+ return self._manager
198
+
199
+ @property
200
+ def identifier(self) -> str:
201
+ return f"{self.__class__.__module__}.{self.__class__.__name__}"
202
+
203
+ def bind_manager(
204
+ self, manager: "WsManager", *, namespace: str | None = None
205
+ ) -> None:
206
+ """Late-bind (or rebind) the manager and optionally the namespace."""
207
+ self._manager = manager
208
+ if namespace is not None:
209
+ self._namespace = namespace
210
+
211
+ # Security flags (mirror ApiHandler)
212
213
@classmethod
214
def requires_loopback(cls) -> bool:
@@ -57,67 +226,164 @@ class WsHandler:
226
def requires_csrf(cls) -> bool:
227
return cls.requires_auth()
228
60
- @abstractmethod
61
- async def process(self, data: dict, sid: str) -> dict | None:
62
- pass
229
+ # Lifecycle hooks
230
64
- async def on_connect(self, sid: str) -> dict | None:
65
- return None
231
+ async def on_connect(self, sid: str) -> None:
232
+ pass
233
234
async def on_disconnect(self, sid: str) -> None:
235
pass
236
237
+ # Event processing
238
+
239
+ @abstractmethod
240
+ async def process(self, event: str, data: dict, sid: str) -> dict | None:
241
+ """Handle an incoming event.
242
+
243
+ Return a dict to include in the acknowledgement, or ``None`` for
244
+ fire-and-forget semantics.
245
+ """
246
+
247
+ # Emit helpers (delegate to WsManager for envelope wrapping)
248
+
249
+ async def emit_to(
250
+ self,
251
+ sid: str,
252
+ event: str,
253
+ data: dict,
254
+ *,
255
+ correlation_id: str | None = None,
256
+ ) -> None:
257
+ await self.manager.emit_to(
258
+ self._namespace, sid, event, data,
259
+ handler_id=self.identifier,
260
+ correlation_id=correlation_id,
261
+ )
262
+
263
+ async def broadcast(
264
+ self,
265
+ event: str,
266
+ data: dict,
267
+ *,
268
+ exclude_sids: str | Iterable[str] | None = None,
269
+ correlation_id: str | None = None,
270
+ ) -> None:
271
+ await self.manager.broadcast(
272
+ self._namespace, event, data,
273
+ exclude_sids=exclude_sids,
274
+ handler_id=self.identifier,
275
+ correlation_id=correlation_id,
276
+ )
277
+
278
+ # Aggregation helper
279
+
280
+ async def dispatch_to_all_sids(
281
+ self,
282
+ event: str,
283
+ data: dict,
284
+ *,
285
+ correlation_id: str | None = None,
286
+ ) -> list[dict[str, Any]]:
287
+ """Dispatch *event* to every connected sid's activated handlers and
288
+ aggregate the results.
289
+
290
+ Returns a list of ``{sid, correlationId, results}`` dicts – one per
291
+ connected sid. This mirrors the shape produced by
292
+ ``WsManager.route_event_all`` so that existing frontend
293
+ assertions remain valid.
294
+ """
295
+ cid = correlation_id or uuid.uuid4().hex
296
+
297
+ with _contexts_lock:
298
+ snapshot = {
299
+ sid: dict(handlers)
300
+ for sid, handlers in _active_handlers.items()
301
+ }
302
+
303
+ aggregated: list[dict[str, Any]] = []
304
+ for sid, handlers in snapshot.items():
305
+ ctx = _ws_contexts.get(sid)
306
+ sid_results: list[dict[str, Any]] = []
307
+ for _path, instance in handlers.items():
308
+ if ctx is not None:
309
+ error = _check_security(type(instance), ctx)
310
+ if error is not None:
311
+ sid_results.append({
312
+ "handlerId": instance.identifier,
313
+ "ok": False,
314
+ "correlationId": cid,
315
+ "error": error,
316
+ })
317
+ continue
318
+ try:
319
+ result = await instance.process(event, dict(data, correlationId=cid), sid)
320
+ if result is not None:
321
+ sid_results.append({
322
+ "handlerId": instance.identifier,
323
+ "ok": True,
324
+ "correlationId": cid,
325
+ "data": result,
326
+ })
327
+ except Exception as e:
328
+ sid_results.append({
329
+ "handlerId": instance.identifier,
330
+ "ok": False,
331
+ "correlationId": cid,
332
+ "error": {"code": "HANDLER_ERROR", "error": str(e)},
333
+ })
334
+ aggregated.append({
335
+ "sid": sid,
336
+ "correlationId": cid,
337
+ "results": sid_results,
338
+ })
339
+ return aggregated
340
+
341
+ # Context helper (shared with ApiHandler)
342
+
343
def use_context(self, ctxid: str, create_if_not_exists: bool = True):
71
- with self.lock:
72
- if not ctxid:
73
- first = AgentContext.first()
74
- if first:
75
- AgentContext.use(first.id)
76
- return first
77
- context = AgentContext(config=initialize_agent(), set_current=True)
78
- return context
79
- got = AgentContext.use(ctxid)
80
- if got:
81
- return got
82
- if create_if_not_exists:
83
- context = AgentContext(config=initialize_agent(), id=ctxid, set_current=True)
84
- return context
85
- else:
86
- raise Exception(f"Context {ctxid} not found")
344
+ from helpers.context_utils import use_context as _use_context
345
+ return _use_context(self.lock, ctxid, create_if_not_exists)
346
+
347
348
+# Security check (aligned with api.py decorators)
349
350
def _check_security(handler_cls: type[WsHandler], ctx: _SecurityContext) -> dict[str, Any] | None:
351
+ """Return an error payload dict if the check fails, or ``None`` on success."""
352
+
353
if handler_cls.requires_loopback():
354
if not ctx.remote_addr or not is_loopback_address(ctx.remote_addr):
92
- return {"ok": False, "error": "Access denied", "code": 403}
355
+ return {"code": "FORBIDDEN", "error": "Access denied"}
356
357
if handler_cls.requires_auth():
358
from helpers import login
359
user_pass_hash = login.get_credentials_hash()
360
if user_pass_hash and ctx.auth_hash != user_pass_hash:
98
- return {"ok": False, "error": "Authentication required", "code": 401}
361
+ return {"code": "AUTH_REQUIRED", "error": "Authentication required"}
362
363
if handler_cls.requires_csrf():
364
if not ctx.csrf_token:
102
- return {"ok": False, "error": "CSRF token not initialised", "code": 403}
365
+ return {"code": "CSRF_MISSING", "error": "CSRF token not initialised"}
366
if not ctx.client_csrf_token or ctx.client_csrf_token != ctx.csrf_token:
104
- return {"ok": False, "error": "CSRF token missing or invalid", "code": 403}
367
+ return {"code": "CSRF_INVALID", "error": "CSRF token missing or invalid"}
368
if ctx.csrf_cookie != ctx.csrf_token:
106
- return {"ok": False, "error": "CSRF cookie mismatch", "code": 403}
369
+ return {"code": "CSRF_COOKIE", "error": "CSRF cookie mismatch"}
370
371
if handler_cls.requires_api_key():
372
from helpers.settings import get_settings
373
valid_key = get_settings().get("mcp_server_token")
374
if not ctx.api_key or ctx.api_key != valid_key:
112
- return {"ok": False, "error": "API key required", "code": 401}
375
+ return {"code": "API_KEY_REQUIRED", "error": "API key required"}
376
377
return None
378
379
380
+# Namespace registration
381
+
382
def register_ws_namespace(
383
socketio_server: socketio.AsyncServer,
384
webapp: Flask,
385
lock: ThreadLockType,
386
+ manager: "WsManager | None" = None,
387
) -> None:
388
from helpers.modules import load_classes_from_file
389
from helpers import plugins, runtime
@@ -132,6 +398,14 @@ def register_ws_namespace(
398
if classes:
399
handler_cls = classes[0]
400
401
+ # Check user api/<path>.py
402
+ if handler_cls is None:
403
+ user_file = files.get_abs_path(files.USER_DIR, f"api/{path}.py")
404
+ if files.exists(user_file):
405
+ classes = load_classes_from_file(user_file, WsHandler)
406
+ if classes:
407
+ handler_cls = classes[0]
408
+
409
# Check plugin api/<handler>.py — path format: plugins/<plugin_name>/<handler>
410
if handler_cls is None and path.startswith("plugins/"):
411
parts = path.split("/", 2)
@@ -156,13 +430,13 @@ def register_ws_namespace(
430
cache.add(CACHE_AREA, path, handler_cls)
431
return handler_cls
432
159
- @socketio_server.on("connect", namespace="/ws") # type: ignore
433
+ @socketio_server.on("connect", namespace=NAMESPACE) # type: ignore
434
async def _on_connect(sid, environ, auth):
435
with webapp.request_context(environ):
436
origin_ok, origin_reason = validate_ws_origin(environ)
437
if not origin_ok:
438
PrintStyle.warning(
165
- f"WS /ws connect rejected for {sid}: {origin_reason or 'invalid'}"
439
+ f"WS connect rejected for {sid}: {origin_reason or 'invalid'}"
440
)
441
return False
442
@@ -182,11 +456,19 @@ def register_ws_namespace(
456
if isinstance(auth, dict) else None
457
),
458
)
459
+ user_id = session.get("user_id") or "single_user"
460
+
461
with _contexts_lock:
462
_ws_contexts[sid] = ctx
463
464
+ # Register with WsManager first so that the dispatcher loop and
465
+ # connection tracking are available before handler on_connect runs
466
+ # (extensions like StateSync depend on manager._dispatcher_loop).
467
+ if manager is not None:
468
+ await manager.handle_connect(NAMESPACE, sid, user_id=user_id)
469
+
470
# Activate handlers declared in auth.handlers
189
- handler_paths = []
471
+ handler_paths: list[str] = []
472
if isinstance(auth, dict):
473
raw = auth.get("handlers")
474
if isinstance(raw, list):
@@ -201,7 +483,10 @@ def register_ws_namespace(
483
error = _check_security(handler_cls, ctx)
484
if error is not None:
485
continue
204
- instance = handler_cls(socketio_server, lock)
486
+ instance = handler_cls(
487
+ socketio_server, lock,
488
+ manager=manager, namespace=NAMESPACE,
489
+ )
490
await instance.on_connect(sid)
491
activated[path] = instance
492
except Exception as e:
@@ -212,7 +497,7 @@ def register_ws_namespace(
497
498
return True
499
215
- @socketio_server.on("disconnect", namespace="/ws") # type: ignore
500
+ @socketio_server.on("disconnect", namespace=NAMESPACE) # type: ignore
501
async def _on_disconnect(sid):
502
with _contexts_lock:
503
activated = _active_handlers.pop(sid, {})
@@ -224,31 +509,83 @@ def register_ws_namespace(
509
except Exception as e:
510
PrintStyle.error(f"WS on_disconnect error ({path}): {format_error(e)}")
511
227
- @socketio_server.on("*", namespace="/ws") # type: ignore
512
+ if manager is not None:
513
+ await manager.handle_disconnect(NAMESPACE, sid)
514
+
515
+ @socketio_server.on("*", namespace=NAMESPACE) # type: ignore
516
async def _dispatch(event, sid, data):
229
- path = event
230
- payload = data if isinstance(data, dict) else {}
517
+ incoming = data if isinstance(data, dict) else {}
518
519
try:
520
with _contexts_lock:
521
ctx = _ws_contexts.get(sid)
235
- activated = _active_handlers.get(sid, {})
236
- if ctx is None:
237
- return {"ok": False, "error": "No security context", "code": 401}
522
+ activated = dict(_active_handlers.get(sid, {}))
523
239
- instance = activated.get(path)
240
- if instance is None:
241
- return {"ok": False, "error": f"WS endpoint not activated: {path}", "code": 404}
524
+ correlation_id = incoming.get("correlationId") or uuid.uuid4().hex
525
243
- # Security check
244
- error = _check_security(type(instance), ctx)
245
- if error is not None:
246
- return error
526
+ if ctx is None:
527
+ return _error_response("AUTH_REQUIRED",
528
+ "No security context", correlation_id)
529
+ if not activated:
530
+ return _error_response("NO_HANDLERS",
531
+ "No handlers activated", correlation_id)
532
+
533
+ # Unwrap nested payload (mirrors WsManager.route_event):
534
+ # frontend sends {ts, data: {actual fields...}, correlationId}
535
+ if "data" in incoming and isinstance(incoming.get("data"), dict):
536
+ handler_payload = dict(incoming["data"])
537
+ else:
538
+ handler_payload = dict(incoming)
539
+ handler_payload["correlationId"] = correlation_id
540
248
- # Use cached instance and process
249
- return await instance.process(payload, sid)
541
+ results: list[dict[str, Any]] = []
542
+ for path, instance in activated.items():
543
+ error = _check_security(type(instance), ctx)
544
+ if error is not None:
545
+ results.append({
546
+ "handlerId": instance.identifier,
547
+ "ok": False,
548
+ "correlationId": correlation_id,
549
+ "error": error,
550
+ })
551
+ continue
552
+ try:
553
+ result = await instance.process(event, handler_payload, sid)
554
+ if result is not None:
555
+ results.append({
556
+ "handlerId": instance.identifier,
557
+ "ok": True,
558
+ "correlationId": correlation_id,
559
+ "data": result,
560
+ })
561
+ except Exception as e:
562
+ error_text = format_error(e)
563
+ PrintStyle.error(f"WS handler error ({path}/{event}): {error_text}")
564
+ results.append({
565
+ "handlerId": instance.identifier,
566
+ "ok": False,
567
+ "correlationId": correlation_id,
568
+ "error": {"code": "HANDLER_ERROR", "error": "Internal server error"},
569
+ })
570
+
571
+ return {"correlationId": correlation_id, "results": results}
572
573
except Exception as e:
574
error_text = format_error(e)
253
- PrintStyle.error(f"WS handler error ({path}): {error_text}")
254
- return {"ok": False, "error": error_text, "code": 500}
\ No newline at end of file
575
+ PrintStyle.error(f"WS dispatch error ({event}): {error_text}")
576
+ return _error_response(
577
+ "INTERNAL_ERROR", "Internal server error",
578
+ incoming.get("correlationId", ""),
579
+ )
580
+
581
+
582
+def _error_response(code: str, message: str,
583
+ correlation_id: str) -> dict[str, Any]:
584
+ return {
585
+ "correlationId": correlation_id,
586
+ "results": [{
587
+ "handlerId": "ws.dispatch",
588
+ "ok": False,
589
+ "error": {"code": code, "error": message},
590
+ }],
591
+ }
\ No newline at end of file
helpers/ws_manager.py
new
+1351
@@ -0,0 +1,1351 @@
1
+from __future__ import annotations
2
+
3
+import asyncio, os
4
+import re
5
+import time
6
+import threading
7
+from collections import defaultdict, deque
8
+from dataclasses import dataclass, field
9
+from datetime import datetime, timedelta, timezone
10
+from typing import Any, Callable, Deque, Dict, Iterable, List, Optional, Set
11
+
12
+import socketio
13
+import uuid
14
+
15
+from helpers.defer import DeferredTask
16
+from helpers.print_style import PrintStyle
17
+from helpers import runtime
18
+from helpers.ws import ConnectionNotFoundError, WsHandler
19
+
20
+
21
+def _ws_debug_enabled() -> bool:
22
+ """Check A0_WS_DEBUG env var — no heavyweight imports needed."""
23
+ value = os.getenv("A0_WS_DEBUG", "").strip().lower()
24
+ return value in {"1", "true", "yes", "on"}
25
+
26
+
27
+# Event validation
28
+
29
+_EVENT_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")
30
+_RESERVED_EVENT_NAMES: set[str] = {
31
+ "connect",
32
+ "disconnect",
33
+ "error",
34
+ "ping",
35
+ "pong",
36
+ "connect_error",
37
+ "reconnect",
38
+ "reconnect_attempt",
39
+ "reconnect_error",
40
+ "reconnect_failed",
41
+}
42
+
43
+
44
+# WsResult – standardized handler return value
45
+
46
+class WsResult:
47
+ """Helper wrapper for standardized handler results.
48
+
49
+ Instances are converted to the canonical ``RequestResultItem`` shape by
50
+ :class:`WsManager`. Helper constructors enforce payload validation
51
+ so handlers no longer need to hand-craft dictionaries.
52
+ """
53
+
54
+ __slots__ = ("_ok", "_data", "_error", "_correlation_id", "_duration_ms")
55
+
56
+ def __init__(
57
+ self,
58
+ ok: bool,
59
+ data: dict[str, Any] | None = None,
60
+ error: dict[str, Any] | None = None,
61
+ correlation_id: str | None = None,
62
+ duration_ms: float | None = None,
63
+ ) -> None:
64
+ if ok and error:
65
+ raise ValueError("Cannot be both ok and have an error")
66
+ if not ok and not error:
67
+ raise ValueError("Must either be ok or have an error")
68
+ if data is not None and not isinstance(data, dict):
69
+ raise TypeError("Data payload must be a dictionary or None")
70
+ if error is not None and not isinstance(error, dict):
71
+ raise TypeError("Error payload must be a dictionary or None")
72
+ if correlation_id is not None and not isinstance(correlation_id, str):
73
+ raise TypeError("Correlation ID must be a string or None")
74
+ if duration_ms is not None and not isinstance(duration_ms, (int, float)):
75
+ raise TypeError("Duration must be a number or None")
76
+
77
+ self._ok = bool(ok)
78
+ self._data = dict(data) if data is not None else None
79
+ self._error = dict(error) if error is not None else None
80
+ self._correlation_id = correlation_id
81
+ self._duration_ms = float(duration_ms) if duration_ms is not None else None
82
+
83
+ @classmethod
84
+ def ok(
85
+ cls,
86
+ data: dict[str, Any] | None = None,
87
+ *,
88
+ correlation_id: str | None = None,
89
+ duration_ms: float | None = None,
90
+ ) -> "WsResult":
91
+ if data is not None and not isinstance(data, dict):
92
+ raise TypeError("WsResult.ok data must be a dict or None")
93
+ payload = dict(data) if data is not None else None
94
+ return cls(
95
+ ok=True,
96
+ data=payload,
97
+ correlation_id=correlation_id,
98
+ duration_ms=duration_ms,
99
+ )
100
+
101
+ @classmethod
102
+ def error(
103
+ cls,
104
+ *,
105
+ code: str,
106
+ message: str,
107
+ details: Any | None = None,
108
+ correlation_id: str | None = None,
109
+ duration_ms: float | None = None,
110
+ ) -> "WsResult":
111
+ if not isinstance(code, str) or not code.strip():
112
+ raise ValueError("Error code must be a non-empty string")
113
+ if not isinstance(message, str) or not message.strip():
114
+ raise ValueError("Error message must be a non-empty string")
115
+
116
+ error_payload: dict[str, Any] = {"code": code, "error": message}
117
+ if details is not None:
118
+ error_payload["details"] = details
119
+ return cls(
120
+ ok=False,
121
+ error=error_payload,
122
+ correlation_id=correlation_id,
123
+ duration_ms=duration_ms,
124
+ )
125
+
126
+ def as_result(
127
+ self,
128
+ *,
129
+ handler_id: str,
130
+ fallback_correlation_id: str | None,
131
+ duration_ms: float | None = None,
132
+ ) -> dict[str, Any]:
133
+ result: dict[str, Any] = {
134
+ "handlerId": handler_id,
135
+ "ok": self._ok,
136
+ }
137
+
138
+ effective_duration = (
139
+ self._duration_ms if self._duration_ms is not None else duration_ms
140
+ )
141
+ if effective_duration is not None:
142
+ result["durationMs"] = round(effective_duration, 4)
143
+
144
+ correlation = (
145
+ self._correlation_id
146
+ if self._correlation_id is not None
147
+ else fallback_correlation_id
148
+ )
149
+ if correlation is not None:
150
+ result["correlationId"] = correlation
151
+
152
+ if self._ok:
153
+ result["data"] = dict(self._data) if self._data is not None else {}
154
+ else:
155
+ result["error"] = dict(self._error) if self._error is not None else {
156
+ "code": "INTERNAL_ERROR",
157
+ "error": "Internal server error",
158
+ }
159
+ return result
160
+
161
+
162
+def validate_event_type(event_type: str) -> str:
163
+ """Validate an event name: must be lowercase_snake_case and not reserved."""
164
+ if not isinstance(event_type, str):
165
+ raise TypeError("Event type must be a string")
166
+ if not _EVENT_NAME_PATTERN.fullmatch(event_type):
167
+ raise ValueError(
168
+ f"Invalid event type '{event_type}' – must match lowercase_snake_case"
169
+ )
170
+ if event_type in _RESERVED_EVENT_NAMES:
171
+ raise ValueError(
172
+ f"Event type '{event_type}' is reserved by Socket.IO and cannot be used"
173
+ )
174
+ return event_type
175
+
176
+
177
+BUFFER_MAX_SIZE = 100
178
+BUFFER_TTL = timedelta(hours=1)
179
+_shared_ws_manager: WsManager | None = None
180
+
181
+
182
+async def send_data(
183
+ event_type: str,
184
+ data: dict[str, Any],
185
+ endpoint_name: str = "/ws",
186
+ connection_id: str | None = None,
187
+) -> None:
188
+ manager = get_shared_ws_manager()
189
+ await manager.send_data(endpoint_name, event_type, data, connection_id)
190
+
191
+
192
+def _utcnow() -> datetime:
193
+ return datetime.now(timezone.utc)
194
+
195
+
196
+def set_shared_ws_manager(manager: "WsManager") -> None:
197
+ global _shared_ws_manager
198
+ _shared_ws_manager = manager
199
+
200
+
201
+def get_shared_ws_manager() -> "WsManager":
202
+ manager = _shared_ws_manager
203
+ if manager is None:
204
+ raise RuntimeError("Shared WsManager has not been initialized")
205
+ return manager
206
+
207
+
208
+@dataclass
209
+class BufferedEvent:
210
+ event_type: str
211
+ data: dict[str, Any]
212
+ handler_id: str | None = None
213
+ correlation_id: str | None = None
214
+ timestamp: datetime = field(default_factory=_utcnow)
215
+
216
+
217
+@dataclass
218
+class ConnectionInfo:
219
+ namespace: str
220
+ sid: str
221
+ connected_at: datetime = field(default_factory=_utcnow)
222
+ last_activity: datetime = field(default_factory=_utcnow)
223
+
224
+
225
+ConnectionIdentity = tuple[str, str] # (namespace, sid)
226
+
227
+
228
+@dataclass
229
+class _HandlerExecution:
230
+ handler: WsHandler
231
+ value: Any
232
+ duration_ms: float | None
233
+
234
+
235
+DIAGNOSTIC_EVENT = "ws_dev_console_event"
236
+LIFECYCLE_CONNECT_EVENT = "ws_lifecycle_connect"
237
+LIFECYCLE_DISCONNECT_EVENT = "ws_lifecycle_disconnect"
238
+
239
+
240
+class WsManager:
241
+ def __init__(self, socketio: socketio.AsyncServer, lock) -> None:
242
+ self.socketio = socketio
243
+ self.lock = lock
244
+ self.handlers: defaultdict[str, List[WsHandler]] = defaultdict(list)
245
+ self.connections: Dict[ConnectionIdentity, ConnectionInfo] = {}
246
+ self.buffers: defaultdict[ConnectionIdentity, Deque[BufferedEvent]] = (
247
+ defaultdict(deque)
248
+ )
249
+ self._known_sids: Set[ConnectionIdentity] = set()
250
+ self._identifier: str = f"{self.__class__.__module__}.{self.__class__.__name__}"
251
+ # Session tracking (single-user default)
252
+ self.user_to_sids: defaultdict[str, Set[ConnectionIdentity]] = defaultdict(set)
253
+ self.sid_to_user: Dict[ConnectionIdentity, str | None] = {}
254
+ self._ALL_USERS_BUCKET = "allUsers"
255
+ self._server_restart_enabled: bool = False
256
+ self._diagnostic_watchers: Set[ConnectionIdentity] = set()
257
+ self._diagnostics_enabled: bool = runtime.is_development()
258
+ self._dispatcher_loop: asyncio.AbstractEventLoop | None = None
259
+ self._handler_worker: DeferredTask | None = None
260
+
261
+ # Internal: development-only debug logging to avoid noise in production
262
+ def _debug(self, message: str) -> None:
263
+ if _ws_debug_enabled():
264
+ PrintStyle.debug(message)
265
+
266
+ def _ensure_dispatcher_loop(self) -> None:
267
+ if self._dispatcher_loop is None:
268
+ try:
269
+ self._dispatcher_loop = asyncio.get_running_loop()
270
+ except RuntimeError:
271
+ return
272
+
273
+ def _get_handler_worker(self) -> DeferredTask:
274
+ if self._handler_worker is None:
275
+ self._handler_worker = DeferredTask(thread_name="WsHandlers")
276
+ return self._handler_worker
277
+
278
+ async def _run_on_dispatcher_loop(self, coro: Any) -> Any:
279
+ self._ensure_dispatcher_loop()
280
+ dispatcher_loop = self._dispatcher_loop
281
+ if dispatcher_loop is None:
282
+ return await coro
283
+ if dispatcher_loop.is_closed():
284
+ try:
285
+ coro.close()
286
+ except Exception: # pragma: no cover - best-effort cleanup
287
+ pass
288
+ raise RuntimeError("Dispatcher event loop is closed")
289
+
290
+ try:
291
+ running_loop = asyncio.get_running_loop()
292
+ except RuntimeError:
293
+ running_loop = None
294
+
295
+ if running_loop is dispatcher_loop:
296
+ return await coro
297
+
298
+ future = asyncio.run_coroutine_threadsafe(coro, dispatcher_loop)
299
+ return await asyncio.wrap_future(future)
300
+
301
+ def _diagnostics_active(self) -> bool:
302
+ if not self._diagnostics_enabled:
303
+ return False
304
+ with self.lock:
305
+ return bool(self._diagnostic_watchers)
306
+
307
+ def _copy_diagnostic_watchers(self) -> list[ConnectionIdentity]:
308
+ with self.lock:
309
+ return list(self._diagnostic_watchers)
310
+
311
+ def register_diagnostic_watcher(self, namespace: str, sid: str) -> bool:
312
+ if not self._diagnostics_enabled:
313
+ return False
314
+ identity: ConnectionIdentity = (namespace, sid)
315
+ with self.lock:
316
+ if identity not in self.connections:
317
+ return False
318
+ self._diagnostic_watchers.add(identity)
319
+ return True
320
+
321
+ def unregister_diagnostic_watcher(self, namespace: str, sid: str) -> None:
322
+ identity: ConnectionIdentity = (namespace, sid)
323
+ with self.lock:
324
+ self._diagnostic_watchers.discard(identity)
325
+
326
+ def _timestamp(self) -> str:
327
+ return _utcnow().isoformat(timespec="milliseconds").replace("+00:00", "Z")
328
+
329
+ def _summarize_payload(self, payload: dict[str, Any] | None) -> dict[str, Any]:
330
+ if not isinstance(payload, dict):
331
+ return {}
332
+ summary: dict[str, Any] = {}
333
+ for key in list(payload.keys())[:5]:
334
+ value = payload[key]
335
+ if isinstance(value, (str, int, float, bool)) or value is None:
336
+ preview = value
337
+ elif isinstance(value, dict):
338
+ preview = f"dict({len(value)})"
339
+ elif isinstance(value, list):
340
+ preview = f"list({len(value)})"
341
+ else:
342
+ preview = value.__class__.__name__
343
+ summary[key] = preview
344
+ summary["__sizeBytes__"] = len(str(payload).encode("utf-8"))
345
+ return summary
346
+
347
+ def _summarize_results(self, results: List[dict[str, Any]]) -> dict[str, Any]:
348
+ summary = {"ok": 0, "error": 0, "handlers": []}
349
+ for result in results:
350
+ handler_id = result.get("handlerId")
351
+ ok = bool(result.get("ok"))
352
+ if ok:
353
+ summary["ok"] += 1
354
+ else:
355
+ summary["error"] += 1
356
+ summary["handlers"].append(
357
+ {
358
+ "handlerId": handler_id,
359
+ "ok": ok,
360
+ "errorCode": (result.get("error") or {}).get("code"),
361
+ "durationMs": result.get("durationMs"),
362
+ }
363
+ )
364
+ summary["handlerCount"] = len(summary["handlers"])
365
+ return summary
366
+
367
+ async def _publish_diagnostic_event(
368
+ self, payload: dict[str, Any] | Callable[[], dict[str, Any]]
369
+ ) -> None:
370
+ if not self._diagnostics_enabled:
371
+ return
372
+ watchers = self._copy_diagnostic_watchers()
373
+ if not watchers:
374
+ return
375
+ effective_payload = payload() if callable(payload) else payload
376
+ if (
377
+ isinstance(effective_payload, dict)
378
+ and "sourceNamespace" not in effective_payload
379
+ ):
380
+ origin = effective_payload.get("namespace")
381
+ if isinstance(origin, str) and origin.strip():
382
+ effective_payload = {
383
+ **effective_payload,
384
+ "sourceNamespace": origin.strip(),
385
+ }
386
+
387
+ async def _emit_to_watcher(identity: ConnectionIdentity) -> None:
388
+ namespace, sid = identity
389
+ try:
390
+ await self.emit_to(
391
+ namespace,
392
+ sid,
393
+ DIAGNOSTIC_EVENT,
394
+ effective_payload,
395
+ handler_id=self._identifier,
396
+ diagnostic=True,
397
+ )
398
+ except ConnectionNotFoundError:
399
+ self.unregister_diagnostic_watcher(namespace, sid)
400
+
401
+ await asyncio.gather(*(_emit_to_watcher(identity) for identity in watchers))
402
+
403
+ def _schedule_lifecycle_broadcast(
404
+ self, namespace: str, event_type: str, payload: dict[str, Any]
405
+ ) -> None:
406
+ async def _broadcast() -> None:
407
+ try:
408
+ await self.broadcast(
409
+ namespace,
410
+ event_type,
411
+ payload,
412
+ diagnostic=True,
413
+ )
414
+ except Exception as exc: # pragma: no cover - diagnostic
415
+ self._debug(f"Failed to broadcast lifecycle event {event_type}: {exc}")
416
+
417
+ asyncio.create_task(_broadcast())
418
+
419
+ def _normalize_handler_filter(self, value: Any, field_name: str) -> Set[str] | None:
420
+ if value is None:
421
+ return None
422
+ if isinstance(value, str):
423
+ return {value}
424
+ try:
425
+ iterator = iter(value)
426
+ except TypeError as exc: # pragma: no cover - defensive
427
+ raise ValueError(
428
+ f"{field_name} must be an array of handler identifiers"
429
+ ) from exc
430
+
431
+ normalized: Set[str] = set()
432
+ for item in iterator:
433
+ if not isinstance(item, str):
434
+ raise ValueError(
435
+ f"{field_name} values must be handler identifier strings"
436
+ )
437
+ normalized.add(item)
438
+ return normalized
439
+
440
+ def _normalize_sid_filter(self, value: str | Iterable[str] | None) -> Set[str]:
441
+ if value is None:
442
+ return set()
443
+ if isinstance(value, str):
444
+ return {value}
445
+ normalized: Set[str] = set()
446
+ for item in value:
447
+ normalized.add(str(item))
448
+ return normalized
449
+
450
+ def _select_handlers(
451
+ self,
452
+ namespace: str,
453
+ *,
454
+ include: Set[str] | None,
455
+ exclude: Set[str] | None,
456
+ ) -> tuple[list[WsHandler], Set[str]]:
457
+ registered = self.handlers.get(namespace, [])
458
+ available_ids = {handler.identifier for handler in registered}
459
+
460
+ if include is not None:
461
+ unknown = include - available_ids
462
+ if unknown:
463
+ raise ValueError(
464
+ f"Unknown handler(s) in includeHandlers for namespace '{namespace}': "
465
+ f"{', '.join(sorted(unknown))}"
466
+ )
467
+ if exclude is not None:
468
+ unknown = exclude - available_ids
469
+ if unknown:
470
+ raise ValueError(
471
+ f"Unknown handler(s) in excludeHandlers for namespace '{namespace}': "
472
+ f"{', '.join(sorted(unknown))}"
473
+ )
474
+
475
+ selected: list[WsHandler] = []
476
+ for handler in registered:
477
+ ident = handler.identifier
478
+ if include is not None and ident not in include:
479
+ continue
480
+ if exclude is not None and ident in exclude:
481
+ continue
482
+ selected.append(handler)
483
+
484
+ return selected, available_ids
485
+
486
+ def _resolve_correlation_id(self, payload: dict[str, Any]) -> str:
487
+ value = payload.get("correlationId")
488
+ if isinstance(value, str) and value.strip():
489
+ correlation_id = value.strip()
490
+ else:
491
+ correlation_id = uuid.uuid4().hex
492
+ payload["correlationId"] = correlation_id
493
+ return correlation_id
494
+
495
+ def register_handlers(
496
+ self, handlers_by_namespace: dict[str, Iterable[WsHandler]]
497
+ ) -> None:
498
+ for namespace, handlers in handlers_by_namespace.items():
499
+ for handler in handlers:
500
+ handler.bind_manager(self, namespace=namespace)
501
+ if _ws_debug_enabled():
502
+ PrintStyle.info(
503
+ "Registered WebSocket handler %s namespace=%s"
504
+ % (handler.identifier, namespace)
505
+ )
506
+ existing = self.handlers.get(namespace, [])
507
+ if handler in existing:
508
+ PrintStyle.warning(
509
+ f"Duplicate handler registration for namespace '{namespace}'"
510
+ )
511
+ self.handlers[namespace].append(handler)
512
+ self._debug(
513
+ f"Registered handler {handler.identifier} namespace={namespace}"
514
+ )
515
+
516
+ def iter_event_types(self, namespace: str) -> Iterable[str]:
517
+ return []
518
+
519
+ def iter_namespaces(self) -> list[str]:
520
+ return list(self.handlers.keys())
521
+
522
+ async def _invoke_handler(
523
+ self,
524
+ handler: WsHandler,
525
+ event_type: str,
526
+ payload: dict[str, Any],
527
+ sid: str,
528
+ ) -> _HandlerExecution:
529
+ instrument = self._diagnostics_active()
530
+ start = time.perf_counter() if instrument else None
531
+ try:
532
+ value = await self._get_handler_worker().execute_inside(
533
+ handler.process, event_type, payload, sid
534
+ )
535
+ except Exception as exc: # pragma: no cover - handled by caller
536
+ duration_ms = (
537
+ (time.perf_counter() - start) * 1000 if start is not None else None
538
+ )
539
+ return _HandlerExecution(handler, exc, duration_ms)
540
+ duration_ms = (
541
+ (time.perf_counter() - start) * 1000 if start is not None else None
542
+ )
543
+ return _HandlerExecution(handler, value, duration_ms)
544
+
545
+ async def handle_connect(
546
+ self, namespace: str, sid: str, user_id: str | None = None
547
+ ) -> None:
548
+ self._ensure_dispatcher_loop()
549
+ user_bucket = user_id or "single_user"
550
+ identity: ConnectionIdentity = (namespace, sid)
551
+ with self.lock:
552
+ self.connections[identity] = ConnectionInfo(namespace=namespace, sid=sid)
553
+ self._known_sids.add(identity)
554
+ self.sid_to_user[identity] = user_bucket
555
+ self.user_to_sids[self._ALL_USERS_BUCKET].add(identity)
556
+ self.user_to_sids[user_bucket].add(identity)
557
+ connection_count = sum(
558
+ 1 for conn_identity in self.connections if conn_identity[0] == namespace
559
+ )
560
+ if _ws_debug_enabled():
561
+ PrintStyle.info(f"WebSocket connected: namespace={namespace} sid={sid}")
562
+ await self._run_lifecycle(namespace, lambda h: h.on_connect(sid))
563
+ await self._flush_buffer(identity)
564
+ if self._server_restart_enabled:
565
+ await self.emit_to(
566
+ namespace,
567
+ sid,
568
+ "server_restart",
569
+ {
570
+ "emittedAt": _utcnow()
571
+ .isoformat(timespec="milliseconds")
572
+ .replace("+00:00", "Z"),
573
+ "runtimeId": runtime.get_runtime_id(),
574
+ },
575
+ handler_id=self._identifier,
576
+ )
577
+ if _ws_debug_enabled():
578
+ PrintStyle.info(
579
+ f"server_restart broadcast emitted to namespace={namespace} sid={sid}"
580
+ )
581
+ lifecycle_payload = {
582
+ "namespace": namespace,
583
+ "sid": sid,
584
+ "connectionCount": connection_count,
585
+ "timestamp": self._timestamp(),
586
+ }
587
+ await self._publish_diagnostic_event(
588
+ {
589
+ "kind": "lifecycle",
590
+ "event": "connect",
591
+ **lifecycle_payload,
592
+ }
593
+ )
594
+ self._schedule_lifecycle_broadcast(
595
+ namespace, LIFECYCLE_CONNECT_EVENT, lifecycle_payload
596
+ )
597
+
598
+ async def handle_disconnect(self, namespace: str, sid: str) -> None:
599
+ self._ensure_dispatcher_loop()
600
+ identity: ConnectionIdentity = (namespace, sid)
601
+ with self.lock:
602
+ self.connections.pop(identity, None)
603
+ # Keep identity in _known_sids so emit_to buffers instead of raising
604
+ # session tracking cleanup
605
+ user_bucket = self.sid_to_user.pop(identity, None)
606
+ if self._ALL_USERS_BUCKET in self.user_to_sids:
607
+ self.user_to_sids[self._ALL_USERS_BUCKET].discard(identity)
608
+ if not self.user_to_sids[self._ALL_USERS_BUCKET]:
609
+ self.user_to_sids.pop(self._ALL_USERS_BUCKET, None)
610
+ if user_bucket and user_bucket in self.user_to_sids:
611
+ self.user_to_sids[user_bucket].discard(identity)
612
+ if not self.user_to_sids[user_bucket]:
613
+ self.user_to_sids.pop(user_bucket, None)
614
+ connection_count = sum(
615
+ 1 for conn_identity in self.connections if conn_identity[0] == namespace
616
+ )
617
+ self.unregister_diagnostic_watcher(namespace, sid)
618
+ PrintStyle.info(f"WebSocket disconnected: namespace={namespace} sid={sid}")
619
+ await self._run_lifecycle(namespace, lambda h: h.on_disconnect(sid))
620
+ lifecycle_payload = {
621
+ "namespace": namespace,
622
+ "sid": sid,
623
+ "connectionCount": connection_count,
624
+ "timestamp": self._timestamp(),
625
+ }
626
+ await self._publish_diagnostic_event(
627
+ {
628
+ "kind": "lifecycle",
629
+ "event": "disconnect",
630
+ **lifecycle_payload,
631
+ }
632
+ )
633
+ self._schedule_lifecycle_broadcast(
634
+ namespace, LIFECYCLE_DISCONNECT_EVENT, lifecycle_payload
635
+ )
636
+
637
+ async def route_event(
638
+ self,
639
+ namespace: str,
640
+ event_type: str,
641
+ data: dict[str, Any],
642
+ sid: str,
643
+ ack: Optional[Callable[[Any], None]] = None,
644
+ *,
645
+ include_handlers: Set[str] | None = None,
646
+ exclude_handlers: Set[str] | None = None,
647
+ allow_exclude: bool = False,
648
+ handler_id: str | None = None,
649
+ ) -> dict[str, Any]:
650
+ self._ensure_dispatcher_loop()
651
+ incoming = dict(data or {})
652
+ correlation_id = self._resolve_correlation_id(incoming)
653
+ self._debug(
654
+ f"Routing event namespace={namespace} '{event_type}' sid={sid} correlation={correlation_id}"
655
+ )
656
+
657
+ include_meta_raw = incoming.pop("includeHandlers", None)
658
+ exclude_meta_raw = incoming.pop("excludeHandlers", None)
659
+
660
+ if "data" in incoming and isinstance(incoming.get("data"), dict):
661
+ handler_payload = dict(incoming.get("data") or {})
662
+ if "excludeSids" in incoming:
663
+ handler_payload["excludeSids"] = incoming.get("excludeSids")
664
+ else:
665
+ handler_payload = dict(incoming)
666
+
667
+ handler_payload["correlationId"] = correlation_id
668
+
669
+ try:
670
+ include_meta = self._normalize_handler_filter(
671
+ include_meta_raw, "includeHandlers"
672
+ )
673
+ except ValueError as exc:
674
+ error = self._build_error_result(
675
+ handler_id=handler_id or self._identifier,
676
+ code="INVALID_FILTER",
677
+ message=str(exc),
678
+ correlation_id=correlation_id,
679
+ )
680
+ if ack:
681
+ ack({"correlationId": correlation_id, "results": [error]})
682
+ return {"correlationId": correlation_id, "results": [error]}
683
+
684
+ try:
685
+ exclude_meta = self._normalize_handler_filter(
686
+ exclude_meta_raw, "excludeHandlers"
687
+ )
688
+ except ValueError as exc:
689
+ error = self._build_error_result(
690
+ handler_id=handler_id or self._identifier,
691
+ code="INVALID_FILTER",
692
+ message=str(exc),
693
+ correlation_id=correlation_id,
694
+ )
695
+ payload_error = {"correlationId": correlation_id, "results": [error]}
696
+ if ack:
697
+ ack(payload_error)
698
+ return payload_error
699
+
700
+ if exclude_meta_raw is not None and not allow_exclude:
701
+ error = self._build_error_result(
702
+ handler_id=handler_id or self._identifier,
703
+ code="INVALID_FILTER",
704
+ message="excludeHandlers is not supported for this operation",
705
+ correlation_id=correlation_id,
706
+ )
707
+ if ack:
708
+ ack({"correlationId": correlation_id, "results": [error]})
709
+ return {"correlationId": correlation_id, "results": [error]}
710
+
711
+ if include_handlers is not None and include_meta is not None:
712
+ if include_handlers != include_meta:
713
+ error = self._build_error_result(
714
+ handler_id=handler_id or self._identifier,
715
+ code="INVALID_FILTER",
716
+ message="Conflicting includeHandlers filters supplied",
717
+ correlation_id=correlation_id,
718
+ )
719
+ if ack:
720
+ ack({"correlationId": correlation_id, "results": [error]})
721
+ return {"correlationId": correlation_id, "results": [error]}
722
+
723
+ if allow_exclude and exclude_handlers is not None and exclude_meta is not None:
724
+ if exclude_handlers != exclude_meta:
725
+ error = self._build_error_result(
726
+ handler_id=handler_id or self._identifier,
727
+ code="INVALID_FILTER",
728
+ message="Conflicting excludeHandlers filters supplied",
729
+ correlation_id=correlation_id,
730
+ )
731
+ if ack:
732
+ ack({"correlationId": correlation_id, "results": [error]})
733
+ return {"correlationId": correlation_id, "results": [error]}
734
+
735
+ include = include_handlers or include_meta
736
+ exclude = exclude_handlers or (exclude_meta if allow_exclude else None)
737
+
738
+ try:
739
+ validate_event_type(event_type)
740
+ except (TypeError, ValueError) as exc:
741
+ error = self._build_error_result(
742
+ handler_id=handler_id or self._identifier,
743
+ code="INVALID_EVENT",
744
+ message=str(exc),
745
+ correlation_id=correlation_id,
746
+ )
747
+ if ack:
748
+ ack({"correlationId": correlation_id, "results": [error]})
749
+ return {"correlationId": correlation_id, "results": [error]}
750
+
751
+ registered = self.handlers.get(namespace, [])
752
+ if not registered:
753
+ PrintStyle.warning(f"No handlers registered for namespace '{namespace}'")
754
+ error = self._build_error_result(
755
+ handler_id=handler_id or self._identifier,
756
+ code="NO_HANDLERS",
757
+ message=f"No handler for namespace '{namespace}'",
758
+ correlation_id=correlation_id,
759
+ )
760
+ if ack:
761
+ ack({"correlationId": correlation_id, "results": [error]})
762
+ return {"correlationId": correlation_id, "results": [error]}
763
+
764
+ try:
765
+ selected_handlers, _ = self._select_handlers(
766
+ namespace, include=include, exclude=exclude
767
+ )
768
+ except ValueError as exc:
769
+ error = self._build_error_result(
770
+ handler_id=handler_id or self._identifier,
771
+ code="INVALID_FILTER",
772
+ message=str(exc),
773
+ correlation_id=correlation_id,
774
+ )
775
+ if ack:
776
+ ack({"correlationId": correlation_id, "results": [error]})
777
+ return {"correlationId": correlation_id, "results": [error]}
778
+
779
+ if not selected_handlers:
780
+ error = self._build_error_result(
781
+ handler_id=handler_id or self._identifier,
782
+ code="NO_HANDLERS",
783
+ message=f"No handler for '{event_type}' after applying filters",
784
+ correlation_id=correlation_id,
785
+ )
786
+ if ack:
787
+ ack({"correlationId": correlation_id, "results": [error]})
788
+ return {"correlationId": correlation_id, "results": [error]}
789
+
790
+ with self.lock:
791
+ info = self.connections.get((namespace, sid))
792
+ if info:
793
+ info.last_activity = _utcnow()
794
+
795
+ executions = await asyncio.gather(
796
+ *[
797
+ self._invoke_handler(handler, event_type, dict(handler_payload), sid)
798
+ for handler in selected_handlers
799
+ ]
800
+ )
801
+
802
+ results: List[dict[str, Any]] = []
803
+ for execution in executions:
804
+ handler = execution.handler
805
+ value = execution.value
806
+ duration_ms = execution.duration_ms
807
+
808
+ if isinstance(value, Exception): # pragma: no cover - defensive logging
809
+ PrintStyle.error(
810
+ f"Error in handler {handler.identifier} for '{event_type}' (correlation {correlation_id}): {value}"
811
+ )
812
+ results.append(
813
+ self._build_error_result(
814
+ handler_id=handler.identifier,
815
+ code="HANDLER_ERROR",
816
+ message="Internal server error",
817
+ details=str(value),
818
+ correlation_id=correlation_id,
819
+ duration_ms=duration_ms,
820
+ )
821
+ )
822
+ continue
823
+
824
+ if isinstance(value, WsResult):
825
+ results.append(
826
+ value.as_result(
827
+ handler_id=handler.identifier,
828
+ fallback_correlation_id=correlation_id,
829
+ duration_ms=duration_ms,
830
+ )
831
+ )
832
+ continue
833
+
834
+ if value is None:
835
+ helper_result = WsResult(ok=True)
836
+ elif isinstance(value, dict):
837
+ helper_result = WsResult(ok=True, data=value)
838
+ else:
839
+ helper_result = WsResult(ok=True, data={"result": value})
840
+
841
+ results.append(
842
+ helper_result.as_result(
843
+ handler_id=handler.identifier,
844
+ fallback_correlation_id=correlation_id,
845
+ duration_ms=duration_ms,
846
+ )
847
+ )
848
+
849
+ await self._publish_diagnostic_event(
850
+ lambda: {
851
+ "kind": "inbound",
852
+ "sourceNamespace": namespace,
853
+ "namespace": namespace,
854
+ "eventType": event_type,
855
+ "sid": sid,
856
+ "correlationId": correlation_id,
857
+ "timestamp": self._timestamp(),
858
+ "handlerCount": len(selected_handlers),
859
+ "durationMs": sum((exec.duration_ms or 0.0) for exec in executions),
860
+ "resultSummary": self._summarize_results(results),
861
+ "payloadSummary": self._summarize_payload(handler_payload),
862
+ }
863
+ )
864
+
865
+ response_payload = {"correlationId": correlation_id, "results": results}
866
+ if ack:
867
+ ack(response_payload)
868
+ self._debug(
869
+ f"Completed event namespace={namespace} '{event_type}' sid={sid} correlation={correlation_id}"
870
+ )
871
+ return response_payload
872
+
873
+ async def request_for_sid(
874
+ self,
875
+ *,
876
+ namespace: str,
877
+ sid: str,
878
+ event_type: str,
879
+ data: dict[str, Any],
880
+ timeout_ms: int = 0,
881
+ handler_id: str | None = None,
882
+ include_handlers: Set[str] | None = None,
883
+ ) -> dict[str, Any]:
884
+ payload = dict(data or {})
885
+ correlation_id = self._resolve_correlation_id(payload)
886
+
887
+ with self.lock:
888
+ connected = (namespace, sid) in self.connections
889
+ if not connected:
890
+ return {
891
+ "correlationId": correlation_id,
892
+ "results": [
893
+ self._build_error_result(
894
+ handler_id=handler_id or self._identifier,
895
+ code="CONNECTION_NOT_FOUND",
896
+ message=f"Connection '{sid}' not found in namespace '{namespace}'",
897
+ correlation_id=correlation_id,
898
+ )
899
+ ],
900
+ }
901
+
902
+ async def _invoke() -> dict[str, Any]:
903
+ return await self.route_event(
904
+ namespace,
905
+ event_type,
906
+ payload,
907
+ sid,
908
+ include_handlers=include_handlers,
909
+ handler_id=handler_id,
910
+ )
911
+
912
+ if timeout_ms and timeout_ms > 0:
913
+ try:
914
+ return await asyncio.wait_for(_invoke(), timeout=timeout_ms / 1000)
915
+ except asyncio.TimeoutError:
916
+ PrintStyle.warning(
917
+ f"request timeout for sid {sid} event '{event_type}'"
918
+ )
919
+ return {
920
+ "correlationId": correlation_id,
921
+ "results": [
922
+ self._build_error_result(
923
+ handler_id=handler_id or self._identifier,
924
+ code="TIMEOUT",
925
+ message="Request timeout",
926
+ correlation_id=correlation_id,
927
+ )
928
+ ],
929
+ }
930
+ return await _invoke()
931
+
932
+ async def route_event_all(
933
+ self,
934
+ namespace: str,
935
+ event_type: str,
936
+ data: dict[str, Any],
937
+ *,
938
+ timeout_ms: int = 0,
939
+ exclude_handlers: Set[str] | None = None,
940
+ handler_id: str | None = None,
941
+ ) -> list[dict[str, Any]]:
942
+ """Fan-out a request to all active connections and aggregate responses."""
943
+
944
+ base_payload = dict(data or {})
945
+ exclude_meta_raw = base_payload.pop("excludeHandlers", None)
946
+ exclude_combined: Set[str] | None = exclude_handlers
947
+ correlation_id = self._resolve_correlation_id(base_payload)
948
+
949
+ if exclude_meta_raw is not None:
950
+ try:
951
+ exclude_meta = self._normalize_handler_filter(
952
+ exclude_meta_raw, "excludeHandlers"
953
+ )
954
+ except ValueError as exc:
955
+ error = self._build_error_result(
956
+ handler_id=handler_id or self._identifier,
957
+ code="INVALID_FILTER",
958
+ message=str(exc),
959
+ correlation_id=correlation_id,
960
+ )
961
+ return [
962
+ {
963
+ "sid": "__invalid__",
964
+ "correlationId": correlation_id,
965
+ "results": [error],
966
+ }
967
+ ]
968
+
969
+ if exclude_combined is None:
970
+ exclude_combined = exclude_meta
971
+ elif exclude_meta is not None and exclude_combined != exclude_meta:
972
+ error = self._build_error_result(
973
+ handler_id=handler_id or self._identifier,
974
+ code="INVALID_FILTER",
975
+ message="Conflicting excludeHandlers filters supplied",
976
+ correlation_id=correlation_id,
977
+ )
978
+ return [
979
+ {
980
+ "sid": "__invalid__",
981
+ "correlationId": correlation_id,
982
+ "results": [error],
983
+ }
984
+ ]
985
+
986
+ self._debug(
987
+ f"Starting requestAll namespace={namespace} for '{event_type}' correlation={correlation_id}"
988
+ )
989
+
990
+ with self.lock:
991
+ active_sids = [
992
+ conn_identity[1]
993
+ for conn_identity in self.connections.keys()
994
+ if conn_identity[0] == namespace
995
+ ]
996
+ if not active_sids:
997
+ self._debug(
998
+ f"No active connections for requestAll namespace={namespace} '{event_type}' correlation={correlation_id}"
999
+ )
1000
+ return []
1001
+
1002
+ timeout_seconds = timeout_ms / 1000 if timeout_ms and timeout_ms > 0 else None
1003
+
1004
+ async def _invoke_for_sid(target_sid: str) -> dict[str, Any]:
1005
+ async def _dispatch() -> dict[str, Any]:
1006
+ return await self.route_event(
1007
+ namespace,
1008
+ event_type,
1009
+ base_payload,
1010
+ target_sid,
1011
+ allow_exclude=True,
1012
+ exclude_handlers=exclude_combined,
1013
+ handler_id=handler_id,
1014
+ )
1015
+
1016
+ if timeout_seconds is None:
1017
+ return await _dispatch()
1018
+
1019
+ try:
1020
+ task = asyncio.create_task(_dispatch())
1021
+ return await asyncio.wait_for(
1022
+ asyncio.shield(task), timeout=timeout_seconds
1023
+ )
1024
+ except asyncio.TimeoutError:
1025
+ PrintStyle.warning(
1026
+ f"requestAll timeout for sid {target_sid} correlation={correlation_id}"
1027
+ )
1028
+ # Ensure any late exceptions are observed so asyncio does not log
1029
+ # "Task exception was never retrieved".
1030
+ try:
1031
+ task.add_done_callback(lambda t: t.exception()) # type: ignore[arg-type]
1032
+ except Exception: # pragma: no cover - defensive
1033
+ pass
1034
+ return {
1035
+ "correlationId": correlation_id,
1036
+ "results": [
1037
+ self._build_error_result(
1038
+ handler_id=handler_id or self._identifier,
1039
+ code="TIMEOUT",
1040
+ message="Request timeout",
1041
+ correlation_id=correlation_id,
1042
+ )
1043
+ ],
1044
+ }
1045
+
1046
+ tasks = {sid: asyncio.create_task(_invoke_for_sid(sid)) for sid in active_sids}
1047
+
1048
+ aggregated: list[dict[str, Any]] = []
1049
+ for sid, task in tasks.items():
1050
+ result = await task
1051
+ if isinstance(result, dict):
1052
+ aggregated.append(
1053
+ {
1054
+ "sid": sid,
1055
+ "correlationId": result.get("correlationId", correlation_id),
1056
+ "results": result.get("results", []),
1057
+ }
1058
+ )
1059
+ else:
1060
+ aggregated.append(
1061
+ {
1062
+ "sid": sid,
1063
+ "correlationId": correlation_id,
1064
+ "results": result,
1065
+ }
1066
+ )
1067
+
1068
+ self._debug(
1069
+ f"Completed requestAll namespace={namespace} for '{event_type}' correlation={correlation_id}"
1070
+ )
1071
+ return aggregated
1072
+
1073
+ def _wrap_envelope(
1074
+ self,
1075
+ handler_id: str | None,
1076
+ data: dict[str, Any],
1077
+ *,
1078
+ correlation_id: str | None = None,
1079
+ ) -> dict[str, Any]:
1080
+ hid = handler_id or self._identifier
1081
+ ts = _utcnow().isoformat(timespec="milliseconds").replace("+00:00", "Z")
1082
+ event_id = str(uuid.uuid4())
1083
+ correlation = correlation_id or str(uuid.uuid4())
1084
+ return {
1085
+ "handlerId": hid,
1086
+ "eventId": event_id,
1087
+ "correlationId": correlation,
1088
+ "ts": ts,
1089
+ "data": data or {},
1090
+ }
1091
+
1092
+ async def emit_to(
1093
+ self,
1094
+ namespace: str,
1095
+ sid: str,
1096
+ event_type: str,
1097
+ data: dict[str, Any],
1098
+ *,
1099
+ handler_id: str | None = None,
1100
+ correlation_id: str | None = None,
1101
+ diagnostic: bool = False,
1102
+ ) -> None:
1103
+ envelope = self._wrap_envelope(
1104
+ handler_id,
1105
+ data,
1106
+ correlation_id=correlation_id,
1107
+ )
1108
+ delivered = False
1109
+ buffered = False
1110
+ identity: ConnectionIdentity = (namespace, sid)
1111
+
1112
+ with self.lock:
1113
+ connected = identity in self.connections
1114
+ known = identity in self._known_sids or identity in self.buffers
1115
+
1116
+ if connected:
1117
+ self._debug(
1118
+ "Emit to namespace=%s sid=%s event=%s eventId=%s correlationId=%s handlerId=%s"
1119
+ % (
1120
+ namespace,
1121
+ sid,
1122
+ event_type,
1123
+ envelope.get("eventId"),
1124
+ envelope.get("correlationId"),
1125
+ envelope.get("handlerId"),
1126
+ )
1127
+ )
1128
+ await self._run_on_dispatcher_loop(
1129
+ self.socketio.emit(event_type, envelope, to=sid, namespace=namespace)
1130
+ )
1131
+ delivered = True
1132
+ else:
1133
+ if not known:
1134
+ raise ConnectionNotFoundError(sid, namespace=namespace)
1135
+ with self.lock:
1136
+ self._buffer_event(
1137
+ identity,
1138
+ event_type,
1139
+ data,
1140
+ handler_id,
1141
+ envelope["correlationId"],
1142
+ )
1143
+ buffered = True
1144
+
1145
+ if not diagnostic:
1146
+ await self._publish_diagnostic_event(
1147
+ lambda: {
1148
+ "kind": "outbound",
1149
+ "direction": "emit_to",
1150
+ "eventType": event_type,
1151
+ "namespace": namespace,
1152
+ "sid": sid,
1153
+ "correlationId": envelope["correlationId"],
1154
+ "handlerId": envelope["handlerId"],
1155
+ "timestamp": self._timestamp(),
1156
+ "delivered": delivered,
1157
+ "buffered": buffered,
1158
+ "payloadSummary": self._summarize_payload(data),
1159
+ }
1160
+ )
1161
+
1162
+ async def send_data(
1163
+ self,
1164
+ endpoint_name: str,
1165
+ event_type: str,
1166
+ data: dict[str, Any],
1167
+ connection_id: str | None = None,
1168
+ ) -> None:
1169
+ if connection_id is not None:
1170
+ await self.emit_to(endpoint_name, connection_id, event_type, data)
1171
+ return
1172
+ await self.broadcast(endpoint_name, event_type, data)
1173
+
1174
+ async def broadcast(
1175
+ self,
1176
+ namespace: str,
1177
+ event_type: str,
1178
+ data: dict[str, Any],
1179
+ *,
1180
+ exclude_sids: str | Iterable[str] | None = None,
1181
+ handler_id: str | None = None,
1182
+ correlation_id: str | None = None,
1183
+ diagnostic: bool = False,
1184
+ ) -> None:
1185
+ excluded = self._normalize_sid_filter(exclude_sids)
1186
+
1187
+ targets: list[str] = []
1188
+ with self.lock:
1189
+ current_identities = list(self.connections.keys())
1190
+ for conn_identity in current_identities:
1191
+ if conn_identity[0] != namespace:
1192
+ continue
1193
+ sid = conn_identity[1]
1194
+ if sid in excluded:
1195
+ continue
1196
+ targets.append(sid)
1197
+
1198
+ if targets:
1199
+ envelope = self._wrap_envelope(
1200
+ handler_id,
1201
+ data,
1202
+ correlation_id=correlation_id,
1203
+ )
1204
+ coros = [
1205
+ self._run_on_dispatcher_loop(
1206
+ self.socketio.emit(event_type, envelope, to=sid, namespace=namespace)
1207
+ )
1208
+ for sid in targets
1209
+ ]
1210
+ await asyncio.gather(*coros)
1211
+
1212
+ if not diagnostic:
1213
+ await self._publish_diagnostic_event(
1214
+ lambda: {
1215
+ "kind": "outbound",
1216
+ "direction": "broadcast",
1217
+ "eventType": event_type,
1218
+ "namespace": namespace,
1219
+ "targets": targets[:10],
1220
+ "targetCount": len(targets),
1221
+ "correlationId": correlation_id,
1222
+ "handlerId": handler_id or self._identifier,
1223
+ "timestamp": self._timestamp(),
1224
+ "payloadSummary": self._summarize_payload(data),
1225
+ }
1226
+ )
1227
+
1228
+ async def _run_lifecycle(
1229
+ self, namespace: str, fn: Callable[[WsHandler], Any]
1230
+ ) -> None:
1231
+ seen: Set[WsHandler] = set()
1232
+ coros: list[Any] = []
1233
+ for handler in self.handlers.get(namespace, []):
1234
+ if handler in seen:
1235
+ continue
1236
+ seen.add(handler)
1237
+ coros.append(self._get_handler_worker().execute_inside(fn, handler))
1238
+ if coros:
1239
+ await asyncio.gather(*coros, return_exceptions=True)
1240
+
1241
+ def _buffer_event(
1242
+ self,
1243
+ identity: ConnectionIdentity,
1244
+ event_type: str,
1245
+ data: dict[str, Any],
1246
+ handler_id: str | None,
1247
+ correlation_id: str | None,
1248
+ ) -> None:
1249
+ namespace, sid = identity
1250
+ buffer = self.buffers[identity]
1251
+ buffer.append(
1252
+ BufferedEvent(
1253
+ event_type=event_type,
1254
+ data=data,
1255
+ handler_id=handler_id,
1256
+ correlation_id=correlation_id,
1257
+ )
1258
+ )
1259
+ while len(buffer) > BUFFER_MAX_SIZE:
1260
+ dropped = buffer.popleft()
1261
+ PrintStyle.warning(
1262
+ f"Dropping buffered event '{dropped.event_type}' for namespace={namespace} sid={sid} (overflow)"
1263
+ )
1264
+ self._debug(
1265
+ f"Buffered event namespace={namespace} '{event_type}' sid={sid} (queue length={len(buffer)})"
1266
+ )
1267
+
1268
+ async def _flush_buffer(self, identity: ConnectionIdentity) -> None:
1269
+ self._ensure_dispatcher_loop()
1270
+ buffer = self.buffers.get(identity)
1271
+ if not buffer:
1272
+ return
1273
+ namespace, sid = identity
1274
+ now = _utcnow()
1275
+ delivered = 0
1276
+ while buffer:
1277
+ event = buffer.popleft()
1278
+ if now - event.timestamp > BUFFER_TTL:
1279
+ self._debug(
1280
+ f"Discarding expired buffered event '{event.event_type}' for sid {sid}"
1281
+ )
1282
+ continue
1283
+ envelope = self._wrap_envelope(
1284
+ event.handler_id,
1285
+ event.data,
1286
+ correlation_id=event.correlation_id,
1287
+ )
1288
+ self._debug(
1289
+ "Flush to sid=%s event=%s eventId=%s correlationId=%s handlerId=%s"
1290
+ % (
1291
+ sid,
1292
+ event.event_type,
1293
+ envelope.get("eventId"),
1294
+ envelope.get("correlationId"),
1295
+ envelope.get("handlerId"),
1296
+ )
1297
+ )
1298
+ await self._run_on_dispatcher_loop(
1299
+ self.socketio.emit(
1300
+ event.event_type, envelope, to=sid, namespace=namespace
1301
+ )
1302
+ )
1303
+ delivered += 1
1304
+ if identity in self.buffers:
1305
+ self.buffers.pop(identity, None)
1306
+ if delivered:
1307
+ PrintStyle.info(
1308
+ f"Flushed {delivered} buffered event(s) to namespace={namespace} sid={sid}"
1309
+ )
1310
+
1311
+ def _build_error_result(
1312
+ self,
1313
+ *,
1314
+ handler_id: str | None = None,
1315
+ code: str,
1316
+ message: str,
1317
+ details: str | None = None,
1318
+ correlation_id: str | None = None,
1319
+ duration_ms: float | None = None,
1320
+ ) -> dict[str, Any]:
1321
+ error_payload = {"code": code, "error": message}
1322
+ if details:
1323
+ error_payload["details"] = details
1324
+ result: dict[str, Any] = {
1325
+ "handlerId": handler_id or self._identifier,
1326
+ "ok": False,
1327
+ "error": error_payload,
1328
+ }
1329
+ if correlation_id is not None:
1330
+ result["correlationId"] = correlation_id
1331
+ if duration_ms is not None:
1332
+ result["durationMs"] = round(duration_ms, 4)
1333
+ return result
1334
+
1335
+ # Session tracking helpers (single-user defaults)
1336
+ def get_sids_for_user(self, user: str | None = None) -> list[ConnectionIdentity]:
1337
+ """Return connection identities for a user; single-user default returns all."""
1338
+ with self.lock:
1339
+ bucket = self._ALL_USERS_BUCKET if user is None else user
1340
+ return list(self.user_to_sids.get(bucket, set()))
1341
+
1342
+ def get_user_for_sid(self, namespace: str, sid: str) -> str | None:
1343
+ """Return user identifier for a connection or None."""
1344
+ identity: ConnectionIdentity = (namespace, sid)
1345
+ with self.lock:
1346
+ return self.sid_to_user.get(identity)
1347
+
1348
+ def set_server_restart_broadcast(self, enabled: bool) -> None:
1349
+ """Enable or disable automatic server restart broadcasts."""
1350
+
1351
+ self._server_restart_enabled = bool(enabled)
run_ui.py
+7
-172
@@ -14,9 +14,8 @@ import initialize
14
from helpers import files, git, mcp_server, fasta2a_server, settings as settings_helper, extension
15
from helpers.files import get_abs_path
16
from helpers import runtime, dotenv, process
17
-from helpers.websocket import WebSocketHandler, validate_ws_origin
17
from helpers.api import register_api_route, requires_auth, csrf_protect
19
-from helpers.ws import register_ws_namespace
18
+from helpers.ws import register_ws_namespace, validate_ws_origin
19
from helpers.print_style import PrintStyle
20
from helpers import login
21
import socketio # type: ignore[import-untyped]
@@ -24,8 +23,7 @@ from socketio import ASGIApp, packet
23
from starlette.applications import Starlette
24
from starlette.routing import Mount
25
from uvicorn.middleware.wsgi import WSGIMiddleware
27
-from helpers.websocket_manager import WebSocketManager, set_shared_websocket_manager
28
-from helpers.websocket_namespace_discovery import discover_websocket_namespaces
26
+from helpers.ws_manager import WsManager, set_shared_ws_manager
27
from flask import send_file
28
29
# disable logging
@@ -73,11 +71,11 @@ socketio_server = socketio.AsyncServer(
71
max_http_buffer_size=50 * 1024 * 1024,
72
)
73
76
-websocket_manager = WebSocketManager(socketio_server, lock)
77
-set_shared_websocket_manager(websocket_manager)
74
+ws_manager = WsManager(socketio_server, lock)
75
+set_shared_ws_manager(ws_manager)
76
_settings = settings_helper.get_settings()
77
settings_helper.set_runtime_settings_snapshot(_settings)
80
-websocket_manager.set_server_restart_broadcast(
78
+ws_manager.set_server_restart_broadcast(
79
_settings.get("websocket_server_restart_enabled", True)
80
)
81
@@ -191,160 +189,6 @@ async def _serve_plugin_asset(plugin_name, asset_path):
189
return Response("Error serving asset", 500)
190
191
194
-def _build_websocket_handlers_by_namespace(
195
- socketio_server: socketio.AsyncServer,
196
- lock: threading.RLock,
197
-) -> dict[str, list[WebSocketHandler]]:
198
- discoveries = discover_websocket_namespaces(
199
- handlers_folder="python/websocket_handlers",
200
- include_root_default=True,
201
- )
202
-
203
- handlers_by_namespace: dict[str, list[WebSocketHandler]] = {}
204
- for discovery in discoveries:
205
- namespace = discovery.namespace
206
- for handler_cls in discovery.handler_classes:
207
- handler = handler_cls.get_instance(socketio_server, lock)
208
- handlers_by_namespace.setdefault(namespace, []).append(handler)
209
-
210
- return handlers_by_namespace
211
-
212
-
213
-def configure_websocket_namespaces(
214
- *,
215
- webapp: Flask,
216
- socketio_server: socketio.AsyncServer,
217
- websocket_manager: WebSocketManager,
218
- handlers_by_namespace: dict[str, list[WebSocketHandler]],
219
-) -> set[str]:
220
- namespace_map: dict[str, list[WebSocketHandler]] = {
221
- namespace: list(handlers) for namespace, handlers in handlers_by_namespace.items()
222
- }
223
-
224
- # Always include the reserved root namespace. It is unhandled for application events by
225
- # default, but request-style calls must resolve deterministically with NO_HANDLERS.
226
- namespace_map.setdefault("/", [])
227
-
228
- websocket_manager.register_handlers(namespace_map)
229
-
230
- allowed_namespaces = set(namespace_map.keys())
231
- original_handle_connect = socketio_server._handle_connect # type: ignore[attr-defined]
232
-
233
- async def _handle_connect_with_namespace_gatekeeper(eio_sid, namespace, data):
234
- requested = namespace or "/"
235
- if requested not in allowed_namespaces:
236
- await socketio_server._send_packet(
237
- eio_sid,
238
- socketio_server.packet_class(
239
- packet.CONNECT_ERROR,
240
- data={
241
- "message": "UNKNOWN_NAMESPACE",
242
- "data": {"code": "UNKNOWN_NAMESPACE", "namespace": requested},
243
- },
244
- namespace=requested,
245
- ),
246
- )
247
- return
248
- await original_handle_connect(eio_sid, namespace, data)
249
-
250
- socketio_server._handle_connect = _handle_connect_with_namespace_gatekeeper # type: ignore[assignment]
251
-
252
- def _register_namespace_handlers(
253
- namespace: str, namespace_handlers: list[WebSocketHandler]
254
- ) -> None:
255
- # A namespace is the WebSocket equivalent of an API endpoint.
256
- # Security requirements must be consistent within the namespace (no any()-based union).
257
- auth_required = False
258
- csrf_required = False
259
- if namespace_handlers:
260
- auth_required = bool(namespace_handlers[0].requires_auth())
261
- csrf_required = bool(namespace_handlers[0].requires_csrf())
262
- for handler in namespace_handlers[1:]:
263
- if (
264
- bool(handler.requires_auth()) != auth_required
265
- or bool(handler.requires_csrf()) != csrf_required
266
- ):
267
- raise ValueError(
268
- f"WebSocket namespace {namespace!r} has mixed auth/csrf requirements across handlers"
269
- )
270
-
271
- @socketio_server.on("connect", namespace=namespace)
272
- async def _connect( # type: ignore[override]
273
- sid,
274
- environ,
275
- _auth,
276
- _namespace: str = namespace,
277
- _auth_required: bool = auth_required,
278
- _csrf_required: bool = csrf_required,
279
- ):
280
- with webapp.request_context(environ):
281
- origin_ok, origin_reason = validate_ws_origin(environ)
282
- if not origin_ok:
283
- PrintStyle.warning(
284
- f"WebSocket origin validation failed for {_namespace} {sid}: {origin_reason or 'invalid'}"
285
- )
286
- return False
287
-
288
- if _auth_required:
289
- credentials_hash = login.get_credentials_hash()
290
- if credentials_hash:
291
- if session.get("authentication") != credentials_hash:
292
- PrintStyle.warning(
293
- f"WebSocket authentication failed for {_namespace} {sid}: session not valid"
294
- )
295
- return False
296
- else:
297
- PrintStyle.debug(
298
- "WebSocket authentication required but credentials not configured; proceeding"
299
- )
300
-
301
- if _csrf_required:
302
- expected_token = session.get("csrf_token")
303
- if not isinstance(expected_token, str) or not expected_token:
304
- PrintStyle.warning(
305
- f"WebSocket CSRF validation failed for {_namespace} {sid}: csrf_token not initialized"
306
- )
307
- return False
308
-
309
- auth_token = None
310
- if isinstance(_auth, dict):
311
- auth_token = _auth.get("csrf_token") or _auth.get("csrfToken")
312
- if not isinstance(auth_token, str) or not auth_token:
313
- PrintStyle.warning(
314
- f"WebSocket CSRF validation failed for {_namespace} {sid}: missing csrf_token in auth"
315
- )
316
- return False
317
- if auth_token != expected_token:
318
- PrintStyle.warning(
319
- f"WebSocket CSRF validation failed for {_namespace} {sid}: csrf_token mismatch"
320
- )
321
- return False
322
-
323
- cookie_name = f"csrf_token_{runtime.get_runtime_id()}"
324
- cookie_token = request.cookies.get(cookie_name)
325
- if cookie_token != expected_token:
326
- PrintStyle.warning(
327
- f"WebSocket CSRF validation failed for {_namespace} {sid}: csrf cookie mismatch"
328
- )
329
- return False
330
-
331
- user_id = session.get("user_id") or "single_user"
332
- await websocket_manager.handle_connect(_namespace, sid, user_id=user_id)
333
- return True
334
-
335
- @socketio_server.on("disconnect", namespace=namespace)
336
- async def _disconnect(sid, _namespace: str = namespace): # type: ignore[override]
337
- await websocket_manager.handle_disconnect(_namespace, sid)
338
-
339
- @socketio_server.on("*", namespace=namespace)
340
- async def _catch_all(event, sid, data, _namespace: str = namespace):
341
- payload = data or {}
342
- return await websocket_manager.route_event(_namespace, event, payload, sid)
343
-
344
- for namespace, namespace_handlers in namespace_map.items():
345
- _register_namespace_handlers(namespace, namespace_handlers)
346
-
347
- return allowed_namespaces
192
193
194
def run():
@@ -373,16 +217,7 @@ def run():
217
218
register_api_route(webapp, lock)
219
376
- handlers_by_namespace = _build_websocket_handlers_by_namespace(socketio_server, lock)
377
- allowed_namespaces = configure_websocket_namespaces(
378
- webapp=webapp,
379
- socketio_server=socketio_server,
380
- websocket_manager=websocket_manager,
381
- handlers_by_namespace=handlers_by_namespace,
382
- )
383
-
384
- register_ws_namespace(socketio_server, webapp, lock)
385
- allowed_namespaces.add("/ws")
220
+ register_ws_namespace(socketio_server, webapp, lock, manager=ws_manager)
221
222
init_a0()
223
@@ -443,7 +278,7 @@ def run():
278
279
280
def wait_for_health(host: str, port: int):
446
- url = f"http://{host}:{port}/health"
281
+ url = f"http://{host}:{port}/api/health"
282
while True:
283
try:
284
with urllib.request.urlopen(url, timeout=2) as resp: