refactor: optimize WebSocket handler lifecycle and fix extension asset path handling

- Change `_active_handlers` from list of tuples to dict mapping paths to handler instances - Cache handler instances on connect instead of recreating them for each event - Reuse cached instances in `_dispatch` instead of instantiating new ones - Remove redundant handler instantiation in `_on_disconnect` - Add type ignore comments for socketio event decorators - Fix extension asset path construction to use proper directory variable

frdel committed Mar 17, 2026 at 17:00 UTC 7eb6d5b19af503048b5905ceb71b1d59fd635359
3 files changed +25 -19
helpers/ws.py
+14 -15
@@ -32,7 +32,7 @@ class _SecurityContext:
32
33
34 _ws_contexts: dict[str, _SecurityContext] = {}
35 -_active_handlers: dict[str, list[tuple[str, type["WsHandler"]]]] = {}
35 +_active_handlers: dict[str, dict[str, "WsHandler"]] = {}
36 _contexts_lock = threading.Lock()
37
38
@@ -156,7 +156,7 @@ def register_ws_namespace(
156 cache.add(CACHE_AREA, path, handler_cls)
157 return handler_cls
158
159 - @socketio_server.on("connect", namespace="/ws")
159 + @socketio_server.on("connect", namespace="/ws") # type: ignore
160 async def _on_connect(sid, environ, auth):
161 with webapp.request_context(environ):
162 origin_ok, origin_reason = validate_ws_origin(environ)
@@ -192,7 +192,7 @@ def register_ws_namespace(
192 if isinstance(raw, list):
193 handler_paths = [p for p in raw if isinstance(p, str)]
194
195 - activated: list[tuple[str, type[WsHandler]]] = []
195 + activated: dict[str, WsHandler] = {}
196 for path in handler_paths:
197 try:
198 handler_cls = _resolve_cached(path)
@@ -203,7 +203,7 @@ def register_ws_namespace(
203 continue
204 instance = handler_cls(socketio_server, lock)
205 await instance.on_connect(sid)
206 - activated.append((path, handler_cls))
206 + activated[path] = instance
207 except Exception as e:
208 PrintStyle.error(f"WS on_connect error ({path}): {format_error(e)}")
209
@@ -212,20 +212,19 @@ def register_ws_namespace(
212
213 return True
214
215 - @socketio_server.on("disconnect", namespace="/ws")
215 + @socketio_server.on("disconnect", namespace="/ws") # type: ignore
216 async def _on_disconnect(sid):
217 with _contexts_lock:
218 - activated = _active_handlers.pop(sid, [])
218 + activated = _active_handlers.pop(sid, {})
219 _ws_contexts.pop(sid, None)
220
221 - for path, handler_cls in activated:
221 + for path, instance in activated.items():
222 try:
223 - instance = handler_cls(socketio_server, lock)
223 await instance.on_disconnect(sid)
224 except Exception as e:
225 PrintStyle.error(f"WS on_disconnect error ({path}): {format_error(e)}")
226
228 - @socketio_server.on("*", namespace="/ws")
227 + @socketio_server.on("*", namespace="/ws") # type: ignore
228 async def _dispatch(event, sid, data):
229 path = event
230 payload = data if isinstance(data, dict) else {}
@@ -233,20 +232,20 @@ def register_ws_namespace(
232 try:
233 with _contexts_lock:
234 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}
238
239 - handler_cls = _resolve_cached(path)
240 - if handler_cls is None:
241 - return {"ok": False, "error": f"WS endpoint not found: {path}", "code": 404}
239 + instance = activated.get(path)
240 + if instance is None:
241 + return {"ok": False, "error": f"WS endpoint not activated: {path}", "code": 404}
242
243 # Security check
244 - error = _check_security(handler_cls, ctx)
244 + error = _check_security(type(instance), ctx)
245 if error is not None:
246 return error
247
248 - # Instantiate and process
249 - instance = handler_cls(socketio_server, lock)
248 + # Use cached instance and process
249 return await instance.process(payload, sid)
250
251 except Exception as e:
run_ui.py
+4 -3
@@ -149,9 +149,10 @@ async def serve_plugin_asset(plugin_name, asset_path):
149 @webapp.route("/extensions/webui/<path:asset_path>", methods=["GET"])
150 @requires_auth
151 async def serve_extension_asset(asset_path):
152 - path = files.get_abs_path("extensions/webui", asset_path)
153 - if not files.is_in_dir(path, "extensions/webui"):
154 - return Response("Access denied", 403)
152 + exts = files.get_abs_path("extensions/webui")
153 + path = files.get_abs_path(exts, asset_path)
154 + if not files.is_in_dir(path, exts):
155 + return Response(f"Access denied", 403)
156 return send_file(path)
157
158
webui/components/sidebar/top-section/quick-actions.html
+7 -1
@@ -184,6 +184,7 @@
184 .quick-actions-dropdown {
185 position: fixed;
186 z-index: 9999;
187 + margin-top: 0;
188 }
189
190 /* Unified scrollbar styling for dropdown */
@@ -209,7 +210,12 @@
210 }
211
212 /* Dropdown item icon sizing */
212 - .dropdown-item .material-symbols-outlined {
213 + .quick-actions-dropdown .dropdown-item,
214 + .quick-actions-dropdown .dropdown-item .material-symbols-outlined {
215 + opacity: 1;
216 + }
217 +
218 + .quick-actions-dropdown .dropdown-item .material-symbols-outlined {
219 font-size: 18px;
220 }
221