Summary
ComponentWebSocketManager (component_framework/core/websocket.py) keeps WebSocket connections and component subscriptions in plain in-memory dicts:
self.connections: dict[str, list[WebSocketConnection]] = {}
self.component_subscribers: dict[str, set[str]] = {}
push_update()/broadcast_update() no-op silently when component_id not in self.component_subscribers — which is exactly what happens whenever the code calling push_update() runs in a different OS process than the one that received the browser's subscribe WebSocket message.
Reproduction
Any consumer app that:
- Serves multiple Uvicorn/Gunicorn worker processes behind one listening socket, and
- Spawns a background
asyncio.create_task(...) from a component event handler that eventually calls ws_manager.push_update(component_id, html, state)
...will intermittently see that push vanish. The browser's WS connection and subscribe message land on worker A; the background task (and its later push_update calls) may run on worker B, C, or D. Worker B's ws_manager.component_subscribers never contains component_id, so the push is dropped with no error, log, or client-visible signal.
Real-world impact (525_OP_Site)
We hit this in production with RagProgressComponent — a long-running AI query streams progress via push_update() from a module-level background task. Under our 4-worker Uvicorn deployment (uvicorn.run(..., workers=4)), the query would complete successfully server-side (confirmed via logs — 14.71s round trip), but the final "complete" push would land on a worker that never subscribed the browser, so the UI stayed stuck on "Preparing query…" indefinitely with no error.
We worked around this in our own app with an app-level polling fallback (persist state to Redis on every push(), add a polling GET endpoint, poll from the client after dispatching the initiating event) — but this duplicates work that belongs in component-framework itself, and every other multi-worker consumer of ComponentWebSocketManager has the same latent bug.
Suggested fix
Back ComponentWebSocketManager with Redis pub/sub (or another cross-process broker) instead of in-memory dicts, so push_update()/broadcast_update() work correctly regardless of which worker process originated the call:
- Each worker subscribes to a Redis channel (e.g.
component:{component_id}) when it receives a browser's subscribe message, and holds the actual WebSocketConnection locally (sockets can't cross processes).
push_update()/broadcast_update() publish to the Redis channel instead of looking up self.component_subscribers directly; every worker holding a live connection for that channel receives the publish and forwards it over its own local WebSocket(s).
- Should degrade gracefully (or fail loudly in dev) when no Redis URL is configured / a single-worker deployment is in use, rather than silently dropping updates as it does today.
Happy to discuss approach further or help test against a real multi-worker deployment if useful — this is what surfaced the bug for us.
Summary
ComponentWebSocketManager(component_framework/core/websocket.py) keeps WebSocket connections and component subscriptions in plain in-memory dicts:push_update()/broadcast_update()no-op silently whencomponent_id not in self.component_subscribers— which is exactly what happens whenever the code callingpush_update()runs in a different OS process than the one that received the browser'ssubscribeWebSocket message.Reproduction
Any consumer app that:
asyncio.create_task(...)from a component event handler that eventually callsws_manager.push_update(component_id, html, state)...will intermittently see that push vanish. The browser's WS connection and
subscribemessage land on worker A; the background task (and its laterpush_updatecalls) may run on worker B, C, or D. Worker B'sws_manager.component_subscribersnever containscomponent_id, so the push is dropped with no error, log, or client-visible signal.Real-world impact (525_OP_Site)
We hit this in production with
RagProgressComponent— a long-running AI query streams progress viapush_update()from a module-level background task. Under our 4-worker Uvicorn deployment (uvicorn.run(..., workers=4)), the query would complete successfully server-side (confirmed via logs — 14.71s round trip), but the final "complete" push would land on a worker that never subscribed the browser, so the UI stayed stuck on "Preparing query…" indefinitely with no error.We worked around this in our own app with an app-level polling fallback (persist state to Redis on every
push(), add a polling GET endpoint, poll from the client after dispatching the initiating event) — but this duplicates work that belongs incomponent-frameworkitself, and every other multi-worker consumer ofComponentWebSocketManagerhas the same latent bug.Suggested fix
Back
ComponentWebSocketManagerwith Redis pub/sub (or another cross-process broker) instead of in-memory dicts, sopush_update()/broadcast_update()work correctly regardless of which worker process originated the call:component:{component_id}) when it receives a browser'ssubscribemessage, and holds the actualWebSocketConnectionlocally (sockets can't cross processes).push_update()/broadcast_update()publish to the Redis channel instead of looking upself.component_subscribersdirectly; every worker holding a live connection for that channel receives the publish and forwards it over its own local WebSocket(s).Happy to discuss approach further or help test against a real multi-worker deployment if useful — this is what surfaced the bug for us.