fix: resolve option whitelist, memory leak, task tracking, and dispatch unification

- Fix Memory Leaks: Resolved SID retention in _known_sids after disconnection and cleaned up unreferenced broadcast tasks in _schedule_lifecycle_broadcast. - Unify Dispatching Paths: Unified client and server event dispatching through the process_client_event() method to ensure diagnostic consistency. - Optimization & Cleanup: Expanded the _OPTION_KEYS whitelist, removed dead code (iter_event_types), and deleted unused websocket exports. - Robustness: Added handling for None responses in process_client_event to prevent cluttering responses with empty results. - Testing: Added test cases to verify SID TTL expiration and stale SID cleanup on disconnect.

keyboardstaff committed Mar 27, 2026 at 01:21 UTC b351de456e20e0c9b2de553a2d44c60208347598
4 files changed +273 -32
helpers/ws.py
+66 -24
@@ -300,37 +300,56 @@ class WsHandler:
300 for sid, handlers in _active_handlers.items()
301 }
302
303 + mgr = self._manager
304 aggregated: list[dict[str, Any]] = []
305 for sid, handlers in snapshot.items():
306 ctx = _ws_contexts.get(sid)
306 - sid_results: list[dict[str, Any]] = []
307 + security_errors: list[dict[str, Any]] = []
308 + passing: list[WsHandler] = []
309 for _path, instance in handlers.items():
310 if ctx is not None:
311 error = _check_security(type(instance), ctx)
312 if error is not None:
311 - sid_results.append({
313 + security_errors.append({
314 "handlerId": instance.identifier,
315 "ok": False,
316 "correlationId": cid,
317 "error": error,
318 })
319 continue
318 - try:
319 - result = await instance.process(event, dict(data, correlationId=cid), sid)
320 - if result is not None:
320 + passing.append(instance)
321 +
322 + if mgr is not None and passing:
323 + result = await mgr.process_client_event(
324 + self._namespace, event,
325 + dict(data, correlationId=cid), sid,
326 + handlers=passing,
327 + )
328 + sid_results = security_errors + result.get("results", [])
329 + else:
330 + # Fallback: inline processing
331 + sid_results = list(security_errors)
332 + for _path, instance in handlers.items():
333 + if instance not in passing:
334 + continue
335 + try:
336 + result = await instance.process(
337 + event, dict(data, correlationId=cid), sid,
338 + )
339 + if result is not None:
340 + sid_results.append({
341 + "handlerId": instance.identifier,
342 + "ok": True,
343 + "correlationId": cid,
344 + "data": result,
345 + })
346 + except Exception as e:
347 sid_results.append({
348 "handlerId": instance.identifier,
323 - "ok": True,
349 + "ok": False,
350 "correlationId": cid,
325 - "data": result,
351 + "error": {"code": "HANDLER_ERROR", "error": str(e)},
352 })
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 - })
353 aggregated.append({
354 "sid": sid,
355 "correlationId": cid,
@@ -530,24 +549,47 @@ def register_ws_namespace(
549 return _error_response("NO_HANDLERS",
550 "No handlers activated", correlation_id)
551
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 -
541 - results: list[dict[str, Any]] = []
552 + # Pre-filter handlers through security checks
553 + passing_handlers: list[WsHandler] = []
554 + security_errors: list[dict[str, Any]] = []
555 for path, instance in activated.items():
556 error = _check_security(type(instance), ctx)
557 if error is not None:
545 - results.append({
558 + security_errors.append({
559 "handlerId": instance.identifier,
560 "ok": False,
561 "correlationId": correlation_id,
562 "error": error,
563 })
564 + else:
565 + passing_handlers.append(instance)
566 +
567 + # Delegate to WsManager for unified processing pipeline
568 + # (worker thread isolation, diagnostic events, WsResult support)
569 + if manager is not None and passing_handlers:
570 + result = await manager.process_client_event(
571 + NAMESPACE, event, incoming, sid,
572 + handlers=passing_handlers,
573 + )
574 + if security_errors:
575 + result["results"] = security_errors + result.get("results", [])
576 + return result
577 +
578 + # All handlers failed security or no manager — return collected errors
579 + if not passing_handlers:
580 + return {"correlationId": correlation_id, "results": security_errors}
581 +
582 + # Fallback: inline processing (no manager — should not happen in practice)
583 + handler_payload: dict[str, Any]
584 + if "data" in incoming and isinstance(incoming.get("data"), dict):
585 + handler_payload = dict(incoming["data"])
586 + else:
587 + handler_payload = dict(incoming)
588 + handler_payload["correlationId"] = correlation_id
589 +
590 + results: list[dict[str, Any]] = list(security_errors)
591 + for path, instance in activated.items():
592 + if instance not in passing_handlers:
593 continue
594 try:
595 result = await instance.process(event, handler_payload, sid)
helpers/ws_manager.py
+156 -5
@@ -247,6 +247,7 @@ class WsManager:
247 defaultdict(deque)
248 )
249 self._known_sids: Set[ConnectionIdentity] = set()
250 + self._disconnect_times: Dict[ConnectionIdentity, datetime] = {}
251 self._identifier: str = f"{self.__class__.__module__}.{self.__class__.__name__}"
252 # Session tracking (single-user default)
253 self.user_to_sids: defaultdict[str, Set[ConnectionIdentity]] = defaultdict(set)
@@ -257,6 +258,7 @@ class WsManager:
258 self._diagnostics_enabled: bool = runtime.is_development()
259 self._dispatcher_loop: asyncio.AbstractEventLoop | None = None
260 self._handler_worker: DeferredTask | None = None
261 + self._lifecycle_tasks: Set[asyncio.Task] = set()
262
263 # Internal: development-only debug logging to avoid noise in production
264 def _debug(self, message: str) -> None:
@@ -414,7 +416,23 @@ class WsManager:
416 except Exception as exc: # pragma: no cover - diagnostic
417 self._debug(f"Failed to broadcast lifecycle event {event_type}: {exc}")
418
417 - asyncio.create_task(_broadcast())
419 + task = asyncio.create_task(_broadcast())
420 + self._lifecycle_tasks.add(task)
421 + task.add_done_callback(self._lifecycle_tasks.discard)
422 +
423 + def _sweep_stale_sids(self) -> None:
424 + """Remove _known_sids entries whose disconnect exceeds BUFFER_TTL."""
425 + now = _utcnow()
426 + with self.lock:
427 + stale = [
428 + identity
429 + for identity, dt in self._disconnect_times.items()
430 + if identity not in self.connections and (now - dt) > BUFFER_TTL
431 + ]
432 + for identity in stale:
433 + self._known_sids.discard(identity)
434 + self._disconnect_times.pop(identity, None)
435 + self.buffers.pop(identity, None)
436
437 def _normalize_handler_filter(self, value: Any, field_name: str) -> Set[str] | None:
438 if value is None:
@@ -513,12 +531,133 @@ class WsManager:
531 f"Registered handler {handler.identifier} namespace={namespace}"
532 )
533
516 - def iter_event_types(self, namespace: str) -> Iterable[str]:
517 - return []
518 -
534 def iter_namespaces(self) -> list[str]:
535 return list(self.handlers.keys())
536
537 + async def process_client_event(
538 + self,
539 + namespace: str,
540 + event_type: str,
541 + data: dict[str, Any],
542 + sid: str,
543 + *,
544 + handlers: list[WsHandler],
545 + ) -> dict[str, Any]:
546 + """Process a client-originated event through provided handler instances.
547 +
548 + Unlike ``route_event`` which selects from globally registered handlers,
549 + this accepts pre-selected instances (e.g. per-connection activated
550 + handlers that have already passed security checks).
551 + """
552 + self._ensure_dispatcher_loop()
553 + incoming = dict(data or {})
554 + correlation_id = self._resolve_correlation_id(incoming)
555 +
556 + if "data" in incoming and isinstance(incoming.get("data"), dict):
557 + handler_payload = dict(incoming["data"])
558 + if "excludeSids" in incoming:
559 + handler_payload["excludeSids"] = incoming["excludeSids"]
560 + else:
561 + handler_payload = dict(incoming)
562 + handler_payload["correlationId"] = correlation_id
563 +
564 + if not handlers:
565 + error = self._build_error_result(
566 + handler_id=self._identifier,
567 + code="NO_HANDLERS",
568 + message="No handlers available after security filtering",
569 + correlation_id=correlation_id,
570 + )
571 + return {"correlationId": correlation_id, "results": [error]}
572 +
573 + with self.lock:
574 + info = self.connections.get((namespace, sid))
575 + if info:
576 + info.last_activity = _utcnow()
577 +
578 + executions = await asyncio.gather(
579 + *[
580 + self._invoke_handler(handler, event_type, dict(handler_payload), sid)
581 + for handler in handlers
582 + ]
583 + )
584 +
585 + results: List[dict[str, Any]] = []
586 + for execution in executions:
587 + handler = execution.handler
588 + value = execution.value
589 + duration_ms = execution.duration_ms
590 +
591 + if isinstance(value, Exception):
592 + PrintStyle.error(
593 + f"Error in handler {handler.identifier} for '{event_type}' "
594 + f"(correlation {correlation_id}): {value}"
595 + )
596 + results.append(
597 + self._build_error_result(
598 + handler_id=handler.identifier,
599 + code="HANDLER_ERROR",
600 + message="Internal server error",
601 + details=str(value),
602 + correlation_id=correlation_id,
603 + duration_ms=duration_ms,
604 + )
605 + )
606 + continue
607 +
608 + if isinstance(value, WsResult):
609 + results.append(
610 + value.as_result(
611 + handler_id=handler.identifier,
612 + fallback_correlation_id=correlation_id,
613 + duration_ms=duration_ms,
614 + )
615 + )
616 + continue
617 +
618 + # Skip handlers that return None — they opted out of contributing
619 + # a result (fire-and-forget semantics, matching legacy _dispatch).
620 + if value is None:
621 + continue
622 +
623 + if isinstance(value, dict):
624 + helper_result = WsResult(ok=True, data=value)
625 + else:
626 + helper_result = WsResult(ok=True, data={"result": value})
627 +
628 + results.append(
629 + helper_result.as_result(
630 + handler_id=handler.identifier,
631 + fallback_correlation_id=correlation_id,
632 + duration_ms=duration_ms,
633 + )
634 + )
635 +
636 + await self._publish_diagnostic_event(
637 + lambda: {
638 + "kind": "inbound",
639 + "sourceNamespace": namespace,
640 + "namespace": namespace,
641 + "eventType": event_type,
642 + "sid": sid,
643 + "correlationId": correlation_id,
644 + "timestamp": self._timestamp(),
645 + "handlerCount": len(handlers),
646 + "durationMs": sum(
647 + (exec.duration_ms or 0.0) for exec in executions
648 + ),
649 + "resultSummary": self._summarize_results(results),
650 + "payloadSummary": self._summarize_payload(handler_payload),
651 + }
652 + )
653 +
654 + response = {"correlationId": correlation_id, "results": results}
655 + self._debug(
656 + f"Completed client event namespace={namespace} '{event_type}' "
657 + f"sid={sid} correlation={correlation_id}"
658 + )
659 + return response
660 +
661 async def _invoke_handler(
662 self,
663 handler: WsHandler,
@@ -551,6 +690,7 @@ class WsManager:
690 with self.lock:
691 self.connections[identity] = ConnectionInfo(namespace=namespace, sid=sid)
692 self._known_sids.add(identity)
693 + self._disconnect_times.pop(identity, None)
694 self.sid_to_user[identity] = user_bucket
695 self.user_to_sids[self._ALL_USERS_BUCKET].add(identity)
696 self.user_to_sids[user_bucket].add(identity)
@@ -600,7 +740,9 @@ class WsManager:
740 identity: ConnectionIdentity = (namespace, sid)
741 with self.lock:
742 self.connections.pop(identity, None)
603 - # Keep identity in _known_sids so emit_to buffers instead of raising
743 + # Keep identity in _known_sids so emit_to buffers instead of raising;
744 + # record disconnect time for TTL-based cleanup
745 + self._disconnect_times[identity] = _utcnow()
746 # session tracking cleanup
747 user_bucket = self.sid_to_user.pop(identity, None)
748 if self._ALL_USERS_BUCKET in self.user_to_sids:
@@ -633,6 +775,7 @@ class WsManager:
775 self._schedule_lifecycle_broadcast(
776 namespace, LIFECYCLE_DISCONNECT_EVENT, lifecycle_payload
777 )
778 + self._sweep_stale_sids()
779
780 async def route_event(
781 self,
@@ -1112,6 +1255,14 @@ class WsManager:
1255 with self.lock:
1256 connected = identity in self.connections
1257 known = identity in self._known_sids or identity in self.buffers
1258 + # Evict if disconnect has exceeded BUFFER_TTL
1259 + if not connected and known:
1260 + dt = self._disconnect_times.get(identity)
1261 + if dt is not None and (_utcnow() - dt) > BUFFER_TTL:
1262 + self._known_sids.discard(identity)
1263 + self._disconnect_times.pop(identity, None)
1264 + self.buffers.pop(identity, None)
1265 + known = False
1266
1267 if connected:
1268 self._debug(
tests/test_ws_manager.py
+50
@@ -396,6 +396,56 @@ async def test_flush_buffer_delivers_and_logs(monkeypatch):
396 assert (NAMESPACE, "sid-1") not in manager.buffers
397
398
399 +@pytest.mark.asyncio
400 +async def test_known_sid_expires_after_buffer_ttl(monkeypatch):
401 + """After BUFFER_TTL, a disconnected sid is swept from _known_sids and emit_to raises."""
402 + socketio = FakeSocketIOServer()
403 + manager = WsManager(socketio, threading.RLock())
404 +
405 + await manager.handle_connect(NAMESPACE, "sid-stale")
406 + await manager.handle_disconnect(NAMESPACE, "sid-stale")
407 +
408 + # Immediately after disconnect, buffering still works
409 + await manager.emit_to(NAMESPACE, "sid-stale", "event", {"x": 1})
410 + assert (NAMESPACE, "sid-stale") in manager.buffers
411 +
412 + from datetime import timedelta, timezone, datetime
413 +
414 + future = datetime.now(timezone.utc) + BUFFER_TTL + timedelta(seconds=10)
415 + monkeypatch.setattr("helpers.ws_manager._utcnow", lambda: future)
416 +
417 + # After TTL, emit_to should raise because the sid is no longer known
418 + with pytest.raises(ConnectionNotFoundError):
419 + await manager.emit_to(NAMESPACE, "sid-stale", "event", {"x": 2})
420 +
421 + # _known_sids and buffers should be cleaned
422 + assert (NAMESPACE, "sid-stale") not in manager._known_sids
423 + assert (NAMESPACE, "sid-stale") not in manager.buffers
424 + assert (NAMESPACE, "sid-stale") not in manager._disconnect_times
425 +
426 +
427 +@pytest.mark.asyncio
428 +async def test_sweep_cleans_stale_sids_on_disconnect(monkeypatch):
429 + """_sweep_stale_sids runs during handle_disconnect and cleans expired entries."""
430 + socketio = FakeSocketIOServer()
431 + manager = WsManager(socketio, threading.RLock())
432 +
433 + await manager.handle_connect(NAMESPACE, "old-sid")
434 + await manager.handle_disconnect(NAMESPACE, "old-sid")
435 +
436 + from datetime import timedelta, timezone, datetime
437 +
438 + future = datetime.now(timezone.utc) + BUFFER_TTL + timedelta(seconds=10)
439 + monkeypatch.setattr("helpers.ws_manager._utcnow", lambda: future)
440 +
441 + # A new connect/disconnect triggers sweep which cleans old-sid
442 + await manager.handle_connect(NAMESPACE, "new-sid")
443 + await manager.handle_disconnect(NAMESPACE, "new-sid")
444 +
445 + assert (NAMESPACE, "old-sid") not in manager._known_sids
446 + assert (NAMESPACE, "old-sid") not in manager._disconnect_times
447 +
448 +
449 @pytest.mark.asyncio
450 async def test_broadcast_excludes_multiple_sids():
451 socketio = FakeSocketIOServer()
webui/js/websocket.js
+1 -3
@@ -5,7 +5,7 @@ const MAX_PAYLOAD_BYTES = 50 * 1024 * 1024; // 50MB hard cap per contract
5 const DEFAULT_TIMEOUT_MS = 0;
6
7 const _UUID_HEX = [..."0123456789abcdef"];
8 -const _OPTION_KEYS = new Set(["correlationId"]);
8 +const _OPTION_KEYS = new Set(["correlationId", "includeHandlers", "excludeHandlers", "excludeSids"]);
9
10 /**
11 * @param {unknown} value
@@ -744,5 +744,3 @@ export function getNamespacedClient(namespace) {
744 _namespacedClients.set(key, client);
745 return client;
746 }
747 -
748 -export const websocket = getNamespacedClient("/");