From 24d8a2bec0059d37eb826a2a9db65b676e9b3fbd Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 22 Aug 2026 22:58:01 +0200 Subject: [PATCH 01/44] bare websockets --- news/6920.feature.md | 1 + .../.templates/web/utils/helpers/websocket.js | 257 +++++++++ .../reflex_base/.templates/web/utils/state.js | 83 ++- .../reflex-base/src/reflex_base/config.py | 4 +- .../event/processor/event_processor.py | 4 +- .../src/reflex_base/utils/console.py | 6 +- .../reflex-base/src/reflex_base/utils/log.py | 6 +- pyproject.toml | 3 +- reflex/app.py | 474 ++-------------- reflex/event_namespace.py | 527 ++++++++++++++++++ reflex/socketio_namespace.py | 185 ++++++ tests/units/test_app.py | 2 +- tests/units/test_event_namespace.py | 407 ++++++++++++++ uv.lock | 72 +-- 14 files changed, 1533 insertions(+), 498 deletions(-) create mode 100644 news/6920.feature.md create mode 100644 packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js create mode 100644 reflex/event_namespace.py create mode 100644 reflex/socketio_namespace.py create mode 100644 tests/units/test_event_namespace.py diff --git a/news/6920.feature.md b/news/6920.feature.md new file mode 100644 index 00000000000..004cfb92704 --- /dev/null +++ b/news/6920.feature.md @@ -0,0 +1 @@ +The default client-server transport is now a plain WebSocket speaking a lightweight JSON event protocol, replacing Socket.IO. `python-socketio` moved to the optional `reflex[socketio]` extra and `socket.io-client` is only loaded by the frontend when configured. The Socket.IO transport remains available via `transport="socketio"` (websocket) or `transport="polling"` in `rxconfig.py`; apps passing a custom `sio` server to `rx.App` must set one of these and install the extra. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js new file mode 100644 index 00000000000..3778b845a27 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js @@ -0,0 +1,257 @@ +// Plain WebSocket transport speaking the Reflex JSON event protocol: each +// frame is a JSON array `[event_name, payload]`. Mirrors the socket.io-client +// surface that state.js and upload.js rely on: connected, connect(), +// disconnect(), emit(), on(), io.opts.query, and _callbacks. + +// Protocol-level message names (must match reflex/event_namespace.py). +const HANDSHAKE_MESSAGE = "_handshake"; +const PING_MESSAGE = "_ping"; +const PONG_MESSAGE = "_pong"; + +// Python's json.dumps emits bare Infinity/-Infinity/NaN tokens (invalid JSON). +// Rewrite them outside string literals so JSON.parse accepts the payload. +// 1e999 / -1e999 overflow to ±Infinity; NaN has no JSON literal, so it is +// swapped for a sentinel string and revived back to NaN after parsing. +// The alternation matches whole string literals first (passed through unchanged), +// guaranteeing bare-token matches only land in numeric positions. +const NAN_SENTINEL = "__reflex_nan__"; +const NON_FINITE_FLOAT_RE = /"(?:[^"\\]|\\.)*"|-?\bInfinity\b|\bNaN\b/g; +const NON_FINITE_REPLACEMENTS = { + Infinity: "1e999", + "-Infinity": "-1e999", + NaN: `"${NAN_SENTINEL}"`, +}; +export const rewriteBareNonFiniteFloats = (str) => + str.replace(NON_FINITE_FLOAT_RE, (match) => + match[0] === '"' ? match : NON_FINITE_REPLACEMENTS[match], + ); +export const reviveNonFiniteFloats = (_k, v) => (v === NAN_SENTINEL ? NaN : v); + +/** + * Serialize an outgoing frame, sending undefined fields as null. + * @param frame The frame array to serialize. + * @returns The JSON string. + */ +const stringifyFrame = (frame) => + JSON.stringify(frame, (k, v) => (v === undefined ? null : v)); + +/** + * Parse an incoming frame, tolerating bare non-finite float tokens. + * @param text The raw frame text. + * @returns The parsed frame, or undefined if unparsable. + */ +const parseFrame = (text) => { + try { + return JSON.parse(text); + } catch (e) { + try { + return JSON.parse( + rewriteBareNonFiniteFloats(text), + reviveNonFiniteFloats, + ); + } catch (e2) { + return undefined; + } + } +}; + +export class ReflexWebSocket { + /** + * Create the transport and start connecting (like socket.io's `io()`). + * @param url The http(s) endpoint URL of the backend event route. + * @param opts Options: `query` (object) and `protocols` (subprotocol list). + */ + constructor(url, opts) { + this._url = new URL(url); + // Exposed as io.opts for socket.io API compatibility: state.js refreshes + // io.opts.query before reconnecting. + this.io = { opts }; + this.connected = false; + // Handler registry shaped like socket.io's component-emitter, because + // upload.js reads socket._callbacks.$event directly. + this._callbacks = {}; + this._ws = null; + // Frames emitted while disconnected, flushed on (re)connect, matching + // socket.io's packet buffering. + this._sendQueue = []; + this._watchdogTimer = null; + // Heartbeat window; refined by the server handshake. + this._watchdogMs = (25 + 120) * 1000; + // Give up on a dial that neither opens nor errors (like socket.io's + // connect timeout), so a connect_error always fires and retries proceed. + this._connectTimeoutMs = 20000; + this._connectTimer = null; + this._closeReason = null; + this.connect(); + } + + /** + * Register a handler for an event. + * @param event The event name. + * @param fn The handler function. + */ + on(event, fn) { + (this._callbacks["$" + event] ??= []).push(fn); + } + + /** + * Invoke the registered handlers for a local event. + * @param event The event name. + * @param args The handler arguments. + */ + _emitLocal(event, ...args) { + for (const fn of this._callbacks["$" + event] ?? []) { + fn(...args); + } + } + + /** + * Open the websocket connection if not already open or connecting. + */ + connect() { + if (this._ws && this._ws.readyState <= WebSocket.OPEN) { + // CONNECTING (0) or OPEN (1): already dialing or connected. + return; + } + const url = new URL(this._url); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.search = new URLSearchParams(this.io.opts.query ?? {}).toString(); + this._closeReason = null; + const ws = new WebSocket(url, this.io.opts.protocols); + this._ws = ws; + this._connectTimer = setTimeout(() => { + if (this._ws === ws && !this.connected) { + ws.close(); + } + }, this._connectTimeoutMs); + ws.onmessage = (msg) => this._onMessage(msg.data); + ws.onclose = (event) => { + if (this._ws !== ws) { + // A newer connection or an explicit disconnect() superseded this one. + return; + } + this._clearConnectTimer(); + this._clearWatchdog(); + const wasConnected = this.connected; + this.connected = false; + if (!wasConnected) { + // Never handshaked: this was a failed connection attempt. + this._emitLocal( + "connect_error", + new Error("websocket connection failed"), + ); + } else { + this._emitLocal("disconnect", this._closeReason ?? "transport close", { + code: event.code, + reason: event.reason, + }); + } + }; + } + + /** + * Close the connection deliberately (reason "io client disconnect"). + */ + disconnect() { + this._clearConnectTimer(); + this._clearWatchdog(); + const ws = this._ws; + if (!ws) { + return; + } + // Detach so the onclose handler does not double-report. + this._ws = null; + if (this.connected) { + this.connected = false; + // Match socket.io: report the client-initiated disconnect synchronously, + // since onclose may never fire during page unload. + this._emitLocal("disconnect", "io client disconnect", undefined); + } + if (ws.readyState <= WebSocket.OPEN) { + ws.onclose = null; + ws.close(1000); + } + } + + /** + * Send an event to the backend, buffering while disconnected. + * @param event The event name. + * @param data The event payload. + */ + emit(event, data) { + const frame = stringifyFrame([event, data]); + if (this.connected && this._ws?.readyState === WebSocket.OPEN) { + this._ws.send(frame); + } else { + this._sendQueue.push(frame); + } + } + + /** + * Handle one incoming frame. + * @param text The raw frame text. + */ + _onMessage(text) { + this._resetWatchdog(); + const message = parseFrame(text); + if (!Array.isArray(message)) { + console.error("Failed to parse websocket message", text); + return; + } + const [event, payload] = message; + if (event === PING_MESSAGE) { + this._ws?.send(stringifyFrame([PONG_MESSAGE])); + return; + } + if (event === HANDSHAKE_MESSAGE) { + // Application-level liveness confirmed; adopt the server's heartbeat + // settings for the connection watchdog. + this._clearConnectTimer(); + this._watchdogMs = (payload.ping_interval + payload.ping_timeout) * 1000; + this._resetWatchdog(); + this.connected = true; + const queue = this._sendQueue; + this._sendQueue = []; + for (const frame of queue) { + this._ws.send(frame); + } + this._emitLocal("connect"); + return; + } + this._emitLocal(event, payload); + } + + /** + * (Re)arm the dead-connection watchdog; fires when no message (heartbeat + * included) arrives within the server's ping interval + timeout. + */ + _resetWatchdog() { + this._clearWatchdog(); + this._watchdogTimer = setTimeout(() => { + if (this._ws && this.connected) { + this._closeReason = "ping timeout"; + this._ws.close(); + } + }, this._watchdogMs); + } + + /** + * Cancel the dead-connection watchdog. + */ + _clearWatchdog() { + if (this._watchdogTimer) { + clearTimeout(this._watchdogTimer); + this._watchdogTimer = null; + } + } + + /** + * Cancel the connect timeout. + */ + _clearConnectTimer() { + if (this._connectTimer) { + clearTimeout(this._connectTimer); + this._connectTimer = null; + } + } +} diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index 8ba6d00509c..d4bd60c9553 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -1,5 +1,4 @@ // State management for Reflex web apps. -import io from "socket.io-client"; import env from "$/env.json"; import reflexEnvironment from "$/reflex.json"; import Cookies from "universal-cookie"; @@ -20,6 +19,11 @@ import { import debounce from "$/utils/helpers/debounce"; import throttle from "$/utils/helpers/throttle"; import { uploadFiles } from "$/utils/helpers/upload"; +import { + ReflexWebSocket, + rewriteBareNonFiniteFloats, + reviveNonFiniteFloats, +} from "$/utils/helpers/websocket"; // Endpoint URLs. const EVENTURL = env.EVENT; @@ -471,25 +475,6 @@ const resolveSocket = (socket) => { return socket?.current ?? socket; }; -// Python's json.dumps emits bare Infinity/-Infinity/NaN tokens (invalid JSON). -// Rewrite them outside string literals so JSON.parse accepts the payload. -// 1e999 / -1e999 overflow to ±Infinity; NaN has no JSON literal, so it is -// swapped for a sentinel string and revived back to NaN after parsing. -// The alternation matches whole string literals first (passed through unchanged), -// guaranteeing bare-token matches only land in numeric positions. -const NAN_SENTINEL = "__reflex_nan__"; -const NON_FINITE_FLOAT_RE = /"(?:[^"\\]|\\.)*"|-?\bInfinity\b|\bNaN\b/g; -const NON_FINITE_REPLACEMENTS = { - Infinity: "1e999", - "-Infinity": "-1e999", - NaN: `"${NAN_SENTINEL}"`, -}; -const rewriteBareNonFiniteFloats = (str) => - str.replace(NON_FINITE_FLOAT_RE, (match) => - match[0] === '"' ? match : NON_FINITE_REPLACEMENTS[match], - ); -const reviveNonFiniteFloats = (_k, v) => (v === NAN_SENTINEL ? NaN : v); - /** * Queue events to be processed and trigger processing of queue. * @param events Array of events to queue. @@ -590,31 +575,43 @@ export const connect = async ( const on_hydrated_queue = []; // Create the socket. - socket.current = io(endpoint.href, { - path: endpoint["pathname"], - transports: transports, - protocols: [reflexEnvironment.version], - autoUnref: false, - query: { token: getToken() }, - reconnection: false, // Reconnection will be handled manually. - }); - socket.current.wait_connect = !socket.current.connected; - // Ensure undefined fields in events are sent as null instead of removed - socket.current.io.encoder.replacer = (k, v) => (v === undefined ? null : v); - socket.current.io.decoder.tryParse = (str) => { - try { - return JSON.parse(str); - } catch (e) { + const transport = transports[0]; + if (transport === "websocket") { + // Default transport: plain WebSocket speaking the Reflex event protocol. + socket.current = new ReflexWebSocket(endpoint.href, { + query: { token: getToken() }, + protocols: [reflexEnvironment.version], + }); + } else { + // Socket.IO transport ("socketio" over websocket, or "polling"); the + // client library is only loaded when this transport is configured. + const { default: io } = await import("socket.io-client"); + socket.current = io(endpoint.href, { + path: endpoint["pathname"], + transports: [transport === "socketio" ? "websocket" : transport], + protocols: [reflexEnvironment.version], + autoUnref: false, + query: { token: getToken() }, + reconnection: false, // Reconnection will be handled manually. + }); + // Ensure undefined fields in events are sent as null instead of removed + socket.current.io.encoder.replacer = (k, v) => (v === undefined ? null : v); + socket.current.io.decoder.tryParse = (str) => { try { - return JSON.parse( - rewriteBareNonFiniteFloats(str), - reviveNonFiniteFloats, - ); - } catch (e2) { - return false; + return JSON.parse(str); + } catch (e) { + try { + return JSON.parse( + rewriteBareNonFiniteFloats(str), + reviveNonFiniteFloats, + ); + } catch (e2) { + return false; + } } - } - }; + }; + } + socket.current.wait_connect = !socket.current.connected; // Set up a reconnect helper function socket.current.reconnect = () => { if ( diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index b15533da731..c2f62c830a7 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -189,7 +189,7 @@ class BaseConfig: hydrate_fallback: Function returning the component shown while the page is hydrating (React Router's HydrateFallback), used when App.hydrate_fallback is not set. Formatted such that `from path_0.path_1... import path[-1]`, and calling it with no arguments would work. For example, "my_app.components.loading". plugins: List of plugins to use in the app. disable_plugins: List of plugin types to disable in the app. - transport: The transport method for client-server communication. + transport: The transport for client-server communication: "websocket" (plain WebSocket, default), or "socketio"/"polling" (Socket.IO; requires the reflex[socketio] extra). """ app_name: str @@ -273,7 +273,7 @@ class BaseConfig: disable_plugins: list[type[Plugin]] = dataclasses.field(default_factory=list) - transport: Literal["websocket", "polling"] = "websocket" + transport: Literal["websocket", "socketio", "polling"] = "websocket" # Whether to skip plugin checks. _skip_plugins_checks: bool = dataclasses.field(default=False, repr=False) diff --git a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py index 71625ebc427..7559c232076 100644 --- a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py +++ b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py @@ -27,8 +27,8 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: - from reflex.app import EventNamespace from reflex.event import Event, EventSpec + from reflex.event_namespace import BaseEventNamespace if hasattr(asyncio, "QueueShutDown"): @@ -133,7 +133,7 @@ def configure( self, *, state_manager: StateManager | None = None, - event_namespace: EventNamespace | None = None, + event_namespace: BaseEventNamespace | None = None, ) -> Self: """Set up the event processor. diff --git a/packages/reflex-base/src/reflex_base/utils/console.py b/packages/reflex-base/src/reflex_base/utils/console.py index b8a5985322f..ea22b5069f7 100644 --- a/packages/reflex-base/src/reflex_base/utils/console.py +++ b/packages/reflex-base/src/reflex_base/utils/console.py @@ -291,11 +291,15 @@ def _exclude_paths_from_frame_info() -> list[Path]: import click import granian - import socketio import typing_extensions import reflex_base + try: + import socketio + except ImportError: + socketio = None + try: import reflex as rx except ImportError: diff --git a/packages/reflex-base/src/reflex_base/utils/log.py b/packages/reflex-base/src/reflex_base/utils/log.py index c7c17e10bf7..95a26a54f54 100644 --- a/packages/reflex-base/src/reflex_base/utils/log.py +++ b/packages/reflex-base/src/reflex_base/utils/log.py @@ -641,11 +641,15 @@ def _exclude_paths_from_frame_info() -> list[Path]: import click import granian - import socketio import typing_extensions import reflex_base + try: + import socketio + except ImportError: + socketio = None + try: import reflex as rx except ImportError: diff --git a/pyproject.toml b/pyproject.toml index 2638de9c41b..ff516e04ecb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,6 @@ dependencies = [ "packaging >=24.2,<27", "psutil >=7.0.0,<8.0; sys_platform == 'win32'", "python-multipart >=0.0.32,<1.0", - "python-socketio >=5.12.0,<6.0", "redis >=6.4,<8.0", "rich >=13,<16", "starlette >=1.3.1", @@ -66,6 +65,7 @@ db = [ "sqlmodel >=0.0.24,<0.1", ] pydantic = ["reflex-base[pydantic]"] +socketio = ["python-socketio >=5.12.0,<6.0"] [project.urls] homepage = "https://reflex.dev" @@ -105,6 +105,7 @@ dev = [ "pytest-split", "pytest", "python-dotenv", + "python-socketio", "pyyaml", "reflex-docgen", "reflex-release", diff --git a/reflex/app.py b/reflex/app.py index 63ec53a4c75..a1e9b659f7a 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -13,9 +13,7 @@ import logging import operator import sys -import time import traceback -import urllib.parse from collections.abc import ( AsyncIterator, Callable, @@ -25,7 +23,6 @@ Sequence, ) from contextvars import Token -from types import SimpleNamespace from typing import TYPE_CHECKING, Any, overload from reflex_base import constants @@ -33,21 +30,14 @@ from reflex_base.config import get_config, reload_config from reflex_base.context.base import BaseContext from reflex_base.environment import environment -from reflex_base.event import ( - _EVENT_FIELDS, - Event, - EventSpec, - EventType, - IndividualEventType, - noop, -) +from reflex_base.event import Event, EventSpec, EventType, IndividualEventType, noop from reflex_base.event.context import EventContext from reflex_base.event.processor import BaseStateEventProcessor, EventProcessor from reflex_base.registry import RegistrationContext from reflex_base.telemetry_context import CompileTrigger, TelemetryContext from reflex_base.utils import memo_paths from reflex_base.utils.imports import ImportVar -from reflex_base.utils.types import ASGIApp, Message, Receive, Scope, Send +from reflex_base.utils.types import ASGIApp, Receive, Scope, Send from reflex_components_core.base.error_boundary import ErrorBoundary from reflex_components_core.base.fragment import Fragment from reflex_components_core.core.banner import ( @@ -58,12 +48,11 @@ from reflex_components_core.core.breakpoints import set_breakpoints from reflex_components_core.core.sticky import sticky from reflex_components_sonner.toast import toast -from socketio import ASGIApp as EngineIOApp -from socketio import AsyncNamespace, AsyncServer from starlette.applications import Starlette from starlette.middleware import cors from starlette.requests import Request from starlette.responses import JSONResponse, Response +from starlette.routing import WebSocketRoute from starlette.staticfiles import StaticFiles from typing_extensions import Unpack @@ -73,7 +62,7 @@ from reflex.app_mixins import AppMixin, LifespanMixin, MiddlewareMixin from reflex.compiler import compiler from reflex.compiler.compiler import readable_name_from_component -from reflex.istate.data import RouterData +from reflex.event_namespace import BaseEventNamespace, WebsocketEventNamespace from reflex.istate.manager import StateManager, StateModificationContext from reflex.istate.manager.token import BaseStateToken from reflex.route import ( @@ -98,7 +87,6 @@ should_prerender_routes, ) from reflex.utils.misc import run_in_thread -from reflex.utils.token_manager import RedisTokenManager, TokenManager logger = logging.getLogger(__name__) @@ -111,11 +99,15 @@ from reflex_base.plugins import Plugin from reflex_base.plugins.base import AddPageProtocol from reflex_base.vars import Var + from socketio import AsyncServer # Define custom types. ComponentCallable = Callable[[], Component | tuple[Component, ...] | str | Var] else: ComponentCallable = Callable[[], Component | tuple[Component, ...] | str] + # Runtime placeholder so annotations resolve without the optional + # python-socketio dependency installed. + AsyncServer = Any Reducer = Callable[[Event], Coroutine[Any, Any, StateUpdate]] @@ -448,7 +440,7 @@ class App(MiddlewareMixin, LifespanMixin): admin_dash: AdminDash | None = None # The async server name space. - _event_namespace: EventNamespace | None = None + _event_namespace: BaseEventNamespace | None = None # The processor queue for handling events. _event_processor: EventProcessor | None = None @@ -478,7 +470,7 @@ class App(MiddlewareMixin, LifespanMixin): ) = None @property - def event_namespace(self) -> EventNamespace | None: + def event_namespace(self) -> BaseEventNamespace | None: """Get the event namespace. Returns: @@ -552,7 +544,8 @@ def _setup_state(self) -> None: """Set up the state for the app. Raises: - RuntimeError: If the socket server is invalid. + RuntimeError: If the socket server is invalid, or the Socket.IO + transport is requested without python-socketio installed. """ if not self._state: return @@ -562,76 +555,46 @@ def _setup_state(self) -> None: # Set up the state manager. self._state_manager = StateManager.create() - # Set up the Socket.IO AsyncServer. - if not self.sio: - self.sio = AsyncServer( - async_mode="asgi", - cors_allowed_origins=( - ( - "*" - if config.cors_allowed_origins == ("*",) - else list(config.cors_allowed_origins) - ) - if config.transport == "websocket" - else [] - ), - cors_credentials=config.transport == "websocket", - max_http_buffer_size=environment.REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE.get(), - ping_interval=environment.REFLEX_SOCKET_INTERVAL.get(), - ping_timeout=environment.REFLEX_SOCKET_TIMEOUT.get(), - json=SimpleNamespace( - dumps=staticmethod(format.json_dumps), - loads=staticmethod(json.loads), - ), - allow_upgrades=False, - transports=[config.transport], - ) - elif getattr(self.sio, "async_mode", "") != "asgi": - msg = f"Custom `sio` must use `async_mode='asgi'`, not '{self.sio.async_mode}'." - raise RuntimeError(msg) - - # Create the socket app. Note event endpoint constant replaces the default 'socket.io' path. - socket_app = EngineIOApp(self.sio, socketio_path="") namespace = config.get_event_namespace() + event_path = config.prepend_backend_path(str(constants.Endpoint.EVENT)) - # Create the event namespace and attach the main app. Not related to any paths. - self._event_namespace = EventNamespace(namespace, self) - - # Register the event namespace with the socket. - self.sio.register_namespace(self.event_namespace) - # Mount the socket app with the API. - if self._api: - - class HeaderMiddleware: - def __init__(self, app: ASGIApp): - self.app = app - - async def __call__(self, scope: Scope, receive: Receive, send: Send): - original_send = send - - async def modified_send(message: Message): - if message["type"] == "websocket.accept": - if scope.get("subprotocols"): - # The following *does* say "subprotocol" instead of "subprotocols", intentionally. - message["subprotocol"] = scope["subprotocols"][0] - - headers = dict(message.get("headers", [])) - header_key = b"sec-websocket-protocol" - if subprotocol := headers.get(header_key): - message["headers"] = [ - *message.get("headers", []), - (header_key, subprotocol), - ] + if self.sio is not None or config.transport in ("socketio", "polling"): + # Legacy Socket.IO transport, kept behind the optional dependency. + if self.sio is not None and config.transport == "websocket": + msg = ( + "A custom `sio` server requires the Socket.IO transport; " + 'set transport="socketio" (or "polling") in rxconfig.py.' + ) + raise RuntimeError(msg) + try: + from reflex.socketio_namespace import ( + EventNamespace, + create_socketio_app, + ) + except ImportError as ex: + msg = ( + f"transport={config.transport!r} requires the python-socketio " + "package. Install it with: pip install 'reflex[socketio]'" + ) + raise RuntimeError(msg) from ex - return await original_send(message) + socket_app = create_socketio_app(self, config) - return await self.app(scope, receive, modified_send) + # Create the event namespace and attach the main app. Not related to any paths. + self._event_namespace = EventNamespace(namespace, self) - socket_app_with_headers = HeaderMiddleware(socket_app) - self._api.mount( - config.prepend_backend_path(str(constants.Endpoint.EVENT)), - socket_app_with_headers, - ) + # Register the event namespace with the socket. + self.sio.register_namespace(self._event_namespace) # pyright: ignore[reportOptionalMemberAccess] + # Mount the socket app with the API. + if self._api: + self._api.mount(event_path, socket_app) + else: + # Default transport: plain WebSocket served by the API itself. + self._event_namespace = WebsocketEventNamespace(namespace, self) + if self._api: + self._api.router.routes.append( + WebSocketRoute(event_path, self._event_namespace.handle_websocket) + ) # Check the exception handlers self._validate_exception_handlers() @@ -1920,336 +1883,21 @@ async def health(_request: Request) -> JSONResponse: return JSONResponse(content=health_status, status_code=status_code) -class EventNamespace(AsyncNamespace): - """The event namespace.""" - - # The application object. - app: App - - # Maximum error-level log entries a single session may produce via the - # client_error event before further reports from it are dropped. - _MAX_CLIENT_ERRORS_PER_SID = 5 - - # Process-wide bound on error-level client_error log entries per time - # window; per-SID budgets alone reset on reconnect, so scripted - # reconnects could otherwise flood the logs. - _CLIENT_ERROR_WINDOW_SECONDS = 60.0 - _MAX_CLIENT_ERRORS_PER_WINDOW = 20 - - def __init__(self, namespace: str, app: App): - """Initialize the event namespace. - - Args: - namespace: The namespace. - app: The application object. - """ - super().__init__(namespace) - self.app = app - - # Use TokenManager for distributed duplicate tab prevention - self._token_manager = TokenManager.create() - - # Number of client_error reports logged per SID, for rate limiting. - self._client_error_counts: dict[str, int] = {} - - # Start time and count of the current process-wide client_error window. - self._client_error_window_start = 0.0 - self._client_error_window_count = 0 - - @property - def token_to_sid(self) -> Mapping[str, str]: - """Get token to SID mapping for backward compatibility. - - Note: this mapping is read-only. - - Returns: - The token to SID mapping. - """ - # For backward compatibility, expose the underlying dict - return self._token_manager.token_to_sid - - @property - def sid_to_token(self) -> dict[str, str]: - """Get SID to token mapping for backward compatibility. - - Returns: - The SID to token mapping dict. - """ - # For backward compatibility, expose the underlying dict - return self._token_manager.sid_to_token - - async def on_connect(self, sid: str, environ: dict): - """Event for when the websocket is connected. - - Args: - sid: The Socket.IO session id. - environ: The request information, including HTTP headers. - """ - if isinstance(self._token_manager, RedisTokenManager): - # Make sure this instance is watching for updates from other instances. - self._token_manager.ensure_lost_and_found_task(self.emit_update) - query_params = urllib.parse.parse_qs(environ.get("QUERY_STRING", "")) - token_list = query_params.get("token", []) - if token_list: - await self.link_token_to_sid(sid, token_list[0]) - else: - logger.warning(f"No token provided in connection for session {sid}") - - subprotocol = environ.get("HTTP_SEC_WEBSOCKET_PROTOCOL") - if subprotocol and subprotocol != constants.Reflex.VERSION: - logger.warning( - f"Frontend version {subprotocol} for session {sid} does not match the backend version {constants.Reflex.VERSION}." - ) - - def on_disconnect(self, sid: str) -> asyncio.Task | None: - """Event for when the websocket disconnects. - - Args: - sid: The Socket.IO session id. - - Returns: - An asyncio Task for cleaning up the token, or None. - """ - self._client_error_counts.pop(sid, None) - # Get token before cleaning up - disconnect_token = self.sid_to_token.get(sid) - if disconnect_token: - # Use async cleanup through token manager - task = asyncio.create_task( - self._token_manager.disconnect_token(disconnect_token, sid), - name=f"reflex_disconnect_token|{disconnect_token}|{time.time()}", - ) - # Don't await to avoid blocking disconnect, but handle potential errors - task.add_done_callback( - lambda t: ( - t.exception() - and logger.error(f"Token cleanup error: {t.exception()}") - ) - ) - return task - return None - - async def emit_update(self, update: StateUpdate, token: str) -> None: - """Emit an update to the client. - - Args: - update: The state update to send. - token: The client token (tab) associated with the event. - """ - socket_record = self._token_manager.token_to_socket.get(token) - if ( - socket_record is None - or socket_record.instance_id != self._token_manager.instance_id - ): - if isinstance(self._token_manager, RedisTokenManager): - # The socket belongs to another instance of the app, send it to the lost and found. - await self._token_manager.emit_lost_and_found(token, update) - else: - # If the socket record is None, we are not connected to a client. Prevent sending - # updates to all clients. - logger.warning( - f"Attempting to send delta to disconnected client {token!r}" - ) - return - # Creating a task prevents the update from being blocked behind other coroutines. - await asyncio.create_task( - self.emit(str(constants.SocketEvent.EVENT), update, to=socket_record.sid), - name=f"reflex_emit_event|{token}|{socket_record.sid}|{time.time()}", - ) - - async def on_event(self, sid: str, data: Any): - """Event for receiving front-end websocket events. - - Args: - sid: The Socket.IO session id. - data: The event data. +def __getattr__(name: str) -> Any: + """Resolve the optional Socket.IO EventNamespace export lazily. - Raises: - RuntimeError: If the Socket.IO is badly initialized. - EventDeserializationError: If the event data is not a dictionary. - """ - # Determine the token for this SID - if (token := self.sid_to_token.get(sid)) is None: - logger.warning( - f"Received event from session {sid} with no associated token. This may indicate a bug. Event data: {data}" - ) - return - - fields = data - - if isinstance(fields, str): - logger.warning( - "Received event data as a string. This generally should not happen and may indicate a bug." - f" Event data: {fields}" - ) - try: - fields = json.loads(fields) - except json.JSONDecodeError as ex: - msg = f"Failed to deserialize event data: {fields}." - raise exceptions.EventDeserializationError(msg) from ex - - if not isinstance(fields, dict): - msg = f"Event data must be a dictionary, but received {fields} of type {type(fields)}." - raise exceptions.EventDeserializationError(msg) - - try: - # Get the event. - event = Event(**{k: v for k, v in fields.items() if k in _EVENT_FIELDS}) - except (TypeError, ValueError) as ex: - msg = f"Failed to deserialize event data: {fields}." - raise exceptions.EventDeserializationError(msg) from ex - - # Get the event environment. - if self.app.sio is None: - msg = "Socket.IO is not initialized." - raise RuntimeError(msg) - environ = self.app.sio.get_environ(sid, self.namespace) - if environ is None: - msg = "Socket.IO environ is not initialized." - raise RuntimeError(msg) - - # Get the client headers. - headers = { - k.decode("utf-8"): v.decode("utf-8") - for (k, v) in environ["asgi.scope"]["headers"] - } - - # Get the client IP - try: - client_ip = environ["asgi.scope"]["client"][0] - headers["asgi-scope-client"] = client_ip - except (KeyError, IndexError): - client_ip = environ.get("REMOTE_ADDR", "0.0.0.0") - - # Unroll reverse proxy forwarded headers. - client_ip = ( - headers - .get( - "x-forwarded-for", - client_ip, - ) - .partition(",")[0] - .strip() - ) - router_data = event.router_data - router_data.update({ - constants.RouteVar.QUERY: format.format_query_params(event.router_data), - constants.RouteVar.CLIENT_TOKEN: token, - constants.RouteVar.SESSION_ID: sid, - constants.RouteVar.HEADERS: headers, - constants.RouteVar.CLIENT_IP: client_ip, - }) - router_data[constants.RouteVar.PATH] = "/" + ( - self.app.router(path) or "404" - if (path := router_data.get(constants.RouteVar.PATH)) - else "404" - ).removeprefix("/") - await self.app.event_processor.enqueue(token, event) - - async def on_ping(self, sid: str): - """Event for testing the API endpoint. - - Args: - sid: The Socket.IO session id. - """ - # Emit the test event. - await self.emit(str(constants.SocketEvent.PING), "pong", to=sid) - - async def on_client_error(self, sid: str, data: Any): - """Handle errors reported by the frontend. - - This is a dedicated socket event rather than a state event - (``FrontendEventExceptionState.handle_frontend_exception``) because a - state event is addressed by a handler name the frontend derives from - its own state definitions. When those definitions are what disagree - with the backend -- the case this handler exists to report -- the name - may not resolve and the report is lost. A fixed socket event name - cannot drift, and it still gets through after the frontend has stopped - sending events on detecting the mismatch. - - Reports are routed through the app's ``frontend_exception_handler``, - so frontend errors (especially state update processing errors) are - visible in backend logs and reach custom exception handlers. - - Args: - sid: The Socket.IO session id. - data: The error data from the client. - """ - if not isinstance(data, dict): - logger.debug(f"Ignoring malformed client_error payload from SID {sid}.") - return - - # Check the sender and the rate limits before sanitizing: sanitizing is - # linear in the size of the client-supplied values, and reports that are - # dropped here must not cost more than the check itself. - if sid not in self.sid_to_token: - # Sockets without a linked token are not known clients; don't let - # them write error-level entries into the backend logs. - logger.debug(f"Ignoring client_error report from unknown SID {sid}.") - return - - # Rate limit per session so a client cannot flood the backend logs. - error_count = self._client_error_counts.get(sid, 0) - if error_count >= self._MAX_CLIENT_ERRORS_PER_SID: - return - - # Also bound total entries per time window: per-SID budgets reset on - # reconnect, so they alone do not stop scripted reconnect loops. - now = time.monotonic() - if now - self._client_error_window_start > self._CLIENT_ERROR_WINDOW_SECONDS: - self._client_error_window_start = now - self._client_error_window_count = 0 - if self._client_error_window_count >= self._MAX_CLIENT_ERRORS_PER_WINDOW: - if self._client_error_window_count == self._MAX_CLIENT_ERRORS_PER_WINDOW: - # Warn once per window so suppression is visible in the logs - # and a flooding client cannot silently starve reports from - # other sessions. - self._client_error_window_count += 1 - logger.warning( - f"Received more than {self._MAX_CLIENT_ERRORS_PER_WINDOW} " - f"client_error reports in {self._CLIENT_ERROR_WINDOW_SECONDS:.0f}s; " - "suppressing further reports for this window." - ) - return - self._client_error_window_count += 1 - self._client_error_counts[sid] = error_count + 1 - - error_type = format.sanitize_client_log_value(data.get("error_type", "unknown")) - if error_type == constants.ClientErrorType.DISPATCH_MISSING: - substate = format.sanitize_client_log_value(data.get("substate", "")) - report = ( - f"[SID: {sid}] State update failed: " - f"no dispatch function for substate(s) '{substate}'. " - "This indicates a frontend/backend state mismatch. " - "Rebuild the frontend or check that api_url points to the matching backend." - ) - else: - message = format.sanitize_client_log_value( - data.get("message", "No error message provided") - ) - report = f"[SID: {sid}] {error_type}: {message}" - # Route through the app's frontend exception handler so custom - # handlers (e.g. error trackers) receive client errors too. - self.app.frontend_exception_handler(Exception(report)) - - async def link_token_to_sid(self, sid: str, token: str): - """Link a token to a session id. + Args: + name: The attribute name. - Args: - sid: The Socket.IO session id. - token: The client token. - """ - # Use TokenManager for duplicate detection and Redis support - new_token = await self._token_manager.link_token_to_sid(token, sid) + Returns: + The resolved attribute. - if new_token: - # Duplicate detected, emit new token to client - await self.emit("new_token", new_token, to=sid) + Raises: + AttributeError: If the attribute is unknown. + """ + if name == "EventNamespace": + from reflex.socketio_namespace import EventNamespace - # Update client state to apply new sid/token for running background tasks. - if self.app._state is not None: - async with self.app.state_manager.modify_state( - BaseStateToken(ident=new_token or token, cls=self.app._state) - ) as state: - state.router_data[constants.RouteVar.SESSION_ID] = sid - state.router = RouterData.from_router_data(state.router_data) + return EventNamespace + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py new file mode 100644 index 00000000000..963e8cc48bd --- /dev/null +++ b/reflex/event_namespace.py @@ -0,0 +1,527 @@ +"""Event namespaces bridging client sessions to the Reflex event loop.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import time +import urllib.parse +import uuid +from abc import ABC, abstractmethod +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any + +from reflex_base import constants +from reflex_base.config import get_config +from reflex_base.environment import environment +from reflex_base.event import _EVENT_FIELDS, Event +from starlette.websockets import WebSocket, WebSocketDisconnect + +from reflex.istate.data import RouterData +from reflex.istate.manager.token import BaseStateToken +from reflex.state import StateUpdate +from reflex.utils import exceptions, format +from reflex.utils.token_manager import RedisTokenManager, TokenManager + +if TYPE_CHECKING: + from reflex.app import App + +logger = logging.getLogger(__name__) + +# Protocol-level message names for the plain WebSocket transport. These are +# reserved (underscore-prefixed) and never dispatched as application events. +# They must match the names in .templates/web/utils/helpers/websocket.js. +HANDSHAKE_MESSAGE = "_handshake" +PING_MESSAGE = "_ping" +PONG_MESSAGE = "_pong" + + +class BaseEventNamespace(ABC): + """Transport-agnostic handler for client event sessions.""" + + # The application object. + app: App + + # Maximum error-level log entries a single session may produce via the + # client_error event before further reports from it are dropped. + _MAX_CLIENT_ERRORS_PER_SID = 5 + + # Process-wide bound on error-level client_error log entries per time + # window; per-SID budgets alone reset on reconnect, so scripted + # reconnects could otherwise flood the logs. + _CLIENT_ERROR_WINDOW_SECONDS = 60.0 + _MAX_CLIENT_ERRORS_PER_WINDOW = 20 + + def __init__(self, namespace: str, app: App): + """Initialize the event namespace. + + Args: + namespace: The namespace. + app: The application object. + """ + self.namespace = namespace + self.app = app + + # Use TokenManager for distributed duplicate tab prevention + self._token_manager = TokenManager.create() + + # Number of client_error reports logged per SID, for rate limiting. + self._client_error_counts: dict[str, int] = {} + + # Start time and count of the current process-wide client_error window. + self._client_error_window_start = 0.0 + self._client_error_window_count = 0 + + @property + def token_to_sid(self) -> Mapping[str, str]: + """Get token to SID mapping for backward compatibility. + + Note: this mapping is read-only. + + Returns: + The token to SID mapping. + """ + # For backward compatibility, expose the underlying dict + return self._token_manager.token_to_sid + + @property + def sid_to_token(self) -> dict[str, str]: + """Get SID to token mapping for backward compatibility. + + Returns: + The SID to token mapping dict. + """ + # For backward compatibility, expose the underlying dict + return self._token_manager.sid_to_token + + @abstractmethod + async def emit(self, event: str, data: Any = None, to: str | None = None) -> None: + """Emit an event to a connected client session. + + Args: + event: The event name. + data: The event payload. + to: The session id to emit to. + """ + + async def handle_connect( + self, sid: str, query_string: str, subprotocol: str | None + ) -> None: + """Handle a new client session connecting. + + Args: + sid: The session id. + query_string: The raw query string of the connection request. + subprotocol: The websocket subprotocol offered by the client. + """ + if isinstance(self._token_manager, RedisTokenManager): + # Make sure this instance is watching for updates from other instances. + self._token_manager.ensure_lost_and_found_task(self.emit_update) + query_params = urllib.parse.parse_qs(query_string) + token_list = query_params.get("token", []) + if token_list: + await self.link_token_to_sid(sid, token_list[0]) + else: + logger.warning(f"No token provided in connection for session {sid}") + + if subprotocol and subprotocol != constants.Reflex.VERSION: + logger.warning( + f"Frontend version {subprotocol} for session {sid} does not match the backend version {constants.Reflex.VERSION}." + ) + + def handle_disconnect(self, sid: str) -> asyncio.Task | None: + """Handle a client session disconnecting. + + Args: + sid: The session id. + + Returns: + An asyncio Task for cleaning up the token, or None. + """ + self._client_error_counts.pop(sid, None) + # Get token before cleaning up + disconnect_token = self.sid_to_token.get(sid) + if disconnect_token: + # Use async cleanup through token manager + task = asyncio.create_task( + self._token_manager.disconnect_token(disconnect_token, sid), + name=f"reflex_disconnect_token|{disconnect_token}|{time.time()}", + ) + # Don't await to avoid blocking disconnect, but handle potential errors + task.add_done_callback( + lambda t: ( + t.exception() + and logger.error(f"Token cleanup error: {t.exception()}") + ) + ) + return task + return None + + async def emit_update(self, update: StateUpdate, token: str) -> None: + """Emit an update to the client. + + Args: + update: The state update to send. + token: The client token (tab) associated with the event. + """ + socket_record = self._token_manager.token_to_socket.get(token) + if ( + socket_record is None + or socket_record.instance_id != self._token_manager.instance_id + ): + if isinstance(self._token_manager, RedisTokenManager): + # The socket belongs to another instance of the app, send it to the lost and found. + await self._token_manager.emit_lost_and_found(token, update) + else: + # If the socket record is None, we are not connected to a client. Prevent sending + # updates to all clients. + logger.warning( + f"Attempting to send delta to disconnected client {token!r}" + ) + return + # Creating a task prevents the update from being blocked behind other coroutines. + await asyncio.create_task( + self.emit(str(constants.SocketEvent.EVENT), update, to=socket_record.sid), + name=f"reflex_emit_event|{token}|{socket_record.sid}|{time.time()}", + ) + + async def handle_event( + self, sid: str, data: Any, asgi_scope: Mapping[str, Any] + ) -> None: + """Handle an incoming front-end event. + + Args: + sid: The session id. + data: The event data. + asgi_scope: The ASGI scope of the client connection. + + Raises: + EventDeserializationError: If the event data is not a dictionary. + """ + # Determine the token for this SID + if (token := self.sid_to_token.get(sid)) is None: + logger.warning( + f"Received event from session {sid} with no associated token. This may indicate a bug. Event data: {data}" + ) + return + + fields = data + + if isinstance(fields, str): + logger.warning( + "Received event data as a string. This generally should not happen and may indicate a bug." + f" Event data: {fields}" + ) + try: + fields = json.loads(fields) + except json.JSONDecodeError as ex: + msg = f"Failed to deserialize event data: {fields}." + raise exceptions.EventDeserializationError(msg) from ex + + if not isinstance(fields, dict): + msg = f"Event data must be a dictionary, but received {fields} of type {type(fields)}." + raise exceptions.EventDeserializationError(msg) + + try: + # Get the event. + event = Event(**{k: v for k, v in fields.items() if k in _EVENT_FIELDS}) + except (TypeError, ValueError) as ex: + msg = f"Failed to deserialize event data: {fields}." + raise exceptions.EventDeserializationError(msg) from ex + + # Get the client headers. + headers = { + k.decode("utf-8"): v.decode("utf-8") for (k, v) in asgi_scope["headers"] + } + + # Get the client IP + client = asgi_scope.get("client") + if client: + client_ip = client[0] + headers["asgi-scope-client"] = client_ip + else: + client_ip = "0.0.0.0" + + # Unroll reverse proxy forwarded headers. + client_ip = ( + headers + .get( + "x-forwarded-for", + client_ip, + ) + .partition(",")[0] + .strip() + ) + router_data = event.router_data + router_data.update({ + constants.RouteVar.QUERY: format.format_query_params(event.router_data), + constants.RouteVar.CLIENT_TOKEN: token, + constants.RouteVar.SESSION_ID: sid, + constants.RouteVar.HEADERS: headers, + constants.RouteVar.CLIENT_IP: client_ip, + }) + router_data[constants.RouteVar.PATH] = "/" + ( + self.app.router(path) or "404" + if (path := router_data.get(constants.RouteVar.PATH)) + else "404" + ).removeprefix("/") + await self.app.event_processor.enqueue(token, event) + + async def handle_ping(self, sid: str) -> None: + """Handle an application-level ping test event. + + Args: + sid: The session id. + """ + # Emit the test event. + await self.emit(str(constants.SocketEvent.PING), "pong", to=sid) + + async def handle_client_error(self, sid: str, data: Any) -> None: + """Handle errors reported by the frontend. + + This is a dedicated socket event rather than a state event + (``FrontendEventExceptionState.handle_frontend_exception``) because a + state event is addressed by a handler name the frontend derives from + its own state definitions. When those definitions are what disagree + with the backend -- the case this handler exists to report -- the name + may not resolve and the report is lost. A fixed socket event name + cannot drift, and it still gets through after the frontend has stopped + sending events on detecting the mismatch. + + Reports are routed through the app's ``frontend_exception_handler``, + so frontend errors (especially state update processing errors) are + visible in backend logs and reach custom exception handlers. + + Args: + sid: The session id. + data: The error data from the client. + """ + if not isinstance(data, dict): + logger.debug(f"Ignoring malformed client_error payload from SID {sid}.") + return + + # Check the sender and the rate limits before sanitizing: sanitizing is + # linear in the size of the client-supplied values, and reports that are + # dropped here must not cost more than the check itself. + if sid not in self.sid_to_token: + # Sockets without a linked token are not known clients; don't let + # them write error-level entries into the backend logs. + logger.debug(f"Ignoring client_error report from unknown SID {sid}.") + return + + # Rate limit per session so a client cannot flood the backend logs. + error_count = self._client_error_counts.get(sid, 0) + if error_count >= self._MAX_CLIENT_ERRORS_PER_SID: + return + + # Also bound total entries per time window: per-SID budgets reset on + # reconnect, so they alone do not stop scripted reconnect loops. + now = time.monotonic() + if now - self._client_error_window_start > self._CLIENT_ERROR_WINDOW_SECONDS: + self._client_error_window_start = now + self._client_error_window_count = 0 + if self._client_error_window_count >= self._MAX_CLIENT_ERRORS_PER_WINDOW: + if self._client_error_window_count == self._MAX_CLIENT_ERRORS_PER_WINDOW: + # Warn once per window so suppression is visible in the logs + # and a flooding client cannot silently starve reports from + # other sessions. + self._client_error_window_count += 1 + logger.warning( + f"Received more than {self._MAX_CLIENT_ERRORS_PER_WINDOW} " + f"client_error reports in {self._CLIENT_ERROR_WINDOW_SECONDS:.0f}s; " + "suppressing further reports for this window." + ) + return + self._client_error_window_count += 1 + self._client_error_counts[sid] = error_count + 1 + + error_type = format.sanitize_client_log_value(data.get("error_type", "unknown")) + if error_type == constants.ClientErrorType.DISPATCH_MISSING: + substate = format.sanitize_client_log_value(data.get("substate", "")) + report = ( + f"[SID: {sid}] State update failed: " + f"no dispatch function for substate(s) '{substate}'. " + "This indicates a frontend/backend state mismatch. " + "Rebuild the frontend or check that api_url points to the matching backend." + ) + else: + message = format.sanitize_client_log_value( + data.get("message", "No error message provided") + ) + report = f"[SID: {sid}] {error_type}: {message}" + # Route through the app's frontend exception handler so custom + # handlers (e.g. error trackers) receive client errors too. + self.app.frontend_exception_handler(Exception(report)) + + async def link_token_to_sid(self, sid: str, token: str): + """Link a token to a session id. + + Args: + sid: The session id. + token: The client token. + """ + # Use TokenManager for duplicate detection and Redis support + new_token = await self._token_manager.link_token_to_sid(token, sid) + + if new_token: + # Duplicate detected, emit new token to client + await self.emit("new_token", new_token, to=sid) + + # Update client state to apply new sid/token for running background tasks. + if self.app._state is not None: + async with self.app.state_manager.modify_state( + BaseStateToken(ident=new_token or token, cls=self.app._state) + ) as state: + state.router_data[constants.RouteVar.SESSION_ID] = sid + state.router = RouterData.from_router_data(state.router_data) + + +class WebsocketEventNamespace(BaseEventNamespace): + """Default event transport speaking JSON frames over a plain WebSocket. + + Each message is a JSON array ``[event_name, payload]``. Heartbeats and the + initial handshake use reserved underscore-prefixed message names. + """ + + def __init__(self, namespace: str, app: App): + """Initialize the websocket event namespace. + + Args: + namespace: The namespace. + app: The application object. + """ + super().__init__(namespace, app) + self._sockets: dict[str, WebSocket] = {} + + async def emit(self, event: str, data: Any = None, to: str | None = None) -> None: + """Emit an event to a connected client session. + + Args: + event: The event name. + data: The event payload. + to: The session id to emit to. + """ + websocket = self._sockets.get(to) if to is not None else None + if websocket is None: + logger.warning(f"Attempted to emit {event!r} to unknown session {to!r}.") + return + try: + await websocket.send_text(format.json_dumps([event, data])) + except Exception: + # The connection went away mid-send; the receive loop cleans up. + logger.debug(f"Failed to emit {event!r} to session {to!r}.", exc_info=True) + + @staticmethod + def _origin_allowed(origin: str | None) -> bool: + """Check a connection's Origin header against the CORS config. + + Args: + origin: The Origin header value, if any. + + Returns: + Whether the connection is allowed. + """ + if origin is None: + # Non-browser clients don't send an Origin header. + return True + allowed_origins = get_config().cors_allowed_origins + return "*" in allowed_origins or origin in allowed_origins + + async def handle_websocket(self, websocket: WebSocket) -> None: + """Serve one client websocket connection for its full lifetime. + + Args: + websocket: The client websocket connection. + """ + if not self._origin_allowed(websocket.headers.get("origin")): + # Reject cross-origin connections before accepting (CSWSH parity + # with the Socket.IO transport's origin check). + await websocket.close(code=1008) + return + subprotocols = websocket.scope.get("subprotocols") or [] + # Echo the client's offered subprotocol (the Reflex version); browsers + # abort the connection if the server selects none. + await websocket.accept(subprotocol=subprotocols[0] if subprotocols else None) + + sid = str(uuid.uuid4()) + ping_interval = environment.REFLEX_SOCKET_INTERVAL.get() + ping_timeout = environment.REFLEX_SOCKET_TIMEOUT.get() + max_message_size = environment.REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE.get() + self._sockets[sid] = websocket + last_received = time.monotonic() + + async def heartbeat() -> None: + try: + while True: + await asyncio.sleep(ping_interval) + if time.monotonic() - last_received > ping_interval + ping_timeout: + await websocket.close(code=1001) + return + await websocket.send_text(format.json_dumps([PING_MESSAGE])) + except Exception: + # Socket went away; the receive loop handles cleanup. + return + + heartbeat_task = asyncio.create_task( + heartbeat(), name=f"reflex_heartbeat|{sid}" + ) + try: + # The handshake confirms application-level liveness and carries the + # heartbeat settings for the client's connection watchdog. + await websocket.send_text( + format.json_dumps([ + HANDSHAKE_MESSAGE, + {"ping_interval": ping_interval, "ping_timeout": ping_timeout}, + ]) + ) + await self.handle_connect( + sid, + websocket.scope.get("query_string", b"").decode(), + subprotocols[0] if subprotocols else None, + ) + while True: + text = await websocket.receive_text() + last_received = time.monotonic() + if len(text) > max_message_size: + await websocket.close(code=1009) + break + try: + message = json.loads(text) + except json.JSONDecodeError: + logger.warning(f"Ignoring malformed message from session {sid}.") + continue + if ( + not isinstance(message, list) + or not message + or not isinstance(message[0], str) + ): + logger.warning(f"Ignoring malformed message from session {sid}.") + continue + event = message[0] + data = message[1] if len(message) > 1 else None + if event == PONG_MESSAGE: + continue + try: + if event == str(constants.SocketEvent.EVENT): + await self.handle_event(sid, data, websocket.scope) + elif event == str(constants.SocketEvent.PING): + await self.handle_ping(sid) + elif event == str(constants.SocketEvent.CLIENT_ERROR): + await self.handle_client_error(sid, data) + else: + logger.debug( + f"Ignoring unknown socket event {event!r} from session {sid}." + ) + except Exception: + # Match Socket.IO behavior: a failing handler is logged and + # the connection survives. + logger.exception( + f"Error handling socket event {event!r} for session {sid}." + ) + except WebSocketDisconnect: + pass + finally: + heartbeat_task.cancel() + self._sockets.pop(sid, None) + self.handle_disconnect(sid) diff --git a/reflex/socketio_namespace.py b/reflex/socketio_namespace.py new file mode 100644 index 00000000000..9ba9337d374 --- /dev/null +++ b/reflex/socketio_namespace.py @@ -0,0 +1,185 @@ +"""Socket.IO event transport (requires the optional python-socketio dependency).""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any + +from reflex_base.environment import environment +from reflex_base.utils.types import ASGIApp, Message, Receive, Scope, Send +from socketio import ASGIApp as EngineIOApp +from socketio import AsyncNamespace, AsyncServer + +from reflex.event_namespace import BaseEventNamespace +from reflex.utils import format + +if TYPE_CHECKING: + import asyncio + + from reflex_base.config import Config + + from reflex.app import App + + +class EventNamespace(AsyncNamespace, BaseEventNamespace): + """The Socket.IO event namespace.""" + + def __init__(self, namespace: str, app: App): + """Initialize the event namespace. + + Args: + namespace: The namespace. + app: The application object. + """ + AsyncNamespace.__init__(self, namespace) + BaseEventNamespace.__init__(self, namespace, app) + + async def on_connect(self, sid: str, environ: dict): + """Event for when the websocket is connected. + + Args: + sid: The Socket.IO session id. + environ: The request information, including HTTP headers. + """ + await self.handle_connect( + sid, + environ.get("QUERY_STRING", ""), + environ.get("HTTP_SEC_WEBSOCKET_PROTOCOL"), + ) + + def on_disconnect(self, sid: str) -> asyncio.Task | None: + """Event for when the websocket disconnects. + + Args: + sid: The Socket.IO session id. + + Returns: + An asyncio Task for cleaning up the token, or None. + """ + return self.handle_disconnect(sid) + + async def on_event(self, sid: str, data: Any): + """Event for receiving front-end websocket events. + + Args: + sid: The Socket.IO session id. + data: The event data. + + Raises: + RuntimeError: If the Socket.IO is badly initialized. + """ + if self.app.sio is None: + msg = "Socket.IO is not initialized." + raise RuntimeError(msg) + environ = self.app.sio.get_environ(sid, self.namespace) + if environ is None: + msg = "Socket.IO environ is not initialized." + raise RuntimeError(msg) + await self.handle_event(sid, data, environ["asgi.scope"]) + + async def on_ping(self, sid: str): + """Event for testing the API endpoint. + + Args: + sid: The Socket.IO session id. + """ + await self.handle_ping(sid) + + async def on_client_error(self, sid: str, data: Any): + """Handle errors reported by the frontend. + + Args: + sid: The Socket.IO session id. + data: The error data from the client. + """ + await self.handle_client_error(sid, data) + + +class _HeaderMiddleware: + """Echo the websocket subprotocol on accept, which engineio does not.""" + + def __init__(self, app: ASGIApp): + """Initialize the middleware. + + Args: + app: The ASGI app to wrap. + """ + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send): + """Handle an ASGI connection. + + Args: + scope: The ASGI scope. + receive: The ASGI receive callable. + send: The ASGI send callable. + + Returns: + The result of the wrapped app. + """ + original_send = send + + async def modified_send(message: Message): + if message["type"] == "websocket.accept": + if scope.get("subprotocols"): + # The following *does* say "subprotocol" instead of "subprotocols", intentionally. + message["subprotocol"] = scope["subprotocols"][0] + + headers = dict(message.get("headers", [])) + header_key = b"sec-websocket-protocol" + if subprotocol := headers.get(header_key): + message["headers"] = [ + *message.get("headers", []), + (header_key, subprotocol), + ] + + return await original_send(message) + + return await self.app(scope, receive, modified_send) + + +def create_socketio_app(app: App, config: Config) -> ASGIApp: + """Create the Socket.IO server for an app and return its ASGI app. + + Creates ``app.sio`` if the user did not supply their own server. + + Args: + app: The Reflex app. + config: The app configuration. + + Returns: + The ASGI app serving the Socket.IO server. + + Raises: + RuntimeError: If a custom ``sio`` server does not use asgi mode. + """ + if not app.sio: + app.sio = AsyncServer( + async_mode="asgi", + cors_allowed_origins=( + ( + "*" + if config.cors_allowed_origins == ("*",) + else list(config.cors_allowed_origins) + ) + if config.transport != "polling" + else [] + ), + cors_credentials=config.transport != "polling", + max_http_buffer_size=environment.REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE.get(), + ping_interval=environment.REFLEX_SOCKET_INTERVAL.get(), + ping_timeout=environment.REFLEX_SOCKET_TIMEOUT.get(), + json=SimpleNamespace( + dumps=staticmethod(format.json_dumps), + loads=staticmethod(json.loads), + ), + allow_upgrades=False, + transports=["polling" if config.transport == "polling" else "websocket"], + ) + elif getattr(app.sio, "async_mode", "") != "asgi": + msg = f"Custom `sio` must use `async_mode='asgi'`, not '{app.sio.async_mode}'." + raise RuntimeError(msg) + + # Create the socket app. Note event endpoint constant replaces the default 'socket.io' path. + return _HeaderMiddleware(EngineIOApp(app.sio, socketio_path="")) diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 484796337a5..7b46e069d79 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -4069,7 +4069,7 @@ def emit(self, record: logging.LogRecord): if key is not None: captured[key].append(record.getMessage()) - app_logger = logging.getLogger("reflex.app") + app_logger = logging.getLogger("reflex.event_namespace") handler = _CaptureHandler(level=logging.DEBUG) previous_level = app_logger.level app_logger.addHandler(handler) diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py new file mode 100644 index 00000000000..d323de4da2c --- /dev/null +++ b/tests/units/test_event_namespace.py @@ -0,0 +1,407 @@ +"""Tests for the plain WebSocket event transport in reflex/event_namespace.py.""" + +import asyncio +import json +from typing import Any +from unittest.mock import AsyncMock, Mock + +import pytest +from starlette.routing import WebSocketRoute +from starlette.websockets import WebSocketDisconnect + +from reflex.app import App +from reflex.event_namespace import ( + HANDSHAKE_MESSAGE, + PONG_MESSAGE, + WebsocketEventNamespace, +) + +_DISCONNECT = object() + + +class FakeWebSocket: + """Minimal stand-in for a starlette WebSocket.""" + + def __init__( + self, + query_string: bytes = b"token=tok1", + origin: str | None = None, + subprotocols: list[str] | None = None, + ): + """Initialize the fake websocket. + + Args: + query_string: The raw query string of the connection. + origin: The Origin header value, if any. + subprotocols: The offered subprotocols. + """ + self.scope: dict[str, Any] = { + "type": "websocket", + "query_string": query_string, + "subprotocols": subprotocols or [], + "headers": [(b"host", b"localhost")], + "client": ("127.0.0.1", 1234), + } + self.headers = {"origin": origin} if origin is not None else {} + self.sent: list[Any] = [] + self.accepted_subprotocol: str | None = None + self.accepted = False + self.close_code: int | None = None + self._incoming: asyncio.Queue = asyncio.Queue() + + async def accept(self, subprotocol: str | None = None): + """Record the accept call. + + Args: + subprotocol: The selected subprotocol. + """ + self.accepted = True + self.accepted_subprotocol = subprotocol + + async def send_text(self, text: str): + """Record an outgoing frame. + + Args: + text: The frame text. + """ + self.sent.append(json.loads(text)) + + async def close(self, code: int = 1000): + """Record the close call. + + Args: + code: The close code. + """ + self.close_code = code + + async def receive_text(self) -> str: + """Return the next queued frame. + + Returns: + The frame text. + + Raises: + WebSocketDisconnect: When the disconnect sentinel is reached. + """ + item = await self._incoming.get() + if item is _DISCONNECT: + raise WebSocketDisconnect(1000) + return item + + def feed(self, *frames: Any): + """Queue incoming frames (lists are JSON-encoded) and a disconnect.""" + for frame in frames: + self._incoming.put_nowait( + frame if isinstance(frame, str) else json.dumps(frame) + ) + self._incoming.put_nowait(_DISCONNECT) + + +@pytest.fixture +def mock_app() -> Mock: + """A mock app for the event namespace. + + Returns: + The mock app. + """ + app = Mock() + app._state = None + app.router = Mock(return_value=None) + app.event_processor.enqueue = AsyncMock() + return app + + +@pytest.fixture +def namespace(mock_app: Mock) -> WebsocketEventNamespace: + """A websocket event namespace with a mock app. + + Args: + mock_app: The mock app. + + Returns: + The namespace. + """ + return WebsocketEventNamespace("/_event", mock_app) + + +async def _drain_tasks(): + """Let pending disconnect-cleanup tasks run to completion.""" + for _ in range(3): + await asyncio.sleep(0) + + +@pytest.mark.asyncio +async def test_handshake_and_token_link(namespace: WebsocketEventNamespace): + """The server sends the handshake first and links the token from the query. + + Args: + namespace: The websocket event namespace. + """ + websocket = FakeWebSocket(subprotocols=["0.0.1"]) + websocket.feed() + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + + assert websocket.accepted + assert websocket.accepted_subprotocol == "0.0.1" + assert websocket.sent[0][0] == HANDSHAKE_MESSAGE + assert set(websocket.sent[0][1]) == {"ping_interval", "ping_timeout"} + await _drain_tasks() + # The session was linked and unlinked again on disconnect. + assert "tok1" not in namespace.token_to_sid + + +@pytest.mark.asyncio +async def test_event_is_enqueued(namespace: WebsocketEventNamespace, mock_app: Mock): + """An incoming event frame reaches the app's event processor. + + Args: + namespace: The websocket event namespace. + mock_app: The mock app. + """ + websocket = FakeWebSocket() + websocket.feed([ + "event", + {"token": "tok1", "name": "state.on_click", "payload": {}, "router_data": {}}, + ]) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + mock_app.event_processor.enqueue.assert_awaited_once() + token, event = mock_app.event_processor.enqueue.await_args.args + assert token == "tok1" + assert event.name == "state.on_click" + assert event.router_data["headers"]["host"] == "localhost" + assert event.router_data["ip"] == "127.0.0.1" + + +@pytest.mark.asyncio +async def test_ping_pong(namespace: WebsocketEventNamespace): + """An application-level ping event gets a pong reply. + + Args: + namespace: The websocket event namespace. + """ + websocket = FakeWebSocket() + websocket.feed(["ping"], [PONG_MESSAGE]) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert ["ping", "pong"] in websocket.sent + + +@pytest.mark.asyncio +async def test_client_error_reaches_exception_handler( + namespace: WebsocketEventNamespace, mock_app: Mock +): + """A client_error frame is routed to the frontend exception handler. + + Args: + namespace: The websocket event namespace. + mock_app: The mock app. + """ + errors: list[str] = [] + mock_app.frontend_exception_handler = lambda exc: errors.append(str(exc)) + websocket = FakeWebSocket() + websocket.feed(["client_error", {"error_type": "boom", "message": "it broke"}]) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert len(errors) == 1 + assert "it broke" in errors[0] + + +@pytest.mark.asyncio +async def test_malformed_frames_are_ignored( + namespace: WebsocketEventNamespace, mock_app: Mock +): + """Malformed frames are skipped without dropping the connection. + + Args: + namespace: The websocket event namespace. + mock_app: The mock app. + """ + websocket = FakeWebSocket() + websocket.feed( + "not json", + '{"an": "object"}', + [42], + ["ping"], + ) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + # The valid ping after the malformed frames was still processed. + assert ["ping", "pong"] in websocket.sent + assert websocket.close_code is None + + +@pytest.mark.asyncio +async def test_oversize_message_closes_connection( + namespace: WebsocketEventNamespace, monkeypatch: pytest.MonkeyPatch +): + """A frame over the size limit closes the connection with 1009. + + Args: + namespace: The websocket event namespace. + monkeypatch: The pytest monkeypatch fixture. + """ + monkeypatch.setenv("REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE", "10") + websocket = FakeWebSocket() + websocket.feed(["event", {"payload": "x" * 100}]) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert websocket.close_code == 1009 + + +@pytest.mark.asyncio +async def test_disallowed_origin_is_rejected( + namespace: WebsocketEventNamespace, mocker +): + """A cross-origin connection is closed before being accepted. + + Args: + namespace: The websocket event namespace. + mocker: The pytest-mock fixture. + """ + from reflex_base.config import get_config + + mocker.patch.object( + get_config(), "cors_allowed_origins", ("https://allowed.example",) + ) + websocket = FakeWebSocket(origin="https://evil.example") + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + + assert not websocket.accepted + assert websocket.close_code == 1008 + + +@pytest.mark.asyncio +async def test_allowed_origin_is_accepted(namespace: WebsocketEventNamespace, mocker): + """A connection from an allowed origin is accepted. + + Args: + namespace: The websocket event namespace. + mocker: The pytest-mock fixture. + """ + from reflex_base.config import get_config + + mocker.patch.object( + get_config(), "cors_allowed_origins", ("https://allowed.example",) + ) + websocket = FakeWebSocket(origin="https://allowed.example") + websocket.feed() + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert websocket.accepted + + +@pytest.mark.asyncio +async def test_duplicate_token_gets_new_token(namespace: WebsocketEventNamespace): + """A second tab connecting with the same token receives a new_token frame. + + Args: + namespace: The websocket event namespace. + """ + first = FakeWebSocket() + second = FakeWebSocket() + namespace._sockets["sid1"] = first # pyright: ignore[reportArgumentType] + namespace._sockets["sid2"] = second # pyright: ignore[reportArgumentType] + await namespace.link_token_to_sid("sid1", "tok1") + await namespace.link_token_to_sid("sid2", "tok1") + + new_token_frames = [frame for frame in second.sent if frame[0] == "new_token"] + assert len(new_token_frames) == 1 + assert new_token_frames[0][1] != "tok1" + + +@pytest.mark.asyncio +async def test_emit_to_unknown_sid_does_not_raise( + namespace: WebsocketEventNamespace, +): + """Emitting to a session that went away is a no-op. + + Args: + namespace: The websocket event namespace. + """ + await namespace.emit("event", {"delta": {}}, to="gone") + + +def test_default_transport_uses_websocket_namespace(): + """The default transport sets up the plain websocket namespace.""" + app = App(enable_state=True) + assert isinstance(app.event_namespace, WebsocketEventNamespace) + assert app.sio is None + assert app._api is not None + websocket_routes = [ + route for route in app._api.router.routes if isinstance(route, WebSocketRoute) + ] + assert [route.path for route in websocket_routes] == ["/_event"] + + +def test_socketio_transport_uses_socketio_namespace( + monkeypatch: pytest.MonkeyPatch, +): + """transport="socketio" sets up the Socket.IO server and namespace. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ + from reflex.socketio_namespace import EventNamespace + + monkeypatch.setenv("REFLEX_TRANSPORT", "socketio") + app = App(enable_state=True) + assert isinstance(app.event_namespace, EventNamespace) + assert app.sio is not None + # Plain websocket transport under the hood. + assert app.sio.eio.transports == ["websocket"] + + +def test_polling_transport_uses_socketio_namespace( + monkeypatch: pytest.MonkeyPatch, +): + """transport="polling" sets up the Socket.IO server with polling only. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ + from reflex.socketio_namespace import EventNamespace + + monkeypatch.setenv("REFLEX_TRANSPORT", "polling") + app = App(enable_state=True) + assert isinstance(app.event_namespace, EventNamespace) + assert app.sio is not None + assert app.sio.eio.transports == ["polling"] + + +def test_custom_sio_requires_socketio_transport(): + """A custom sio server with the default transport raises a clear error.""" + from socketio import AsyncServer + + with pytest.raises(RuntimeError, match=r"requires the Socket\.IO transport"): + App(sio=AsyncServer(async_mode="asgi")) + + +def test_custom_sio_with_socketio_transport(monkeypatch: pytest.MonkeyPatch): + """A custom sio server works with the Socket.IO transport. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ + from socketio import AsyncServer + + monkeypatch.setenv("REFLEX_TRANSPORT", "socketio") + sio = AsyncServer(async_mode="asgi") + app = App(sio=sio) + assert app.sio is sio + + +def test_app_event_namespace_reexport(): + """reflex.app.EventNamespace still resolves to the Socket.IO namespace.""" + import reflex.app + from reflex.socketio_namespace import EventNamespace + + assert reflex.app.EventNamespace is EventNamespace + with pytest.raises(AttributeError): + _ = reflex.app.DoesNotExist diff --git a/uv.lock b/uv.lock index ab4dfd64387..df873c81aa4 100644 --- a/uv.lock +++ b/uv.lock @@ -585,7 +585,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -663,7 +663,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } @@ -1003,7 +1003,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1879,15 +1879,15 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "cycler", marker = "python_full_version < '3.11'" }, - { name = "fonttools", marker = "python_full_version < '3.11'" }, - { name = "kiwisolver", marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "pillow", marker = "python_full_version < '3.11'" }, - { name = "pyparsing", marker = "python_full_version < '3.11'" }, - { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" } }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } wheels = [ @@ -1963,16 +1963,16 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "cycler", marker = "python_full_version >= '3.11'" }, - { name = "fonttools", marker = "python_full_version >= '3.11'" }, - { name = "kiwisolver", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" } }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pillow", marker = "python_full_version >= '3.11'" }, - { name = "pyparsing", marker = "python_full_version >= '3.11'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/49/64/f9a391af28f518b11ad45a8a712353c94a0aefce09d3703200e5c54b610a/matplotlib-3.11.1.tar.gz", hash = "sha256:69647db5746941c793d6e445a4cd349323ffb87d9cc958c2ad84a659b4832d30", size = 32612045, upload-time = "2026-07-18T03:39:46.63Z" } wheels = [ @@ -2534,10 +2534,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "python-dateutil", marker = "python_full_version < '3.11'" }, - { name = "pytz", marker = "python_full_version < '3.11'" }, - { name = "tzdata", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -2606,10 +2606,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, - { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } wheels = [ @@ -3645,7 +3645,6 @@ dependencies = [ { name = "packaging" }, { name = "psutil", marker = "sys_platform == 'win32'" }, { name = "python-multipart" }, - { name = "python-socketio" }, { name = "redis" }, { name = "reflex-base" }, { name = "reflex-components-code" }, @@ -3676,6 +3675,9 @@ db = [ pydantic = [ { name = "reflex-base", extra = ["pydantic"] }, ] +socketio = [ + { name = "python-socketio" }, +] [package.dev-dependencies] dev = [ @@ -3710,6 +3712,7 @@ dev = [ { name = "pytest-rerunfailures" }, { name = "pytest-split" }, { name = "python-dotenv" }, + { name = "python-socketio" }, { name = "pyyaml" }, { name = "reflex-docgen" }, { name = "reflex-release" }, @@ -3736,7 +3739,7 @@ requires-dist = [ { name = "psutil", marker = "sys_platform == 'win32'", specifier = ">=7.0.0,<8.0" }, { name = "pydantic", marker = "extra == 'db'", specifier = ">=2.12.0,<3.0" }, { name = "python-multipart", specifier = ">=0.0.32,<1.0" }, - { name = "python-socketio", specifier = ">=5.12.0,<6.0" }, + { name = "python-socketio", marker = "extra == 'socketio'", specifier = ">=5.12.0,<6.0" }, { name = "redis", specifier = ">=6.4,<8.0" }, { name = "reflex-base", editable = "packages/reflex-base" }, { name = "reflex-base", extras = ["pydantic"], marker = "extra == 'pydantic'", editable = "packages/reflex-base" }, @@ -3759,7 +3762,7 @@ requires-dist = [ { name = "typing-extensions", specifier = ">=4.13.0" }, { name = "wrapt", specifier = ">=1.17.0,<2.2" }, ] -provides-extras = ["db", "pydantic"] +provides-extras = ["db", "pydantic", "socketio"] [package.metadata.requires-dev] dev = [ @@ -3791,6 +3794,7 @@ dev = [ { name = "pytest-rerunfailures" }, { name = "pytest-split" }, { name = "python-dotenv" }, + { name = "python-socketio" }, { name = "pyyaml" }, { name = "reflex-docgen", editable = "packages/reflex-docgen" }, { name = "reflex-release", editable = "packages/reflex-release" }, @@ -4306,7 +4310,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -4367,7 +4371,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -4446,7 +4450,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [ From b6e1c629de03702b759c50fad2be42fd10d833d1 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 22 Aug 2026 23:05:42 +0200 Subject: [PATCH 02/44] dry it up --- .../.templates/web/utils/helpers/websocket.js | 35 ++++++++++++------- .../reflex_base/.templates/web/utils/state.js | 22 +++--------- 2 files changed, 27 insertions(+), 30 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js index 3778b845a27..4f185caca3c 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js @@ -21,26 +21,28 @@ const NON_FINITE_REPLACEMENTS = { "-Infinity": "-1e999", NaN: `"${NAN_SENTINEL}"`, }; -export const rewriteBareNonFiniteFloats = (str) => +const rewriteBareNonFiniteFloats = (str) => str.replace(NON_FINITE_FLOAT_RE, (match) => match[0] === '"' ? match : NON_FINITE_REPLACEMENTS[match], ); -export const reviveNonFiniteFloats = (_k, v) => (v === NAN_SENTINEL ? NaN : v); +const reviveNonFiniteFloats = (_k, v) => (v === NAN_SENTINEL ? NaN : v); /** - * Serialize an outgoing frame, sending undefined fields as null. - * @param frame The frame array to serialize. - * @returns The JSON string. + * JSON.stringify replacer that sends undefined fields as null instead of + * removing them. Also assigned as the socket.io encoder replacer. + * @param _k The key being serialized. + * @param v The value being serialized. + * @returns The value to serialize. */ -const stringifyFrame = (frame) => - JSON.stringify(frame, (k, v) => (v === undefined ? null : v)); +export const undefinedToNull = (_k, v) => (v === undefined ? null : v); /** - * Parse an incoming frame, tolerating bare non-finite float tokens. - * @param text The raw frame text. - * @returns The parsed frame, or undefined if unparsable. + * Parse JSON, tolerating bare non-finite float tokens. + * @param text The text to parse. + * @param fallback The value to return if the text is unparsable. + * @returns The parsed value, or the fallback. */ -const parseFrame = (text) => { +export const parseJsonLenient = (text, fallback) => { try { return JSON.parse(text); } catch (e) { @@ -50,11 +52,18 @@ const parseFrame = (text) => { reviveNonFiniteFloats, ); } catch (e2) { - return undefined; + return fallback; } } }; +/** + * Serialize an outgoing frame. + * @param frame The frame array to serialize. + * @returns The JSON string. + */ +const stringifyFrame = (frame) => JSON.stringify(frame, undefinedToNull); + export class ReflexWebSocket { /** * Create the transport and start connecting (like socket.io's `io()`). @@ -193,7 +202,7 @@ export class ReflexWebSocket { */ _onMessage(text) { this._resetWatchdog(); - const message = parseFrame(text); + const message = parseJsonLenient(text, undefined); if (!Array.isArray(message)) { console.error("Failed to parse websocket message", text); return; diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index d4bd60c9553..4ed06ed11a1 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -21,8 +21,8 @@ import throttle from "$/utils/helpers/throttle"; import { uploadFiles } from "$/utils/helpers/upload"; import { ReflexWebSocket, - rewriteBareNonFiniteFloats, - reviveNonFiniteFloats, + parseJsonLenient, + undefinedToNull, } from "$/utils/helpers/websocket"; // Endpoint URLs. @@ -595,21 +595,9 @@ export const connect = async ( reconnection: false, // Reconnection will be handled manually. }); // Ensure undefined fields in events are sent as null instead of removed - socket.current.io.encoder.replacer = (k, v) => (v === undefined ? null : v); - socket.current.io.decoder.tryParse = (str) => { - try { - return JSON.parse(str); - } catch (e) { - try { - return JSON.parse( - rewriteBareNonFiniteFloats(str), - reviveNonFiniteFloats, - ); - } catch (e2) { - return false; - } - } - }; + socket.current.io.encoder.replacer = undefinedToNull; + // The decoder API expects false (not undefined) for unparsable input. + socket.current.io.decoder.tryParse = (str) => parseJsonLenient(str, false); } socket.current.wait_connect = !socket.current.connected; // Set up a reconnect helper function From 3055a853650f48f94c150261aa1db43cf4fa65c2 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 22 Aug 2026 23:18:18 +0200 Subject: [PATCH 03/44] fix ci --- tests/units/test_event_namespace.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index d323de4da2c..020ccecb742 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -112,15 +112,21 @@ def mock_app() -> Mock: @pytest.fixture -def namespace(mock_app: Mock) -> WebsocketEventNamespace: - """A websocket event namespace with a mock app. +def namespace(mock_app: Mock, mocker) -> WebsocketEventNamespace: + """A websocket event namespace with a mock app and a local token manager. + + Redis is disabled so token linking stays in-process: these tests must not + write session records into a shared Redis instance (which would leak into + other tests) or depend on Redis I/O timing for disconnect cleanup. Args: mock_app: The mock app. + mocker: The pytest-mock fixture. Returns: The namespace. """ + mocker.patch("reflex.utils.prerequisites.check_redis_used", return_value=False) return WebsocketEventNamespace("/_event", mock_app) From 44f6f24bb4655f514dbff55fcba09c0db0f2d355 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 22 Aug 2026 23:23:48 +0200 Subject: [PATCH 04/44] review comments --- .../.templates/web/utils/helpers/websocket.js | 13 +++--- reflex/event_namespace.py | 10 ++++- reflex/testing.py | 1 + reflex/utils/exec.py | 7 +++ tests/units/test_event_namespace.py | 44 +++++++++++++++++++ 5 files changed, 69 insertions(+), 6 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js index 4f185caca3c..9d0264cbdec 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js @@ -84,11 +84,13 @@ export class ReflexWebSocket { // socket.io's packet buffering. this._sendQueue = []; this._watchdogTimer = null; - // Heartbeat window; refined by the server handshake. + // Heartbeat window: 145 seconds (25s ping interval + 120s ping timeout, + // mirroring the server defaults) in ms; refined by the server handshake. this._watchdogMs = (25 + 120) * 1000; - // Give up on a dial that neither opens nor errors (like socket.io's - // connect timeout), so a connect_error always fires and retries proceed. - this._connectTimeoutMs = 20000; + // Give up after 20 seconds on a dial that neither opens nor errors (like + // socket.io's connect timeout), so a connect_error always fires and + // retries proceed. + this._connectTimeoutMs = 20 * 1000; this._connectTimer = null; this._closeReason = null; this.connect(); @@ -214,7 +216,8 @@ export class ReflexWebSocket { } if (event === HANDSHAKE_MESSAGE) { // Application-level liveness confirmed; adopt the server's heartbeat - // settings for the connection watchdog. + // settings (sent in seconds, converted to ms) for the connection + // watchdog. this._clearConnectTimer(); this._watchdogMs = (payload.ping_interval + payload.ping_timeout) * 1000; this._resetWatchdog(); diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index 963e8cc48bd..b49f94120f8 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -483,7 +483,15 @@ async def heartbeat() -> None: while True: text = await websocket.receive_text() last_received = time.monotonic() - if len(text) > max_message_size: + # The limit is in bytes; UTF-8 encodes 1-4 bytes per character, + # so more characters than the limit is certainly over, and a + # quarter or fewer certainly under -- only encode to count the + # exact bytes in between. + text_length = len(text) + if text_length > max_message_size or ( + text_length * 4 > max_message_size + and len(text.encode("utf-8")) > max_message_size + ): await websocket.close(code=1009) break try: diff --git a/reflex/testing.py b/reflex/testing.py index e684353e0c7..22197c6be9a 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -345,6 +345,7 @@ def _start_backend(self, port: int = 0): app=self.app_asgi, host="127.0.0.1", port=port, + ws_max_size=environment.REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE.get(), ) ) self.backend.shutdown = self._get_backend_shutdown_handler() diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 780e86d7473..b48eaf70017 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -630,6 +630,8 @@ def run_uvicorn_backend(host: str, port: int, loglevel: LogLevel): reload=True, reload_dirs=list(map(str, get_reload_paths())), reload_delay=0.1, + # Enforce the websocket message size limit before buffering the frame. + ws_max_size=environment.REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE.get(), ) @@ -749,6 +751,11 @@ def run_uvicorn_backend_prod( *("--host", host), *("--port", str(port)), *("--workers", str(_get_backend_workers())), + # Enforce the websocket message size limit before buffering the frame. + *( + "--ws-max-size", + str(environment.REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE.get()), + ), "--factory", app_module, ] diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 020ccecb742..78e5c63dab4 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -260,6 +260,50 @@ async def test_oversize_message_closes_connection( assert websocket.close_code == 1009 +@pytest.mark.asyncio +async def test_oversize_multibyte_message_closes_connection( + namespace: WebsocketEventNamespace, monkeypatch: pytest.MonkeyPatch +): + """The size limit counts bytes, so multibyte text cannot sneak past it. + + Args: + namespace: The websocket event namespace. + monkeypatch: The pytest monkeypatch fixture. + """ + monkeypatch.setenv("REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE", "25") + # 15 characters (under the limit) but 29 UTF-8 bytes (over it). + frame = '["x","€€€€€€€"]' + assert len(frame) <= 25 < len(frame.encode("utf-8")) + websocket = FakeWebSocket() + websocket.feed(frame) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert websocket.close_code == 1009 + + +@pytest.mark.asyncio +async def test_multibyte_message_within_limit_is_processed( + namespace: WebsocketEventNamespace, monkeypatch: pytest.MonkeyPatch +): + """Multibyte frames within the byte limit pass through the exact check. + + Args: + namespace: The websocket event namespace. + monkeypatch: The pytest monkeypatch fixture. + """ + # 12 characters, 14 bytes: over limit/4 (triggers the exact byte count) + # but within the limit itself. + monkeypatch.setenv("REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE", "14") + websocket = FakeWebSocket() + websocket.feed('["ping","€"]') + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert websocket.close_code is None + assert ["ping", "pong"] in websocket.sent + + @pytest.mark.asyncio async def test_disallowed_origin_is_rejected( namespace: WebsocketEventNamespace, mocker From 2b755737dd011b02c23463fad5d7539ef1aa2ebe Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 22 Aug 2026 23:37:44 +0200 Subject: [PATCH 05/44] fix(events): don't attach unrelated events to a finished stream-delta future --- news/6932.bugfix.md | 1 + .../event/processor/event_processor.py | 7 +++-- reflex/event_namespace.py | 5 ++-- .../event/processor/test_event_processor.py | 28 +++++++++++++++++++ tests/units/test_event_namespace.py | 4 +-- 5 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 news/6932.bugfix.md diff --git a/news/6932.bugfix.md b/news/6932.bugfix.md new file mode 100644 index 00000000000..b37302c397d --- /dev/null +++ b/news/6932.bugfix.md @@ -0,0 +1 @@ +`enqueue_stream_delta` (used by streaming uploads) no longer registers its event future under the root context's txid. Previously, any unrelated event enqueued while a streamed upload's future still lingered was spuriously attached to it as a child, failing with "Cannot add a child to an EventFuture that is already done" once the upload finished — a latent race that Socket.IO's extra latency usually hid. diff --git a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py index 7559c232076..873dcc590ec 100644 --- a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py +++ b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py @@ -473,9 +473,12 @@ async def _emit_delta_impl( task_future = await self.enqueue( token, event, + # Fork for a fresh txid: replacing on the root context would keep + # its txid, registering this future under it, and every event that + # later forks from the root context would attach to this stream as + # a child (raising once the stream's future is done). ev_ctx=dataclasses.replace( - self._root_context, - token=token, + self._root_context.fork(token=token), emit_delta_impl=_emit_delta_impl, ), ) diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index b49f94120f8..9052f77c016 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -378,10 +378,9 @@ async def link_token_to_sid(self, sid: str, token: str): class WebsocketEventNamespace(BaseEventNamespace): - """Default event transport speaking JSON frames over a plain WebSocket. + """Default event transport over a plain WebSocket. - Each message is a JSON array ``[event_name, payload]``. Heartbeats and the - initial handshake use reserved underscore-prefixed message names. + Frames are JSON arrays ``[event_name, payload]``. """ def __init__(self, namespace: str, app: App): diff --git a/tests/units/reflex_base/event/processor/test_event_processor.py b/tests/units/reflex_base/event/processor/test_event_processor.py index d5dda19dca3..80afc1f8514 100644 --- a/tests/units/reflex_base/event/processor/test_event_processor.py +++ b/tests/units/reflex_base/event/processor/test_event_processor.py @@ -611,6 +611,34 @@ async def test_stream_delta_noop_handler_yields_nothing(token: str): assert collected == [] +async def test_stream_delta_future_does_not_claim_root_txid(token: str): + """Regression: a streamed event must not reuse the root context's txid. + + A stream future registered under the root txid captured unrelated events + as children, raising once the stream was done (#6932). + + Args: + token: The client token. + """ + ep = EventProcessor(graceful_shutdown_timeout=2) + ep.configure() + assert ep._root_context is not None + root_txid = ep._root_context.txid + async with ep: + event = Event.from_event_type(delta_event())[0] + root_txid_futures = [] + parents = [] + async for _ in ep.enqueue_stream_delta(token, event): + root_txid_futures.append(ep._futures.get(root_txid)) + unrelated = await ep.enqueue(token, Event.from_event_type(noop_event())[0]) + parents.append(unrelated.parent) + assert root_txid_futures + assert all(f is None for f in root_txid_futures) + assert parents + assert all(parent is None for parent in parents) + await ep.join(timeout=5) + + async def test_stream_delta_not_configured_raises(): """enqueue_stream_delta raises RuntimeError if processor is not configured.""" ep = EventProcessor() diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 78e5c63dab4..763d8937580 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -115,9 +115,7 @@ def mock_app() -> Mock: def namespace(mock_app: Mock, mocker) -> WebsocketEventNamespace: """A websocket event namespace with a mock app and a local token manager. - Redis is disabled so token linking stays in-process: these tests must not - write session records into a shared Redis instance (which would leak into - other tests) or depend on Redis I/O timing for disconnect cleanup. + Redis is disabled so token linking cannot leak into a shared Redis. Args: mock_app: The mock app. From 6702e65a838ed02385ff8a98e16595fa9521ca60 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 22 Aug 2026 23:49:29 +0200 Subject: [PATCH 06/44] news --- news/{6920.feature.md => 6932.feature.md} | 0 {news => packages/reflex-base/news}/6932.bugfix.md | 0 packages/reflex-base/news/6932.feature.md | 1 + 3 files changed, 1 insertion(+) rename news/{6920.feature.md => 6932.feature.md} (100%) rename {news => packages/reflex-base/news}/6932.bugfix.md (100%) create mode 100644 packages/reflex-base/news/6932.feature.md diff --git a/news/6920.feature.md b/news/6932.feature.md similarity index 100% rename from news/6920.feature.md rename to news/6932.feature.md diff --git a/news/6932.bugfix.md b/packages/reflex-base/news/6932.bugfix.md similarity index 100% rename from news/6932.bugfix.md rename to packages/reflex-base/news/6932.bugfix.md diff --git a/packages/reflex-base/news/6932.feature.md b/packages/reflex-base/news/6932.feature.md new file mode 100644 index 00000000000..4f4ed3e1661 --- /dev/null +++ b/packages/reflex-base/news/6932.feature.md @@ -0,0 +1 @@ +The default client transport is a plain WebSocket speaking JSON `[event_name, payload]` frames. `config.transport` gains a `"socketio"` value; socket.io-client is only loaded in the browser when `"socketio"` or `"polling"` is configured. From 38afa28c008e91856609012228492e49ec9b77af Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 23 Aug 2026 00:00:59 +0200 Subject: [PATCH 07/44] fix: detect browser offline on the plain websocket transport --- .../.templates/web/utils/helpers/websocket.js | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js index 9d0264cbdec..97ff3cc2178 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js @@ -93,6 +93,15 @@ export class ReflexWebSocket { this._connectTimeoutMs = 20 * 1000; this._connectTimer = null; this._closeReason = null; + // Network emulation and OS offline do not interrupt established + // websockets, so (like engine.io-client) treat the browser's offline + // event as a disconnect. Localhost connections keep working offline. + if ( + typeof addEventListener === "function" && + this._url.hostname !== "localhost" + ) { + addEventListener("offline", () => this._onOffline(), false); + } this.connect(); } @@ -164,6 +173,28 @@ export class ReflexWebSocket { * Close the connection deliberately (reason "io client disconnect"). */ disconnect() { + this._teardown("io client disconnect", undefined); + } + + /** + * Handle the browser going offline: report the disconnect immediately so + * reconnect attempts (and their connect_error reports) start right away. + */ + _onOffline() { + if (this.connected) { + this._teardown("transport close", { + description: "network connection lost", + }); + } + } + + /** + * Tear down the current connection, reporting the disconnect synchronously + * (onclose may never fire during page unload or while offline). + * @param reason The disconnect reason to report. + * @param details The disconnect details to report. + */ + _teardown(reason, details) { this._clearConnectTimer(); this._clearWatchdog(); const ws = this._ws; @@ -174,9 +205,7 @@ export class ReflexWebSocket { this._ws = null; if (this.connected) { this.connected = false; - // Match socket.io: report the client-initiated disconnect synchronously, - // since onclose may never fire during page unload. - this._emitLocal("disconnect", "io client disconnect", undefined); + this._emitLocal("disconnect", reason, details); } if (ws.readyState <= WebSocket.OPEN) { ws.onclose = null; From 05c5e0ddd1588da481ad6497e12e1c356deee58e Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 23 Aug 2026 00:30:02 +0200 Subject: [PATCH 08/44] =?UTF-8?q?fix(websocket):=20address=20cubic=20revie?= =?UTF-8?q?w=20=E2=80=94=20socket.off()=20compat,=20connect=20race,=20awai?= =?UTF-8?q?ted=20disconnect=20cleanup;=20revert=20server-wide=20ws=5Fmax?= =?UTF-8?q?=5Fsize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../.templates/web/utils/helpers/websocket.js | 31 ++++++++++- .../reflex_base/.templates/web/utils/state.js | 55 +++++++++++-------- reflex/event_namespace.py | 10 +++- reflex/testing.py | 1 - reflex/utils/exec.py | 7 --- 5 files changed, 72 insertions(+), 32 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js index 97ff3cc2178..c8f26a3aada 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js @@ -96,15 +96,44 @@ export class ReflexWebSocket { // Network emulation and OS offline do not interrupt established // websockets, so (like engine.io-client) treat the browser's offline // event as a disconnect. Localhost connections keep working offline. + this._offlineListener = null; if ( typeof addEventListener === "function" && this._url.hostname !== "localhost" ) { - addEventListener("offline", () => this._onOffline(), false); + this._offlineListener = () => this._onOffline(); + addEventListener("offline", this._offlineListener, false); } this.connect(); } + /** + * Remove registered handlers (socket.io-compatible). With no arguments, + * all handlers are removed and the global offline listener is released: + * state.js calls this form when discarding the transport on unmount. + * @param event The event name; omit to remove all handlers. + * @param fn The handler to remove; omit to remove all handlers for event. + */ + off(event, fn) { + if (event === undefined) { + this._callbacks = {}; + if (this._offlineListener) { + removeEventListener("offline", this._offlineListener, false); + this._offlineListener = null; + } + return; + } + if (fn === undefined) { + delete this._callbacks["$" + event]; + return; + } + const handlers = this._callbacks["$" + event]; + const ix = handlers ? handlers.indexOf(fn) : -1; + if (ix !== -1) { + handlers.splice(ix, 1); + } + } + /** * Register a handler for an event. * @param event The event name. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index 4ed06ed11a1..9b51a0b48e0 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -569,6 +569,12 @@ export const connect = async ( } return; } + // Another connect() call may be awaiting the socket.io-client import; + // don't create a second transport. + if (socket.connecting) { + return; + } + socket.connecting = true; // Get backend URL object from the endpoint. const endpoint = getBackendURL(EVENTURL); @@ -576,28 +582,33 @@ export const connect = async ( // Create the socket. const transport = transports[0]; - if (transport === "websocket") { - // Default transport: plain WebSocket speaking the Reflex event protocol. - socket.current = new ReflexWebSocket(endpoint.href, { - query: { token: getToken() }, - protocols: [reflexEnvironment.version], - }); - } else { - // Socket.IO transport ("socketio" over websocket, or "polling"); the - // client library is only loaded when this transport is configured. - const { default: io } = await import("socket.io-client"); - socket.current = io(endpoint.href, { - path: endpoint["pathname"], - transports: [transport === "socketio" ? "websocket" : transport], - protocols: [reflexEnvironment.version], - autoUnref: false, - query: { token: getToken() }, - reconnection: false, // Reconnection will be handled manually. - }); - // Ensure undefined fields in events are sent as null instead of removed - socket.current.io.encoder.replacer = undefinedToNull; - // The decoder API expects false (not undefined) for unparsable input. - socket.current.io.decoder.tryParse = (str) => parseJsonLenient(str, false); + try { + if (transport === "websocket") { + // Default transport: plain WebSocket speaking the Reflex event protocol. + socket.current = new ReflexWebSocket(endpoint.href, { + query: { token: getToken() }, + protocols: [reflexEnvironment.version], + }); + } else { + // Socket.IO transport ("socketio" over websocket, or "polling"); the + // client library is only loaded when this transport is configured. + const { default: io } = await import("socket.io-client"); + socket.current = io(endpoint.href, { + path: endpoint["pathname"], + transports: [transport === "socketio" ? "websocket" : transport], + protocols: [reflexEnvironment.version], + autoUnref: false, + query: { token: getToken() }, + reconnection: false, // Reconnection will be handled manually. + }); + // Ensure undefined fields in events are sent as null instead of removed + socket.current.io.encoder.replacer = undefinedToNull; + // The decoder API expects false (not undefined) for unparsable input. + socket.current.io.decoder.tryParse = (str) => + parseJsonLenient(str, false); + } + } finally { + socket.connecting = false; } socket.current.wait_connect = !socket.current.connected; // Set up a reconnect helper function diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index 9052f77c016..fb46f2bead2 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import contextlib import json import logging import time @@ -531,4 +532,11 @@ async def heartbeat() -> None: finally: heartbeat_task.cancel() self._sockets.pop(sid, None) - self.handle_disconnect(sid) + cleanup_task = self.handle_disconnect(sid) + if cleanup_task is not None: + # Await the token cleanup so an immediate reconnect is not + # treated as a duplicate tab; shielded so cancellation (e.g. + # server shutdown) cannot abort it. Errors are logged by the + # task's done callback. + with contextlib.suppress(Exception): + await asyncio.shield(cleanup_task) diff --git a/reflex/testing.py b/reflex/testing.py index 22197c6be9a..e684353e0c7 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -345,7 +345,6 @@ def _start_backend(self, port: int = 0): app=self.app_asgi, host="127.0.0.1", port=port, - ws_max_size=environment.REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE.get(), ) ) self.backend.shutdown = self._get_backend_shutdown_handler() diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index b48eaf70017..780e86d7473 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -630,8 +630,6 @@ def run_uvicorn_backend(host: str, port: int, loglevel: LogLevel): reload=True, reload_dirs=list(map(str, get_reload_paths())), reload_delay=0.1, - # Enforce the websocket message size limit before buffering the frame. - ws_max_size=environment.REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE.get(), ) @@ -751,11 +749,6 @@ def run_uvicorn_backend_prod( *("--host", host), *("--port", str(port)), *("--workers", str(_get_backend_workers())), - # Enforce the websocket message size limit before buffering the frame. - *( - "--ws-max-size", - str(environment.REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE.get()), - ), "--factory", app_module, ] From 5a691e27655d6ce626a09acb761c01ddcbbfbaab Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 23 Aug 2026 00:44:54 +0200 Subject: [PATCH 09/44] wip --- reflex/event_namespace.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index fb46f2bead2..a735a38b3d4 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -483,10 +483,14 @@ async def heartbeat() -> None: while True: text = await websocket.receive_text() last_received = time.monotonic() + # ASGI delivers complete messages, so the server has already + # buffered the frame; its protocol-level caps (enforced during + # frame reassembly) bound that allocation. This check applies + # the Reflex policy limit on top. # The limit is in bytes; UTF-8 encodes 1-4 bytes per character, # so more characters than the limit is certainly over, and a # quarter or fewer certainly under -- only encode to count the - # exact bytes in between. + # exact bytes in between (bounding the copy to 4x the limit). text_length = len(text) if text_length > max_message_size or ( text_length * 4 > max_message_size From 5c54dab022b54ae6130b04d1f410513339243e04 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 23 Aug 2026 11:04:16 +0200 Subject: [PATCH 10/44] cubic --- .../.templates/web/utils/helpers/websocket.js | 12 +++++++++-- .../reflex_base/.templates/web/utils/state.js | 8 +++++++ reflex/event_namespace.py | 7 ++++++- tests/units/test_event_namespace.py | 21 ++++++++++++++++++- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js index c8f26a3aada..1739d86d4dd 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js @@ -163,7 +163,9 @@ export class ReflexWebSocket { return; } const url = new URL(this._url); - url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + // Secure endpoints (https or already-wss) stay secure. + url.protocol = + url.protocol === "https:" || url.protocol === "wss:" ? "wss:" : "ws:"; url.search = new URLSearchParams(this.io.opts.query ?? {}).toString(); this._closeReason = null; const ws = new WebSocket(url, this.io.opts.protocols); @@ -173,7 +175,12 @@ export class ReflexWebSocket { ws.close(); } }, this._connectTimeoutMs); - ws.onmessage = (msg) => this._onMessage(msg.data); + ws.onmessage = (msg) => { + if (this._ws === ws) { + // Ignore stragglers from a superseded connection. + this._onMessage(msg.data); + } + }; ws.onclose = (event) => { if (this._ws !== ws) { // A newer connection or an explicit disconnect() superseded this one. @@ -238,6 +245,7 @@ export class ReflexWebSocket { } if (ws.readyState <= WebSocket.OPEN) { ws.onclose = null; + ws.onmessage = null; ws.close(1000); } } diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index 9b51a0b48e0..fbae8649237 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -562,6 +562,8 @@ export const connect = async ( navigate, params, ) => { + // Connecting (again) revokes a pending unmount cancellation. + socket.cancelConnect = false; // Socket already allocated, just reconnect it if needed. if (socket.current) { if (!socket.current.connected) { @@ -593,6 +595,10 @@ export const connect = async ( // Socket.IO transport ("socketio" over websocket, or "polling"); the // client library is only loaded when this transport is configured. const { default: io } = await import("socket.io-client"); + if (socket.cancelConnect) { + // The event loop unmounted while the import was pending. + return; + } socket.current = io(endpoint.href, { path: endpoint["pathname"], transports: [transport === "socketio" ? "websocket" : transport], @@ -1109,6 +1115,8 @@ export const useEventLoop = ( // Cleanup function. return () => { mounted.current = false; + // Abort a connect() that is still awaiting the socket.io-client import. + socket.cancelConnect = true; if (socket.current) { socket.current.disconnect(); socket.current.off(); diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index a735a38b3d4..763bec4e728 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -481,7 +481,12 @@ async def heartbeat() -> None: subprotocols[0] if subprotocols else None, ) while True: - text = await websocket.receive_text() + try: + text = await websocket.receive_text() + except KeyError: + # Binary frame; not part of the protocol. + await websocket.close(code=1003) + break last_received = time.monotonic() # ASGI delivers complete messages, so the server has already # buffered the frame; its protocol-level caps (enforced during diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 763d8937580..7dbbdd04409 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -82,17 +82,21 @@ async def receive_text(self) -> str: Raises: WebSocketDisconnect: When the disconnect sentinel is reached. + KeyError: For a binary frame, matching starlette's behavior. """ item = await self._incoming.get() if item is _DISCONNECT: raise WebSocketDisconnect(1000) + if isinstance(item, bytes): + missing_key = "text" + raise KeyError(missing_key) return item def feed(self, *frames: Any): """Queue incoming frames (lists are JSON-encoded) and a disconnect.""" for frame in frames: self._incoming.put_nowait( - frame if isinstance(frame, str) else json.dumps(frame) + frame if isinstance(frame, (str, bytes)) else json.dumps(frame) ) self._incoming.put_nowait(_DISCONNECT) @@ -302,6 +306,21 @@ async def test_multibyte_message_within_limit_is_processed( assert ["ping", "pong"] in websocket.sent +@pytest.mark.asyncio +async def test_binary_frame_closes_connection(namespace: WebsocketEventNamespace): + """A binary frame closes the connection with 1003 (unsupported data). + + Args: + namespace: The websocket event namespace. + """ + websocket = FakeWebSocket() + websocket.feed(b"\x00\x01") + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert websocket.close_code == 1003 + + @pytest.mark.asyncio async def test_disallowed_origin_is_rejected( namespace: WebsocketEventNamespace, mocker From 12734c6e02f32a7d5c21a4f5f179e64f1051640b Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 23 Aug 2026 11:23:15 +0200 Subject: [PATCH 11/44] benchmark new websockets --- tests/benchmarks/test_event_transport.py | 329 +++++++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 tests/benchmarks/test_event_transport.py diff --git a/tests/benchmarks/test_event_transport.py b/tests/benchmarks/test_event_transport.py new file mode 100644 index 00000000000..e09cc12a337 --- /dev/null +++ b/tests/benchmarks/test_event_transport.py @@ -0,0 +1,329 @@ +"""Benchmarks comparing the plain WebSocket transport with Socket.IO. + +Measures the server-side transport layer in isolation: inbound event frames +from an established connection to the (mocked) event processor, and outbound +state updates to the (mocked) wire. Both transports share BaseEventNamespace, +so the difference is the framing and dispatch layer this PR replaced. +Socket.IO runs with ``async_handlers=False`` (inline dispatch, no per-message +task), which biases the comparison in Socket.IO's favor. +""" + +import asyncio +import json +from types import SimpleNamespace +from typing import Any +from unittest import mock + +import pytest +import pytest_asyncio +from pytest_codspeed import BenchmarkFixture +from reflex_base.utils import format +from starlette.websockets import WebSocketDisconnect + +from reflex.event_namespace import WebsocketEventNamespace +from reflex.state import StateUpdate + +NUM_MESSAGES = 100 +NAMESPACE = "/_event" +TOKEN = "benchmark-token" + +_EVENT_FIELDS = { + "name": "benchmark___state.increment", + "router_data": { + "pathname": "/benchmark", + "asPath": "/benchmark?tab=2", + "query": {"tab": "2"}, + }, + "payload": {"value": 42, "label": "increment", "flag": True}, +} + +_UPDATE = StateUpdate( + delta={ + "benchmark___state": {f"var_{i}": f"value_{i}" for i in range(15)} + | {"counter": 42, "flag": True, "items": list(range(10))}, + } +) + +_DISCONNECT = object() + +_ASGI_SCOPE = { + "type": "websocket", + "headers": [(b"host", b"localhost")], + "client": ("127.0.0.1", 1234), +} + + +def _make_app(sio: Any = None) -> SimpleNamespace: + """Build a minimal app double for the event namespace. + + Args: + sio: The Socket.IO server, for the legacy transport. + + Returns: + The app double. + """ + enqueued: list[Any] = [] + + async def enqueue(token: str, event: Any) -> None: # noqa: RUF029 + enqueued.append((token, event)) + + return SimpleNamespace( + _state=None, + sio=sio, + router=lambda _path: None, + event_processor=SimpleNamespace(enqueue=enqueue), + enqueued=enqueued, + ) + + +class FakeWebSocket: + """Minimal stand-in for a starlette WebSocket.""" + + def __init__(self, frames: list[str]): + """Initialize with the inbound frames to deliver. + + Args: + frames: The frames to deliver before disconnecting. + """ + self.scope: dict[str, Any] = { + "type": "websocket", + "query_string": f"token={TOKEN}".encode(), + "subprotocols": [], + "headers": [(b"host", b"localhost")], + "client": ("127.0.0.1", 1234), + } + self.headers: dict[str, str] = {} + self.sent: list[str] = [] + self._incoming = [*frames, _DISCONNECT] + self._pos = 0 + + async def accept(self, subprotocol: str | None = None): + """Accept the connection. + + Args: + subprotocol: The selected subprotocol. + """ + + async def send_text(self, text: str): + """Record an outgoing frame. + + Args: + text: The frame text. + """ + self.sent.append(text) + + async def receive_text(self) -> str: + """Return the next queued frame. + + Returns: + The frame text. + + Raises: + WebSocketDisconnect: When all frames are consumed. + """ + item = self._incoming[self._pos] + self._pos += 1 + if item is _DISCONNECT: + raise WebSocketDisconnect(1000) + return item # pyright: ignore[reportReturnType] + + async def close(self, code: int = 1000): + """Close the connection. + + Args: + code: The close code. + """ + + +@pytest_asyncio.fixture +async def websocket_inbound(): # noqa: RUF029 - async so it runs on the benchmark loop + """Runner delivering NUM_MESSAGES event frames over the plain transport. + + Yields: + An async callable running one full connection lifecycle. + """ + with mock.patch("reflex.utils.prerequisites.check_redis_used", return_value=False): + app = _make_app() + namespace = WebsocketEventNamespace(NAMESPACE, app) # pyright: ignore[reportArgumentType] + frames = [json.dumps(["event", _EVENT_FIELDS])] * NUM_MESSAGES + + async def run() -> None: + websocket = FakeWebSocket(frames) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + assert len(app.enqueued) >= NUM_MESSAGES + + yield run + + +@pytest_asyncio.fixture +async def socketio_inbound(): # noqa: RUF029 - async so it runs on the benchmark loop + """Runner delivering NUM_MESSAGES event packets over Socket.IO. + + Yields: + An async callable running one full connection lifecycle. + """ + pytest.importorskip("socketio") + from socketio import AsyncServer + + from reflex.socketio_namespace import EventNamespace + + sio = AsyncServer( + async_mode="asgi", + async_handlers=False, + json=SimpleNamespace( + dumps=staticmethod(format.json_dumps), + loads=staticmethod(json.loads), + ), + ) + with mock.patch("reflex.utils.prerequisites.check_redis_used", return_value=False): + app = _make_app(sio=sio) + namespace = EventNamespace(NAMESPACE, app) # pyright: ignore[reportArgumentType] + sio.register_namespace(namespace) + + async def eio_send(_eio_sid: str, _data: str) -> None: + pass + + sio.eio.send = eio_send + event_packet = "2" + NAMESPACE + "," + json.dumps(["event", _EVENT_FIELDS]) + counter = 0 + + async def run() -> None: + nonlocal counter + counter += 1 + eio_sid = f"eio-{counter}" + await sio._handle_eio_connect( + eio_sid, + {"QUERY_STRING": f"token={TOKEN}-{counter}", "asgi.scope": _ASGI_SCOPE}, + ) + await sio._handle_eio_message(eio_sid, "0" + NAMESPACE + ",") + for _ in range(NUM_MESSAGES): + await sio._handle_eio_message(eio_sid, event_packet) + await sio._handle_eio_message(eio_sid, "1" + NAMESPACE + ",") + # Let the disconnect cleanup task run. + for _ in range(3): + await asyncio.sleep(0) + assert len(app.enqueued) >= NUM_MESSAGES + + yield run + + +@pytest_asyncio.fixture +async def websocket_outbound(): + """Runner emitting NUM_MESSAGES state updates over the plain transport. + + Yields: + An async callable emitting the updates. + """ + with mock.patch("reflex.utils.prerequisites.check_redis_used", return_value=False): + app = _make_app() + namespace = WebsocketEventNamespace(NAMESPACE, app) # pyright: ignore[reportArgumentType] + websocket = FakeWebSocket([]) + namespace._sockets["sid-1"] = websocket # pyright: ignore[reportArgumentType] + await namespace.link_token_to_sid("sid-1", TOKEN) + + async def run() -> None: + for _ in range(NUM_MESSAGES): + await namespace.emit_update(_UPDATE, TOKEN) + + yield run + + +@pytest_asyncio.fixture +async def socketio_outbound(): + """Runner emitting NUM_MESSAGES state updates over Socket.IO. + + Yields: + An async callable emitting the updates. + """ + pytest.importorskip("socketio") + from socketio import AsyncServer + + from reflex.socketio_namespace import EventNamespace + + sio = AsyncServer( + async_mode="asgi", + async_handlers=False, + json=SimpleNamespace( + dumps=staticmethod(format.json_dumps), + loads=staticmethod(json.loads), + ), + ) + with mock.patch("reflex.utils.prerequisites.check_redis_used", return_value=False): + app = _make_app(sio=sio) + namespace = EventNamespace(NAMESPACE, app) # pyright: ignore[reportArgumentType] + sio.register_namespace(namespace) + + async def eio_send(_eio_sid: str, _data: str) -> None: + pass + + sio.eio.send = eio_send + + async def run() -> None: + for _ in range(NUM_MESSAGES): + await namespace.emit_update(_UPDATE, TOKEN) + + # Connect a socket.io session and link the token to its sid. + eio_sid = "eio-emit" + await sio._handle_eio_connect( + eio_sid, {"QUERY_STRING": f"token={TOKEN}", "asgi.scope": _ASGI_SCOPE} + ) + await sio._handle_eio_message(eio_sid, "0" + NAMESPACE + ",") + assert TOKEN in namespace.token_to_sid + + yield run + + +def test_transport_inbound_websocket(websocket_inbound, benchmark: BenchmarkFixture): + """Benchmark inbound event handling on the plain WebSocket transport. + + Args: + websocket_inbound: The runner. + benchmark: The codspeed benchmark fixture. + """ + loop = asyncio.get_event_loop() + + @benchmark + def _(): + loop.run_until_complete(websocket_inbound()) + + +def test_transport_inbound_socketio(socketio_inbound, benchmark: BenchmarkFixture): + """Benchmark inbound event handling on the Socket.IO transport. + + Args: + socketio_inbound: The runner. + benchmark: The codspeed benchmark fixture. + """ + loop = asyncio.get_event_loop() + + @benchmark + def _(): + loop.run_until_complete(socketio_inbound()) + + +def test_transport_outbound_websocket(websocket_outbound, benchmark: BenchmarkFixture): + """Benchmark emitting state updates on the plain WebSocket transport. + + Args: + websocket_outbound: The runner. + benchmark: The codspeed benchmark fixture. + """ + loop = asyncio.get_event_loop() + + @benchmark + def _(): + loop.run_until_complete(websocket_outbound()) + + +def test_transport_outbound_socketio(socketio_outbound, benchmark: BenchmarkFixture): + """Benchmark emitting state updates on the Socket.IO transport. + + Args: + socketio_outbound: The runner. + benchmark: The codspeed benchmark fixture. + """ + loop = asyncio.get_event_loop() + + @benchmark + def _(): + loop.run_until_complete(socketio_outbound()) From 2f1b78870e60eb4c9613c6d5b132ec55215f766d Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 23 Aug 2026 11:29:38 +0200 Subject: [PATCH 12/44] fix(websocket): remove visibility listener on unmount, branch on ASGI message shape, log protocol closes; add transport benchmarks --- .../reflex_base/.templates/web/utils/state.js | 10 ++++++++++ reflex/event_namespace.py | 14 ++++++++++---- tests/benchmarks/test_event_transport.py | 14 +++++--------- tests/units/test_event_namespace.py | 18 ++++++------------ 4 files changed, 31 insertions(+), 25 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index fbae8649237..1d89d06df65 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -791,6 +791,9 @@ export const connect = async ( window.sessionStorage.setItem(TOKEN_KEY, new_token); }); + // Track the handler on the ref so unmount cleanup can remove it; a + // surviving listener would resurrect a transport for the unmounted hook. + socket.visibilityHandler = checkVisibility; document.addEventListener("visibilitychange", checkVisibility); }; @@ -1117,6 +1120,13 @@ export const useEventLoop = ( mounted.current = false; // Abort a connect() that is still awaiting the socket.io-client import. socket.cancelConnect = true; + if (socket.visibilityHandler) { + document.removeEventListener( + "visibilitychange", + socket.visibilityHandler, + ); + socket.visibilityHandler = null; + } if (socket.current) { socket.current.disconnect(); socket.current.off(); diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index 763bec4e728..c89bfe45c99 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -481,13 +481,16 @@ async def heartbeat() -> None: subprotocols[0] if subprotocols else None, ) while True: - try: - text = await websocket.receive_text() - except KeyError: + received = await websocket.receive() + if received["type"] == "websocket.disconnect": + break + last_received = time.monotonic() + text = received.get("text") + if text is None: # Binary frame; not part of the protocol. + logger.debug(f"Closing session {sid}: received a binary frame.") await websocket.close(code=1003) break - last_received = time.monotonic() # ASGI delivers complete messages, so the server has already # buffered the frame; its protocol-level caps (enforced during # frame reassembly) bound that allocation. This check applies @@ -501,6 +504,9 @@ async def heartbeat() -> None: text_length * 4 > max_message_size and len(text.encode("utf-8")) > max_message_size ): + logger.debug( + f"Closing session {sid}: message over {max_message_size} bytes." + ) await websocket.close(code=1009) break try: diff --git a/tests/benchmarks/test_event_transport.py b/tests/benchmarks/test_event_transport.py index e09cc12a337..a08fb45c86f 100644 --- a/tests/benchmarks/test_event_transport.py +++ b/tests/benchmarks/test_event_transport.py @@ -18,7 +18,6 @@ import pytest_asyncio from pytest_codspeed import BenchmarkFixture from reflex_base.utils import format -from starlette.websockets import WebSocketDisconnect from reflex.event_namespace import WebsocketEventNamespace from reflex.state import StateUpdate @@ -112,20 +111,17 @@ async def send_text(self, text: str): """ self.sent.append(text) - async def receive_text(self) -> str: - """Return the next queued frame. + async def receive(self) -> dict[str, Any]: + """Return the next queued frame as an ASGI message. Returns: - The frame text. - - Raises: - WebSocketDisconnect: When all frames are consumed. + The ASGI websocket message. """ item = self._incoming[self._pos] self._pos += 1 if item is _DISCONNECT: - raise WebSocketDisconnect(1000) - return item # pyright: ignore[reportReturnType] + return {"type": "websocket.disconnect", "code": 1000} + return {"type": "websocket.receive", "text": item} async def close(self, code: int = 1000): """Close the connection. diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 7dbbdd04409..5d6984b5332 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -7,7 +7,6 @@ import pytest from starlette.routing import WebSocketRoute -from starlette.websockets import WebSocketDisconnect from reflex.app import App from reflex.event_namespace import ( @@ -74,23 +73,18 @@ async def close(self, code: int = 1000): """ self.close_code = code - async def receive_text(self) -> str: - """Return the next queued frame. + async def receive(self) -> dict[str, Any]: + """Return the next queued frame as an ASGI message. Returns: - The frame text. - - Raises: - WebSocketDisconnect: When the disconnect sentinel is reached. - KeyError: For a binary frame, matching starlette's behavior. + The ASGI websocket message. """ item = await self._incoming.get() if item is _DISCONNECT: - raise WebSocketDisconnect(1000) + return {"type": "websocket.disconnect", "code": 1000} if isinstance(item, bytes): - missing_key = "text" - raise KeyError(missing_key) - return item + return {"type": "websocket.receive", "bytes": item} + return {"type": "websocket.receive", "text": item} def feed(self, *frames: Any): """Queue incoming frames (lists are JSON-encoded) and a disconnect.""" From c7f21804b993b89ba6a7e8a2c1847ad220e76ce1 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 23 Aug 2026 11:38:10 +0200 Subject: [PATCH 13/44] perf(websocket): hoist hot-path constants, cache connection headers, cut client watchdog churn --- .../.templates/web/utils/helpers/websocket.js | 5 ++- reflex/event_namespace.py | 44 +++++++++++++------ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js index 1739d86d4dd..d06641271d3 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js @@ -269,7 +269,6 @@ export class ReflexWebSocket { * @param text The raw frame text. */ _onMessage(text) { - this._resetWatchdog(); const message = parseJsonLenient(text, undefined); if (!Array.isArray(message)) { console.error("Failed to parse websocket message", text); @@ -277,6 +276,10 @@ export class ReflexWebSocket { } const [event, payload] = message; if (event === PING_MESSAGE) { + // The server pings unconditionally every interval, so resetting the + // watchdog only here (not per data message) detects dead connections + // just as well without timer churn on the hot path. + this._resetWatchdog(); this._ws?.send(stringifyFrame([PONG_MESSAGE])); return; } diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index c89bfe45c99..66fec4bfff9 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -10,7 +10,7 @@ import urllib.parse import uuid from abc import ABC, abstractmethod -from collections.abc import Mapping +from collections.abc import Mapping, MutableMapping from typing import TYPE_CHECKING, Any from reflex_base import constants @@ -37,6 +37,14 @@ PING_MESSAGE = "_ping" PONG_MESSAGE = "_pong" +# Application-level socket event names, resolved once for the hot paths. +_EVENT = str(constants.SocketEvent.EVENT) +_PING = str(constants.SocketEvent.PING) +_CLIENT_ERROR = str(constants.SocketEvent.CLIENT_ERROR) + +# The heartbeat frame is static; serialize it once. +_PING_FRAME = json.dumps([PING_MESSAGE]) + class BaseEventNamespace(ABC): """Transport-agnostic handler for client event sessions.""" @@ -183,12 +191,12 @@ async def emit_update(self, update: StateUpdate, token: str) -> None: return # Creating a task prevents the update from being blocked behind other coroutines. await asyncio.create_task( - self.emit(str(constants.SocketEvent.EVENT), update, to=socket_record.sid), + self.emit(_EVENT, update, to=socket_record.sid), name=f"reflex_emit_event|{token}|{socket_record.sid}|{time.time()}", ) async def handle_event( - self, sid: str, data: Any, asgi_scope: Mapping[str, Any] + self, sid: str, data: Any, asgi_scope: MutableMapping[str, Any] ) -> None: """Handle an incoming front-end event. @@ -231,10 +239,16 @@ async def handle_event( msg = f"Failed to deserialize event data: {fields}." raise exceptions.EventDeserializationError(msg) from ex - # Get the client headers. - headers = { - k.decode("utf-8"): v.decode("utf-8") for (k, v) in asgi_scope["headers"] - } + # Decode the connection headers once: the scope is per-connection + # state, so cache the decoded mapping in it and copy per event (the + # copy is mutated below and ends up in the event's router_data). + base_headers = asgi_scope.get("_reflex_headers") + if base_headers is None: + base_headers = { + k.decode("utf-8"): v.decode("utf-8") for (k, v) in asgi_scope["headers"] + } + asgi_scope["_reflex_headers"] = base_headers + headers = dict(base_headers) # Get the client IP client = asgi_scope.get("client") @@ -276,7 +290,7 @@ async def handle_ping(self, sid: str) -> None: sid: The session id. """ # Emit the test event. - await self.emit(str(constants.SocketEvent.PING), "pong", to=sid) + await self.emit(_PING, "pong", to=sid) async def handle_client_error(self, sid: str, data: Any) -> None: """Handle errors reported by the frontend. @@ -458,7 +472,7 @@ async def heartbeat() -> None: if time.monotonic() - last_received > ping_interval + ping_timeout: await websocket.close(code=1001) return - await websocket.send_text(format.json_dumps([PING_MESSAGE])) + await websocket.send_text(_PING_FRAME) except Exception: # Socket went away; the receive loop handles cleanup. return @@ -523,14 +537,16 @@ async def heartbeat() -> None: continue event = message[0] data = message[1] if len(message) > 1 else None - if event == PONG_MESSAGE: - continue try: - if event == str(constants.SocketEvent.EVENT): + # Ordered by frequency: events are the hot path, heartbeat + # pongs arrive once per ping interval. + if event == _EVENT: await self.handle_event(sid, data, websocket.scope) - elif event == str(constants.SocketEvent.PING): + elif event == PONG_MESSAGE: + continue + elif event == _PING: await self.handle_ping(sid) - elif event == str(constants.SocketEvent.CLIENT_ERROR): + elif event == _CLIENT_ERROR: await self.handle_client_error(sid, data) else: logger.debug( From 6ad39f4e730873200d3ef31a63ece258bfadfffa Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 23 Aug 2026 11:39:30 +0200 Subject: [PATCH 14/44] cleanup docstrings --- .../.templates/web/utils/helpers/websocket.js | 32 ++++++++----------- .../event/processor/event_processor.py | 6 ++-- reflex/event_namespace.py | 6 ++-- tests/benchmarks/test_event_transport.py | 5 ++- .../event/processor/test_event_processor.py | 4 +-- 5 files changed, 21 insertions(+), 32 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js index d06641271d3..dc09fa8f517 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js @@ -66,7 +66,7 @@ const stringifyFrame = (frame) => JSON.stringify(frame, undefinedToNull); export class ReflexWebSocket { /** - * Create the transport and start connecting (like socket.io's `io()`). + * Create the transport and start connecting. * @param url The http(s) endpoint URL of the backend event route. * @param opts Options: `query` (object) and `protocols` (subprotocol list). */ @@ -76,26 +76,23 @@ export class ReflexWebSocket { // io.opts.query before reconnecting. this.io = { opts }; this.connected = false; - // Handler registry shaped like socket.io's component-emitter, because // upload.js reads socket._callbacks.$event directly. this._callbacks = {}; this._ws = null; - // Frames emitted while disconnected, flushed on (re)connect, matching - // socket.io's packet buffering. + // Frames emitted while disconnected, flushed on (re)connect. this._sendQueue = []; this._watchdogTimer = null; - // Heartbeat window: 145 seconds (25s ping interval + 120s ping timeout, - // mirroring the server defaults) in ms; refined by the server handshake. + // Heartbeat window: 145 seconds (25s ping interval + 120s ping timeout) + // in ms; refined by the server handshake. this._watchdogMs = (25 + 120) * 1000; - // Give up after 20 seconds on a dial that neither opens nor errors (like - // socket.io's connect timeout), so a connect_error always fires and - // retries proceed. + // Give up after 20 seconds on a dial that neither opens nor errors, so + // a connect_error always fires and retries proceed. this._connectTimeoutMs = 20 * 1000; this._connectTimer = null; this._closeReason = null; // Network emulation and OS offline do not interrupt established - // websockets, so (like engine.io-client) treat the browser's offline - // event as a disconnect. Localhost connections keep working offline. + // websockets, so treat the browser's offline event as a disconnect. + // Localhost connections keep working offline. this._offlineListener = null; if ( typeof addEventListener === "function" && @@ -108,9 +105,8 @@ export class ReflexWebSocket { } /** - * Remove registered handlers (socket.io-compatible). With no arguments, - * all handlers are removed and the global offline listener is released: - * state.js calls this form when discarding the transport on unmount. + * Remove registered handlers. With no arguments, also releases the global + * offline listener (transport disposal). * @param event The event name; omit to remove all handlers. * @param fn The handler to remove; omit to remove all handlers for event. */ @@ -213,8 +209,7 @@ export class ReflexWebSocket { } /** - * Handle the browser going offline: report the disconnect immediately so - * reconnect attempts (and their connect_error reports) start right away. + * Report the disconnect immediately when the browser goes offline. */ _onOffline() { if (this.connected) { @@ -276,9 +271,8 @@ export class ReflexWebSocket { } const [event, payload] = message; if (event === PING_MESSAGE) { - // The server pings unconditionally every interval, so resetting the - // watchdog only here (not per data message) detects dead connections - // just as well without timer churn on the hot path. + // The server pings every interval regardless of traffic, so resetting + // the watchdog only here avoids timer churn per data message. this._resetWatchdog(); this._ws?.send(stringifyFrame([PONG_MESSAGE])); return; diff --git a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py index 873dcc590ec..702ee075ca6 100644 --- a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py +++ b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py @@ -473,10 +473,8 @@ async def _emit_delta_impl( task_future = await self.enqueue( token, event, - # Fork for a fresh txid: replacing on the root context would keep - # its txid, registering this future under it, and every event that - # later forks from the root context would attach to this stream as - # a child (raising once the stream's future is done). + # Fork for a fresh txid: reusing the root txid would register this + # future under it, attaching unrelated events as children. ev_ctx=dataclasses.replace( self._root_context.fork(token=token), emit_delta_impl=_emit_delta_impl, diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index 66fec4bfff9..ba0829e7f50 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -449,8 +449,7 @@ async def handle_websocket(self, websocket: WebSocket) -> None: websocket: The client websocket connection. """ if not self._origin_allowed(websocket.headers.get("origin")): - # Reject cross-origin connections before accepting (CSWSH parity - # with the Socket.IO transport's origin check). + # Reject cross-origin connections before accepting. await websocket.close(code=1008) return subprotocols = websocket.scope.get("subprotocols") or [] @@ -553,8 +552,7 @@ async def heartbeat() -> None: f"Ignoring unknown socket event {event!r} from session {sid}." ) except Exception: - # Match Socket.IO behavior: a failing handler is logged and - # the connection survives. + # A failing handler is logged; the connection survives. logger.exception( f"Error handling socket event {event!r} for session {sid}." ) diff --git a/tests/benchmarks/test_event_transport.py b/tests/benchmarks/test_event_transport.py index a08fb45c86f..c4230eab69a 100644 --- a/tests/benchmarks/test_event_transport.py +++ b/tests/benchmarks/test_event_transport.py @@ -3,9 +3,8 @@ Measures the server-side transport layer in isolation: inbound event frames from an established connection to the (mocked) event processor, and outbound state updates to the (mocked) wire. Both transports share BaseEventNamespace, -so the difference is the framing and dispatch layer this PR replaced. -Socket.IO runs with ``async_handlers=False`` (inline dispatch, no per-message -task), which biases the comparison in Socket.IO's favor. +so the difference is the framing and dispatch layer. Socket.IO runs with +``async_handlers=False`` (inline dispatch), its cheapest configuration. """ import asyncio diff --git a/tests/units/reflex_base/event/processor/test_event_processor.py b/tests/units/reflex_base/event/processor/test_event_processor.py index 80afc1f8514..65b469c6689 100644 --- a/tests/units/reflex_base/event/processor/test_event_processor.py +++ b/tests/units/reflex_base/event/processor/test_event_processor.py @@ -614,8 +614,8 @@ async def test_stream_delta_noop_handler_yields_nothing(token: str): async def test_stream_delta_future_does_not_claim_root_txid(token: str): """Regression: a streamed event must not reuse the root context's txid. - A stream future registered under the root txid captured unrelated events - as children, raising once the stream was done (#6932). + Otherwise unrelated events forking from the root context attach to the + stream's future as children (#6932). Args: token: The client token. From 8e373880f74ccb9f8dd06ace713a92ce0329f3b2 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 23 Aug 2026 16:13:39 +0200 Subject: [PATCH 15/44] migrate harness to granian. --- news/6932.feature.md | 2 +- pyproject.toml | 4 +- reflex/testing.py | 185 ++++++++++++------ reflex/utils/exec.py | 9 + .../tests_playwright/test_stateless_app.py | 2 +- uv.lock | 11 +- 6 files changed, 152 insertions(+), 61 deletions(-) diff --git a/news/6932.feature.md b/news/6932.feature.md index 004cfb92704..e2c80c4affb 100644 --- a/news/6932.feature.md +++ b/news/6932.feature.md @@ -1 +1 @@ -The default client-server transport is now a plain WebSocket speaking a lightweight JSON event protocol, replacing Socket.IO. `python-socketio` moved to the optional `reflex[socketio]` extra and `socket.io-client` is only loaded by the frontend when configured. The Socket.IO transport remains available via `transport="socketio"` (websocket) or `transport="polling"` in `rxconfig.py`; apps passing a custom `sio` server to `rx.App` must set one of these and install the extra. +The default client-server transport is now a plain WebSocket speaking a lightweight JSON event protocol, replacing Socket.IO. `python-socketio` moved to the optional `reflex[socketio]` extra and `socket.io-client` is only loaded by the frontend when configured. The Socket.IO transport remains available via `transport="socketio"` (websocket) or `transport="polling"` in `rxconfig.py`; apps passing a custom `sio` server to `rx.App` must set one of these and install the extra. Uvicorn is now fully optional: `AppHarness` serves tests with Granian's embedded server (native websocket support), and the uvicorn backend fallback requires the new `reflex[uvicorn]` extra, which brings the `websockets` protocol library uvicorn needs for the WebSocket transport. diff --git a/pyproject.toml b/pyproject.toml index ff516e04ecb..fbff1836b2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ keywords = ["web", "framework"] requires-python = ">=3.10,<4.0" dependencies = [ "click >=8.2", - "granian[reload] >=2.7.4", + "granian[reload] >=2.8.1", "httpx >=0.26,<1.0", "packaging >=24.2,<27", "psutil >=7.0.0,<8.0; sys_platform == 'win32'", @@ -66,6 +66,8 @@ db = [ ] pydantic = ["reflex-base[pydantic]"] socketio = ["python-socketio >=5.12.0,<6.0"] +# The optional uvicorn backend needs a websocket protocol library of its own. +uvicorn = ["uvicorn >=0.20.0", "websockets >=13.0"] [project.urls] homepage = "https://reflex.dev" diff --git a/reflex/testing.py b/reflex/testing.py index e684353e0c7..0714972b274 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -25,7 +25,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar -import uvicorn +from granian.constants import Interfaces from reflex_base.components.memo import MEMOS from reflex_base.config import get_config, reload_config from reflex_base.environment import environment @@ -73,6 +73,117 @@ FRONTEND_POPEN_ARGS["start_new_session"] = True +class _EmbeddedServer: + """In-process granian server with a uvicorn-like control surface. + + Serves the given ASGI app object directly, so the harness shares the app + and state instances with the running server, and granian's native + websocket support means no separate websocket library is required. The + port is resolved up front because granian cannot report an OS-assigned + port back to Python. + """ + + def __init__(self, app: ASGIApp, host: str = "127.0.0.1", port: int = 0) -> None: + """Prepare the server without starting it. + + Args: + app: the ASGI app object to serve. + host: the address to bind to. + port: the port to bind to; 0 picks a free port immediately. + """ + if port == 0: + with socket.socket() as probe: + probe.bind((host, 0)) + port = probe.getsockname()[1] + self.app = app + self.host = host + self.port = port + # Monkeypatchable async shutdown hook, mirroring uvicorn.Server.shutdown. + self.shutdown: Callable[..., Coroutine[Any, Any, None]] = self._noop_shutdown + self._should_exit = threading.Event() + self._loop: asyncio.AbstractEventLoop | None = None + self._server: Any = None + + @staticmethod + async def _noop_shutdown(*args, **kwargs) -> None: + """Default shutdown hook. + + Args: + *args: ignored. + **kwargs: ignored. + """ + + def getsockname(self) -> tuple[str, int]: + """The address the server is bound to. + + Returns: + The (host, port) tuple the server serves on. + """ + return (self.host, self.port) + + def is_listening(self) -> bool: + """Whether the server accepts connections. + + Returns: + True if a TCP connection to the bound address succeeds. + """ + try: + socket.create_connection((self.host, self.port), timeout=0.1).close() + except OSError: + return False + return True + + @property + def should_exit(self) -> bool: + """Whether the server was asked to stop. + + Returns: + True after `should_exit` has been set. + """ + return self._should_exit.is_set() + + @should_exit.setter + def should_exit(self, value: bool) -> None: + if not value: + return + self._should_exit.set() + loop, server = self._loop, self._server + if loop is not None and server is not None: + + def _interrupt() -> None: + server.interrupt_signal = True + server.main_loop_interrupt.set() + + # A closed loop means the server is already down. + with contextlib.suppress(RuntimeError): + loop.call_soon_threadsafe(_interrupt) + + def run(self) -> None: + """Serve the app until `should_exit` is set; used as a thread target.""" + asyncio.run(self._serve()) + + async def _serve(self) -> None: + from granian.server.embed import Server + + server = Server( + self.app, + address=self.host, + port=self.port, + interface=Interfaces.ASGI, + log_enabled=False, + ) + self._server = server + self._loop = asyncio.get_running_loop() + if self._should_exit.is_set(): + # Stopped before startup: let serve() exit right after binding. + server.interrupt_signal = True + server.main_loop_interrupt.set() + try: + await server.serve() + finally: + await self.shutdown() + + # borrowed from py3.11 class chdir(contextlib.AbstractContextManager): # noqa: N801 """Non thread-safe context manager to change the current working directory.""" @@ -117,7 +228,7 @@ class AppHarness: frontend_url: str | None = None frontend_output_thread: threading.Thread | None = None backend_thread: threading.Thread | None = None - backend: uvicorn.Server | None = None + backend: _EmbeddedServer | None = None _frontends: list[WebDriver] = dataclasses.field(default_factory=list) _registry_token: contextvars.Token[RegistrationContext] | None = None _base_registration_context: ClassVar[RegistrationContext] | None = None @@ -340,13 +451,7 @@ def _start_backend(self, port: int = 0): if self.app_asgi is None: msg = "App was not initialized." raise RuntimeError(msg) - self.backend = uvicorn.Server( - uvicorn.Config( - app=self.app_asgi, - host="127.0.0.1", - port=port, - ) - ) + self.backend = _EmbeddedServer(self.app_asgi, port=port) self.backend.shutdown = self._get_backend_shutdown_handler() def _run_backend(context: contextvars.Context) -> None: @@ -568,38 +673,27 @@ async def _poll_for_async( await asyncio.sleep(step) return False - def _poll_for_servers(self, timeout: TimeoutType = None) -> socket.socket: - """Poll backend server for listening sockets. + def _poll_for_servers(self, timeout: TimeoutType = None) -> _EmbeddedServer: + """Poll the backend server until it is listening. Args: - timeout: how long to wait for listening socket. + timeout: how long to wait for the listening server. Returns: - first active listening socket on the backend + the backend server, exposing `getsockname()` for its bound address Raises: RuntimeError: when the backend hasn't started running - TimeoutError: when server or sockets are not ready + TimeoutError: when the server is not ready """ if self.backend is None: msg = "Backend is not running." raise RuntimeError(msg) backend = self.backend - # check for servers to be initialized - if not self._poll_for( - target=lambda: getattr(backend, "servers", False), - timeout=timeout, - ): - msg = "Backend servers are not initialized." - raise TimeoutError(msg) - # check for sockets to be listening - if not self._poll_for( - target=lambda: getattr(backend.servers[0], "sockets", False), - timeout=timeout, - ): + if not self._poll_for(target=backend.is_listening, timeout=timeout): msg = "Backend is not listening." raise TimeoutError(msg) - return backend.servers[0].sockets[0] + return backend def frontend( self, @@ -809,23 +903,16 @@ class AppHarnessProd(AppHarness): """AppHarnessProd executes a reflex app in-process for testing. In prod mode, instead of running `react-router dev` the app is exported as static - files and served via Starlette StaticFiles in a dedicated Uvicorn server. - Additionally, the backend runs in multi-worker mode. + files and served via Starlette StaticFiles on a dedicated embedded server. """ frontend_thread: threading.Thread | None = None - frontend_server: uvicorn.Server | None = None + frontend_server: _EmbeddedServer | None = None def _run_frontend(self): with chdir(self.app_path): frontend_app = reflex.utils.exec._frontend_prod_app() - self.frontend_server = uvicorn.Server( - uvicorn.Config( - app=frontend_app, - host="127.0.0.1", - port=0, - ) - ) + self.frontend_server = _EmbeddedServer(frontend_app) self.frontend_server.run() def _start_frontend(self): @@ -861,22 +948,15 @@ def _start_frontend(self): def _wait_frontend(self): self._poll_for( lambda: ( - self.frontend_server is not None - and getattr(self.frontend_server, "servers", []) - and self.frontend_server.servers[0].sockets + self.frontend_server is not None and self.frontend_server.is_listening() ) ) - if ( - self.frontend_server is None - or not self.frontend_server.servers[0].sockets - or not self.frontend_server.servers[0].sockets[0].fileno() - ): + if self.frontend_server is None or not self.frontend_server.is_listening(): msg = "Frontend did not start" raise RuntimeError(msg) - frontend_socket = self.frontend_server.servers[0].sockets[0] config = get_config() self.frontend_url = "http://{}:{}".format( - *frontend_socket.getsockname() + *self.frontend_server.getsockname() ) + config.prepend_frontend_path("/") config.deploy_url = self.frontend_url @@ -885,14 +965,7 @@ def _start_backend(self): msg = "App was not initialized." raise RuntimeError(msg) environment.REFLEX_SKIP_COMPILE.set(True) - self.backend = uvicorn.Server( - uvicorn.Config( - app=self.app_asgi, - host="127.0.0.1", - port=0, - workers=reflex.utils.processes.get_num_workers(), - ), - ) + self.backend = _EmbeddedServer(self.app_asgi) self.backend.shutdown = self._get_backend_shutdown_handler() def _run_backend(context: contextvars.Context) -> None: @@ -908,7 +981,7 @@ def _run_backend(context: contextvars.Context) -> None: self.backend_thread.start() print("Backend started.") # for pytest diagnosis #noqa: T201 - def _poll_for_servers(self, timeout: TimeoutType = None) -> socket.socket: + def _poll_for_servers(self, timeout: TimeoutType = None) -> _EmbeddedServer: try: return super()._poll_for_servers(timeout) finally: diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 780e86d7473..8c5e2d54371 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -407,6 +407,15 @@ def _warn_user_about_uvicorn(): logger.warning( "Using Uvicorn for backend as it is installed. This behavior will change in 0.8.0 to use Granian by default." ) + if ( + importlib.util.find_spec("websockets") is None + and importlib.util.find_spec("wsproto") is None + ): + logger.warning( + "Uvicorn has no websocket protocol library installed, so the default " + "WebSocket transport will not connect. Install `reflex[uvicorn]` or " + "use Granian (REFLEX_USE_GRANIAN=1)." + ) def should_use_granian(): diff --git a/tests/integration/tests_playwright/test_stateless_app.py b/tests/integration/tests_playwright/test_stateless_app.py index 0451230a556..de88ef12f53 100644 --- a/tests/integration/tests_playwright/test_stateless_app.py +++ b/tests/integration/tests_playwright/test_stateless_app.py @@ -48,7 +48,7 @@ def test_statelessness(stateless_app: AppHarness, page: Page): """ assert stateless_app.frontend_url is not None assert stateless_app.backend is not None - assert stateless_app.backend.started + assert stateless_app.backend.is_listening() config = get_config() res = httpx.get(config.api_url + config.prepend_backend_path(str(Endpoint.EVENT))) diff --git a/uv.lock b/uv.lock index df873c81aa4..820192a269c 100644 --- a/uv.lock +++ b/uv.lock @@ -3678,6 +3678,11 @@ pydantic = [ socketio = [ { name = "python-socketio" }, ] +uvicorn = [ + { name = "uvicorn" }, + { name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "websockets", version = "17.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] [package.dev-dependencies] dev = [ @@ -3733,7 +3738,7 @@ dev = [ requires-dist = [ { name = "alembic", marker = "extra == 'db'", specifier = ">=1.15.2,<2.0" }, { name = "click", specifier = ">=8.2" }, - { name = "granian", extras = ["reload"], specifier = ">=2.7.4" }, + { name = "granian", extras = ["reload"], specifier = ">=2.8.1" }, { name = "httpx", specifier = ">=0.26,<1.0" }, { name = "packaging", specifier = ">=24.2,<27" }, { name = "psutil", marker = "sys_platform == 'win32'", specifier = ">=7.0.0,<8.0" }, @@ -3760,9 +3765,11 @@ requires-dist = [ { name = "sqlmodel", marker = "extra == 'db'", specifier = ">=0.0.24,<0.1" }, { name = "starlette", specifier = ">=1.3.1" }, { name = "typing-extensions", specifier = ">=4.13.0" }, + { name = "uvicorn", marker = "extra == 'uvicorn'", specifier = ">=0.20.0" }, + { name = "websockets", marker = "extra == 'uvicorn'", specifier = ">=13.0" }, { name = "wrapt", specifier = ">=1.17.0,<2.2" }, ] -provides-extras = ["db", "pydantic", "socketio"] +provides-extras = ["db", "pydantic", "socketio", "uvicorn"] [package.metadata.requires-dev] dev = [ From 9381b48416ebd35e310fc60407416127d180d67e Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 23 Aug 2026 16:58:15 +0200 Subject: [PATCH 16/44] fix(testing): retry embedded server on taken port; complete reflex[uvicorn] extra with gunicorn --- pyproject.toml | 4 +-- reflex/event_namespace.py | 4 ++- reflex/testing.py | 41 +++++++++++++++++++---------- tests/units/test_event_namespace.py | 13 +++++++-- tests/units/test_testing.py | 40 ++++++++++++++++++++++++++++ uv.lock | 14 ++++++++++ 6 files changed, 97 insertions(+), 19 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fbff1836b2a..45f1b602b51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,8 +66,8 @@ db = [ ] pydantic = ["reflex-base[pydantic]"] socketio = ["python-socketio >=5.12.0,<6.0"] -# The optional uvicorn backend needs a websocket protocol library of its own. -uvicorn = ["uvicorn >=0.20.0", "websockets >=13.0"] +# The uvicorn backend: server, websocket support, and the posix prod runner. +uvicorn = ["uvicorn >=0.20.0", "websockets >=13.0", "gunicorn >=23.0"] [project.urls] homepage = "https://reflex.dev" diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index ba0829e7f50..d93f02f9bb6 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -418,7 +418,9 @@ async def emit(self, event: str, data: Any = None, to: str | None = None) -> Non """ websocket = self._sockets.get(to) if to is not None else None if websocket is None: - logger.warning(f"Attempted to emit {event!r} to unknown session {to!r}.") + # Routine race: the client disconnected while an event was still + # being processed, so its remaining updates have nowhere to go. + logger.debug(f"Attempted to emit {event!r} to unknown session {to!r}.") return try: await websocket.send_text(format.json_dumps([event, data])) diff --git a/reflex/testing.py b/reflex/testing.py index 0714972b274..8a79b17ebb9 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -165,21 +165,34 @@ def run(self) -> None: async def _serve(self) -> None: from granian.server.embed import Server - server = Server( - self.app, - address=self.host, - port=self.port, - interface=Interfaces.ASGI, - log_enabled=False, - ) - self._server = server - self._loop = asyncio.get_running_loop() - if self._should_exit.is_set(): - # Stopped before startup: let serve() exit right after binding. - server.interrupt_signal = True - server.main_loop_interrupt.set() try: - await server.serve() + # Another process can claim the probed port before granian binds + # it; retry on a fresh port until a stop was actually requested. + for _ in range(10): + server = Server( + self.app, + address=self.host, + port=self.port, + interface=Interfaces.ASGI, + log_enabled=False, + ) + self._server = server + self._loop = asyncio.get_running_loop() + if self._should_exit.is_set(): + # Stopped before startup: let serve() exit right after binding. + server.interrupt_signal = True + server.main_loop_interrupt.set() + try: + await server.serve() + except (OSError, RuntimeError) as ex: + # Granian surfaces bind failures as RuntimeError. + if "address already in use" not in str(ex).lower(): + raise + if self._should_exit.is_set(): + break + with socket.socket() as probe: + probe.bind((self.host, 0)) + self.port = probe.getsockname()[1] finally: await self.shutdown() diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 5d6984b5332..4860cc46547 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -380,13 +380,22 @@ async def test_duplicate_token_gets_new_token(namespace: WebsocketEventNamespace @pytest.mark.asyncio async def test_emit_to_unknown_sid_does_not_raise( namespace: WebsocketEventNamespace, + caplog: pytest.LogCaptureFixture, ): - """Emitting to a session that went away is a no-op. + """Emitting to a session that went away is a silent no-op. + + A client disconnecting mid-event is routine, so nothing above DEBUG may be + logged. Args: namespace: The websocket event namespace. + caplog: The pytest log capture fixture. """ - await namespace.emit("event", {"delta": {}}, to="gone") + import logging + + with caplog.at_level(logging.DEBUG, logger="reflex.event_namespace"): + await namespace.emit("event", {"delta": {}}, to="gone") + assert all(record.levelno <= logging.DEBUG for record in caplog.records) def test_default_transport_uses_websocket_namespace(): diff --git a/tests/units/test_testing.py b/tests/units/test_testing.py index aa125d72e1a..20aa44f3166 100644 --- a/tests/units/test_testing.py +++ b/tests/units/test_testing.py @@ -187,3 +187,43 @@ def test_app_harness_initialize_reloads_existing_imported_app( harness._initialize_app() harness_mocks.get_and_validate_app.assert_called_once_with(reload=True) + + +def test_embedded_server_retries_taken_port(): + """The embedded server rebinds to a fresh port when its probed port is taken.""" + import socket + import threading + import time + + async def app(scope, receive, send): + if scope["type"] == "lifespan": + while True: + message = await receive() + if message["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif message["type"] == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + + server = reflex_testing._EmbeddedServer(app) + probed_port = server.port + # Steal the probed port before the server binds it. + blocker = socket.socket() + blocker.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + blocker.bind((server.host, probed_port)) + blocker.listen(1) + thread = threading.Thread(target=server.run) + thread.start() + try: + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + if server.port != probed_port and server.is_listening(): + break + time.sleep(0.05) + assert server.port != probed_port + assert server.is_listening() + finally: + blocker.close() + server.should_exit = True + thread.join(timeout=15) + assert not thread.is_alive() diff --git a/uv.lock b/uv.lock index 820192a269c..aee828ef476 100644 --- a/uv.lock +++ b/uv.lock @@ -1412,6 +1412,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, ] +[[package]] +name = "gunicorn" +version = "26.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -3679,6 +3691,7 @@ socketio = [ { name = "python-socketio" }, ] uvicorn = [ + { name = "gunicorn" }, { name = "uvicorn" }, { name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "websockets", version = "17.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -3739,6 +3752,7 @@ requires-dist = [ { name = "alembic", marker = "extra == 'db'", specifier = ">=1.15.2,<2.0" }, { name = "click", specifier = ">=8.2" }, { name = "granian", extras = ["reload"], specifier = ">=2.8.1" }, + { name = "gunicorn", marker = "extra == 'uvicorn'", specifier = ">=23.0" }, { name = "httpx", specifier = ">=0.26,<1.0" }, { name = "packaging", specifier = ">=24.2,<27" }, { name = "psutil", marker = "sys_platform == 'win32'", specifier = ">=7.0.0,<8.0" }, From d91bfaf5d014f9d8936a647288418e25a7213a1c Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 23 Aug 2026 17:16:47 +0200 Subject: [PATCH 17/44] make windows work. --- reflex/testing.py | 25 +++++++++++++++++-------- tests/units/test_testing.py | 24 ++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/reflex/testing.py b/reflex/testing.py index 8a79b17ebb9..29bafa64477 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -167,7 +167,7 @@ async def _serve(self) -> None: try: # Another process can claim the probed port before granian binds - # it; retry on a fresh port until a stop was actually requested. + # it; retry the bind on a fresh port. for _ in range(10): server = Server( self.app, @@ -185,14 +185,23 @@ async def _serve(self) -> None: try: await server.serve() except (OSError, RuntimeError) as ex: - # Granian surfaces bind failures as RuntimeError. - if "address already in use" not in str(ex).lower(): + # Granian surfaces bind failures as RuntimeError; the + # message is platform-specific (os error 98 / 10048). + message = str(ex).lower() + if ( + "address already in use" not in message + and "os error 10048" not in message + ): raise - if self._should_exit.is_set(): - break - with socket.socket() as probe: - probe.bind((self.host, 0)) - self.port = probe.getsockname()[1] + if self._should_exit.is_set(): + break + with socket.socket() as probe: + probe.bind((self.host, 0)) + self.port = probe.getsockname()[1] + continue + # serve() returned: shutdown, or a server failure after + # startup -- never restart on a different port. + break finally: await self.shutdown() diff --git a/tests/units/test_testing.py b/tests/units/test_testing.py index 20aa44f3166..08dd9890f21 100644 --- a/tests/units/test_testing.py +++ b/tests/units/test_testing.py @@ -207,9 +207,11 @@ async def app(scope, receive, send): server = reflex_testing._EmbeddedServer(app) probed_port = server.port - # Steal the probed port before the server binds it. + # Steal the probed port before the server binds it. On Windows only + # SO_EXCLUSIVEADDRUSE makes the port unavailable to other binders. blocker = socket.socket() - blocker.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if exclusive := getattr(socket, "SO_EXCLUSIVEADDRUSE", None): + blocker.setsockopt(socket.SOL_SOCKET, exclusive, 1) blocker.bind((server.host, probed_port)) blocker.listen(1) thread = threading.Thread(target=server.run) @@ -227,3 +229,21 @@ async def app(scope, receive, send): server.should_exit = True thread.join(timeout=15) assert not thread.is_alive() + + +def test_embedded_server_stops_after_unexpected_serve_return(monkeypatch): + """A serve() return that was not requested stops the server without rebinding.""" + import granian.server.embed + + class FakeServer: + def __init__(self, *args, **kwargs): + pass + + async def serve(self): + return + + monkeypatch.setattr(granian.server.embed, "Server", FakeServer) + server = reflex_testing._EmbeddedServer(app=FakeServer) # pyright: ignore[reportArgumentType] + port_before = server.port + server.run() + assert server.port == port_before From c320bbe0a08017918a762a1fca18443d4d986d0d Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 23 Aug 2026 17:26:46 +0200 Subject: [PATCH 18/44] fix: recognize WSAEACCES (os error 10013) as a taken port on windows --- reflex/testing.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/reflex/testing.py b/reflex/testing.py index 29bafa64477..925b8a19d7d 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -186,11 +186,14 @@ async def _serve(self) -> None: await server.serve() except (OSError, RuntimeError) as ex: # Granian surfaces bind failures as RuntimeError; the - # message is platform-specific (os error 98 / 10048). + # message is platform-specific: os error 98 (posix), or + # 10048/10013 (windows; exclusively-held ports fail with + # WSAEACCES rather than WSAEADDRINUSE). message = str(ex).lower() if ( "address already in use" not in message and "os error 10048" not in message + and "os error 10013" not in message ): raise if self._should_exit.is_set(): From 1826a010e0d0483c1a1c384429d860ca43364f16 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 23 Aug 2026 17:34:20 +0200 Subject: [PATCH 19/44] some granian harness improvements --- reflex/testing.py | 7 +++++-- tests/units/test_testing.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/reflex/testing.py b/reflex/testing.py index 925b8a19d7d..c519022c3ee 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -168,7 +168,7 @@ async def _serve(self) -> None: try: # Another process can claim the probed port before granian binds # it; retry the bind on a fresh port. - for _ in range(10): + for attempts_left in reversed(range(10)): server = Server( self.app, address=self.host, @@ -194,10 +194,13 @@ async def _serve(self) -> None: "address already in use" not in message and "os error 10048" not in message and "os error 10013" not in message - ): + ) or not attempts_left: raise if self._should_exit.is_set(): break + logger.warning( + f"Port {self.port} unavailable ({ex}); retrying on a fresh port." + ) with socket.socket() as probe: probe.bind((self.host, 0)) self.port = probe.getsockname()[1] diff --git a/tests/units/test_testing.py b/tests/units/test_testing.py index 08dd9890f21..aa5f88eeb97 100644 --- a/tests/units/test_testing.py +++ b/tests/units/test_testing.py @@ -247,3 +247,21 @@ async def serve(self): port_before = server.port server.run() assert server.port == port_before + + +def test_embedded_server_raises_after_retries_exhausted(monkeypatch): + """Exhausted bind retries re-raise the error instead of returning silently.""" + import granian.server.embed + + class FakeServer: + def __init__(self, *args, **kwargs): + pass + + async def serve(self): + bind_error = "Address already in use (os error 98)" + raise RuntimeError(bind_error) + + monkeypatch.setattr(granian.server.embed, "Server", FakeServer) + server = reflex_testing._EmbeddedServer(app=FakeServer) # pyright: ignore[reportArgumentType] + with pytest.raises(RuntimeError, match="in use"): + server.run() From 84233c59d424ba2c4aa91691f6a6abb0dc758830 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 23 Aug 2026 17:42:23 +0200 Subject: [PATCH 20/44] more harness nits --- reflex/testing.py | 4 +++- tests/units/test_testing.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/reflex/testing.py b/reflex/testing.py index c519022c3ee..c2a21c0f79c 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -194,10 +194,12 @@ async def _serve(self) -> None: "address already in use" not in message and "os error 10048" not in message and "os error 10013" not in message - ) or not attempts_left: + ): raise if self._should_exit.is_set(): break + if not attempts_left: + raise logger.warning( f"Port {self.port} unavailable ({ex}); retrying on a fresh port." ) diff --git a/tests/units/test_testing.py b/tests/units/test_testing.py index aa5f88eeb97..098b361306d 100644 --- a/tests/units/test_testing.py +++ b/tests/units/test_testing.py @@ -253,11 +253,14 @@ def test_embedded_server_raises_after_retries_exhausted(monkeypatch): """Exhausted bind retries re-raise the error instead of returning silently.""" import granian.server.embed + calls = [] + class FakeServer: def __init__(self, *args, **kwargs): pass async def serve(self): + calls.append(1) bind_error = "Address already in use (os error 98)" raise RuntimeError(bind_error) @@ -265,3 +268,32 @@ async def serve(self): server = reflex_testing._EmbeddedServer(app=FakeServer) # pyright: ignore[reportArgumentType] with pytest.raises(RuntimeError, match="in use"): server.run() + assert len(calls) == 10 + + +def test_embedded_server_shutdown_wins_over_exhausted_retries(monkeypatch): + """A stop requested during the last failed bind ends the server cleanly.""" + import threading + + import granian.server.embed + + calls = [] + holder = {} + + class FakeServer: + def __init__(self, *args, **kwargs): + self.interrupt_signal = False + self.main_loop_interrupt = threading.Event() + + async def serve(self): + calls.append(1) + if len(calls) == 10: + holder["server"].should_exit = True + bind_error = "Address already in use (os error 98)" + raise RuntimeError(bind_error) + + monkeypatch.setattr(granian.server.embed, "Server", FakeServer) + server = reflex_testing._EmbeddedServer(app=FakeServer) # pyright: ignore[reportArgumentType] + holder["server"] = server + server.run() + assert len(calls) == 10 From 90e389edb950eaa228e5e761f22759da76f9f49f Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 23 Aug 2026 18:10:05 +0200 Subject: [PATCH 21/44] fix: close on malformed frames and tokenless connections instead of logging per frame --- reflex/event_namespace.py | 15 ++- tests/benchmarks/test_event_transport.py | 55 ++------ tests/units/test_event_namespace.py | 162 ++++++----------------- 3 files changed, 57 insertions(+), 175 deletions(-) diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index d93f02f9bb6..7847da63b7e 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -495,6 +495,10 @@ async def heartbeat() -> None: websocket.scope.get("query_string", b"").decode(), subprotocols[0] if subprotocols else None, ) + if sid not in self._token_manager.sid_to_token: + # No token was linked; not a Reflex client. + await websocket.close(code=1008) + return while True: received = await websocket.receive() if received["type"] == "websocket.disconnect": @@ -527,15 +531,18 @@ async def heartbeat() -> None: try: message = json.loads(text) except json.JSONDecodeError: - logger.warning(f"Ignoring malformed message from session {sid}.") - continue + message = None if ( not isinstance(message, list) or not message or not isinstance(message[0], str) ): - logger.warning(f"Ignoring malformed message from session {sid}.") - continue + # A Reflex client never sends malformed frames; close + # instead of logging per frame, which a hostile client + # could use to flood the logs. + logger.debug(f"Closing session {sid}: malformed frame.") + await websocket.close(code=1002) + break event = message[0] data = message[1] if len(message) > 1 else None try: diff --git a/tests/benchmarks/test_event_transport.py b/tests/benchmarks/test_event_transport.py index c4230eab69a..4c9292ab084 100644 --- a/tests/benchmarks/test_event_transport.py +++ b/tests/benchmarks/test_event_transport.py @@ -54,9 +54,6 @@ def _make_app(sio: Any = None) -> SimpleNamespace: """Build a minimal app double for the event namespace. - Args: - sio: The Socket.IO server, for the legacy transport. - Returns: The app double. """ @@ -78,11 +75,7 @@ class FakeWebSocket: """Minimal stand-in for a starlette WebSocket.""" def __init__(self, frames: list[str]): - """Initialize with the inbound frames to deliver. - - Args: - frames: The frames to deliver before disconnecting. - """ + """Initialize with the inbound frames to deliver.""" self.scope: dict[str, Any] = { "type": "websocket", "query_string": f"token={TOKEN}".encode(), @@ -96,18 +89,10 @@ def __init__(self, frames: list[str]): self._pos = 0 async def accept(self, subprotocol: str | None = None): - """Accept the connection. - - Args: - subprotocol: The selected subprotocol. - """ + """Accept the connection.""" async def send_text(self, text: str): - """Record an outgoing frame. - - Args: - text: The frame text. - """ + """Record an outgoing frame.""" self.sent.append(text) async def receive(self) -> dict[str, Any]: @@ -123,11 +108,7 @@ async def receive(self) -> dict[str, Any]: return {"type": "websocket.receive", "text": item} async def close(self, code: int = 1000): - """Close the connection. - - Args: - code: The close code. - """ + """Close the connection.""" @pytest_asyncio.fixture @@ -269,12 +250,7 @@ async def run() -> None: def test_transport_inbound_websocket(websocket_inbound, benchmark: BenchmarkFixture): - """Benchmark inbound event handling on the plain WebSocket transport. - - Args: - websocket_inbound: The runner. - benchmark: The codspeed benchmark fixture. - """ + """Benchmark inbound event handling on the plain WebSocket transport.""" loop = asyncio.get_event_loop() @benchmark @@ -283,12 +259,7 @@ def _(): def test_transport_inbound_socketio(socketio_inbound, benchmark: BenchmarkFixture): - """Benchmark inbound event handling on the Socket.IO transport. - - Args: - socketio_inbound: The runner. - benchmark: The codspeed benchmark fixture. - """ + """Benchmark inbound event handling on the Socket.IO transport.""" loop = asyncio.get_event_loop() @benchmark @@ -297,12 +268,7 @@ def _(): def test_transport_outbound_websocket(websocket_outbound, benchmark: BenchmarkFixture): - """Benchmark emitting state updates on the plain WebSocket transport. - - Args: - websocket_outbound: The runner. - benchmark: The codspeed benchmark fixture. - """ + """Benchmark emitting state updates on the plain WebSocket transport.""" loop = asyncio.get_event_loop() @benchmark @@ -311,12 +277,7 @@ def _(): def test_transport_outbound_socketio(socketio_outbound, benchmark: BenchmarkFixture): - """Benchmark emitting state updates on the Socket.IO transport. - - Args: - socketio_outbound: The runner. - benchmark: The codspeed benchmark fixture. - """ + """Benchmark emitting state updates on the Socket.IO transport.""" loop = asyncio.get_event_loop() @benchmark diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 4860cc46547..f17c29c7b6d 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -27,13 +27,7 @@ def __init__( origin: str | None = None, subprotocols: list[str] | None = None, ): - """Initialize the fake websocket. - - Args: - query_string: The raw query string of the connection. - origin: The Origin header value, if any. - subprotocols: The offered subprotocols. - """ + """Initialize the fake websocket.""" self.scope: dict[str, Any] = { "type": "websocket", "query_string": query_string, @@ -49,28 +43,16 @@ def __init__( self._incoming: asyncio.Queue = asyncio.Queue() async def accept(self, subprotocol: str | None = None): - """Record the accept call. - - Args: - subprotocol: The selected subprotocol. - """ + """Record the accept call.""" self.accepted = True self.accepted_subprotocol = subprotocol async def send_text(self, text: str): - """Record an outgoing frame. - - Args: - text: The frame text. - """ + """Record an outgoing frame.""" self.sent.append(json.loads(text)) async def close(self, code: int = 1000): - """Record the close call. - - Args: - code: The close code. - """ + """Record the close call.""" self.close_code = code async def receive(self) -> dict[str, Any]: @@ -115,10 +97,6 @@ def namespace(mock_app: Mock, mocker) -> WebsocketEventNamespace: Redis is disabled so token linking cannot leak into a shared Redis. - Args: - mock_app: The mock app. - mocker: The pytest-mock fixture. - Returns: The namespace. """ @@ -134,11 +112,7 @@ async def _drain_tasks(): @pytest.mark.asyncio async def test_handshake_and_token_link(namespace: WebsocketEventNamespace): - """The server sends the handshake first and links the token from the query. - - Args: - namespace: The websocket event namespace. - """ + """The server sends the handshake first and links the token from the query.""" websocket = FakeWebSocket(subprotocols=["0.0.1"]) websocket.feed() await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] @@ -154,12 +128,7 @@ async def test_handshake_and_token_link(namespace: WebsocketEventNamespace): @pytest.mark.asyncio async def test_event_is_enqueued(namespace: WebsocketEventNamespace, mock_app: Mock): - """An incoming event frame reaches the app's event processor. - - Args: - namespace: The websocket event namespace. - mock_app: The mock app. - """ + """An incoming event frame reaches the app's event processor.""" websocket = FakeWebSocket() websocket.feed([ "event", @@ -178,11 +147,7 @@ async def test_event_is_enqueued(namespace: WebsocketEventNamespace, mock_app: M @pytest.mark.asyncio async def test_ping_pong(namespace: WebsocketEventNamespace): - """An application-level ping event gets a pong reply. - - Args: - namespace: The websocket event namespace. - """ + """An application-level ping event gets a pong reply.""" websocket = FakeWebSocket() websocket.feed(["ping"], [PONG_MESSAGE]) await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] @@ -195,12 +160,7 @@ async def test_ping_pong(namespace: WebsocketEventNamespace): async def test_client_error_reaches_exception_handler( namespace: WebsocketEventNamespace, mock_app: Mock ): - """A client_error frame is routed to the frontend exception handler. - - Args: - namespace: The websocket event namespace. - mock_app: The mock app. - """ + """A client_error frame is routed to the frontend exception handler.""" errors: list[str] = [] mock_app.frontend_exception_handler = lambda exc: errors.append(str(exc)) websocket = FakeWebSocket() @@ -213,40 +173,38 @@ async def test_client_error_reaches_exception_handler( @pytest.mark.asyncio -async def test_malformed_frames_are_ignored( - namespace: WebsocketEventNamespace, mock_app: Mock +@pytest.mark.parametrize("frame", ["not json", '{"an": "object"}', "[42]"]) +async def test_malformed_frame_closes_connection( + namespace: WebsocketEventNamespace, frame: str ): - """Malformed frames are skipped without dropping the connection. - - Args: - namespace: The websocket event namespace. - mock_app: The mock app. - """ + """A malformed frame closes the connection with 1002 (protocol error).""" websocket = FakeWebSocket() - websocket.feed( - "not json", - '{"an": "object"}', - [42], - ["ping"], - ) + websocket.feed(frame, ["ping"]) await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] await _drain_tasks() - # The valid ping after the malformed frames was still processed. - assert ["ping", "pong"] in websocket.sent - assert websocket.close_code is None + assert websocket.close_code == 1002 + # Nothing after the malformed frame is processed. + assert ["ping", "pong"] not in websocket.sent + + +@pytest.mark.asyncio +async def test_tokenless_connection_rejected(namespace: WebsocketEventNamespace): + """A connection without a token closes with 1008 (policy violation).""" + websocket = FakeWebSocket(query_string=b"") + websocket.feed(["ping"]) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert websocket.close_code == 1008 + assert ["ping", "pong"] not in websocket.sent @pytest.mark.asyncio async def test_oversize_message_closes_connection( namespace: WebsocketEventNamespace, monkeypatch: pytest.MonkeyPatch ): - """A frame over the size limit closes the connection with 1009. - - Args: - namespace: The websocket event namespace. - monkeypatch: The pytest monkeypatch fixture. - """ + """A frame over the size limit closes the connection with 1009.""" monkeypatch.setenv("REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE", "10") websocket = FakeWebSocket() websocket.feed(["event", {"payload": "x" * 100}]) @@ -260,12 +218,7 @@ async def test_oversize_message_closes_connection( async def test_oversize_multibyte_message_closes_connection( namespace: WebsocketEventNamespace, monkeypatch: pytest.MonkeyPatch ): - """The size limit counts bytes, so multibyte text cannot sneak past it. - - Args: - namespace: The websocket event namespace. - monkeypatch: The pytest monkeypatch fixture. - """ + """The size limit counts bytes, so multibyte text cannot sneak past it.""" monkeypatch.setenv("REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE", "25") # 15 characters (under the limit) but 29 UTF-8 bytes (over it). frame = '["x","€€€€€€€"]' @@ -282,12 +235,7 @@ async def test_oversize_multibyte_message_closes_connection( async def test_multibyte_message_within_limit_is_processed( namespace: WebsocketEventNamespace, monkeypatch: pytest.MonkeyPatch ): - """Multibyte frames within the byte limit pass through the exact check. - - Args: - namespace: The websocket event namespace. - monkeypatch: The pytest monkeypatch fixture. - """ + """Multibyte frames within the byte limit pass through the exact check.""" # 12 characters, 14 bytes: over limit/4 (triggers the exact byte count) # but within the limit itself. monkeypatch.setenv("REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE", "14") @@ -302,11 +250,7 @@ async def test_multibyte_message_within_limit_is_processed( @pytest.mark.asyncio async def test_binary_frame_closes_connection(namespace: WebsocketEventNamespace): - """A binary frame closes the connection with 1003 (unsupported data). - - Args: - namespace: The websocket event namespace. - """ + """A binary frame closes the connection with 1003 (unsupported data).""" websocket = FakeWebSocket() websocket.feed(b"\x00\x01") await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] @@ -319,12 +263,7 @@ async def test_binary_frame_closes_connection(namespace: WebsocketEventNamespace async def test_disallowed_origin_is_rejected( namespace: WebsocketEventNamespace, mocker ): - """A cross-origin connection is closed before being accepted. - - Args: - namespace: The websocket event namespace. - mocker: The pytest-mock fixture. - """ + """A cross-origin connection is closed before being accepted.""" from reflex_base.config import get_config mocker.patch.object( @@ -339,12 +278,7 @@ async def test_disallowed_origin_is_rejected( @pytest.mark.asyncio async def test_allowed_origin_is_accepted(namespace: WebsocketEventNamespace, mocker): - """A connection from an allowed origin is accepted. - - Args: - namespace: The websocket event namespace. - mocker: The pytest-mock fixture. - """ + """A connection from an allowed origin is accepted.""" from reflex_base.config import get_config mocker.patch.object( @@ -360,11 +294,7 @@ async def test_allowed_origin_is_accepted(namespace: WebsocketEventNamespace, mo @pytest.mark.asyncio async def test_duplicate_token_gets_new_token(namespace: WebsocketEventNamespace): - """A second tab connecting with the same token receives a new_token frame. - - Args: - namespace: The websocket event namespace. - """ + """A second tab connecting with the same token receives a new_token frame.""" first = FakeWebSocket() second = FakeWebSocket() namespace._sockets["sid1"] = first # pyright: ignore[reportArgumentType] @@ -386,10 +316,6 @@ async def test_emit_to_unknown_sid_does_not_raise( A client disconnecting mid-event is routine, so nothing above DEBUG may be logged. - - Args: - namespace: The websocket event namespace. - caplog: The pytest log capture fixture. """ import logging @@ -413,11 +339,7 @@ def test_default_transport_uses_websocket_namespace(): def test_socketio_transport_uses_socketio_namespace( monkeypatch: pytest.MonkeyPatch, ): - """transport="socketio" sets up the Socket.IO server and namespace. - - Args: - monkeypatch: The pytest monkeypatch fixture. - """ + """transport="socketio" sets up the Socket.IO server and namespace.""" from reflex.socketio_namespace import EventNamespace monkeypatch.setenv("REFLEX_TRANSPORT", "socketio") @@ -431,11 +353,7 @@ def test_socketio_transport_uses_socketio_namespace( def test_polling_transport_uses_socketio_namespace( monkeypatch: pytest.MonkeyPatch, ): - """transport="polling" sets up the Socket.IO server with polling only. - - Args: - monkeypatch: The pytest monkeypatch fixture. - """ + """transport="polling" sets up the Socket.IO server with polling only.""" from reflex.socketio_namespace import EventNamespace monkeypatch.setenv("REFLEX_TRANSPORT", "polling") @@ -454,11 +372,7 @@ def test_custom_sio_requires_socketio_transport(): def test_custom_sio_with_socketio_transport(monkeypatch: pytest.MonkeyPatch): - """A custom sio server works with the Socket.IO transport. - - Args: - monkeypatch: The pytest monkeypatch fixture. - """ + """A custom sio server works with the Socket.IO transport.""" from socketio import AsyncServer monkeypatch.setenv("REFLEX_TRANSPORT", "socketio") From 49f323bf0e57608d57d24e752f24b514a9c8ca7d Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 23 Aug 2026 18:19:46 +0200 Subject: [PATCH 22/44] fix: close on undeserializable events; keep server-side handler errors logged --- reflex/event_namespace.py | 9 +++++++- tests/units/test_event_namespace.py | 33 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index 7847da63b7e..7f49880ed79 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -560,8 +560,15 @@ async def heartbeat() -> None: logger.debug( f"Ignoring unknown socket event {event!r} from session {sid}." ) + except exceptions.EventDeserializationError: + # Client-controlled input a Reflex client never sends; + # close instead of logging per frame. + logger.debug(f"Closing session {sid}: undeserializable event.") + await websocket.close(code=1002) + break except Exception: - # A failing handler is logged; the connection survives. + # A failing handler is a server-side bug: log it loudly; + # the connection survives. logger.exception( f"Error handling socket event {event!r} for session {sid}." ) diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index f17c29c7b6d..73be51f8cf5 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -188,6 +188,39 @@ async def test_malformed_frame_closes_connection( assert ["ping", "pong"] not in websocket.sent +@pytest.mark.asyncio +@pytest.mark.parametrize("payload", [None, "not an event", 42]) +async def test_undeserializable_event_closes_connection( + namespace: WebsocketEventNamespace, payload: object +): + """An event frame that fails deserialization closes with 1002.""" + websocket = FakeWebSocket() + websocket.feed(["event", payload], ["ping"]) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert websocket.close_code == 1002 + assert ["ping", "pong"] not in websocket.sent + + +@pytest.mark.asyncio +async def test_handler_error_keeps_connection( + namespace: WebsocketEventNamespace, mock_app: Mock +): + """A server-side handler failure is logged and the connection survives.""" + mock_app.event_processor.enqueue.side_effect = RuntimeError("server bug") + websocket = FakeWebSocket() + websocket.feed( + ["event", {"name": "state.on_click", "payload": {}, "router_data": {}}], + ["ping"], + ) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert websocket.close_code is None + assert ["ping", "pong"] in websocket.sent + + @pytest.mark.asyncio async def test_tokenless_connection_rejected(namespace: WebsocketEventNamespace): """A connection without a token closes with 1008 (policy violation).""" From b653e3c56e04cff3b5f3ff37ba98bdf50c4bf477 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 23 Aug 2026 18:22:46 +0200 Subject: [PATCH 23/44] even more ai reviews --- tests/units/test_event_namespace.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 73be51f8cf5..d6aeba01ae5 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -205,20 +205,30 @@ async def test_undeserializable_event_closes_connection( @pytest.mark.asyncio async def test_handler_error_keeps_connection( - namespace: WebsocketEventNamespace, mock_app: Mock + namespace: WebsocketEventNamespace, + mock_app: Mock, + caplog: pytest.LogCaptureFixture, ): """A server-side handler failure is logged and the connection survives.""" + import logging + mock_app.event_processor.enqueue.side_effect = RuntimeError("server bug") websocket = FakeWebSocket() websocket.feed( ["event", {"name": "state.on_click", "payload": {}, "router_data": {}}], ["ping"], ) - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + with caplog.at_level(logging.ERROR, logger="reflex.event_namespace"): + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] await _drain_tasks() assert websocket.close_code is None assert ["ping", "pong"] in websocket.sent + assert any( + record.levelno == logging.ERROR + and "Error handling socket event" in record.getMessage() + for record in caplog.records + ) @pytest.mark.asyncio From 3ed074a567b9e88c375f235484effa40a320c32d Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 23 Aug 2026 18:32:29 +0200 Subject: [PATCH 24/44] fix: treat invalid event field types and router_data as deserialization errors --- reflex/event_namespace.py | 40 +++++++++++++++++++---------- tests/units/test_event_namespace.py | 12 ++++++++- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index 7f49880ed79..0c25f462219 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -206,7 +206,7 @@ async def handle_event( asgi_scope: The ASGI scope of the client connection. Raises: - EventDeserializationError: If the event data is not a dictionary. + EventDeserializationError: If the event data is malformed. """ # Determine the token for this SID if (token := self.sid_to_token.get(sid)) is None: @@ -239,6 +239,15 @@ async def handle_event( msg = f"Failed to deserialize event data: {fields}." raise exceptions.EventDeserializationError(msg) from ex + # The dataclass does not validate field types. + if ( + not isinstance(event.name, str) + or not isinstance(event.payload, dict) + or not isinstance(event.router_data, dict) + ): + msg = "Event fields have invalid types." + raise exceptions.EventDeserializationError(msg) + # Decode the connection headers once: the scope is per-connection # state, so cache the decoded mapping in it and copy per event (the # copy is mutated below and ends up in the event's router_data). @@ -269,18 +278,23 @@ async def handle_event( .strip() ) router_data = event.router_data - router_data.update({ - constants.RouteVar.QUERY: format.format_query_params(event.router_data), - constants.RouteVar.CLIENT_TOKEN: token, - constants.RouteVar.SESSION_ID: sid, - constants.RouteVar.HEADERS: headers, - constants.RouteVar.CLIENT_IP: client_ip, - }) - router_data[constants.RouteVar.PATH] = "/" + ( - self.app.router(path) or "404" - if (path := router_data.get(constants.RouteVar.PATH)) - else "404" - ).removeprefix("/") + try: + # The nested values are still client-controlled. + router_data.update({ + constants.RouteVar.QUERY: format.format_query_params(event.router_data), + constants.RouteVar.CLIENT_TOKEN: token, + constants.RouteVar.SESSION_ID: sid, + constants.RouteVar.HEADERS: headers, + constants.RouteVar.CLIENT_IP: client_ip, + }) + router_data[constants.RouteVar.PATH] = "/" + ( + self.app.router(path) or "404" + if (path := router_data.get(constants.RouteVar.PATH)) + else "404" + ).removeprefix("/") + except (AttributeError, LookupError, TypeError, ValueError) as ex: + msg = "Failed to normalize event router_data." + raise exceptions.EventDeserializationError(msg) from ex await self.app.event_processor.enqueue(token, event) async def handle_ping(self, sid: str) -> None: diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index d6aeba01ae5..b4b1daec5cb 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -189,7 +189,17 @@ async def test_malformed_frame_closes_connection( @pytest.mark.asyncio -@pytest.mark.parametrize("payload", [None, "not an event", 42]) +@pytest.mark.parametrize( + "payload", + [ + None, + "not an event", + 42, + {"name": 123, "payload": {}, "router_data": {}}, + {"name": "x", "payload": "nope", "router_data": {}}, + {"name": "x", "payload": {}, "router_data": {"query": "not-a-dict"}}, + ], +) async def test_undeserializable_event_closes_connection( namespace: WebsocketEventNamespace, payload: object ): From d72513e2bfed6591cd0d081754bb3cf23b0ef4b9 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 23 Aug 2026 18:48:19 +0200 Subject: [PATCH 25/44] feat(uvicorn): honor a raised socket message policy at the protocol layer via ws_max_size make greptile happy --- reflex/utils/exec.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 8c5e2d54371..0e3e8142a89 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -639,9 +639,22 @@ def run_uvicorn_backend(host: str, port: int, loglevel: LogLevel): reload=True, reload_dirs=list(map(str, get_reload_paths())), reload_delay=0.1, + ws_max_size=_uvicorn_ws_max_size(), ) +def _uvicorn_ws_max_size() -> int: + """Websocket message size limit for uvicorn. + + Never below uvicorn's 16 MiB default, so unrelated websocket endpoints + keep working; raised when the Reflex policy limit needs more. + + Returns: + The message size limit in bytes. + """ + return max(environment.REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE.get(), 16 * 1024 * 1024) + + HOTRELOAD_IGNORE_EXTENSIONS = ( "txt", "toml", @@ -758,6 +771,7 @@ def run_uvicorn_backend_prod( *("--host", host), *("--port", str(port)), *("--workers", str(_get_backend_workers())), + *("--ws-max-size", str(_uvicorn_ws_max_size())), "--factory", app_module, ] From f9b63d537bcd770356687d51a3f41c395aa5afab Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Fri, 28 Aug 2026 00:29:52 +0200 Subject: [PATCH 26/44] DRY it up --- .../src/reflex_base/utils/console.py | 81 +--------- reflex/event_namespace.py | 142 ++++++++++-------- reflex/socketio_namespace.py | 14 +- reflex/testing.py | 24 ++- tests/benchmarks/test_event_transport.py | 93 +++++------- tests/units/test_event_namespace.py | 30 ++++ tests/units/test_testing.py | 94 ++++++------ 7 files changed, 230 insertions(+), 248 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/utils/console.py b/packages/reflex-base/src/reflex_base/utils/console.py index ea22b5069f7..b380485af58 100644 --- a/packages/reflex-base/src/reflex_base/utils/console.py +++ b/packages/reflex-base/src/reflex_base/utils/console.py @@ -11,14 +11,9 @@ import contextlib import datetime -import functools -import inspect -import shutil -import sys import time from collections.abc import Sequence from pathlib import Path -from types import FrameType, ModuleType from typing import TYPE_CHECKING, overload from rich.console import Console, OverflowMethod @@ -285,78 +280,10 @@ def warn(msg: str, *, dedupe: bool = False, **kwargs): print_to_log_file(f"[orange1]Warning: {msg}[/orange1]", **kwargs) -@once -def _exclude_paths_from_frame_info() -> list[Path]: - import importlib.util - - import click - import granian - import typing_extensions - - import reflex_base - - try: - import socketio - except ImportError: - socketio = None - - try: - import reflex as rx - except ImportError: - rx = None - - # Exclude utility modules that should never be the source of deprecated reflex usage. - exclude_modules: list[ModuleType | None] = [ - click, - rx, - typing_extensions, - socketio, - granian, - reflex_base, - ] - - modules_paths = [file for m in exclude_modules if m and (file := m.__file__)] + [ - spec.origin - for m in [*sys.builtin_module_names, *sys.stdlib_module_names] - if (spec := importlib.util.find_spec(m)) and spec.origin - ] - exclude_roots = [ - p.parent.resolve() if (p := Path(file)).name == "__init__.py" else p.resolve() - for file in modules_paths - ] - # Specifically exclude the reflex cli module. - if reflex_bin := shutil.which(b"reflex"): - exclude_roots.append(Path(reflex_bin.decode())) - - return exclude_roots - - -@functools.cache -def _is_framework_filename(filename: str) -> bool: - """Check if a code filename belongs to an excluded framework/stdlib root. - - Cached per filename: module file locations do not move within a process, - but resolving a path and comparing it against every exclude root is far - too expensive to repeat for each frame on every deprecation check. - - Args: - filename: The ``co_filename`` of a frame's code object. - - Returns: - Whether the file lives under one of the excluded framework roots. - """ - frame_path = Path(filename).resolve() - return any( - frame_path.is_relative_to(root) for root in _exclude_paths_from_frame_info() - ) - - -def _get_first_non_framework_frame() -> FrameType | None: - frame = inspect.currentframe() - while frame := frame and frame.f_back: - if not _is_framework_filename(frame.f_code.co_filename): - break - return frame +# Frame attribution is shared with the logging pipeline: one implementation, +# one populated cache of framework paths (building it walks every stdlib +# module). Bound here so callers and tests can still patch it on this module. +_get_first_non_framework_frame = _log._get_first_non_framework_frame def deprecate( diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index 3cf2c8e5c89..95d2611da1d 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -459,6 +459,80 @@ def _origin_allowed(origin: str | None) -> bool: allowed_origins = get_config().cors_allowed_origins return "*" in allowed_origins or origin in allowed_origins + async def _handle_frame( + self, sid: str, text: str, scope: MutableMapping[str, Any], max_size: int + ) -> int | None: + """Validate and dispatch one inbound text frame. + + Args: + sid: The session id. + text: The raw frame text. + scope: The ASGI scope of the client connection. + max_size: The message size limit in bytes. + + Returns: + The websocket close code the session must end with, or None to + keep serving it. + """ + # ASGI delivers complete messages, so the server has already buffered + # the frame; its protocol-level caps (enforced during frame + # reassembly) bound that allocation. This check applies the Reflex + # policy limit on top. + # The limit is in bytes; UTF-8 encodes 1-4 bytes per character, so + # more characters than the limit is certainly over, and a quarter or + # fewer certainly under -- only encode to count the exact bytes in + # between (bounding the copy to 4x the limit). + text_length = len(text) + if text_length > max_size or ( + text_length * 4 > max_size and len(text.encode("utf-8")) > max_size + ): + logger.debug(f"Closing session {sid}: message over {max_size} bytes.") + return 1009 + try: + message = json.loads(text) + except json.JSONDecodeError: + message = None + if ( + not isinstance(message, list) + or not message + or not isinstance(message[0], str) + ): + # A Reflex client never sends malformed frames; close instead of + # logging per frame, which a hostile client could use to flood + # the logs. + logger.debug(f"Closing session {sid}: malformed frame.") + return 1002 + event = message[0] + data = message[1] if len(message) > 1 else None + try: + # Ordered by frequency: events are the hot path, heartbeat pongs + # arrive once per ping interval. + if event == _EVENT: + await self.handle_event(sid, data, scope) + elif event == PONG_MESSAGE: + # Receiving it already refreshed the liveness deadline. + pass + elif event == _PING: + await self.handle_ping(sid) + elif event == _CLIENT_ERROR: + await self.handle_client_error(sid, data) + else: + logger.debug( + f"Ignoring unknown socket event {event!r} from session {sid}." + ) + except exceptions.EventDeserializationError: + # Client-controlled input a Reflex client never sends; close + # instead of logging per frame. + logger.debug(f"Closing session {sid}: undeserializable event.") + return 1002 + except Exception: + # A failing handler is a server-side bug: log it loudly; the + # connection survives. + logger.exception( + f"Error handling socket event {event!r} for session {sid}." + ) + return None + async def handle_websocket(self, websocket: WebSocket) -> None: """Serve one client websocket connection for its full lifetime. @@ -523,70 +597,14 @@ async def heartbeat() -> None: if text is None: # Binary frame; not part of the protocol. logger.debug(f"Closing session {sid}: received a binary frame.") - await websocket.close(code=1003) - break - # ASGI delivers complete messages, so the server has already - # buffered the frame; its protocol-level caps (enforced during - # frame reassembly) bound that allocation. This check applies - # the Reflex policy limit on top. - # The limit is in bytes; UTF-8 encodes 1-4 bytes per character, - # so more characters than the limit is certainly over, and a - # quarter or fewer certainly under -- only encode to count the - # exact bytes in between (bounding the copy to 4x the limit). - text_length = len(text) - if text_length > max_message_size or ( - text_length * 4 > max_message_size - and len(text.encode("utf-8")) > max_message_size - ): - logger.debug( - f"Closing session {sid}: message over {max_message_size} bytes." + close_code = 1003 + else: + close_code = await self._handle_frame( + sid, text, websocket.scope, max_message_size ) - await websocket.close(code=1009) - break - try: - message = json.loads(text) - except json.JSONDecodeError: - message = None - if ( - not isinstance(message, list) - or not message - or not isinstance(message[0], str) - ): - # A Reflex client never sends malformed frames; close - # instead of logging per frame, which a hostile client - # could use to flood the logs. - logger.debug(f"Closing session {sid}: malformed frame.") - await websocket.close(code=1002) + if close_code is not None: + await websocket.close(code=close_code) break - event = message[0] - data = message[1] if len(message) > 1 else None - try: - # Ordered by frequency: events are the hot path, heartbeat - # pongs arrive once per ping interval. - if event == _EVENT: - await self.handle_event(sid, data, websocket.scope) - elif event == PONG_MESSAGE: - continue - elif event == _PING: - await self.handle_ping(sid) - elif event == _CLIENT_ERROR: - await self.handle_client_error(sid, data) - else: - logger.debug( - f"Ignoring unknown socket event {event!r} from session {sid}." - ) - except exceptions.EventDeserializationError: - # Client-controlled input a Reflex client never sends; - # close instead of logging per frame. - logger.debug(f"Closing session {sid}: undeserializable event.") - await websocket.close(code=1002) - break - except Exception: - # A failing handler is a server-side bug: log it loudly; - # the connection survives. - logger.exception( - f"Error handling socket event {event!r} for session {sid}." - ) except WebSocketDisconnect: pass finally: diff --git a/reflex/socketio_namespace.py b/reflex/socketio_namespace.py index 9ba9337d374..658c77a6303 100644 --- a/reflex/socketio_namespace.py +++ b/reflex/socketio_namespace.py @@ -22,6 +22,15 @@ from reflex.app import App +# The JSON codec socket.io serializes packets with: Reflex's dumps (which +# emits the non-finite float tokens the frontend revives) and the stdlib +# loads for client-supplied data. +_SOCKET_JSON_CODEC = SimpleNamespace( + dumps=staticmethod(format.json_dumps), + loads=staticmethod(json.loads), +) + + class EventNamespace(AsyncNamespace, BaseEventNamespace): """The Socket.IO event namespace.""" @@ -170,10 +179,7 @@ def create_socketio_app(app: App, config: Config) -> ASGIApp: max_http_buffer_size=environment.REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE.get(), ping_interval=environment.REFLEX_SOCKET_INTERVAL.get(), ping_timeout=environment.REFLEX_SOCKET_TIMEOUT.get(), - json=SimpleNamespace( - dumps=staticmethod(format.json_dumps), - loads=staticmethod(json.loads), - ), + json=_SOCKET_JSON_CODEC, allow_upgrades=False, transports=["polling" if config.transport == "polling" else "websocket"], ) diff --git a/reflex/testing.py b/reflex/testing.py index 672a94373c8..66f73dd1c66 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -92,19 +92,29 @@ def __init__(self, app: ASGIApp, host: str = "127.0.0.1", port: int = 0) -> None host: the address to bind to. port: the port to bind to; 0 picks a free port immediately. """ - if port == 0: - with socket.socket() as probe: - probe.bind((host, 0)) - port = probe.getsockname()[1] self.app = app self.host = host - self.port = port + self.port = port or self._pick_free_port(host) # Monkeypatchable async shutdown hook, mirroring uvicorn.Server.shutdown. self.shutdown: Callable[..., Coroutine[Any, Any, None]] = self._noop_shutdown self._should_exit = threading.Event() self._loop: asyncio.AbstractEventLoop | None = None self._server: Any = None + @staticmethod + def _pick_free_port(host: str) -> int: + """Ask the OS for a free port and release it again. + + Args: + host: the address the port has to be free on. + + Returns: + The port number. + """ + with socket.socket() as probe: + probe.bind((host, 0)) + return probe.getsockname()[1] + @staticmethod async def _noop_shutdown(*args, **kwargs) -> None: """Default shutdown hook. @@ -204,9 +214,7 @@ async def _serve(self) -> None: logger.warning( f"Port {self.port} unavailable ({ex}); retrying on a fresh port." ) - with socket.socket() as probe: - probe.bind((self.host, 0)) - self.port = probe.getsockname()[1] + self.port = self._pick_free_port(self.host) continue # serve() returned: shutdown, or a server failure after # startup -- never restart on a different port. diff --git a/tests/benchmarks/test_event_transport.py b/tests/benchmarks/test_event_transport.py index e5ec25eb4d1..0c750cb5db9 100644 --- a/tests/benchmarks/test_event_transport.py +++ b/tests/benchmarks/test_event_transport.py @@ -9,6 +9,7 @@ import asyncio import json +from collections.abc import Awaitable, Callable from types import SimpleNamespace from typing import Any from unittest import mock @@ -16,7 +17,6 @@ import pytest import pytest_asyncio from pytest_codspeed import BenchmarkFixture -from reflex_base.utils import format from reflex.event_namespace import WebsocketEventNamespace from reflex.state import StateUpdate @@ -131,26 +131,21 @@ async def run() -> None: yield run -@pytest_asyncio.fixture -async def socketio_inbound(): # noqa: RUF029 - async so it runs on the benchmark loop - """Runner delivering NUM_MESSAGES event packets over Socket.IO. +def _make_socketio_transport() -> tuple[Any, Any, SimpleNamespace]: + """Build a Socket.IO server and namespace over an app double. + + The server runs with inline dispatch and a discarding writer, so a + benchmark measures framing and dispatch rather than the network. Returns: - An async callable running one full connection lifecycle. + The (server, namespace, app double) triple. """ pytest.importorskip("socketio") from socketio import AsyncServer - from reflex.socketio_namespace import EventNamespace + from reflex.socketio_namespace import _SOCKET_JSON_CODEC, EventNamespace - sio = AsyncServer( - async_mode="asgi", - async_handlers=False, - json=SimpleNamespace( - dumps=staticmethod(format.json_dumps), - loads=staticmethod(json.loads), - ), - ) + sio = AsyncServer(async_mode="asgi", async_handlers=False, json=_SOCKET_JSON_CODEC) with mock.patch("reflex.utils.prerequisites.check_redis_used", return_value=False): app = _make_app(sio=sio) namespace = EventNamespace(NAMESPACE, app) # pyright: ignore[reportArgumentType] @@ -160,6 +155,17 @@ async def eio_send(_eio_sid: str, _data: str) -> None: pass sio.eio.send = eio_send + return sio, namespace, app + + +@pytest_asyncio.fixture +async def socketio_inbound(): # noqa: RUF029 - async so it runs on the benchmark loop + """Runner delivering NUM_MESSAGES event packets over Socket.IO. + + Returns: + An async callable running one full connection lifecycle. + """ + sio, _namespace, app = _make_socketio_transport() event_packet = "2" + NAMESPACE + "," + json.dumps(["event", _EVENT_FIELDS]) counter = 0 @@ -211,28 +217,7 @@ async def socketio_outbound(): Returns: An async callable emitting the updates. """ - pytest.importorskip("socketio") - from socketio import AsyncServer - - from reflex.socketio_namespace import EventNamespace - - sio = AsyncServer( - async_mode="asgi", - async_handlers=False, - json=SimpleNamespace( - dumps=staticmethod(format.json_dumps), - loads=staticmethod(json.loads), - ), - ) - with mock.patch("reflex.utils.prerequisites.check_redis_used", return_value=False): - app = _make_app(sio=sio) - namespace = EventNamespace(NAMESPACE, app) # pyright: ignore[reportArgumentType] - sio.register_namespace(namespace) - - async def eio_send(_eio_sid: str, _data: str) -> None: - pass - - sio.eio.send = eio_send + sio, namespace, _app = _make_socketio_transport() async def run() -> None: for _ in range(NUM_MESSAGES): @@ -249,37 +234,37 @@ async def run() -> None: return run -def test_transport_inbound_websocket(websocket_inbound, benchmark: BenchmarkFixture): - """Benchmark inbound event handling on the plain WebSocket transport.""" +def _benchmark_runner( + benchmark: BenchmarkFixture, runner: Callable[[], Awaitable[None]] +): + """Benchmark one full run of an async transport runner. + + Args: + benchmark: The benchmark fixture. + runner: The async callable to measure. + """ loop = asyncio.get_event_loop() @benchmark def _(): - loop.run_until_complete(websocket_inbound()) + loop.run_until_complete(runner()) + + +def test_transport_inbound_websocket(websocket_inbound, benchmark: BenchmarkFixture): + """Benchmark inbound event handling on the plain WebSocket transport.""" + _benchmark_runner(benchmark, websocket_inbound) def test_transport_inbound_socketio(socketio_inbound, benchmark: BenchmarkFixture): """Benchmark inbound event handling on the Socket.IO transport.""" - loop = asyncio.get_event_loop() - - @benchmark - def _(): - loop.run_until_complete(socketio_inbound()) + _benchmark_runner(benchmark, socketio_inbound) def test_transport_outbound_websocket(websocket_outbound, benchmark: BenchmarkFixture): """Benchmark emitting state updates on the plain WebSocket transport.""" - loop = asyncio.get_event_loop() - - @benchmark - def _(): - loop.run_until_complete(websocket_outbound()) + _benchmark_runner(benchmark, websocket_outbound) def test_transport_outbound_socketio(socketio_outbound, benchmark: BenchmarkFixture): """Benchmark emitting state updates on the Socket.IO transport.""" - loop = asyncio.get_event_loop() - - @benchmark - def _(): - loop.run_until_complete(socketio_outbound()) + _benchmark_runner(benchmark, socketio_outbound) diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index b4b1daec5cb..982c91e1027 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -2,6 +2,8 @@ import asyncio import json +import re +from pathlib import Path from typing import Any from unittest.mock import AsyncMock, Mock @@ -11,12 +13,18 @@ from reflex.app import App from reflex.event_namespace import ( HANDSHAKE_MESSAGE, + PING_MESSAGE, PONG_MESSAGE, WebsocketEventNamespace, ) _DISCONNECT = object() +WEBSOCKET_JS_TEMPLATE = ( + Path(__file__).parents[2] + / "packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js" +) + class FakeWebSocket: """Minimal stand-in for a starlette WebSocket.""" @@ -442,3 +450,25 @@ def test_app_event_namespace_reexport(): assert reflex.app.EventNamespace is EventNamespace with pytest.raises(AttributeError): _ = reflex.app.DoesNotExist + + +def test_protocol_message_names_match_the_client(): + """The client speaks the same protocol message names as the server. + + Both ends declare these independently, and a rename on one side is + invisible until a browser fails to connect: the client would never + answer a heartbeat, so every session would be dropped on ping timeout. + """ + declarations = dict( + re.findall( + r'^const (\w+_MESSAGE) = "([^"]+)";$', + WEBSOCKET_JS_TEMPLATE.read_text(), + re.MULTILINE, + ) + ) + + assert declarations == { + "HANDSHAKE_MESSAGE": HANDSHAKE_MESSAGE, + "PING_MESSAGE": PING_MESSAGE, + "PONG_MESSAGE": PONG_MESSAGE, + } diff --git a/tests/units/test_testing.py b/tests/units/test_testing.py index e77e4874aff..0e49d54c7dd 100644 --- a/tests/units/test_testing.py +++ b/tests/units/test_testing.py @@ -1,9 +1,14 @@ """Unit tests for the included testing tools.""" +import socket import sys +import threading +import time +from collections.abc import Callable from types import ModuleType, SimpleNamespace from unittest import mock +import granian.server.embed import pytest import reflex_base.config from reflex_base.components.memo import MEMOS @@ -189,11 +194,40 @@ def test_app_harness_initialize_reloads_existing_imported_app( harness_mocks.get_and_validate_app.assert_called_once_with(reload=True) +def _patch_embedded_granian( + monkeypatch: pytest.MonkeyPatch, on_serve: Callable[[], None] +): + """Replace granian's embedded server with a fake driven by `on_serve`. + + Args: + monkeypatch: the pytest monkeypatch fixture. + on_serve: called once per `serve()`; raise from it to simulate a + failed bind. + """ + + class FakeServer: + def __init__(self, *args, **kwargs): + self.interrupt_signal = False + self.main_loop_interrupt = threading.Event() + + # Async to match granian's Server.serve(). + async def serve(self): + on_serve() + + monkeypatch.setattr(granian.server.embed, "Server", FakeServer) + + +def _bind_error() -> RuntimeError: + """A granian bind failure for an already-claimed port. + + Returns: + The error granian raises when the port is taken. + """ + return RuntimeError("Address already in use (os error 98)") + + def test_embedded_server_retries_taken_port(): """The embedded server rebinds to a fresh port when its probed port is taken.""" - import socket - import threading - import time async def app(scope, receive, send): if scope["type"] == "lifespan": @@ -233,17 +267,8 @@ async def app(scope, receive, send): def test_embedded_server_stops_after_unexpected_serve_return(monkeypatch): """A serve() return that was not requested stops the server without rebinding.""" - import granian.server.embed - - class FakeServer: - def __init__(self, *args, **kwargs): - pass - - async def serve(self): - return - - monkeypatch.setattr(granian.server.embed, "Server", FakeServer) - server = reflex_testing._EmbeddedServer(app=FakeServer) # pyright: ignore[reportArgumentType] + _patch_embedded_granian(monkeypatch, lambda: None) + server = reflex_testing._EmbeddedServer(app=mock.Mock()) port_before = server.port server.run() assert server.port == port_before @@ -251,21 +276,14 @@ async def serve(self): def test_embedded_server_raises_after_retries_exhausted(monkeypatch): """Exhausted bind retries re-raise the error instead of returning silently.""" - import granian.server.embed - calls = [] - class FakeServer: - def __init__(self, *args, **kwargs): - pass - - async def serve(self): - calls.append(1) - bind_error = "Address already in use (os error 98)" - raise RuntimeError(bind_error) + def on_serve() -> None: + calls.append(1) + raise _bind_error() - monkeypatch.setattr(granian.server.embed, "Server", FakeServer) - server = reflex_testing._EmbeddedServer(app=FakeServer) # pyright: ignore[reportArgumentType] + _patch_embedded_granian(monkeypatch, on_serve) + server = reflex_testing._EmbeddedServer(app=mock.Mock()) with pytest.raises(RuntimeError, match="in use"): server.run() assert len(calls) == 10 @@ -273,27 +291,17 @@ async def serve(self): def test_embedded_server_shutdown_wins_over_exhausted_retries(monkeypatch): """A stop requested during the last failed bind ends the server cleanly.""" - import threading - - import granian.server.embed - calls = [] holder = {} - class FakeServer: - def __init__(self, *args, **kwargs): - self.interrupt_signal = False - self.main_loop_interrupt = threading.Event() + def on_serve() -> None: + calls.append(1) + if len(calls) == 10: + holder["server"].should_exit = True + raise _bind_error() - async def serve(self): - calls.append(1) - if len(calls) == 10: - holder["server"].should_exit = True - bind_error = "Address already in use (os error 98)" - raise RuntimeError(bind_error) - - monkeypatch.setattr(granian.server.embed, "Server", FakeServer) - server = reflex_testing._EmbeddedServer(app=FakeServer) # pyright: ignore[reportArgumentType] + _patch_embedded_granian(monkeypatch, on_serve) + server = reflex_testing._EmbeddedServer(app=mock.Mock()) holder["server"] = server server.run() assert len(calls) == 10 From c695256d45817f89d00381bc782cd55639d70c8d Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Fri, 4 Sep 2026 10:10:35 +0200 Subject: [PATCH 27/44] less verbose --- reflex/event_namespace.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index 95d2611da1d..abadec367f8 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -132,7 +132,9 @@ async def handle_connect( if token_list: await self.link_token_to_sid(sid, token_list[0]) else: - logger.warning(f"No token provided in connection for session {sid}") + # A Reflex client always sends a token; the transport closes the + # session, so a warning per hostile connect would only flood logs. + logger.debug(f"No token provided in connection for session {sid}.") if subprotocol and subprotocol != constants.Reflex.VERSION: logger.warning( From 1120e0e2c454af7a2ddf8fa562070316d1cb7b63 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Fri, 4 Sep 2026 13:21:01 +0200 Subject: [PATCH 28/44] only warn about version mismatch for linked sessions and reject string event payloads --- news/6932.feature.md | 2 +- reflex/event_namespace.py | 36 ++++++++----------- tests/units/test_event_namespace.py | 55 +++++++++++++++++++++++------ 3 files changed, 59 insertions(+), 34 deletions(-) diff --git a/news/6932.feature.md b/news/6932.feature.md index e2c80c4affb..5ec940f2479 100644 --- a/news/6932.feature.md +++ b/news/6932.feature.md @@ -1 +1 @@ -The default client-server transport is now a plain WebSocket speaking a lightweight JSON event protocol, replacing Socket.IO. `python-socketio` moved to the optional `reflex[socketio]` extra and `socket.io-client` is only loaded by the frontend when configured. The Socket.IO transport remains available via `transport="socketio"` (websocket) or `transport="polling"` in `rxconfig.py`; apps passing a custom `sio` server to `rx.App` must set one of these and install the extra. Uvicorn is now fully optional: `AppHarness` serves tests with Granian's embedded server (native websocket support), and the uvicorn backend fallback requires the new `reflex[uvicorn]` extra, which brings the `websockets` protocol library uvicorn needs for the WebSocket transport. +The default client-server transport is now a plain WebSocket speaking a lightweight JSON event protocol, replacing Socket.IO. `python-socketio` moved to the optional `reflex[socketio]` extra and `socket.io-client` is only loaded by the frontend when configured. The Socket.IO transport remains available via `transport="socketio"` (websocket) or `transport="polling"` in `rxconfig.py`; apps passing a custom `sio` server to `rx.App` must set one of these and install the extra. Uvicorn is now fully optional: `AppHarness` serves tests with Granian's embedded server (native websocket support), and the uvicorn backend fallback requires the new `reflex[uvicorn]` extra, which brings the `websockets` protocol library uvicorn needs for the WebSocket transport. Event payloads must be JSON objects; the fallback that parsed a string-encoded event payload was removed, and such frames now close the connection like any other malformed event. diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index abadec367f8..8041edd2774 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -129,16 +129,18 @@ async def handle_connect( self._token_manager.ensure_lost_and_found_task(self.emit_update) query_params = urllib.parse.parse_qs(query_string) token_list = query_params.get("token", []) - if token_list: - await self.link_token_to_sid(sid, token_list[0]) - else: + if not token_list: # A Reflex client always sends a token; the transport closes the # session, so a warning per hostile connect would only flood logs. logger.debug(f"No token provided in connection for session {sid}.") - + return + await self.link_token_to_sid(sid, token_list[0]) + # Only report the version for linked sessions; the value is + # client-controlled, so sanitize it before it reaches the logs. if subprotocol and subprotocol != constants.Reflex.VERSION: logger.warning( - f"Frontend version {subprotocol} for session {sid} does not match the backend version {constants.Reflex.VERSION}." + f"Frontend version {format.sanitize_client_log_value(subprotocol)} " + f"for session {sid} does not match the backend version {constants.Reflex.VERSION}." ) def handle_disconnect(self, sid: str) -> asyncio.Task | None: @@ -218,28 +220,18 @@ async def handle_event( ) return - fields = data - - if isinstance(fields, str): - logger.warning( - "Received event data as a string. This generally should not happen and may indicate a bug." - f" Event data: {fields}" - ) - try: - fields = json.loads(fields) - except json.JSONDecodeError as ex: - msg = f"Failed to deserialize event data: {fields}." - raise exceptions.EventDeserializationError(msg) from ex - - if not isinstance(fields, dict): - msg = f"Event data must be a dictionary, but received {fields} of type {type(fields)}." + # Both transports JSON-decode the frame, so a Reflex client's event + # arrives as a dict; anything else (including a JSON-encoded string) + # is rejected rather than logged per frame. + if not isinstance(data, dict): + msg = f"Event data must be a dictionary, but received {data} of type {type(data)}." raise exceptions.EventDeserializationError(msg) try: # Get the event. - event = Event(**{k: v for k, v in fields.items() if k in _EVENT_FIELDS}) + event = Event(**{k: v for k, v in data.items() if k in _EVENT_FIELDS}) except (TypeError, ValueError) as ex: - msg = f"Failed to deserialize event data: {fields}." + msg = f"Failed to deserialize event data: {data}." raise exceptions.EventDeserializationError(msg) from ex # The dataclass does not validate field types. diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 982c91e1027..d74a289c7c3 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -2,6 +2,7 @@ import asyncio import json +import logging import re from pathlib import Path from typing import Any @@ -202,6 +203,8 @@ async def test_malformed_frame_closes_connection( [ None, "not an event", + # A JSON-encoded event is still a string, not an event. + json.dumps({"name": "state.on_click", "payload": {}, "router_data": {}}), 42, {"name": 123, "payload": {}, "router_data": {}}, {"name": "x", "payload": "nope", "router_data": {}}, @@ -209,16 +212,23 @@ async def test_malformed_frame_closes_connection( ], ) async def test_undeserializable_event_closes_connection( - namespace: WebsocketEventNamespace, payload: object + namespace: WebsocketEventNamespace, + mock_app: Mock, + payload: object, + caplog: pytest.LogCaptureFixture, ): - """An event frame that fails deserialization closes with 1002.""" + """An event frame that fails deserialization closes with 1002 and no warning.""" websocket = FakeWebSocket() websocket.feed(["event", payload], ["ping"]) - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + with caplog.at_level(logging.DEBUG, logger="reflex.event_namespace"): + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] await _drain_tasks() assert websocket.close_code == 1002 assert ["ping", "pong"] not in websocket.sent + mock_app.event_processor.enqueue.assert_not_awaited() + # Client-controlled input must not write above debug level. + assert all(record.levelno <= logging.DEBUG for record in caplog.records) @pytest.mark.asyncio @@ -228,8 +238,6 @@ async def test_handler_error_keeps_connection( caplog: pytest.LogCaptureFixture, ): """A server-side handler failure is logged and the connection survives.""" - import logging - mock_app.event_processor.enqueue.side_effect = RuntimeError("server bug") websocket = FakeWebSocket() websocket.feed( @@ -250,15 +258,42 @@ async def test_handler_error_keeps_connection( @pytest.mark.asyncio -async def test_tokenless_connection_rejected(namespace: WebsocketEventNamespace): - """A connection without a token closes with 1008 (policy violation).""" - websocket = FakeWebSocket(query_string=b"") +async def test_tokenless_connection_rejected( + namespace: WebsocketEventNamespace, caplog: pytest.LogCaptureFixture +): + """A connection without a token closes with 1008 and logs nothing above debug. + + The version mismatch is not reported either: it is client-controlled and + the session is rejected anyway, so warning would only let anonymous + connects flood the logs. + """ + websocket = FakeWebSocket(query_string=b"", subprotocols=["0.0.1"]) websocket.feed(["ping"]) - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + with caplog.at_level(logging.DEBUG, logger="reflex.event_namespace"): + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] await _drain_tasks() assert websocket.close_code == 1008 assert ["ping", "pong"] not in websocket.sent + assert all(record.levelno <= logging.DEBUG for record in caplog.records) + + +@pytest.mark.asyncio +async def test_version_mismatch_warns_for_linked_session( + namespace: WebsocketEventNamespace, caplog: pytest.LogCaptureFixture +): + """A linked session with a stale frontend gets one sanitized warning.""" + websocket = FakeWebSocket(subprotocols=["0.0.1\x1b[31m"]) + websocket.feed() + with caplog.at_level(logging.WARNING, logger="reflex.event_namespace"): + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "0.0.1" in warnings[0] + assert "does not match the backend version" in warnings[0] + assert "\x1b" not in warnings[0] @pytest.mark.asyncio @@ -378,8 +413,6 @@ async def test_emit_to_unknown_sid_does_not_raise( A client disconnecting mid-event is routine, so nothing above DEBUG may be logged. """ - import logging - with caplog.at_level(logging.DEBUG, logger="reflex.event_namespace"): await namespace.emit("event", {"delta": {}}, to="gone") assert all(record.levelno <= logging.DEBUG for record in caplog.records) From e3a63619dfc5bd086339ed61699d9820223cd029 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 12 Sep 2026 14:34:07 +0200 Subject: [PATCH 29/44] multiplexing/channels wip --- docs/api-reference/channels.md | 133 ++++++ docs/app/news/+channels-reference.docs.md | 1 + .../sidebar/sidebar_items/reference.py | 1 + news/+socket-deflate-flag.performance.md | 1 + news/+websocket-channels.feature.md | 1 + .../news/+socket-deflate-flag.performance.md | 1 + .../news/+websocket-channels.feature.md | 1 + .../.templates/web/utils/helpers/websocket.js | 418 ++++++++++++++-- .../reflex_base/.templates/web/utils/state.js | 10 + .../src/reflex_base/environment.py | 6 + pyi_hashes.json | 2 +- reflex/__init__.py | 1 + reflex/app.py | 31 ++ reflex/channels.py | 258 ++++++++++ reflex/event_namespace.py | 398 +++++++++++++++- reflex/socketio_namespace.py | 4 +- reflex/utils/exec.py | 38 +- reflex/utils/uvicorn_worker.py | 22 + .../tests_playwright/test_channels.py | 182 +++++++ tests/units/test_app.py | 49 ++ tests/units/test_channels.py | 160 +++++++ tests/units/test_event_namespace.py | 447 +++++++++++++++++- tests/units/utils/test_exec.py | 59 +++ 23 files changed, 2155 insertions(+), 69 deletions(-) create mode 100644 docs/api-reference/channels.md create mode 100644 docs/app/news/+channels-reference.docs.md create mode 100644 news/+socket-deflate-flag.performance.md create mode 100644 news/+websocket-channels.feature.md create mode 100644 packages/reflex-base/news/+socket-deflate-flag.performance.md create mode 100644 packages/reflex-base/news/+websocket-channels.feature.md create mode 100644 reflex/channels.py create mode 100644 reflex/utils/uvicorn_worker.py create mode 100644 tests/integration/tests_playwright/test_channels.py create mode 100644 tests/units/test_channels.py diff --git a/docs/api-reference/channels.md b/docs/api-reference/channels.md new file mode 100644 index 00000000000..ef7af95ff4a --- /dev/null +++ b/docs/api-reference/channels.md @@ -0,0 +1,133 @@ +```python exec +import reflex as rx +``` + +# Channels + +A channel is an application-defined message stream multiplexed onto the +websocket your app already uses for state updates. It is the supported way for +a component or a third-party package to move data that does not belong in +state — streaming chart columns, a live cursor position, an audio buffer — +without opening a second connection. + +Because a channel rides the app's own socket, it inherits the connection's +origin checks, client token, reconnect handling and reverse-proxy setup. + +Channels require state to be enabled (the default) and the `websocket` +transport. They are not available under `transport="socketio"` or +`transport="polling"`. + +## Defining a channel + +Subclass `rx.channels.Channel`, give it a name, and handle messages: + +```python +import reflex as rx + + +class Ticks(rx.channels.Channel): + name = "ticks" + + async def on_open(self, session): + session.data["symbols"] = set() + + async def on_message(self, session, event, data, buffers): + if event == "subscribe": + session.join(data["symbol"]) + session.data["symbols"].add(data["symbol"]) + elif event == "unsubscribe": + session.leave(data["symbol"]) + + async def on_close(self, session): + del session.data["symbols"] + + +app = rx.App() +app.register_channel(Ticks()) +``` + +A package can register its channel from a plugin's `post_compile` hook instead, +which runs at backend startup with the live app. + +Every field of an inbound message is client-controlled and unvalidated. A +handler that raises is logged and the connection keeps serving, so a channel +bug never drops the app's socket. + +## Sending to clients + +`ChannelSession.send` answers one client. `Channel.send_to_room` fans out to +everyone who joined a room, and `Channel.send_to_token` addresses every +session belonging to one client token (browser tab): + +```python +await session.send("tick", {"symbol": "RFX", "price": 42.0}) +await self.send_to_room("RFX", "tick", {"price": 42.0}) +``` + +Rooms and sessions are local to the worker holding the connection. A client +reconnecting to another worker opens its session there, so anything that must +outlive a connection belongs in Reflex state, not in the channel. + +To push from a background task or a thread, hold the serving event loop and +schedule the coroutine on it with `asyncio.run_coroutine_threadsafe`. + +## Binary payloads + +Messages may carry binary attachments beside their JSON metadata. They travel +as raw bytes — no base64, no JSON numbers — and arrive in the browser as +`Uint8Array` views, each aligned so it can be read as a typed array without +copying: + +```python +class Frames(rx.channels.Channel): + name = "frames" + # Inbound attachments are refused unless the channel opts in. + accepts_binary = True + + async def on_message(self, session, event, data, buffers): + await session.send("frame", {"rows": len(buffers[0]) // 8}, buffers) +``` + +A message may carry up to 64 attachments. Inbound frames are capped by +`REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE` (1 MB by default); raise it if clients +send larger payloads. + +## Using a channel from the frontend + +`getChannel` returns a handle that survives reconnects and remounts, so a +component can register its handlers once: + +```javascript +import { getChannel } from "$/utils/state"; + +const channel = getChannel("ticks"); + +channel.on("connect", () => channel.emit("subscribe", { symbol: "RFX" })); +channel.on("tick", (data, buffers) => console.log(data.price)); +channel.on("error", (error) => console.error(error.code, error.message)); + +// Attachments may be ArrayBuffers or typed arrays. +channel.emit("frame", { seq: 1 }, [new Float64Array([1, 2, 3])]); +``` + +Messages emitted before the channel is open are queued and flushed on +`connect`. `error` reports a channel-level failure: an unknown channel name, a +backend too old to speak channels, or a transport that cannot carry them. + +## Performance + +On uvicorn, websocket messages are compressed with permessage-deflate by +default. That is a good trade for JSON state updates and a poor one for binary +data, which barely shrinks while costing milliseconds of event loop time per +message. Apps that stream binary over a channel should turn it off: + +```bash +REFLEX_SOCKET_PER_MESSAGE_DEFLATE=false reflex run --env prod +``` + +Granian, the default server, does not negotiate permessage-deflate at all, so +the setting only changes uvicorn deployments. + +Channel messages share one connection with state updates, so a very large +message delays the deltas queued behind it. Prefer messages bounded by what the +client actually needs — a screenful of data, not a whole dataset. diff --git a/docs/app/news/+channels-reference.docs.md b/docs/app/news/+channels-reference.docs.md new file mode 100644 index 00000000000..afb19494174 --- /dev/null +++ b/docs/app/news/+channels-reference.docs.md @@ -0,0 +1 @@ +Document channels: multiplexed message streams, with binary payloads, carried on the app's event websocket. diff --git a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/reference.py b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/reference.py index e72968f9edd..5f0be0b9b27 100644 --- a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/reference.py +++ b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/reference.py @@ -25,6 +25,7 @@ def get_sidebar_items_api_reference(): api_reference.special_events, api_reference.browser_storage, api_reference.browser_javascript, + api_reference.channels, api_reference.plugins, api_reference.utils, api_reference.telemetry, diff --git a/news/+socket-deflate-flag.performance.md b/news/+socket-deflate-flag.performance.md new file mode 100644 index 00000000000..0feb9f1a3a0 --- /dev/null +++ b/news/+socket-deflate-flag.performance.md @@ -0,0 +1 @@ +Add `REFLEX_SOCKET_PER_MESSAGE_DEFLATE` to control websocket permessage-deflate compression (uvicorn only, on by default). Turning it off is worthwhile for apps sending binary data over a channel: compressing it costs milliseconds of event loop time per message and barely shrinks it. diff --git a/news/+websocket-channels.feature.md b/news/+websocket-channels.feature.md new file mode 100644 index 00000000000..08afff09e2a --- /dev/null +++ b/news/+websocket-channels.feature.md @@ -0,0 +1 @@ +Add channels: an application-defined message stream, including binary payloads, multiplexed onto the websocket your app already uses for state updates. A component or package can stream data that does not belong in state without opening a second connection. See the [channels reference](https://reflex.dev/docs/api-reference/channels/). diff --git a/packages/reflex-base/news/+socket-deflate-flag.performance.md b/packages/reflex-base/news/+socket-deflate-flag.performance.md new file mode 100644 index 00000000000..43e2dd75807 --- /dev/null +++ b/packages/reflex-base/news/+socket-deflate-flag.performance.md @@ -0,0 +1 @@ +Add the `REFLEX_SOCKET_PER_MESSAGE_DEFLATE` environment variable, controlling whether the websocket server compresses messages with permessage-deflate. diff --git a/packages/reflex-base/news/+websocket-channels.feature.md b/packages/reflex-base/news/+websocket-channels.feature.md new file mode 100644 index 00000000000..93c27b9888d --- /dev/null +++ b/packages/reflex-base/news/+websocket-channels.feature.md @@ -0,0 +1 @@ +The frontend websocket client can carry channels: named message streams, with binary attachments, multiplexed onto the app's event socket. Components reach one with `getChannel(name)` from `$/utils/state`. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js index dc09fa8f517..58273709232 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js @@ -7,6 +7,22 @@ const HANDSHAKE_MESSAGE = "_handshake"; const PING_MESSAGE = "_ping"; const PONG_MESSAGE = "_pong"; +const OPEN_MESSAGE = "_open"; +const OPENED_MESSAGE = "_opened"; +const CLOSE_MESSAGE = "_close"; +const CHANNEL_ERROR_MESSAGE = "_error"; + +// Backend protocol version that speaks channels. A backend older than this +// closes the connection on a binary frame, so channels stay shut until the +// handshake proves otherwise. +const CHANNEL_PROTOCOL_VERSION = 2; + +// Binary frames align every attachment to this boundary, so a handler can +// view one as a Float64Array without copying. +const FRAME_ALIGNMENT = 8; + +// Messages a channel buffers while it is not open, oldest dropped first. +const MAX_QUEUED_CHANNEL_MESSAGES = 64; // Python's json.dumps emits bare Infinity/-Infinity/NaN tokens (invalid JSON). // Rewrite them outside string literals so JSON.parse accepts the payload. @@ -64,20 +80,319 @@ export const parseJsonLenient = (text, fallback) => { */ const stringifyFrame = (frame) => JSON.stringify(frame, undefinedToNull); -export class ReflexWebSocket { +/** + * View any binary value as bytes without copying it. + * @param buffer An ArrayBuffer, typed array or DataView. + * @returns A Uint8Array over the same memory. + */ +const asBytes = (buffer) => + buffer instanceof ArrayBuffer + ? new Uint8Array(buffer) + : new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + +/** + * Bytes of padding needed to reach the next attachment boundary. + * @param offset The current offset. + * @returns The padding length. + */ +const padding = (offset) => + (FRAME_ALIGNMENT - (offset % FRAME_ALIGNMENT)) % FRAME_ALIGNMENT; + +/** + * Serialize a channel message carrying binary attachments. + * @param event The message name. + * @param data The JSON metadata. + * @param channel The channel name. + * @param buffers The binary attachments. + * @returns The frame as an ArrayBuffer. + */ +export const encodeChannelFrame = (event, data, channel, buffers) => { + const views = buffers.map(asBytes); + const header = new TextEncoder().encode( + stringifyFrame([event, data, channel, views.map((v) => v.byteLength)]), + ); + let size = 4 + header.byteLength; + for (const view of views) { + size += padding(size) + view.byteLength; + } + const frame = new ArrayBuffer(size); + const bytes = new Uint8Array(frame); + new DataView(frame).setUint32(0, header.byteLength, true); + bytes.set(header, 4); + let offset = 4 + header.byteLength; + for (const view of views) { + offset += padding(offset); + bytes.set(view, offset); + offset += view.byteLength; + } + return frame; +}; + +/** + * Deserialize a binary channel frame. + * @param frame The received ArrayBuffer. + * @returns [event, data, channel, buffers], or undefined if malformed. + */ +export const decodeChannelFrame = (frame) => { + if (frame.byteLength < 4) { + return undefined; + } + const headerSize = new DataView(frame).getUint32(0, true); + if (4 + headerSize > frame.byteLength) { + return undefined; + } + const header = parseJsonLenient( + new TextDecoder().decode(new Uint8Array(frame, 4, headerSize)), + undefined, + ); + if (!Array.isArray(header) || !Array.isArray(header[3])) { + return undefined; + } + const [event, data, channel, lengths] = header; + const buffers = []; + let offset = 4 + headerSize; + for (const length of lengths) { + offset += padding(offset); + if (offset + length > frame.byteLength) { + return undefined; + } + buffers.push(new Uint8Array(frame, offset, length)); + offset += length; + } + return [event, data, channel, buffers]; +}; + +/** + * Local handler registry shared by the transport and its channels. + * + * Handlers are keyed with socket.io's "$"-prefixed convention because + * upload.js reads `socket._callbacks.$event` directly. + */ +class LocalEmitter { + /** + * Create an emitter with no handlers registered. + */ + constructor() { + this._callbacks = {}; + } + + /** + * Register a handler for an event. + * @param event The event name. + * @param fn The handler function. + */ + on(event, fn) { + (this._callbacks["$" + event] ??= []).push(fn); + } + + /** + * Remove a handler, every handler for an event, or all of them. + * @param event The event name; omit to remove all handlers. + * @param fn The handler to remove; omit to remove all handlers for event. + */ + off(event, fn) { + if (event === undefined) { + this._callbacks = {}; + return; + } + if (fn === undefined) { + delete this._callbacks["$" + event]; + return; + } + const handlers = this._callbacks["$" + event]; + const ix = handlers ? handlers.indexOf(fn) : -1; + if (ix !== -1) { + handlers.splice(ix, 1); + } + } + + /** + * Invoke the registered handlers for a local event. + * @param event The event name. + * @param args The handler arguments. + */ + _emitLocal(event, ...args) { + for (const fn of this._callbacks["$" + event] ?? []) { + fn(...args); + } + } +} + +// Channel handles by name, and the transport they currently ride. A handle +// outlives every transport: the event loop recreates the socket on remount and +// hot reload, and a channel must survive that without its consumer +// re-registering handlers. +const channels = new Map(); +let activeTransport = null; +let channelsUnsupportedReason = null; + +class ReflexChannel extends LocalEmitter { + /** + * Create a channel handle. Use getChannel() instead of constructing one. + * + * Handlers live on the channel rather than on the transport, whose table + * the event loop clears wholesale with socket.off() on unmount. + * @param name The channel name, matching the backend registration. + */ + constructor(name) { + super(); + this.name = name; + this.connected = false; + this._queue = []; + this._transport = null; + } + + /** + * Send a message to the channel's backend, buffering until it is open. + * @param event The message name. + * @param data The JSON metadata. + * @param buffers Binary attachments (ArrayBuffers or typed arrays). + */ + emit(event, data, buffers = []) { + if (this.connected && this._transport) { + this._transport.emitChannel(this.name, event, data, buffers); + return; + } + if (this._queue.length >= MAX_QUEUED_CHANNEL_MESSAGES) { + // The backend is unreachable and the producer is not waiting for + // "connect"; drop the oldest rather than grow without bound. + this._queue.shift(); + } + this._queue.push([event, data, buffers]); + } + + /** + * Open the channel on a newly connected transport. + * @param transport The connected transport. + */ + _attach(transport) { + this._transport = transport; + transport.emitChannel(this.name, OPEN_MESSAGE, null, []); + } + + /** + * Report the transport going away; queued messages survive for the next one. + * @param reason The disconnect reason. + */ + _detach(reason) { + this._transport = null; + if (this.connected) { + this.connected = false; + this._emitLocal("disconnect", reason); + } + } + + /** + * Report that this deployment cannot carry channels at all. + * @param reason Why channels are unavailable. + */ + _unsupported(reason) { + this._emitLocal("error", { code: "channels_unsupported", message: reason }); + } + + /** + * Dispatch one message received for this channel. + * @param event The message name. + * @param data The JSON metadata. + * @param buffers Binary attachments, as Uint8Array views. + */ + _receive(event, data, buffers) { + if (event === OPENED_MESSAGE) { + this.connected = true; + const queued = this._queue; + this._queue = []; + for (const [queuedEvent, queuedData, queuedBuffers] of queued) { + // Through emit(), so a transport that went away mid-flush re-queues + // rather than throwing. + this.emit(queuedEvent, queuedData, queuedBuffers); + } + this._emitLocal("connect"); + return; + } + if (event === CHANNEL_ERROR_MESSAGE) { + this._emitLocal("error", data); + return; + } + this._emitLocal(event, data, buffers); + } +} + +/** + * Get the handle for a named channel, creating it on first use. + * @param name The channel name, matching the backend registration. + * @returns The channel handle. + */ +export const getChannel = (name) => { + let channel = channels.get(name); + if (channel === undefined) { + channel = new ReflexChannel(name); + channels.set(name, channel); + if (channelsUnsupportedReason !== null) { + channel._unsupported(channelsUnsupportedReason); + } else if (activeTransport !== null) { + channel._attach(activeTransport); + } + } + return channel; +}; + +/** + * Declare that channels cannot run against this backend, failing every handle. + * @param reason Why channels are unavailable. + */ +export const disableChannels = (reason) => { + if (channelsUnsupportedReason === reason) { + // Already reported; a remount must not fire "error" at every consumer again. + return; + } + channelsUnsupportedReason = reason; + activeTransport = null; + for (const channel of channels.values()) { + channel._detach(reason); + channel._unsupported(reason); + } +}; + +/** + * Open every channel on a transport that just finished its handshake. + * @param transport The connected transport. + */ +const attachChannels = (transport) => { + channelsUnsupportedReason = null; + activeTransport = transport; + for (const channel of channels.values()) { + channel._attach(transport); + } +}; + +/** + * Detach every channel from a transport that went away. + * @param transport The transport reporting the disconnect. + * @param reason The disconnect reason. + */ +const detachChannels = (transport, reason) => { + if (activeTransport !== transport) { + return; + } + activeTransport = null; + for (const channel of channels.values()) { + channel._detach(reason); + } +}; + +export class ReflexWebSocket extends LocalEmitter { /** * Create the transport and start connecting. * @param url The http(s) endpoint URL of the backend event route. * @param opts Options: `query` (object) and `protocols` (subprotocol list). */ constructor(url, opts) { + super(); this._url = new URL(url); // Exposed as io.opts for socket.io API compatibility: state.js refreshes // io.opts.query before reconnecting. this.io = { opts }; this.connected = false; - // upload.js reads socket._callbacks.$event directly. - this._callbacks = {}; this._ws = null; // Frames emitted while disconnected, flushed on (re)connect. this._sendQueue = []; @@ -111,42 +426,10 @@ export class ReflexWebSocket { * @param fn The handler to remove; omit to remove all handlers for event. */ off(event, fn) { - if (event === undefined) { - this._callbacks = {}; - if (this._offlineListener) { - removeEventListener("offline", this._offlineListener, false); - this._offlineListener = null; - } - return; - } - if (fn === undefined) { - delete this._callbacks["$" + event]; - return; - } - const handlers = this._callbacks["$" + event]; - const ix = handlers ? handlers.indexOf(fn) : -1; - if (ix !== -1) { - handlers.splice(ix, 1); - } - } - - /** - * Register a handler for an event. - * @param event The event name. - * @param fn The handler function. - */ - on(event, fn) { - (this._callbacks["$" + event] ??= []).push(fn); - } - - /** - * Invoke the registered handlers for a local event. - * @param event The event name. - * @param args The handler arguments. - */ - _emitLocal(event, ...args) { - for (const fn of this._callbacks["$" + event] ?? []) { - fn(...args); + super.off(event, fn); + if (event === undefined && this._offlineListener) { + removeEventListener("offline", this._offlineListener, false); + this._offlineListener = null; } } @@ -165,6 +448,9 @@ export class ReflexWebSocket { url.search = new URLSearchParams(this.io.opts.query ?? {}).toString(); this._closeReason = null; const ws = new WebSocket(url, this.io.opts.protocols); + // Channel attachments arrive as binary frames; take them as ArrayBuffers + // so handlers can view them as typed arrays without a copy. + ws.binaryType = "arraybuffer"; this._ws = ws; this._connectTimer = setTimeout(() => { if (this._ws === ws && !this.connected) { @@ -186,6 +472,7 @@ export class ReflexWebSocket { this._clearWatchdog(); const wasConnected = this.connected; this.connected = false; + detachChannels(this, this._closeReason ?? "transport close"); if (!wasConnected) { // Never handshaked: this was a failed connection attempt. this._emitLocal( @@ -234,6 +521,7 @@ export class ReflexWebSocket { } // Detach so the onclose handler does not double-report. this._ws = null; + detachChannels(this, reason); if (this.connected) { this.connected = false; this._emitLocal("disconnect", reason, details); @@ -251,7 +539,29 @@ export class ReflexWebSocket { * @param data The event payload. */ emit(event, data) { - const frame = stringifyFrame([event, data]); + this._send(stringifyFrame([event, data])); + } + + /** + * Send a channel message to the backend, buffering while disconnected. + * @param channel The channel name. + * @param event The message name. + * @param data The JSON metadata. + * @param buffers Binary attachments (ArrayBuffers or typed arrays). + */ + emitChannel(channel, event, data, buffers) { + this._send( + buffers?.length + ? encodeChannelFrame(event, data, channel, buffers) + : stringifyFrame([event, data, channel]), + ); + } + + /** + * Write one serialized frame, buffering while disconnected. + * @param frame The serialized text or binary frame. + */ + _send(frame) { if (this.connected && this._ws?.readyState === WebSocket.OPEN) { this._ws.send(frame); } else { @@ -261,9 +571,20 @@ export class ReflexWebSocket { /** * Handle one incoming frame. - * @param text The raw frame text. + * @param data The raw frame: text, or an ArrayBuffer for a channel message. */ - _onMessage(text) { + _onMessage(data) { + if (data instanceof ArrayBuffer) { + const frame = decodeChannelFrame(data); + if (frame === undefined) { + console.error("Failed to parse binary websocket message"); + return; + } + const [event, payload, channel, buffers] = frame; + channels.get(channel)?._receive(event, payload, buffers); + return; + } + const text = data; const message = parseJsonLenient(text, undefined); if (!Array.isArray(message)) { console.error("Failed to parse websocket message", text); @@ -290,9 +611,22 @@ export class ReflexWebSocket { for (const frame of queue) { this._ws.send(frame); } + if ((payload.protocol ?? 1) >= CHANNEL_PROTOCOL_VERSION) { + attachChannels(this); + } else { + // A backend older than the channel protocol closes the connection on + // the binary frames channels use, so never open one against it. + disableChannels( + "This backend predates channel support; upgrade Reflex to use channels.", + ); + } this._emitLocal("connect"); return; } + if (message.length > 2) { + channels.get(message[2])?._receive(event, payload, []); + return; + } this._emitLocal(event, payload); } diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index 9698d27a3de..fba59444545 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -15,10 +15,16 @@ import throttle from "$/utils/helpers/throttle"; import { uploadFiles } from "$/utils/helpers/upload"; import { ReflexWebSocket, + disableChannels, + getChannel, parseJsonLenient, undefinedToNull, } from "$/utils/helpers/websocket"; +// Re-exported so components can reach a side channel through the module they +// already import for getBackendURL/getToken. +export { getChannel }; + // Endpoint URLs. const EVENTURL = env.EVENT; @@ -609,6 +615,10 @@ export const connect = async ( // The decoder API expects false (not undefined) for unparsable input. socket.current.io.decoder.tryParse = (str) => parseJsonLenient(str, false); + // Channels are a plain-WebSocket protocol feature. + disableChannels( + `Channels require transport="websocket", not "${transport}".`, + ); } } finally { socket.connecting = false; diff --git a/packages/reflex-base/src/reflex_base/environment.py b/packages/reflex-base/src/reflex_base/environment.py index 3bb80d6970b..c86cd396908 100644 --- a/packages/reflex-base/src/reflex_base/environment.py +++ b/packages/reflex-base/src/reflex_base/environment.py @@ -705,6 +705,12 @@ class EnvironmentVariables: # The timeout to wait for a pong from the websocket server in seconds. REFLEX_SOCKET_TIMEOUT: EnvVar[int] = env_var(constants.Ping.TIMEOUT) + # Whether the websocket server compresses messages with permessage-deflate + # (uvicorn only; granian does not negotiate it). Worth turning off for apps + # sending binary data over a channel: deflate barely shrinks it and costs + # milliseconds of event loop time per message. + REFLEX_SOCKET_PER_MESSAGE_DEFLATE: EnvVar[bool] = env_var(True) + # Whether to run Granian in a spawn process. This enables Reflex to pick up on environment variable changes between hot reloads. REFLEX_STRICT_HOT_RELOAD: EnvVar[bool] = env_var(False) diff --git a/pyi_hashes.json b/pyi_hashes.json index 603fc724e48..1aeea35f77f 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -118,7 +118,7 @@ "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "99ebcfc07868061bdc3c2010d85a153f", "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "4f6c26f8c76543cc41e2b9dc400ece8a", "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "f170ac685b6ba5892370166c80684db3", - "reflex/__init__.pyi": "a3e1782fab4a9aed55f66cc98af8c217", + "reflex/__init__.pyi": "844f5dc53b5330896bff479f94a0da74", "reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388", "reflex/experimental/memo.pyi": "27a73a66e238746e5da5accf99a8fdfd" } diff --git a/reflex/__init__.py b/reflex/__init__.py index 8aa2bfc2880..9d15f02d5a6 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -241,6 +241,7 @@ _SUBMODULES: set[str] = { "components", "app", + "channels", "style", "admin", "base", diff --git a/reflex/app.py b/reflex/app.py index 4dfc807559d..ff247403610 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -60,6 +60,7 @@ from reflex._upload import UploadFile as UploadFile from reflex.admin import AdminDash from reflex.app_mixins import AppMixin, LifespanMixin, MiddlewareMixin +from reflex.channels import Channel from reflex.compiler import compiler from reflex.compiler.compiler import readable_name_from_component from reflex.event_namespace import BaseEventNamespace, WebsocketEventNamespace @@ -442,6 +443,9 @@ class App(MiddlewareMixin, LifespanMixin): # The async server name space. _event_namespace: BaseEventNamespace | None = None + # Side channels multiplexed onto the event websocket, by channel name. + _channels: dict[str, Channel] = dataclasses.field(default_factory=dict) + # The processor queue for handling events. _event_processor: EventProcessor | None = None @@ -478,6 +482,33 @@ def event_namespace(self) -> BaseEventNamespace | None: """ return self._event_namespace + def register_channel(self, channel: Channel) -> None: + """Register a side channel multiplexed onto the event websocket. + + Args: + channel: The channel to serve. + + Raises: + RuntimeError: If the app cannot serve channels, or the name is taken. + """ + name = type(channel).name + if self._state is None: + msg = ( + f"Channel {name!r} needs the event websocket, which exists only " + "when state is enabled (rx.App(enable_state=True), the default)." + ) + raise RuntimeError(msg) + if get_config().transport != "websocket": + msg = ( + f"Channel {name!r} requires the plain WebSocket transport; " + 'remove the transport setting in rxconfig.py or set transport="websocket".' + ) + raise RuntimeError(msg) + if name in self._channels: + msg = f"A channel named {name!r} is already registered." + raise RuntimeError(msg) + self._channels[name] = channel + @property def event_processor(self) -> EventProcessor: """The event processor. diff --git a/reflex/channels.py b/reflex/channels.py new file mode 100644 index 00000000000..b68b128104f --- /dev/null +++ b/reflex/channels.py @@ -0,0 +1,258 @@ +"""Multiplexed side channels on the Reflex event websocket. + +A channel carries an application-defined message stream -- including binary +payloads -- over the connection that already delivers state deltas, so a +package needing its own data plane inherits the app's origin checks, client +token, reconnect handling and proxy configuration instead of reimplementing +them on a second socket. + +Register a channel on the app and messages from the matching client-side +channel are dispatched to it:: + + class Ticks(rx.channels.Channel): + name = "ticks" + + async def on_message(self, session, event, data, buffers): + if event == "subscribe": + session.join(data["symbol"]) + + app.register_channel(Ticks()) +""" + +from __future__ import annotations + +import dataclasses +import re +from abc import ABC, abstractmethod +from collections.abc import Awaitable, Callable, Sequence +from typing import Any, ClassVar + +# Channel names ride in every frame and name a client-side channel handle; +# keep them short and free of JSON or URL escaping. +_NAME_PATTERN = re.compile(r"[A-Za-z0-9_./:-]{1,64}") + +# Maximum binary attachments one channel message may carry, in either +# direction. Bounds the work a single inbound frame can ask for. +MAX_MESSAGE_BUFFERS = 64 + +# Sends one channel message to a connected session: (sid, channel, event, +# data, buffers). Supplied by the transport when the session opens. +ChannelSender = Callable[[str, str, str, Any, Sequence[bytes]], Awaitable[None]] + + +def validate_channel_name(name: str) -> None: + """Check a channel name against the wire format. + + Args: + name: The channel name. + + Raises: + ValueError: If the name is unusable on the wire. + """ + if not _NAME_PATTERN.fullmatch(name): + msg = ( + f"Invalid channel name {name!r}: expected 1-64 characters from " + "[A-Za-z0-9_./:-]." + ) + raise ValueError(msg) + + +@dataclasses.dataclass(eq=False, slots=True) +class ChannelSession: + """One client connection's participation in a channel.""" + + # The transport session id, shared with the app's event session. + sid: str + + # The Reflex client token (tab) the connection authenticated with. + client_token: str + + # The channel this session belongs to. + channel: Channel + + # Per-connection scratch space owned by the channel implementation. + data: dict[str, Any] + + _send: ChannelSender + + _rooms: set[str] + + async def send( + self, event: str, data: Any = None, buffers: Sequence[bytes] = () + ) -> None: + """Send one message to this client. + + Args: + event: The message name. + data: The JSON-serializable metadata. + buffers: Binary attachments delivered alongside the metadata. + """ + await self._send(self.sid, self.channel.name, event, data, buffers) + + def join(self, room: str) -> None: + """Add this session to a room for fan-out. + + Args: + room: The room name. + """ + self._rooms.add(room) + self.channel._rooms.setdefault(room, set()).add(self) + + def leave(self, room: str) -> None: + """Remove this session from a room. + + Args: + room: The room name. + """ + self._rooms.discard(room) + members = self.channel._rooms.get(room) + if members is not None: + members.discard(self) + if not members: + del self.channel._rooms[room] + + +class Channel(ABC): + """A named message stream multiplexed onto the event websocket. + + Rooms and sessions are local to the worker holding the connection, which + is what a socket-bound side channel can offer: a client reconnecting to + another worker opens its session there. State that must outlive a + connection belongs in Reflex state, not in the channel. + """ + + # The channel name, matching the name the client opens. + name: ClassVar[str] + + # Whether inbound messages from this channel may carry binary + # attachments. Off by default: a channel that never expects binary + # should not accept the allocation. + accepts_binary: ClassVar[bool] = False + + def __init__(self): + """Initialize the channel's session and room bookkeeping.""" + validate_channel_name(type(self).name) + # Sessions by client token, for token-addressed sends. + self._sessions: dict[str, set[ChannelSession]] = {} + # Room name to member sessions. + self._rooms: dict[str, set[ChannelSession]] = {} + + async def on_open(self, session: ChannelSession) -> None: + """Handle a client opening the channel. + + Args: + session: The opening session. + """ + return + + @abstractmethod + async def on_message( + self, + session: ChannelSession, + event: str, + data: Any, + buffers: list[bytes], + ) -> None: + """Handle one message from a client. + + Every field is client-controlled and unvalidated. + + Args: + session: The sending session. + event: The message name. + data: The JSON metadata, as decoded from the frame. + buffers: Binary attachments, empty unless ``accepts_binary``. + """ + + async def on_close(self, session: ChannelSession) -> None: + """Handle a session going away. + + Args: + session: The closing session. + """ + return + + async def send_to_room( + self, + room: str, + event: str, + data: Any = None, + buffers: Sequence[bytes] = (), + ) -> None: + """Send one message to every session in a room. + + Args: + room: The room name. + event: The message name. + data: The JSON-serializable metadata. + buffers: Binary attachments delivered alongside the metadata. + """ + members = self._rooms.get(room) + if not members: + return + # A send can close a session and mutate the room, so iterate a copy. + for session in tuple(members): + await session.send(event, data, buffers) + + async def send_to_token( + self, + client_token: str, + event: str, + data: Any = None, + buffers: Sequence[bytes] = (), + ) -> bool: + """Send one message to every session of a client token on this worker. + + Args: + client_token: The Reflex client token (tab). + event: The message name. + data: The JSON-serializable metadata. + buffers: Binary attachments delivered alongside the metadata. + + Returns: + Whether a session received the message. + """ + sessions = self._sessions.get(client_token) + if not sessions: + return False + for session in tuple(sessions): + await session.send(event, data, buffers) + return True + + def open_session( + self, sid: str, client_token: str, send: ChannelSender + ) -> ChannelSession: + """Create and track a session for a connection. Called by the transport. + + Args: + sid: The transport session id. + client_token: The client token the connection authenticated with. + send: The transport's send callable. + + Returns: + The new session. + """ + session = ChannelSession( + sid=sid, + client_token=client_token, + channel=self, + data={}, + _send=send, + _rooms=set(), + ) + self._sessions.setdefault(client_token, set()).add(session) + return session + + def forget_session(self, session: ChannelSession) -> None: + """Drop a session's rooms and tracking. Called by the transport. + + Args: + session: The session going away. + """ + for room in tuple(session._rooms): + session.leave(room) + sessions = self._sessions.get(session.client_token) + if sessions is not None: + sessions.discard(session) + if not sessions: + del self._sessions[session.client_token] diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index 3b6ac694115..337f93f30c0 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -10,7 +10,7 @@ import urllib.parse import uuid from abc import ABC, abstractmethod -from collections.abc import Mapping, MutableMapping +from collections.abc import Mapping, MutableMapping, Sequence from typing import TYPE_CHECKING, Any from reflex_base import constants, otel @@ -19,6 +19,7 @@ from reflex_base.event import _EVENT_FIELDS, Event from starlette.websockets import WebSocket, WebSocketDisconnect +from reflex.channels import MAX_MESSAGE_BUFFERS, ChannelSession from reflex.istate.data import RouterData from reflex.istate.manager.token import BaseStateToken from reflex.state import StateUpdate @@ -36,6 +37,22 @@ HANDSHAKE_MESSAGE = "_handshake" PING_MESSAGE = "_ping" PONG_MESSAGE = "_pong" +OPEN_MESSAGE = "_open" +OPENED_MESSAGE = "_opened" +CLOSE_MESSAGE = "_close" +CHANNEL_ERROR_MESSAGE = "_error" + +# Wire protocol version, announced in the handshake. The client gates channel +# frames on it: a backend that predates channels closes the connection on the +# binary frames they use. +PROTOCOL_VERSION = 2 + +# Binary channel frames align every attachment to this boundary so the client +# can view them as typed arrays without copying. +_FRAME_ALIGNMENT = 8 + +# Bound on the JSON header of a binary channel frame. +_MAX_FRAME_HEADER_SIZE = 64 * 1024 # Application-level socket event names, resolved once for the hot paths. _EVENT = str(constants.SocketEvent.EVENT) @@ -46,20 +63,102 @@ _PING_FRAME = json.dumps([PING_MESSAGE]) -def utf8_size(data: str) -> int: +def utf8_size(data: str | bytes) -> int: """Size of a serialized message in UTF-8 bytes. ASCII payloads (the common case) are sized without encoding a copy. Args: - data: The serialized message. + data: The serialized message, text or binary. Returns: The number of bytes the message occupies on the wire. """ + if isinstance(data, bytes): + return len(data) return len(data) if data.isascii() else len(data.encode()) +def encode_channel_frame( + event: str, data: Any, channel: str, buffers: Sequence[bytes] +) -> bytes: + """Serialize a channel message carrying binary attachments. + + The frame is a 4-byte little-endian header length, the JSON header + ``[event, data, channel, [lengths]]``, then the attachments, each padded + so every payload starts on an 8-byte boundary. + + Args: + event: The message name. + data: The JSON-serializable metadata. + channel: The channel name. + buffers: The binary attachments. + + Returns: + The frame bytes. + """ + header = format.json_dumps([ + event, + data, + channel, + [len(buffer) for buffer in buffers], + ]).encode() + parts = [len(header).to_bytes(4, "little"), header] + offset = 4 + len(header) + for buffer in buffers: + padding = -offset % _FRAME_ALIGNMENT + if padding: + parts.append(bytes(padding)) + parts.append(buffer) + offset += padding + len(buffer) + return b"".join(parts) + + +def decode_channel_frame(frame: bytes) -> tuple[str, Any, str, list[bytes]]: + """Deserialize a binary channel frame. + + Args: + frame: The raw frame bytes. + + Returns: + The message name, metadata, channel name and binary attachments. + + Raises: + ValueError: If the frame does not follow the binary channel format. + """ + if len(frame) < 4: + msg = "Binary frame is too short to hold a header length." + raise ValueError(msg) + header_size = int.from_bytes(frame[:4], "little") + if header_size > _MAX_FRAME_HEADER_SIZE or 4 + header_size > len(frame): + msg = f"Binary frame declares an unusable header size {header_size}." + raise ValueError(msg) + header = json.loads(frame[4 : 4 + header_size]) + match header: + case [str(event), data, str(channel), [*lengths]] if all( + isinstance(length, int) and not isinstance(length, bool) and length >= 0 + for length in lengths + ): + pass + case _: + msg = "Binary frame header is malformed." + raise ValueError(msg) + if len(lengths) > MAX_MESSAGE_BUFFERS: + msg = f"Binary frame carries more than {MAX_MESSAGE_BUFFERS} attachments." + raise ValueError(msg) + buffers: list[bytes] = [] + offset = 4 + header_size + for length in lengths: + offset += -offset % _FRAME_ALIGNMENT + end = offset + length + if end > len(frame): + msg = "Binary frame is shorter than its declared attachments." + raise ValueError(msg) + buffers.append(frame[offset:end]) + offset = end + return event, data, channel, buffers + + class BaseEventNamespace(ABC): """Transport-agnostic handler for client event sessions.""" @@ -438,29 +537,271 @@ def __init__(self, namespace: str, app: App): """ super().__init__(namespace, app) self._sockets: dict[str, WebSocket] = {} + # Open channel sessions per connection, by session id and channel name. + self._channel_sessions: dict[str, dict[str, ChannelSession]] = {} - async def emit(self, event: str, data: Any = None, to: str | None = None) -> None: - """Emit an event to a connected client session. + async def _deliver(self, to: str | None, payload: str | bytes, label: str) -> None: + """Write one serialized frame to a connected client session. Args: - event: The event name. - data: The event payload. - to: The session id to emit to. + to: The session id to send to. + payload: The serialized text or binary frame. + label: The message name, for diagnostics. """ websocket = self._sockets.get(to) if to is not None else None if websocket is None: # Routine race: the client disconnected while an event was still # being processed, so its remaining updates have nowhere to go. - logger.debug(f"Attempted to emit {event!r} to unknown session {to!r}.") + logger.debug(f"Attempted to emit {label!r} to unknown session {to!r}.") return - text = format.json_dumps([event, data]) if otel.enabled: - otel.record_message_size(utf8_size(text), "transmit") + otel.record_message_size(utf8_size(payload), "transmit") try: - await websocket.send_text(text) + if isinstance(payload, str): + await websocket.send_text(payload) + else: + await websocket.send_bytes(payload) except Exception: # The connection went away mid-send; the receive loop cleans up. - logger.debug(f"Failed to emit {event!r} to session {to!r}.", exc_info=True) + logger.debug(f"Failed to emit {label!r} to session {to!r}.", exc_info=True) + + async def emit(self, event: str, data: Any = None, to: str | None = None) -> None: + """Emit an event to a connected client session. + + Args: + event: The event name. + data: The event payload. + to: The session id to emit to. + """ + await self._deliver(to, format.json_dumps([event, data]), event) + + async def _send_channel_message( + self, + sid: str, + channel: str, + event: str, + data: Any, + buffers: Sequence[bytes], + ) -> None: + """Send one channel message to a connected client session. + + Args: + sid: The session id to send to. + channel: The channel name. + event: The message name. + data: The JSON-serializable metadata. + buffers: The binary attachments. + """ + payload = ( + encode_channel_frame(event, data, channel, buffers) + if buffers + else format.json_dumps([event, data, channel]) + ) + await self._deliver(sid, payload, event) + + async def _send_channel_error( + self, sid: str, channel: str, code: str, message: str + ) -> None: + """Report a channel-level failure to a client session. + + Args: + sid: The session id. + channel: The channel name. + code: The machine-readable error code. + message: The human-readable explanation. + """ + await self._send_channel_message( + sid, channel, CHANNEL_ERROR_MESSAGE, {"code": code, "message": message}, () + ) + + async def _open_channel_session(self, sid: str, channel_name: str) -> None: + """Open a channel session for a connection, answering the client. + + Args: + sid: The session id. + channel_name: The channel the client is opening. + """ + if channel_name in self._channel_sessions.get(sid, ()): + # Opening twice would orphan the first session in its rooms; the + # client only opens once per connection, so answer and move on. + await self._send_channel_message( + sid, channel_name, OPENED_MESSAGE, None, () + ) + return + channel = self.app._channels.get(channel_name) + if channel is None: + await self._send_channel_error( + sid, + channel_name, + "unknown_channel", + f"No channel named {channel_name!r} is registered.", + ) + return + token = self.sid_to_token.get(sid) + if token is None: + # The token was unlinked while the frame was in flight. + logger.debug(f"Ignoring channel open from session {sid} with no token.") + return + session = channel.open_session(sid, token, self._send_channel_message) + # Track the session before the hook runs: on_open may join rooms, and + # if it is interrupted the disconnect cleanup must still find it. + sessions = self._channel_sessions.setdefault(sid, {}) + sessions[channel_name] = session + try: + await channel.on_open(session) + except Exception: + self._drop_channel_session(sid, channel_name) + logger.exception( + f"Error opening channel {channel_name!r} for session {sid}." + ) + await self._send_channel_error( + sid, channel_name, "open_failed", "The channel failed to open." + ) + return + await self._send_channel_message(sid, channel_name, OPENED_MESSAGE, None, ()) + + async def _close_channel_session(self, sid: str, channel_name: str) -> None: + """Close one open channel session. + + Args: + sid: The session id. + channel_name: The channel to close. + """ + session = self._drop_channel_session(sid, channel_name) + if session is not None: + await self._notify_channel_close(sid, channel_name, session) + + def _drop_channel_session( + self, sid: str, channel_name: str + ) -> ChannelSession | None: + """Remove one session from the connection, along with its rooms. + + Args: + sid: The session id. + channel_name: The channel to drop. + + Returns: + The dropped session, or None if it was not open. + """ + sessions = self._channel_sessions.get(sid) + session = sessions.pop(channel_name, None) if sessions is not None else None + if session is None: + return None + if not sessions: + del self._channel_sessions[sid] + session.channel.forget_session(session) + return session + + async def _close_channel_sessions(self, sid: str) -> None: + """Close every channel session of a disconnected connection. + + Args: + sid: The session id. + """ + sessions = self._channel_sessions.pop(sid, None) + if not sessions: + return + # Drop rooms and tracking for all of them first: this runs during + # teardown, where a cancellation at the first await would otherwise + # leave the remaining sessions reachable by fan-out forever. + for session in sessions.values(): + session.channel.forget_session(session) + for channel_name, session in sessions.items(): + await self._notify_channel_close(sid, channel_name, session) + + @staticmethod + async def _notify_channel_close( + sid: str, channel_name: str, session: ChannelSession + ) -> None: + """Run a channel's close hook, logging a failure instead of raising. + + Args: + sid: The session id. + channel_name: The channel being closed. + session: The session being closed. + """ + try: + await session.channel.on_close(session) + except Exception: + logger.exception( + f"Error closing channel {channel_name!r} for session {sid}." + ) + + async def _handle_channel_message( + self, sid: str, channel_name: str, event: str, data: Any, buffers: list[bytes] + ) -> None: + """Dispatch one inbound channel frame. + + Never raises: a channel failing is a bug in that channel, not a reason + to drop the app's connection. + + Args: + sid: The session id. + channel_name: The channel the frame is addressed to. + event: The message name. + data: The message metadata. + buffers: The binary attachments. + """ + try: + if event == OPEN_MESSAGE: + await self._open_channel_session(sid, channel_name) + return + sessions = self._channel_sessions.get(sid) + session = sessions.get(channel_name) if sessions is not None else None + if session is None: + await self._send_channel_error( + sid, + channel_name, + "channel_not_open", + f"Channel {channel_name!r} is not open on this connection.", + ) + return + if event == CLOSE_MESSAGE: + await self._close_channel_session(sid, channel_name) + return + if buffers and not session.channel.accepts_binary: + await self._send_channel_error( + sid, + channel_name, + "binary_not_accepted", + f"Channel {channel_name!r} does not accept binary attachments.", + ) + return + await session.channel.on_message(session, event, data, buffers) + except Exception: + logger.exception( + f"Error handling {event!r} on channel {channel_name!r} " + f"for session {sid}." + ) + + async def _handle_binary_frame( + self, sid: str, frame: bytes, max_size: int + ) -> int | None: + """Validate and dispatch one inbound binary channel frame. + + Args: + sid: The session id. + frame: The raw frame bytes. + max_size: The message size limit in bytes. + + Returns: + The websocket close code the session must end with, or None to + keep serving it. + """ + if len(frame) > max_size: + logger.debug(f"Closing session {sid}: message over {max_size} bytes.") + return 1009 + if otel.enabled: + otel.record_message_size(len(frame), "receive") + try: + event, data, channel_name, buffers = decode_channel_frame(frame) + except ValueError: + # A Reflex client never sends malformed frames; close instead of + # logging per frame. + logger.debug(f"Closing session {sid}: malformed binary frame.") + return 1002 + await self._handle_channel_message(sid, channel_name, event, data, buffers) + return None @staticmethod def _origin_allowed(origin: str | None) -> bool: @@ -525,6 +866,12 @@ async def _handle_frame( return 1002 event = message[0] data = message[1] if len(message) > 1 else None + if len(message) > 2: + if not isinstance(message[2], str): + logger.debug(f"Closing session {sid}: malformed channel frame.") + return 1002 + await self._handle_channel_message(sid, message[2], event, data, []) + return None try: # Ordered by frequency: events are the hot path, heartbeat pongs # arrive once per ping interval. @@ -597,7 +944,11 @@ async def heartbeat() -> None: await websocket.send_text( format.json_dumps([ HANDSHAKE_MESSAGE, - {"ping_interval": ping_interval, "ping_timeout": ping_timeout}, + { + "ping_interval": ping_interval, + "ping_timeout": ping_timeout, + "protocol": PROTOCOL_VERSION, + }, ]) ) await self.handle_connect( @@ -615,14 +966,20 @@ async def heartbeat() -> None: break last_received = time.monotonic() text = received.get("text") - if text is None: - # Binary frame; not part of the protocol. - logger.debug(f"Closing session {sid}: received a binary frame.") - close_code = 1003 - else: + if text is not None: close_code = await self._handle_frame( sid, text, websocket.scope, max_message_size ) + elif ( + frame := received.get("bytes") + ) is not None and self.app._channels: + close_code = await self._handle_binary_frame( + sid, frame, max_message_size + ) + else: + # Binary frame with no channel to carry it. + logger.debug(f"Closing session {sid}: received a binary frame.") + close_code = 1003 if close_code is not None: await websocket.close(code=close_code) break @@ -631,7 +988,10 @@ async def heartbeat() -> None: finally: heartbeat_task.cancel() self._sockets.pop(sid, None) + # Start the token cleanup before any teardown await: a cancelled + # shutdown must not leave the token linked to a dead session. cleanup_task = self.handle_disconnect(sid) + await self._close_channel_sessions(sid) if cleanup_task is not None: # Await the token cleanup so an immediate reconnect is not # treated as a duplicate tab; shielded so cancellation (e.g. diff --git a/reflex/socketio_namespace.py b/reflex/socketio_namespace.py index 5e739d5e7e0..7528718b726 100644 --- a/reflex/socketio_namespace.py +++ b/reflex/socketio_namespace.py @@ -53,9 +53,7 @@ def _sio_loads(data: str | bytes, **kwargs: Any) -> Any: The decoded payload. """ if otel.enabled: - otel.record_message_size( - utf8_size(data) if isinstance(data, str) else len(data), "receive" - ) + otel.record_message_size(utf8_size(data), "receive") return json.loads(data, **kwargs) diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index c5a866450b9..e18347b59fa 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -666,7 +666,7 @@ def run_uvicorn_backend(host: str, port: int, loglevel: LogLevel): reload=True, reload_dirs=list(map(str, get_reload_paths())), reload_delay=0.1, - ws_max_size=_uvicorn_ws_max_size(), + **uvicorn_websocket_options(), ) @@ -682,6 +682,35 @@ def _uvicorn_ws_max_size() -> int: return max(environment.REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE.get(), 16 * 1024 * 1024) +def uvicorn_websocket_options() -> dict[str, Any]: + """The app's websocket policy as uvicorn settings. + + Every uvicorn launch path applies these, including the gunicorn worker + class, which is how the production server receives options gunicorn itself + does not forward. + + Returns: + The uvicorn configuration keyword arguments. + """ + return { + "ws_max_size": _uvicorn_ws_max_size(), + "ws_per_message_deflate": environment.REFLEX_SOCKET_PER_MESSAGE_DEFLATE.get(), + } + + +def _uvicorn_websocket_args() -> list[str]: + """The app's websocket policy as uvicorn command line arguments. + + Returns: + The command line arguments. + """ + options = uvicorn_websocket_options() + return [ + *("--ws-max-size", str(options["ws_max_size"])), + *([] if options["ws_per_message_deflate"] else ["--no-ws-per-message-deflate"]), + ] + + HOTRELOAD_IGNORE_EXTENSIONS = ( "txt", "toml", @@ -798,7 +827,7 @@ def run_uvicorn_backend_prod( *("--host", host), *("--port", str(port)), *("--workers", str(_get_backend_workers())), - *("--ws-max-size", str(_uvicorn_ws_max_size())), + *_uvicorn_websocket_args(), "--factory", app_module, ] @@ -814,7 +843,10 @@ def run_uvicorn_backend_prod( "-m", "gunicorn", "--preload", - *("--worker-class", "uvicorn.workers.UvicornH11Worker"), + *( + "--worker-class", + "reflex.utils.uvicorn_worker.ReflexUvicornWorker", + ), *("--threads", str(_get_backend_workers())), *("--bind", f"{host}:{port}"), *env_args, diff --git a/reflex/utils/uvicorn_worker.py b/reflex/utils/uvicorn_worker.py new file mode 100644 index 00000000000..3c9c90b9245 --- /dev/null +++ b/reflex/utils/uvicorn_worker.py @@ -0,0 +1,22 @@ +"""Gunicorn worker carrying the Reflex websocket settings into uvicorn. + +Gunicorn takes no uvicorn options on its command line, so the production +backend passes them through a worker class instead. Imported by dotted path +from the gunicorn process; nothing else imports it, which keeps the optional +uvicorn dependency optional. +""" + +from __future__ import annotations + +from uvicorn.workers import UvicornH11Worker + +from reflex.utils.exec import uvicorn_websocket_options + + +class ReflexUvicornWorker(UvicornH11Worker): + """Uvicorn worker applying the app's websocket message policy.""" + + CONFIG_KWARGS = { + **UvicornH11Worker.CONFIG_KWARGS, + **uvicorn_websocket_options(), + } diff --git a/tests/integration/tests_playwright/test_channels.py b/tests/integration/tests_playwright/test_channels.py new file mode 100644 index 00000000000..3a22f8d81ea --- /dev/null +++ b/tests/integration/tests_playwright/test_channels.py @@ -0,0 +1,182 @@ +"""Integration tests for channels multiplexed onto the event websocket. + +Exercises the parts only a browser can prove: the client opens its channel +over the app's own socket, binary attachments survive the round trip in both +directions, and each one lands aligned enough to read as a typed array. +""" + +from __future__ import annotations + +from collections.abc import Generator + +import pytest +from playwright.sync_api import Page, expect + +from reflex.testing import AppHarness + + +def ChannelApp(): + """App serving an echo channel driven from the browser.""" + import reflex as rx + + PROBE_SETUP = """ + if (typeof window !== "undefined") { + const probeChannel = getChannel("probe"); + window.__probe = { received: [], errors: [], connected: false, connects: 0 }; + probeChannel.on("connect", () => { + window.__probe.connected = true; + window.__probe.connects += 1; + }); + probeChannel.on("disconnect", () => { + window.__probe.connected = false; + }); + // Drop the underlying socket the way a network blip would, so the test + // can watch the channel come back with the app's own reconnect. + window.__probe.drop = () => probeChannel._transport._ws.close(); + probeChannel.on("error", (error) => { + window.__probe.errors.push(error); + }); + probeChannel.on("echo", (data, buffers) => { + window.__probe.received.push({ + data, + bytes: Array.from(buffers[0]), + aligned: buffers[0].byteOffset % 8 === 0, + }); + }); + window.__probe.push = (n) => + probeChannel.emit("push", { n }, [new Uint8Array([1, 2, 3, 4])]); + window.__probe.broadcast = () => probeChannel.emit("broadcast", null); + } + """ + + class EchoChannel(rx.channels.Channel): + name = "probe" + accepts_binary = True + + async def on_open(self, session): + session.join("all") + + async def on_message(self, session, event, data, buffers): + if event == "push": + await session.send( + "echo", + {"n": data["n"], "sizes": [len(buffer) for buffer in buffers]}, + [bytes(reversed(buffers[0]))], + ) + elif event == "broadcast": + await self.send_to_room( + "all", "echo", {"n": -1, "sizes": []}, [b"\x09\x08\x07"] + ) + + class ChannelProbe(rx.Fragment): + def add_imports(self): + return {"$/utils/state": ["getChannel"]} + + def add_custom_code(self): + return [PROBE_SETUP] + + @rx.page("/") + def index(): + return rx.box(ChannelProbe.create(), rx.text("ready", id="ready")) + + app = rx.App() + app.register_channel(EchoChannel()) + + +@pytest.fixture(scope="module") +def channel_app( + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[AppHarness, None, None]: + """Start the ChannelApp. + + Args: + tmp_path_factory: pytest fixture for creating temporary directories. + + Yields: + Running AppHarness instance. + """ + with AppHarness.create( + root=tmp_path_factory.mktemp("channel_app"), + app_source=ChannelApp, + ) as harness: + assert harness.app_instance is not None, "app is not running" + yield harness + + +def _connected_probe(channel_app: AppHarness, page: Page) -> None: + """Load the page and wait for the channel to open. + + Args: + channel_app: Running AppHarness instance. + page: Playwright page fixture. + """ + assert channel_app.frontend_url is not None + page.goto(channel_app.frontend_url) + expect(page.locator("#ready")).to_have_text("ready") + # A cold frontend build reloads the page once the dev server finishes + # optimizing its dependencies, which resets the probe; let that settle + # before the test starts counting on its state. + page.wait_for_load_state("networkidle") + page.wait_for_function("window.__probe?.connected === true") + + +def test_binary_message_round_trip(channel_app: AppHarness, page: Page): + """A binary attachment survives the round trip and arrives aligned. + + Args: + channel_app: Running AppHarness instance. + page: Playwright page fixture. + """ + _connected_probe(channel_app, page) + + page.evaluate("window.__probe.push(7)") + page.wait_for_function("window.__probe.received.length > 0") + + received = page.evaluate("window.__probe.received[0]") + assert received["data"] == {"n": 7, "sizes": [4]} + assert received["bytes"] == [4, 3, 2, 1] + assert received["aligned"] + assert page.evaluate("window.__probe.errors") == [] + + +def test_room_broadcast_reaches_the_client(channel_app: AppHarness, page: Page): + """A server-side room fan-out reaches a subscribed browser. + + Args: + channel_app: Running AppHarness instance. + page: Playwright page fixture. + """ + _connected_probe(channel_app, page) + + page.evaluate("window.__probe.broadcast()") + page.wait_for_function("window.__probe.received.length > 0") + + received = page.evaluate("window.__probe.received[0]") + assert received["data"] == {"n": -1, "sizes": []} + assert received["bytes"] == [9, 8, 7] + + +def test_channel_reopens_and_flushes_after_a_reconnect( + channel_app: AppHarness, page: Page +): + """A dropped socket reopens the channel and delivers what was queued. + + Args: + channel_app: Running AppHarness instance. + page: Playwright page fixture. + """ + _connected_probe(channel_app, page) + connects = page.evaluate("window.__probe.connects") + + page.evaluate("window.__probe.drop()") + page.wait_for_function("window.__probe.connected === false") + # Emitted while the socket is down: queued, not lost. + page.evaluate("window.__probe.push(9)") + # Reconnects are retried with backoff, which outlasts the default wait on + # a machine busy building the frontend. + page.wait_for_function(f"window.__probe.connects > {connects}", timeout=30_000) + page.wait_for_function("window.__probe.received.length > 0", timeout=30_000) + + received = page.evaluate("window.__probe.received[0]") + assert received["data"] == {"n": 9, "sizes": [4]} + assert received["bytes"] == [4, 3, 2, 1] diff --git a/tests/units/test_app.py b/tests/units/test_app.py index b062702d4b7..d444abd860e 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -4752,3 +4752,52 @@ def test_compile_emits_stage_spans( parent = spans[name].parent assert parent is not None assert parent.span_id == root.get_span_context().span_id + + +class _ProbeChannel(rx.channels.Channel): + """A channel that ignores everything, for registration tests.""" + + name = "probe" + + async def on_message(self, session, event, data, buffers) -> None: + """Ignore inbound messages.""" + return + + +def test_register_channel_serves_the_channel(): + """A registered channel is reachable by name for the transport.""" + app = App(enable_state=True) + channel = _ProbeChannel() + + app.register_channel(channel) + + assert app._channels == {"probe": channel} + + +def test_register_channel_rejects_a_duplicate_name(): + """Two channels cannot claim the same name.""" + app = App(enable_state=True) + app.register_channel(_ProbeChannel()) + + with pytest.raises(RuntimeError, match="already registered"): + app.register_channel(_ProbeChannel()) + + +def test_register_channel_requires_state(): + """A stateless app has no event websocket to carry a channel.""" + with RegistrationContext.get().fork(): + app = App(enable_state=False) + + with pytest.raises(RuntimeError, match="needs the event websocket"): + app.register_channel(_ProbeChannel()) + + +def test_register_channel_requires_the_websocket_transport( + monkeypatch: pytest.MonkeyPatch, +): + """Channels are a plain-WebSocket feature; Socket.IO cannot carry them.""" + monkeypatch.setenv("REFLEX_TRANSPORT", "socketio") + app = App(enable_state=True) + + with pytest.raises(RuntimeError, match="requires the plain WebSocket transport"): + app.register_channel(_ProbeChannel()) diff --git a/tests/units/test_channels.py b/tests/units/test_channels.py new file mode 100644 index 00000000000..314141165f1 --- /dev/null +++ b/tests/units/test_channels.py @@ -0,0 +1,160 @@ +"""Tests for the channel API in reflex/channels.py.""" + +from typing import Any + +import pytest + +from reflex.channels import Channel, ChannelSession, validate_channel_name + + +class CollectingChannel(Channel): + """A channel that records every message its transport was asked to send.""" + + name = "probe" + + def __init__(self): + """Initialize the channel and its recorded sends.""" + super().__init__() + self.sent: list[tuple[str, str, str, Any, list[bytes]]] = [] + + async def on_message( + self, session: ChannelSession, event: str, data: Any, buffers: list[bytes] + ) -> None: + """Ignore inbound messages; these tests drive the channel directly.""" + return + + async def _record( + self, + sid: str, + channel: str, + event: str, + data: Any, + buffers: Any, + ) -> None: + """Stand in for the transport's send callable.""" + self.sent.append((sid, channel, event, data, list(buffers))) + + def session(self, sid: str, client_token: str = "tok") -> ChannelSession: + """Open a session wired to the recording sender. + + Args: + sid: The session id. + client_token: The client token. + + Returns: + The open session. + """ + return self.open_session(sid, client_token, self._record) + + +@pytest.mark.parametrize("name", ["xy", "_xy", "/_xy", "a.b-c:d/e", "x" * 64]) +def test_valid_channel_names(name: str): + """Names usable on the wire pass validation.""" + validate_channel_name(name) + + +@pytest.mark.parametrize("name", ["", "x" * 65, "has space", 'quote"', "new\nline"]) +def test_invalid_channel_names(name: str): + """Names that would need escaping on the wire are rejected.""" + with pytest.raises(ValueError, match="Invalid channel name"): + validate_channel_name(name) + + +def test_channel_rejects_an_unusable_name(): + """A channel declaring an unusable name fails at construction.""" + + class BadChannel(CollectingChannel): + name = "not a name" + + with pytest.raises(ValueError, match="Invalid channel name"): + BadChannel() + + +@pytest.mark.asyncio +async def test_session_send_reaches_the_transport(): + """A session's send is handed to the transport with its channel name.""" + channel = CollectingChannel() + session = channel.session("sid1") + + await session.send("payload", {"fig": "f1"}, [b"\x00"]) + + assert channel.sent == [("sid1", "probe", "payload", {"fig": "f1"}, [b"\x00"])] + + +@pytest.mark.asyncio +async def test_room_fan_out_reaches_members_only(): + """Sending to a room reaches its members and no one else.""" + channel = CollectingChannel() + first = channel.session("sid1") + second = channel.session("sid2") + channel.session("sid3") + first.join("fig:1") + second.join("fig:1") + + await channel.send_to_room("fig:1", "push", {"n": 1}) + + assert sorted(sent[0] for sent in channel.sent) == ["sid1", "sid2"] + + +@pytest.mark.asyncio +async def test_leaving_a_room_stops_delivery(): + """A session that left a room no longer receives its messages.""" + channel = CollectingChannel() + session = channel.session("sid1") + session.join("fig:1") + session.leave("fig:1") + + await channel.send_to_room("fig:1", "push") + + assert channel.sent == [] + # The empty room is not kept around. + assert channel._rooms == {} + + +@pytest.mark.asyncio +async def test_sending_to_an_unknown_room_is_a_noop(): + """Fan-out to a room nobody joined does nothing.""" + channel = CollectingChannel() + + await channel.send_to_room("fig:missing", "push") + + assert channel.sent == [] + + +@pytest.mark.asyncio +async def test_send_to_token_reports_delivery(): + """Token-addressed sends reach that client's sessions, and report misses.""" + channel = CollectingChannel() + channel.session("sid1", client_token="tok1") + channel.session("sid2", client_token="tok2") + + assert await channel.send_to_token("tok1", "push", {"n": 1}) is True + assert await channel.send_to_token("missing", "push") is False + assert [sent[0] for sent in channel.sent] == ["sid1"] + + +@pytest.mark.asyncio +async def test_forgetting_a_session_clears_rooms_and_tokens(): + """A forgotten session leaves no room membership or token entry behind.""" + channel = CollectingChannel() + session = channel.session("sid1", client_token="tok1") + session.join("fig:1") + + channel.forget_session(session) + + assert channel._rooms == {} + assert channel._sessions == {} + assert await channel.send_to_token("tok1", "push") is False + await channel.send_to_room("fig:1", "push") + assert channel.sent == [] + + +def test_sessions_of_one_token_are_tracked_together(): + """Two tabs sharing a token both stay reachable until each is forgotten.""" + channel = CollectingChannel() + first = channel.session("sid1", client_token="tok1") + second = channel.session("sid2", client_token="tok1") + + channel.forget_session(first) + + assert channel._sessions == {"tok1": {second}} diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 34ead28de05..00dc83e9cf6 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -1,9 +1,12 @@ """Tests for the plain WebSocket event transport in reflex/event_namespace.py.""" import asyncio +import base64 import json import logging import re +import shutil +import subprocess from pathlib import Path from typing import Any from unittest.mock import AsyncMock, Mock @@ -14,11 +17,19 @@ from starlette.routing import WebSocketRoute from reflex.app import App +from reflex.channels import Channel, ChannelSession from reflex.event_namespace import ( + CHANNEL_ERROR_MESSAGE, + CLOSE_MESSAGE, HANDSHAKE_MESSAGE, + OPEN_MESSAGE, + OPENED_MESSAGE, PING_MESSAGE, PONG_MESSAGE, + PROTOCOL_VERSION, WebsocketEventNamespace, + decode_channel_frame, + encode_channel_frame, ) from reflex.utils import format @@ -65,6 +76,10 @@ async def send_text(self, text: str): """Record an outgoing frame.""" self.sent.append(json.loads(text)) + async def send_bytes(self, data: bytes): + """Record an outgoing binary frame.""" + self.sent.append(data) + async def close(self, code: int = 1000): """Record the close call.""" self.close_code = code @@ -100,6 +115,7 @@ def mock_app() -> Mock: """ app = Mock() app._state = None + app._channels = {} app.router = Mock(return_value=None) app.event_processor.enqueue = AsyncMock() return app @@ -134,7 +150,8 @@ async def test_handshake_and_token_link(namespace: WebsocketEventNamespace): assert websocket.accepted assert websocket.accepted_subprotocol == "0.0.1" assert websocket.sent[0][0] == HANDSHAKE_MESSAGE - assert set(websocket.sent[0][1]) == {"ping_interval", "ping_timeout"} + assert set(websocket.sent[0][1]) == {"ping_interval", "ping_timeout", "protocol"} + assert websocket.sent[0][1]["protocol"] == PROTOCOL_VERSION await _drain_tasks() # The session was linked and unlinked again on disconnect. assert "tok1" not in namespace.token_to_sid @@ -559,4 +576,432 @@ def test_protocol_message_names_match_the_client(): "HANDSHAKE_MESSAGE": HANDSHAKE_MESSAGE, "PING_MESSAGE": PING_MESSAGE, "PONG_MESSAGE": PONG_MESSAGE, + "OPEN_MESSAGE": OPEN_MESSAGE, + "OPENED_MESSAGE": OPENED_MESSAGE, + "CLOSE_MESSAGE": CLOSE_MESSAGE, + "CHANNEL_ERROR_MESSAGE": CHANNEL_ERROR_MESSAGE, } + + +class RecordingChannel(Channel): + """A channel that records what the transport hands it.""" + + name = "probe" + + def __init__(self, accepts_binary: bool = False): + """Initialize the channel, recording opens, messages and closes.""" + type(self).accepts_binary = accepts_binary + super().__init__() + self.opened: list[ChannelSession] = [] + self.closed: list[ChannelSession] = [] + self.messages: list[tuple[str, Any, list[bytes]]] = [] + + async def on_open(self, session: ChannelSession) -> None: + """Record the opened session.""" + self.opened.append(session) + + async def on_message( + self, session: ChannelSession, event: str, data: Any, buffers: list[bytes] + ) -> None: + """Record the message and answer an echo.""" + self.messages.append((event, data, buffers)) + await session.send("echo", data, buffers) + + async def on_close(self, session: ChannelSession) -> None: + """Record the closed session.""" + self.closed.append(session) + + +def channel_frames(websocket: FakeWebSocket, channel: str = "probe") -> list[Any]: + """Text frames the server sent on a channel. + + Args: + websocket: The fake websocket. + channel: The channel name. + + Returns: + The matching frames. + """ + return [ + frame + for frame in websocket.sent + if isinstance(frame, list) and len(frame) > 2 and frame[2] == channel + ] + + +def test_channel_frame_round_trip(): + """A binary frame decodes to the values it was built from.""" + buffers = [b"\x01\x02\x03", b"", bytes(range(16))] + frame = encode_channel_frame("payload", {"fig": "f1"}, "probe", buffers) + assert decode_channel_frame(frame) == ("payload", {"fig": "f1"}, "probe", buffers) + + +def test_channel_frame_aligns_attachments(): + """Every attachment starts on an 8-byte boundary, whatever the header size.""" + for name_length in range(1, 24): + frame = encode_channel_frame( + "e", {"pad": "x" * name_length}, "probe", [b"\x01" * 3, b"\x02" * 5] + ) + offsets = [] + offset = 4 + int.from_bytes(frame[:4], "little") + for length in (3, 5): + offset += -offset % 8 + offsets.append(offset) + offset += length + assert all(candidate % 8 == 0 for candidate in offsets) + assert frame[offsets[0] : offsets[0] + 3] == b"\x01" * 3 + assert frame[offsets[1] : offsets[1] + 5] == b"\x02" * 5 + + +@pytest.mark.parametrize( + "frame", + [ + b"", + b"\x02\x00", + (255).to_bytes(4, "little") + b'["e",null,"c",[]]', + (17).to_bytes(4, "little") + b'["e",null,"c",42]', + (18).to_bytes(4, "little") + b'["e",null,"c",[1]]', + (21).to_bytes(4, "little") + b'["e",null,"c",[-1]]xx', + (17).to_bytes(4, "little") + b'{"not": "a list"}', + (11).to_bytes(4, "little") + b"not json at all", + ], +) +def test_malformed_channel_frame_is_rejected(frame: bytes): + """A frame that does not follow the binary format raises.""" + with pytest.raises(ValueError): + decode_channel_frame(frame) + + +def test_channel_frame_rejects_too_many_attachments(): + """A frame declaring more attachments than the cap raises.""" + header = json.dumps(["e", None, "c", [0] * 65]).encode() + with pytest.raises(ValueError, match="more than"): + decode_channel_frame(len(header).to_bytes(4, "little") + header) + + +@pytest.mark.asyncio +async def test_channel_open_and_message( + namespace: WebsocketEventNamespace, mock_app: Mock +): + """Opening a channel answers _opened and routes messages to the channel.""" + channel = RecordingChannel() + mock_app._channels = {"probe": channel} + websocket = FakeWebSocket() + websocket.feed([OPEN_MESSAGE, None, "probe"], ["sub", {"fig": "f1"}, "probe"]) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert channel_frames(websocket)[0] == [OPENED_MESSAGE, None, "probe"] + assert [session.client_token for session in channel.opened] == ["tok1"] + assert channel.messages == [("sub", {"fig": "f1"}, [])] + assert ["echo", {"fig": "f1"}, "probe"] in websocket.sent + # The disconnect closed the session again. + assert channel.closed == channel.opened + + +@pytest.mark.asyncio +async def test_channel_unknown_name_reports_error( + namespace: WebsocketEventNamespace, mock_app: Mock +): + """Opening a channel the backend does not serve answers _error.""" + mock_app._channels = {} + websocket = FakeWebSocket() + websocket.feed([OPEN_MESSAGE, None, "nope"]) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + error = channel_frames(websocket, "nope")[0] + assert error[0] == CHANNEL_ERROR_MESSAGE + assert error[1]["code"] == "unknown_channel" + + +@pytest.mark.asyncio +async def test_channel_message_before_open_reports_error( + namespace: WebsocketEventNamespace, mock_app: Mock +): + """A message for an unopened channel answers _error and is not dispatched.""" + channel = RecordingChannel() + mock_app._channels = {"probe": channel} + websocket = FakeWebSocket() + websocket.feed(["sub", {}, "probe"]) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert channel_frames(websocket)[0][1]["code"] == "channel_not_open" + assert channel.messages == [] + + +@pytest.mark.asyncio +async def test_channel_close_ends_the_session( + namespace: WebsocketEventNamespace, mock_app: Mock +): + """A _close frame runs on_close and stops dispatching to the channel.""" + channel = RecordingChannel() + mock_app._channels = {"probe": channel} + websocket = FakeWebSocket() + websocket.feed( + [OPEN_MESSAGE, None, "probe"], + [CLOSE_MESSAGE, None, "probe"], + ["sub", {}, "probe"], + ) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert len(channel.closed) == 1 + assert channel.messages == [] + assert channel_frames(websocket)[-1][1]["code"] == "channel_not_open" + + +@pytest.mark.asyncio +async def test_channel_binary_message_round_trip( + namespace: WebsocketEventNamespace, mock_app: Mock +): + """A binary frame reaches a channel that accepts binary, and echoes back.""" + channel = RecordingChannel(accepts_binary=True) + mock_app._channels = {"probe": channel} + websocket = FakeWebSocket() + websocket.feed( + [OPEN_MESSAGE, None, "probe"], + encode_channel_frame("push", {"n": 2}, "probe", [b"\x00\x01", b"\x02"]), + ) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert channel.messages == [("push", {"n": 2}, [b"\x00\x01", b"\x02"])] + echoed = [frame for frame in websocket.sent if isinstance(frame, bytes)] + assert decode_channel_frame(echoed[0]) == ( + "echo", + {"n": 2}, + "probe", + [b"\x00\x01", b"\x02"], + ) + + +@pytest.mark.asyncio +async def test_channel_binary_rejected_when_not_accepted( + namespace: WebsocketEventNamespace, mock_app: Mock +): + """Binary attachments to a text-only channel answer _error.""" + channel = RecordingChannel(accepts_binary=False) + mock_app._channels = {"probe": channel} + websocket = FakeWebSocket() + websocket.feed( + [OPEN_MESSAGE, None, "probe"], + encode_channel_frame("push", None, "probe", [b"\x00"]), + ) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert channel_frames(websocket)[-1][1]["code"] == "binary_not_accepted" + assert channel.messages == [] + + +@pytest.mark.asyncio +async def test_malformed_binary_frame_closes_connection( + namespace: WebsocketEventNamespace, mock_app: Mock +): + """A binary frame that is not a channel frame closes the connection.""" + mock_app._channels = {"probe": RecordingChannel()} + websocket = FakeWebSocket() + websocket.feed(b"\xff\xff\xff\xff not a frame") + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert websocket.close_code == 1002 + + +@pytest.mark.asyncio +async def test_oversize_binary_frame_closes_connection( + namespace: WebsocketEventNamespace, mock_app: Mock, monkeypatch: pytest.MonkeyPatch +): + """A binary frame over the size limit closes the connection with 1009.""" + mock_app._channels = {"probe": RecordingChannel()} + monkeypatch.setenv("REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE", "16") + websocket = FakeWebSocket() + websocket.feed(encode_channel_frame("push", None, "probe", [b"\x00" * 64])) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert websocket.close_code == 1009 + + +@pytest.mark.asyncio +async def test_channel_handler_error_keeps_connection( + namespace: WebsocketEventNamespace, mock_app: Mock, caplog +): + """A channel handler raising is logged without dropping the connection.""" + + class BoomChannel(Channel): + name = "boom" + + async def on_message(self, session, event, data, buffers) -> None: + msg = "handler failed" + raise RuntimeError(msg) + + mock_app._channels = {"boom": BoomChannel()} + websocket = FakeWebSocket() + with caplog.at_level(logging.ERROR): + websocket.feed([OPEN_MESSAGE, None, "boom"], ["go", None, "boom"], ["ping"]) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert websocket.close_code is None + assert ["ping", "pong"] in websocket.sent + assert "handler failed" in caplog.text + + +@pytest.mark.asyncio +async def test_channel_frame_with_non_string_name_closes_connection( + namespace: WebsocketEventNamespace, +): + """A frame whose channel element is not a string closes the connection.""" + websocket = FakeWebSocket() + websocket.feed(["sub", None, 42]) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert websocket.close_code == 1002 + + +NODE = shutil.which("node") or "" + + +@pytest.mark.skipif(not NODE, reason="Requires node to run the client codec") +def test_binary_frame_codec_matches_the_client(tmp_path: Path): + """Both ends agree on the binary frame layout, both ways. + + The layout (header length, padding, alignment) is implemented twice, and a + disagreement would only surface as an unreadable payload in a browser. + """ + buffers = [b"\x01\x02\x03", b"", bytes(range(24))] + frame = encode_channel_frame("payload", {"fig": "f1", "n": 3}, "probe", buffers) + script = tmp_path / "codec.mjs" + script.write_text(f""" +import {{ encodeChannelFrame, decodeChannelFrame }} from "{WEBSOCKET_JS_TEMPLATE}"; + +const fromPython = Uint8Array.from(Buffer.from(process.argv[2], "base64")); +const [event, data, channel, buffers] = decodeChannelFrame(fromPython.buffer); +const encoded = encodeChannelFrame(event, data, channel, buffers); +console.log(JSON.stringify({{ + event, + data, + channel, + lengths: buffers.map((b) => b.byteLength), + // Every attachment must be readable as a typed array in place. + aligned: buffers.every((b) => b.byteOffset % 8 === 0), + encoded: Buffer.from(encoded).toString("base64"), +}})); +""") + result = subprocess.run( + [NODE, str(script), base64.b64encode(frame).decode()], + capture_output=True, + text=True, + check=True, + ) + decoded = json.loads(result.stdout) + + assert decoded["event"] == "payload" + assert decoded["data"] == {"fig": "f1", "n": 3} + assert decoded["channel"] == "probe" + assert decoded["lengths"] == [len(buffer) for buffer in buffers] + assert decoded["aligned"] + # The frame the client built decodes back to the same message. + assert decode_channel_frame(base64.b64decode(decoded["encoded"])) == ( + "payload", + {"fig": "f1", "n": 3}, + "probe", + buffers, + ) + + +@pytest.mark.asyncio +async def test_repeated_channel_open_reuses_the_session( + namespace: WebsocketEventNamespace, mock_app: Mock +): + """Opening an already-open channel answers again without a second session. + + A second session would keep the first one's room membership alive with + nothing left to close it. + """ + + class RoomChannel(RecordingChannel): + name = "probe" + + async def on_open(self, session: ChannelSession) -> None: + """Join a room so an orphaned session would be observable.""" + await super().on_open(session) + session.join("all") + + channel = RoomChannel() + mock_app._channels = {"probe": channel} + websocket = FakeWebSocket() + websocket.feed([OPEN_MESSAGE, None, "probe"], [OPEN_MESSAGE, None, "probe"]) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert len(channel.opened) == 1 + assert [frame[0] for frame in channel_frames(websocket)] == [ + OPENED_MESSAGE, + OPENED_MESSAGE, + ] + # The disconnect left nothing behind. + assert channel._rooms == {} + assert channel._sessions == {} + + +@pytest.mark.asyncio +async def test_interrupted_teardown_still_unlinks_the_token( + namespace: WebsocketEventNamespace, mock_app: Mock +): + """Token cleanup starts before any teardown await can be interrupted. + + Server shutdown cancels the connection task, and an await in the teardown + path raises again inside a cancelled scope. A token left linked would make + the client's reconnect look like a duplicate tab. + """ + + class StallingChannel(RecordingChannel): + name = "probe" + + async def on_close(self, session: ChannelSession) -> None: + """Fail the way a cancelled cleanup await would.""" + raise asyncio.CancelledError + + mock_app._channels = {"probe": StallingChannel()} + websocket = FakeWebSocket() + websocket.feed([OPEN_MESSAGE, None, "probe"]) + with pytest.raises(asyncio.CancelledError): + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert "tok1" not in namespace.token_to_sid + + +@pytest.mark.asyncio +async def test_interrupted_open_leaves_no_session_behind( + namespace: WebsocketEventNamespace, mock_app: Mock +): + """A session interrupted inside on_open is still cleaned up on disconnect. + + on_open may already have joined rooms, so a session the transport never + recorded would stay reachable by fan-out with nothing left to close it. + """ + + class InterruptedChannel(RecordingChannel): + name = "probe" + + async def on_open(self, session: ChannelSession) -> None: + """Join a room, then fail the way a cancellation would.""" + session.join("all") + raise asyncio.CancelledError + + channel = InterruptedChannel() + mock_app._channels = {"probe": channel} + websocket = FakeWebSocket() + websocket.feed([OPEN_MESSAGE, None, "probe"]) + with pytest.raises(asyncio.CancelledError): + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert channel._rooms == {} + assert channel._sessions == {} diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py index 81b5b634879..0b63ecdc9ba 100644 --- a/tests/units/utils/test_exec.py +++ b/tests/units/utils/test_exec.py @@ -3,6 +3,7 @@ import builtins import multiprocessing import os +import sys from pathlib import Path import pytest @@ -191,3 +192,61 @@ def test_arbitrate_ssr_env_var_wins(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv(environment.REFLEX_SSR.name, "False") assert exec_utils.arbitrate_ssr(True) is False + + +@pytest.mark.parametrize("deflate", [True, False]) +def test_run_uvicorn_backend_passes_the_socket_policy( + tmp_path: Path, + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + deflate: bool, +): + """The dev server gets the app's websocket size and compression settings.""" + monkeypatch.setenv("REFLEX_SOCKET_PER_MESSAGE_DEFLATE", str(deflate).lower()) + mocker.patch.object( + exec_utils, + "get_dev_backend_reload_marker", + return_value=tmp_path / exec_utils.DEV_BACKEND_RELOAD_MARKER, + ) + mocker.patch.object(exec_utils, "get_app_instance", return_value="app:app") + mocker.patch.object(exec_utils, "get_reload_paths", return_value=[]) + uvicorn = pytest.importorskip("uvicorn") + run = mocker.patch.object(uvicorn, "run") + + exec_utils.run_uvicorn_backend( + host="0.0.0.0", port=8000, loglevel=exec_utils.LogLevel.INFO + ) + + kwargs = run.call_args.kwargs + assert kwargs["ws_per_message_deflate"] is deflate + assert kwargs["ws_max_size"] == exec_utils._uvicorn_ws_max_size() + + +def test_uvicorn_websocket_args_match_the_options(monkeypatch: pytest.MonkeyPatch): + """The command line form carries the same policy as the keyword form.""" + monkeypatch.setenv("REFLEX_SOCKET_PER_MESSAGE_DEFLATE", "false") + args = exec_utils._uvicorn_websocket_args() + + assert "--no-ws-per-message-deflate" in args + assert args[args.index("--ws-max-size") + 1] == str( + exec_utils.uvicorn_websocket_options()["ws_max_size"] + ) + + monkeypatch.setenv("REFLEX_SOCKET_PER_MESSAGE_DEFLATE", "true") + assert "--no-ws-per-message-deflate" not in exec_utils._uvicorn_websocket_args() + + +def test_uvicorn_worker_carries_the_socket_policy(monkeypatch: pytest.MonkeyPatch): + """The gunicorn worker class applies the settings gunicorn cannot pass on.""" + pytest.importorskip("gunicorn") + pytest.importorskip("uvicorn") + monkeypatch.setenv("REFLEX_SOCKET_PER_MESSAGE_DEFLATE", "false") + # The class body reads the environment at import time. + sys.modules.pop("reflex.utils.uvicorn_worker", None) + from reflex.utils.uvicorn_worker import ReflexUvicornWorker + + assert ( + ReflexUvicornWorker.CONFIG_KWARGS.items() + >= exec_utils.uvicorn_websocket_options().items() + ) + assert ReflexUvicornWorker.CONFIG_KWARGS["ws_per_message_deflate"] is False From 60aa61490fcc15cfa14fe89ec493ff389e872695 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 12 Sep 2026 15:17:20 +0200 Subject: [PATCH 30/44] harden the channel API and fix the Windows unit test and uvicorn deflate flag --- docs/api-reference/channels.md | 4 + .../.templates/web/utils/helpers/websocket.js | 44 +++++++--- reflex/app.py | 4 +- reflex/channels.py | 27 ++++-- reflex/event_namespace.py | 9 +- reflex/utils/exec.py | 6 +- .../tests_playwright/test_channels.py | 20 +++-- tests/units/test_app.py | 22 +++-- tests/units/test_channels.py | 84 ++++++++++++++++++- tests/units/test_event_namespace.py | 67 ++++++++++++++- tests/units/utils/test_exec.py | 28 +++++-- 11 files changed, 271 insertions(+), 44 deletions(-) diff --git a/docs/api-reference/channels.md b/docs/api-reference/channels.md index ef7af95ff4a..7cdfe37fe20 100644 --- a/docs/api-reference/channels.md +++ b/docs/api-reference/channels.md @@ -64,6 +64,10 @@ await session.send("tick", {"symbol": "RFX", "price": 42.0}) await self.send_to_room("RFX", "tick", {"price": 42.0}) ``` +A handler that awaits — a rebuild, a thread hop — can come back to a session +whose client has gone; `session.open` reports that before you commit to +expensive or long-lived work. + Rooms and sessions are local to the worker holding the connection. A client reconnecting to another worker opens its session there, so anything that must outlive a connection belongs in Reflex state, not in the channel. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js index 58273709232..c3c6c210969 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js @@ -249,8 +249,14 @@ class ReflexChannel extends LocalEmitter { * @param buffers Binary attachments (ArrayBuffers or typed arrays). */ emit(event, data, buffers = []) { + // Serialize now, connected or not: a queued frame must carry what was + // emitted, not whatever the caller's payload and typed arrays hold by the + // time the channel opens. + const frame = buffers?.length + ? encodeChannelFrame(event, data, this.name, buffers) + : stringifyFrame([event, data, this.name]); if (this.connected && this._transport) { - this._transport.emitChannel(this.name, event, data, buffers); + this._transport._send(frame); return; } if (this._queue.length >= MAX_QUEUED_CHANNEL_MESSAGES) { @@ -258,7 +264,7 @@ class ReflexChannel extends LocalEmitter { // "connect"; drop the oldest rather than grow without bound. this._queue.shift(); } - this._queue.push([event, data, buffers]); + this._queue.push(frame); } /** @@ -301,10 +307,9 @@ class ReflexChannel extends LocalEmitter { this.connected = true; const queued = this._queue; this._queue = []; - for (const [queuedEvent, queuedData, queuedBuffers] of queued) { - // Through emit(), so a transport that went away mid-flush re-queues - // rather than throwing. - this.emit(queuedEvent, queuedData, queuedBuffers); + for (const frame of queued) { + // Back onto the transport's own queue if it went away mid-flush. + this._transport._send(frame); } this._emitLocal("connect"); return; @@ -328,7 +333,10 @@ export const getChannel = (name) => { channel = new ReflexChannel(name); channels.set(name, channel); if (channelsUnsupportedReason !== null) { - channel._unsupported(channelsUnsupportedReason); + // After this turn: the caller registers its "error" handler on the + // handle we are still returning. + const reason = channelsUnsupportedReason; + queueMicrotask(() => channel._unsupported(reason)); } else if (activeTransport !== null) { channel._attach(activeTransport); } @@ -630,18 +638,28 @@ export class ReflexWebSocket extends LocalEmitter { this._emitLocal(event, payload); } + /** + * Drop the socket as a transport failure, which the event loop reconnects + * from -- unlike disconnect(), which reports an intentional close. + * @param reason The disconnect reason to report. + */ + _dropConnection(reason) { + if (this._ws && this.connected) { + this._closeReason = reason; + this._ws.close(); + } + } + /** * (Re)arm the dead-connection watchdog; fires when no message (heartbeat * included) arrives within the server's ping interval + timeout. */ _resetWatchdog() { this._clearWatchdog(); - this._watchdogTimer = setTimeout(() => { - if (this._ws && this.connected) { - this._closeReason = "ping timeout"; - this._ws.close(); - } - }, this._watchdogMs); + this._watchdogTimer = setTimeout( + () => this._dropConnection("ping timeout"), + this._watchdogMs, + ); } /** diff --git a/reflex/app.py b/reflex/app.py index ff247403610..cab1122d921 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -492,7 +492,9 @@ def register_channel(self, channel: Channel) -> None: RuntimeError: If the app cannot serve channels, or the name is taken. """ name = type(channel).name - if self._state is None: + if self.event_namespace is None: + # A supplied `_state` is not enough: without enable_state the app + # never sets up a transport, so the channel would be unreachable. msg = ( f"Channel {name!r} needs the event websocket, which exists only " "when state is enabled (rx.App(enable_state=True), the default)." diff --git a/reflex/channels.py b/reflex/channels.py index b68b128104f..64db65eae43 100644 --- a/reflex/channels.py +++ b/reflex/channels.py @@ -40,16 +40,16 @@ async def on_message(self, session, event, data, buffers): ChannelSender = Callable[[str, str, str, Any, Sequence[bytes]], Awaitable[None]] -def validate_channel_name(name: str) -> None: +def validate_channel_name(name: Any) -> None: """Check a channel name against the wire format. Args: - name: The channel name. + name: The channel name declared by a Channel subclass. Raises: - ValueError: If the name is unusable on the wire. + ValueError: If the name is missing or unusable on the wire. """ - if not _NAME_PATTERN.fullmatch(name): + if not isinstance(name, str) or not _NAME_PATTERN.fullmatch(name): msg = ( f"Invalid channel name {name!r}: expected 1-64 characters from " "[A-Za-z0-9_./:-]." @@ -77,6 +77,11 @@ class ChannelSession: _rooms: set[str] + # Whether the client is still connected. A handler that awaits (a rebuild, + # a thread hop) can come back to a session whose socket is gone; anything + # expensive or long-lived should check before proceeding. + open: bool = True + async def send( self, event: str, data: Any = None, buffers: Sequence[bytes] = () ) -> None: @@ -86,7 +91,18 @@ async def send( event: The message name. data: The JSON-serializable metadata. buffers: Binary attachments delivered alongside the metadata. + + Raises: + ValueError: If the message carries more attachments than a frame + may hold. Raised before anything is sent, so a fan-out fails + whole rather than reaching some clients. """ + if len(buffers) > MAX_MESSAGE_BUFFERS: + msg = ( + f"Channel message {event!r} carries {len(buffers)} attachments, " + f"over the {MAX_MESSAGE_BUFFERS} a frame may hold." + ) + raise ValueError(msg) await self._send(self.sid, self.channel.name, event, data, buffers) def join(self, room: str) -> None: @@ -131,7 +147,7 @@ class Channel(ABC): def __init__(self): """Initialize the channel's session and room bookkeeping.""" - validate_channel_name(type(self).name) + validate_channel_name(getattr(type(self), "name", None)) # Sessions by client token, for token-addressed sends. self._sessions: dict[str, set[ChannelSession]] = {} # Room name to member sessions. @@ -249,6 +265,7 @@ def forget_session(self, session: ChannelSession) -> None: Args: session: The session going away. """ + session.open = False for room in tuple(session._rooms): session.leave(room) sessions = self._sessions.get(session.client_token) diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index 337f93f30c0..b78fa4f565f 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -795,9 +795,10 @@ async def _handle_binary_frame( otel.record_message_size(len(frame), "receive") try: event, data, channel_name, buffers = decode_channel_frame(frame) - except ValueError: + except (ValueError, RecursionError): # A Reflex client never sends malformed frames; close instead of - # logging per frame. + # logging per frame. Deeply nested JSON exhausts the decoder's + # stack, which is malformed input all the same. logger.debug(f"Closing session {sid}: malformed binary frame.") return 1002 await self._handle_channel_message(sid, channel_name, event, data, buffers) @@ -852,7 +853,9 @@ async def _handle_frame( otel.record_message_size(utf8_size(text), "receive") try: message = json.loads(text) - except json.JSONDecodeError: + except (json.JSONDecodeError, RecursionError): + # Deeply nested JSON exhausts the decoder's stack rather than + # failing to parse; both are just a malformed frame here. message = None if ( not isinstance(message, list) diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index e18347b59fa..7ea345a4bb7 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -707,7 +707,11 @@ def _uvicorn_websocket_args() -> list[str]: options = uvicorn_websocket_options() return [ *("--ws-max-size", str(options["ws_max_size"])), - *([] if options["ws_per_message_deflate"] else ["--no-ws-per-message-deflate"]), + # A BOOLEAN-valued option, not a flag: uvicorn rejects --no-... forms. + *( + "--ws-per-message-deflate", + str(options["ws_per_message_deflate"]).lower(), + ), ] diff --git a/tests/integration/tests_playwright/test_channels.py b/tests/integration/tests_playwright/test_channels.py index 3a22f8d81ea..3dfaf601f84 100644 --- a/tests/integration/tests_playwright/test_channels.py +++ b/tests/integration/tests_playwright/test_channels.py @@ -30,9 +30,18 @@ def ChannelApp(): probeChannel.on("disconnect", () => { window.__probe.connected = false; }); - // Drop the underlying socket the way a network blip would, so the test - // can watch the channel come back with the app's own reconnect. - window.__probe.drop = () => probeChannel._transport._ws.close(); + // Drop the socket the way the transport's own watchdog does on a dead + // connection, so the test exercises the supported reconnect path. + window.__probe.drop = () => probeChannel._transport._dropConnection("test"); + // Emit while down, then mutate what was passed: the queued message must + // still carry the values it was emitted with. + window.__probe.pushThenMutate = (n) => { + const meta = { n }; + const bytes = new Uint8Array([1, 2, 3, 4]); + probeChannel.emit("push", meta, [bytes]); + meta.n = -1; + bytes.fill(9); + }; probeChannel.on("error", (error) => { window.__probe.errors.push(error); }); @@ -170,8 +179,9 @@ def test_channel_reopens_and_flushes_after_a_reconnect( page.evaluate("window.__probe.drop()") page.wait_for_function("window.__probe.connected === false") - # Emitted while the socket is down: queued, not lost. - page.evaluate("window.__probe.push(9)") + # Emitted while the socket is down: queued, not lost, and not rewritten by + # what the caller did with the payload afterwards. + page.evaluate("window.__probe.pushThenMutate(9)") # Reconnects are retried with backoff, which outlasts the default wait on # a machine busy building the frontend. page.wait_for_function(f"window.__probe.connects > {connects}", timeout=30_000) diff --git a/tests/units/test_app.py b/tests/units/test_app.py index d444abd860e..c7d73e16a63 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -29,6 +29,7 @@ from pytest_mock import MockerFixture from reflex_base import otel from reflex_base.components.component import Component +from reflex_base.config import get_config from reflex_base.constants.state import FIELD_MARKER from reflex_base.event import Event from reflex_base.event.context import EventContext @@ -4783,10 +4784,16 @@ def test_register_channel_rejects_a_duplicate_name(): app.register_channel(_ProbeChannel()) -def test_register_channel_requires_state(): - """A stateless app has no event websocket to carry a channel.""" +@pytest.mark.parametrize("state", [None, State]) +def test_register_channel_requires_the_event_websocket(state: type[State] | None): + """Without state there is no transport, whatever `_state` was passed. + + A supplied `_state` does not set one up on its own: `enable_state=False` + skips the setup that creates the event namespace and its route. + """ with RegistrationContext.get().fork(): - app = App(enable_state=False) + app = App(_state=state, enable_state=False) + assert app.event_namespace is None with pytest.raises(RuntimeError, match="needs the event websocket"): app.register_channel(_ProbeChannel()) @@ -4795,9 +4802,14 @@ def test_register_channel_requires_state(): def test_register_channel_requires_the_websocket_transport( monkeypatch: pytest.MonkeyPatch, ): - """Channels are a plain-WebSocket feature; Socket.IO cannot carry them.""" - monkeypatch.setenv("REFLEX_TRANSPORT", "socketio") + """Channels are a plain-WebSocket feature; Socket.IO cannot carry them. + + The transport is patched on the loaded config rather than requested + through the environment: building a Socket.IO app would need the optional + python-socketio package, which this check has nothing to do with. + """ app = App(enable_state=True) + monkeypatch.setattr(get_config(), "transport", "socketio") with pytest.raises(RuntimeError, match="requires the plain WebSocket transport"): app.register_channel(_ProbeChannel()) diff --git a/tests/units/test_channels.py b/tests/units/test_channels.py index 314141165f1..6b0c22eaeb2 100644 --- a/tests/units/test_channels.py +++ b/tests/units/test_channels.py @@ -4,7 +4,12 @@ import pytest -from reflex.channels import Channel, ChannelSession, validate_channel_name +from reflex.channels import ( + MAX_MESSAGE_BUFFERS, + Channel, + ChannelSession, + validate_channel_name, +) class CollectingChannel(Channel): @@ -158,3 +163,80 @@ def test_sessions_of_one_token_are_tracked_together(): channel.forget_session(first) assert channel._sessions == {"tok1": {second}} + + +def test_forgotten_session_reports_itself_closed(): + """A session whose client went away reports it, for handlers mid-await.""" + channel = CollectingChannel() + session = channel.session("sid1") + assert session.open is True + + channel.forget_session(session) + + assert session.open is False + + +@pytest.mark.asyncio +async def test_send_rejects_more_attachments_than_a_frame_holds(): + """An oversized message raises instead of building an unsendable frame.""" + channel = CollectingChannel() + session = channel.session("sid1") + buffers = [b"\x00"] * (MAX_MESSAGE_BUFFERS + 1) + + with pytest.raises(ValueError, match="attachments"): + await session.send("push", None, buffers) + + assert channel.sent == [] + + +@pytest.mark.asyncio +async def test_room_fan_out_rejects_oversized_messages_before_delivering(): + """A fan-out that cannot be framed reaches nobody, rather than some.""" + channel = CollectingChannel() + channel.session("sid1").join("all") + channel.session("sid2").join("all") + + with pytest.raises(ValueError, match="attachments"): + await channel.send_to_room( + "all", "push", None, [b""] * (MAX_MESSAGE_BUFFERS + 1) + ) + + assert channel.sent == [] + + +@pytest.mark.asyncio +async def test_send_accepts_the_full_attachment_budget(): + """The limit is inclusive, so a channel can use all of it.""" + channel = CollectingChannel() + session = channel.session("sid1") + + await session.send("push", None, [b""] * MAX_MESSAGE_BUFFERS) + + assert len(channel.sent) == 1 + + +@pytest.mark.parametrize("name", [42, b"bytes", None]) +def test_channel_with_an_unusable_name_reports_it(name: Any): + """A non-string name fails with the explicit error, not a TypeError.""" + + class BadChannel(CollectingChannel): + pass + + BadChannel.name = name # pyright: ignore[reportAttributeAccessIssue] + + with pytest.raises(ValueError, match="Invalid channel name"): + BadChannel() + + +def test_channel_without_a_name_reports_it(): + """A channel that never declared a name fails the same way.""" + + class NamelessChannel(Channel): + async def on_message( + self, session: ChannelSession, event: str, data: Any, buffers: list[bytes] + ) -> None: + """Ignore inbound messages.""" + return + + with pytest.raises(ValueError, match="Invalid channel name"): + NamelessChannel() diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 00dc83e9cf6..36cfe5306a0 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -7,6 +7,7 @@ import re import shutil import subprocess +import sys from pathlib import Path from typing import Any from unittest.mock import AsyncMock, Mock @@ -876,8 +877,11 @@ def test_binary_frame_codec_matches_the_client(tmp_path: Path): buffers = [b"\x01\x02\x03", b"", bytes(range(24))] frame = encode_channel_frame("payload", {"fig": "f1", "n": 3}, "probe", buffers) script = tmp_path / "codec.mjs" + # A file URL, not a path: an absolute Windows path is neither a valid ESM + # specifier nor a valid JS string literal (its separators are escapes). + template = json.dumps(WEBSOCKET_JS_TEMPLATE.as_uri()) script.write_text(f""" -import {{ encodeChannelFrame, decodeChannelFrame }} from "{WEBSOCKET_JS_TEMPLATE}"; +import {{ encodeChannelFrame, decodeChannelFrame }} from {template}; const fromPython = Uint8Array.from(Buffer.from(process.argv[2], "base64")); const [event, data, channel, buffers] = decodeChannelFrame(fromPython.buffer); @@ -896,8 +900,10 @@ def test_binary_frame_codec_matches_the_client(tmp_path: Path): [NODE, str(script), base64.b64encode(frame).decode()], capture_output=True, text=True, - check=True, + check=False, ) + + assert result.returncode == 0, result.stderr decoded = json.loads(result.stdout) assert decoded["event"] == "payload" @@ -1005,3 +1011,60 @@ async def on_open(self, session: ChannelSession) -> None: assert channel._rooms == {} assert channel._sessions == {} + + +@pytest.mark.asyncio +async def test_disconnect_marks_the_session_closed( + namespace: WebsocketEventNamespace, mock_app: Mock +): + """The session a channel holds reports the disconnect to its handlers.""" + channel = RecordingChannel() + mock_app._channels = {"probe": channel} + websocket = FakeWebSocket() + websocket.feed([OPEN_MESSAGE, None, "probe"]) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert [session.open for session in channel.opened] == [False] + + +@pytest.mark.asyncio +async def test_deeply_nested_frame_closes_connection( + namespace: WebsocketEventNamespace, +): + """A frame the JSON decoder cannot recurse through closes the connection. + + Deep nesting exhausts the decoder's stack instead of failing to parse, and + a RecursionError escaping the receive loop would drop the session with a + traceback rather than a protocol close. + """ + depth = sys.getrecursionlimit() * 200 + websocket = FakeWebSocket() + websocket.feed("[" * depth + "]" * depth) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert websocket.close_code == 1002 + + +@pytest.mark.asyncio +async def test_unparseable_binary_header_closes_connection( + namespace: WebsocketEventNamespace, mock_app: Mock, mocker +): + """A binary header that exhausts the decoder closes the connection too. + + The header size cap bounds how deep an inbound header can nest, and how + deep is too deep depends on the interpreter, so the decoder's failure is + simulated rather than provoked. + """ + mock_app._channels = {"probe": RecordingChannel()} + mocker.patch( + "reflex.event_namespace.decode_channel_frame", + side_effect=RecursionError("too deep"), + ) + websocket = FakeWebSocket() + websocket.feed(encode_channel_frame("push", None, "probe", [b"\x00"])) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert websocket.close_code == 1002 diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py index 0b63ecdc9ba..674e28151db 100644 --- a/tests/units/utils/test_exec.py +++ b/tests/units/utils/test_exec.py @@ -222,18 +222,30 @@ def test_run_uvicorn_backend_passes_the_socket_policy( assert kwargs["ws_max_size"] == exec_utils._uvicorn_ws_max_size() -def test_uvicorn_websocket_args_match_the_options(monkeypatch: pytest.MonkeyPatch): - """The command line form carries the same policy as the keyword form.""" - monkeypatch.setenv("REFLEX_SOCKET_PER_MESSAGE_DEFLATE", "false") - args = exec_utils._uvicorn_websocket_args() +@pytest.mark.parametrize("deflate", [True, False]) +def test_uvicorn_websocket_args_match_the_options( + monkeypatch: pytest.MonkeyPatch, deflate: bool +): + """Uvicorn's own CLI accepts the args, and reads the same policy from them. + + The Windows production backend passes these on a command line, where a + misspelled option is not a wrong setting but a server that refuses to + start, so they are checked against uvicorn's parser rather than a + hand-written expectation. + """ + pytest.importorskip("uvicorn") + from uvicorn.main import main as uvicorn_cli + + monkeypatch.setenv("REFLEX_SOCKET_PER_MESSAGE_DEFLATE", str(deflate).lower()) + options = exec_utils.uvicorn_websocket_options() - assert "--no-ws-per-message-deflate" in args - assert args[args.index("--ws-max-size") + 1] == str( - exec_utils.uvicorn_websocket_options()["ws_max_size"] + context = uvicorn_cli.make_context( + "uvicorn", [*exec_utils._uvicorn_websocket_args(), "app:app"] ) - monkeypatch.setenv("REFLEX_SOCKET_PER_MESSAGE_DEFLATE", "true") + assert context.params["ws_per_message_deflate"] is deflate assert "--no-ws-per-message-deflate" not in exec_utils._uvicorn_websocket_args() + assert context.params["ws_max_size"] == options["ws_max_size"] def test_uvicorn_worker_carries_the_socket_policy(monkeypatch: pytest.MonkeyPatch): From e1925bc2f894f4de37dbc60307264884b64553a5 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 12 Sep 2026 19:51:16 +0200 Subject: [PATCH 31/44] report a too deeply nested channel frame header as a malformed frame --- reflex/event_namespace.py | 15 +++++++++---- tests/units/test_event_namespace.py | 33 ++++++++++++++--------------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index b78fa4f565f..a6b668acf4f 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -133,7 +133,15 @@ def decode_channel_frame(frame: bytes) -> tuple[str, Any, str, list[bytes]]: if header_size > _MAX_FRAME_HEADER_SIZE or 4 + header_size > len(frame): msg = f"Binary frame declares an unusable header size {header_size}." raise ValueError(msg) - header = json.loads(frame[4 : 4 + header_size]) + try: + header = json.loads(frame[4 : 4 + header_size]) + except RecursionError as ex: + # The JSON decoder recurses per nesting level, so a header nested + # deeper than its stack allows never parses. That is malformed input + # like any other, and callers should not have to know that the + # decoder signals it differently. + msg = "Binary frame header is nested too deeply." + raise ValueError(msg) from ex match header: case [str(event), data, str(channel), [*lengths]] if all( isinstance(length, int) and not isinstance(length, bool) and length >= 0 @@ -795,10 +803,9 @@ async def _handle_binary_frame( otel.record_message_size(len(frame), "receive") try: event, data, channel_name, buffers = decode_channel_frame(frame) - except (ValueError, RecursionError): + except ValueError: # A Reflex client never sends malformed frames; close instead of - # logging per frame. Deeply nested JSON exhausts the decoder's - # stack, which is malformed input all the same. + # logging per frame. logger.debug(f"Closing session {sid}: malformed binary frame.") return 1002 await self._handle_channel_message(sid, channel_name, event, data, buffers) diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 36cfe5306a0..6d19f59ea34 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -9,6 +9,7 @@ import subprocess import sys from pathlib import Path +from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, Mock @@ -17,6 +18,7 @@ from reflex_base import otel from starlette.routing import WebSocketRoute +from reflex import event_namespace from reflex.app import App from reflex.channels import Channel, ChannelSession from reflex.event_namespace import ( @@ -1047,24 +1049,21 @@ async def test_deeply_nested_frame_closes_connection( assert websocket.close_code == 1002 -@pytest.mark.asyncio -async def test_unparseable_binary_header_closes_connection( - namespace: WebsocketEventNamespace, mock_app: Mock, mocker -): - """A binary header that exhausts the decoder closes the connection too. +def test_decoding_a_too_deeply_nested_header_is_a_value_error(mocker): + """A header the decoder cannot recurse through fails like any bad frame. - The header size cap bounds how deep an inbound header can nest, and how - deep is too deep depends on the interpreter, so the decoder's failure is - simulated rather than provoked. + Callers handle malformed frames by catching ValueError; that the decoder + signals deep nesting with RecursionError is its own business, and the + header size cap makes how deep is too deep interpreter-specific, so the + failure is simulated rather than provoked. """ - mock_app._channels = {"probe": RecordingChannel()} - mocker.patch( - "reflex.event_namespace.decode_channel_frame", - side_effect=RecursionError("too deep"), + # Only this module's reference to json, so nothing else loses its decoder. + mocker.patch.object( + event_namespace, + "json", + SimpleNamespace(loads=Mock(side_effect=RecursionError("too deep"))), ) - websocket = FakeWebSocket() - websocket.feed(encode_channel_frame("push", None, "probe", [b"\x00"])) - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] - await _drain_tasks() + frame = encode_channel_frame("push", None, "probe", [b"\x00"]) - assert websocket.close_code == 1002 + with pytest.raises(ValueError, match="nested too deeply"): + decode_channel_frame(frame) From b8f3a9dda689bb943dc87a5e2e00b4ac75dfc8bf Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 12 Sep 2026 20:42:55 +0200 Subject: [PATCH 32/44] fix channel frame loss on reconnect and enforce channel limits where messages are built --- docs/api-reference/channels.md | 27 +++++++++-- .../.templates/web/utils/helpers/websocket.js | 45 ++++++++++++++++--- reflex/channels.py | 20 +++++++-- reflex/event_namespace.py | 3 ++ .../tests_playwright/test_channels.py | 32 +++++++++++++ tests/units/test_channels.py | 14 ++++++ tests/units/test_event_namespace.py | 45 ++++++++++++++++++- 7 files changed, 171 insertions(+), 15 deletions(-) diff --git a/docs/api-reference/channels.md b/docs/api-reference/channels.md index 7cdfe37fe20..98361f47b20 100644 --- a/docs/api-reference/channels.md +++ b/docs/api-reference/channels.md @@ -50,8 +50,22 @@ A package can register its channel from a plugin's `post_compile` hook instead, which runs at backend startup with the live app. Every field of an inbound message is client-controlled and unvalidated. A -handler that raises is logged and the connection keeps serving, so a channel -bug never drops the app's socket. +handler that raises is logged and the connection keeps serving, so a failing +handler never drops the app's socket. + +Handlers run inline on their connection's receive loop, which keeps messages +in order and lets a slow channel push back on its client. It also means a +handler that awaits something slow stalls that connection — its state updates +wait, its heartbeat replies stop, and after `REFLEX_SOCKET_INTERVAL + +REFLEX_SOCKET_TIMEOUT` the server closes it as unresponsive. Hand long work to +`asyncio.to_thread` (or a task) and answer when it finishes: + +```python +async def on_message(self, session, event, data, buffers): + rows = await asyncio.to_thread(expensive_query, data["filter"]) + if session.open: + await session.send("rows", {"count": len(rows)}, [rows.tobytes()]) +``` ## Sending to clients @@ -92,9 +106,14 @@ class Frames(rx.channels.Channel): await session.send("frame", {"rows": len(buffers[0]) // 8}, buffers) ``` -A message may carry up to 64 attachments. Inbound frames are capped by +A message may carry up to 64 attachments, and a frame may not exceed `REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE` (1 MB by default); raise it if clients -send larger payloads. +send larger payloads. Both limits are enforced where the message is built — +`session.send` raises and `channel.emit` throws — because a frame that broke +them on the wire would cost the app its whole websocket. + +`connect`, `disconnect` and `error` are reserved message names: the client +handle reports its own lifecycle under them, so `session.send` refuses them. ## Using a channel from the frontend diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js index c3c6c210969..14d31ca6565 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js @@ -24,6 +24,16 @@ const FRAME_ALIGNMENT = 8; // Messages a channel buffers while it is not open, oldest dropped first. const MAX_QUEUED_CHANNEL_MESSAGES = 64; +// Attachments one channel message may carry (must match +// reflex.channels.MAX_MESSAGE_BUFFERS). The backend closes the connection over +// a frame that breaks its limits, so they are enforced here, where the mistake +// is, rather than losing the app's socket for it. +const MAX_MESSAGE_BUFFERS = 64; + +// A channel handle reports its own lifecycle under these names, so a message +// may not use them (must match reflex.channels.RESERVED_EVENTS). +const LIFECYCLE_EVENTS = new Set(["connect", "disconnect", "error"]); + // Python's json.dumps emits bare Infinity/-Infinity/NaN tokens (invalid JSON). // Rewrite them outside string literals so JSON.parse accepts the payload. // 1e999 / -1e999 overflow to ±Infinity; NaN has no JSON literal, so it is @@ -249,12 +259,25 @@ class ReflexChannel extends LocalEmitter { * @param buffers Binary attachments (ArrayBuffers or typed arrays). */ emit(event, data, buffers = []) { + if (buffers?.length > MAX_MESSAGE_BUFFERS) { + throw new Error( + `Channel message "${event}" carries ${buffers.length} attachments, ` + + `over the ${MAX_MESSAGE_BUFFERS} a frame may hold.`, + ); + } // Serialize now, connected or not: a queued frame must carry what was // emitted, not whatever the caller's payload and typed arrays hold by the // time the channel opens. const frame = buffers?.length ? encodeChannelFrame(event, data, this.name, buffers) : stringifyFrame([event, data, this.name]); + const limit = this._transport?._maxMessageSize; + if (limit && frame.byteLength > limit) { + throw new Error( + `Channel message "${event}" is ${frame.byteLength} bytes, over the ` + + `${limit} the backend accepts (REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE).`, + ); + } if (this.connected && this._transport) { this._transport._send(frame); return; @@ -318,6 +341,12 @@ class ReflexChannel extends LocalEmitter { this._emitLocal("error", data); return; } + if (LIFECYCLE_EVENTS.has(event)) { + // The backend rejects these names; a frame carrying one is not from a + // Reflex backend and must not be mistaken for the handle's own events. + console.error(`Ignoring channel message named "${event}" (reserved)`); + return; + } this._emitLocal(event, data, buffers); } } @@ -413,6 +442,8 @@ export class ReflexWebSocket extends LocalEmitter { this._connectTimeoutMs = 20 * 1000; this._connectTimer = null; this._closeReason = null; + // The backend's inbound message limit, learned from the handshake. + this._maxMessageSize = null; // Network emulation and OS offline do not interrupt established // websockets, so treat the browser's offline event as a disconnect. // Localhost connections keep working offline. @@ -614,11 +645,10 @@ export class ReflexWebSocket extends LocalEmitter { this._watchdogMs = (payload.ping_interval + payload.ping_timeout) * 1000; this._resetWatchdog(); this.connected = true; - const queue = this._sendQueue; - this._sendQueue = []; - for (const frame of queue) { - this._ws.send(frame); - } + this._maxMessageSize = payload.max_message_size ?? null; + // Open the channels first: a frame queued on the transport for one of + // them has to arrive after its _open, or the backend has no session to + // dispatch it to and answers channel_not_open. if ((payload.protocol ?? 1) >= CHANNEL_PROTOCOL_VERSION) { attachChannels(this); } else { @@ -628,6 +658,11 @@ export class ReflexWebSocket extends LocalEmitter { "This backend predates channel support; upgrade Reflex to use channels.", ); } + const queue = this._sendQueue; + this._sendQueue = []; + for (const frame of queue) { + this._ws.send(frame); + } this._emitLocal("connect"); return; } diff --git a/reflex/channels.py b/reflex/channels.py index 64db65eae43..ba6e0871591 100644 --- a/reflex/channels.py +++ b/reflex/channels.py @@ -35,6 +35,11 @@ async def on_message(self, session, event, data, buffers): # direction. Bounds the work a single inbound frame can ask for. MAX_MESSAGE_BUFFERS = 64 +# Names a client-side channel handle reports its own lifecycle under. A +# message may not use them, or a consumer could not tell an application +# message from the transport event it is named after. +RESERVED_EVENTS = frozenset({"connect", "disconnect", "error"}) + # Sends one channel message to a connected session: (sid, channel, event, # data, buffers). Supplied by the transport when the session opens. ChannelSender = Callable[[str, str, str, Any, Sequence[bytes]], Awaitable[None]] @@ -93,10 +98,17 @@ async def send( buffers: Binary attachments delivered alongside the metadata. Raises: - ValueError: If the message carries more attachments than a frame - may hold. Raised before anything is sent, so a fan-out fails - whole rather than reaching some clients. + ValueError: If the message name is reserved, or it carries more + attachments than a frame may hold. Raised before anything is + sent, so a fan-out fails whole rather than reaching some + clients. """ + if event in RESERVED_EVENTS: + msg = ( + f"Channel message name {event!r} is reserved: the client-side " + "handle reports its own lifecycle under it." + ) + raise ValueError(msg) if len(buffers) > MAX_MESSAGE_BUFFERS: msg = ( f"Channel message {event!r} carries {len(buffers)} attachments, " @@ -142,7 +154,7 @@ class Channel(ABC): # Whether inbound messages from this channel may carry binary # attachments. Off by default: a channel that never expects binary - # should not accept the allocation. + # answers an error instead of handing one to its handler. accepts_binary: ClassVar[bool] = False def __init__(self): diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index a6b668acf4f..b4e870006f0 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -958,6 +958,9 @@ async def heartbeat() -> None: "ping_interval": ping_interval, "ping_timeout": ping_timeout, "protocol": PROTOCOL_VERSION, + # So a client can refuse an oversized frame itself + # rather than lose the connection to one. + "max_message_size": max_message_size, }, ]) ) diff --git a/tests/integration/tests_playwright/test_channels.py b/tests/integration/tests_playwright/test_channels.py index 3dfaf601f84..1591ce085ed 100644 --- a/tests/integration/tests_playwright/test_channels.py +++ b/tests/integration/tests_playwright/test_channels.py @@ -33,6 +33,13 @@ def ChannelApp(): // Drop the socket the way the transport's own watchdog does on a dead // connection, so the test exercises the supported reconnect path. window.__probe.drop = () => probeChannel._transport._dropConnection("test"); + // Drop and emit in the same tick: the socket is CLOSING but the channel + // still reports connected, so the frame queues on the transport rather + // than on the channel. + window.__probe.dropThenEmit = (n) => { + probeChannel._transport._dropConnection("test"); + probeChannel.emit("push", { n }, [new Uint8Array([1, 2, 3, 4])]); + }; // Emit while down, then mutate what was passed: the queued message must // still carry the values it was emitted with. window.__probe.pushThenMutate = (n) => { @@ -190,3 +197,28 @@ def test_channel_reopens_and_flushes_after_a_reconnect( received = page.evaluate("window.__probe.received[0]") assert received["data"] == {"n": 9, "sizes": [4]} assert received["bytes"] == [4, 3, 2, 1] + + +def test_frame_queued_on_the_transport_survives_the_reconnect( + channel_app: AppHarness, page: Page +): + """A frame emitted as the socket drops is delivered once it is back. + + Such a frame waits on the transport's own queue, so the channel has to be + reopened before that queue is flushed -- otherwise it reaches the backend + ahead of its `_open` and is answered with `channel_not_open`. + + Args: + channel_app: Running AppHarness instance. + page: Playwright page fixture. + """ + _connected_probe(channel_app, page) + connects = page.evaluate("window.__probe.connects") + + page.evaluate("window.__probe.dropThenEmit(5)") + page.wait_for_function(f"window.__probe.connects > {connects}", timeout=30_000) + page.wait_for_function("window.__probe.received.length > 0", timeout=30_000) + + received = page.evaluate("window.__probe.received[0]") + assert received["data"] == {"n": 5, "sizes": [4]} + assert page.evaluate("window.__probe.errors") == [] diff --git a/tests/units/test_channels.py b/tests/units/test_channels.py index 6b0c22eaeb2..c8100c573b4 100644 --- a/tests/units/test_channels.py +++ b/tests/units/test_channels.py @@ -6,6 +6,7 @@ from reflex.channels import ( MAX_MESSAGE_BUFFERS, + RESERVED_EVENTS, Channel, ChannelSession, validate_channel_name, @@ -240,3 +241,16 @@ async def on_message( with pytest.raises(ValueError, match="Invalid channel name"): NamelessChannel() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("event", sorted(RESERVED_EVENTS)) +async def test_send_rejects_reserved_message_names(event: str): + """A message may not impersonate the client handle's lifecycle events.""" + channel = CollectingChannel() + session = channel.session("sid1") + + with pytest.raises(ValueError, match="reserved"): + await session.send(event, {"anything": True}) + + assert channel.sent == [] diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 6d19f59ea34..8e8e94ca906 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -20,7 +20,12 @@ from reflex import event_namespace from reflex.app import App -from reflex.channels import Channel, ChannelSession +from reflex.channels import ( + MAX_MESSAGE_BUFFERS, + RESERVED_EVENTS, + Channel, + ChannelSession, +) from reflex.event_namespace import ( CHANNEL_ERROR_MESSAGE, CLOSE_MESSAGE, @@ -153,7 +158,12 @@ async def test_handshake_and_token_link(namespace: WebsocketEventNamespace): assert websocket.accepted assert websocket.accepted_subprotocol == "0.0.1" assert websocket.sent[0][0] == HANDSHAKE_MESSAGE - assert set(websocket.sent[0][1]) == {"ping_interval", "ping_timeout", "protocol"} + assert set(websocket.sent[0][1]) == { + "ping_interval", + "ping_timeout", + "protocol", + "max_message_size", + } assert websocket.sent[0][1]["protocol"] == PROTOCOL_VERSION await _drain_tasks() # The session was linked and unlinked again on disconnect. @@ -1067,3 +1077,34 @@ def test_decoding_a_too_deeply_nested_header_is_a_value_error(mocker): with pytest.raises(ValueError, match="nested too deeply"): decode_channel_frame(frame) + + +@pytest.mark.asyncio +async def test_handshake_advertises_the_message_limit( + namespace: WebsocketEventNamespace, monkeypatch: pytest.MonkeyPatch +): + """The client needs the inbound limit to refuse an oversized frame itself.""" + monkeypatch.setenv("REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE", "4096") + websocket = FakeWebSocket() + websocket.feed() + + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert websocket.sent[0][1]["max_message_size"] == 4096 + + +def test_client_limits_match_the_protocol(): + """The client enforces the same caps the backend closes the connection over. + + Both ends declare them independently; a client cap that drifted above the + backend's would turn a loud local error back into a dropped connection. + """ + template = WEBSOCKET_JS_TEMPLATE.read_text() + + assert f"const MAX_MESSAGE_BUFFERS = {MAX_MESSAGE_BUFFERS};" in template + reserved = re.search(r"const LIFECYCLE_EVENTS = new Set\(\[([^\]]+)\]\);", template) + assert reserved is not None + assert {name.strip().strip('"') for name in reserved.group(1).split(",")} == set( + RESERVED_EVENTS + ) From 23dca66f34af4b97ee57c44d52e6939e4b1d71a0 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 12 Sep 2026 20:53:32 +0200 Subject: [PATCH 33/44] enforce the channel frame size limit on text frames and on queued messages --- docs/api-reference/channels.md | 16 +++-- .../.templates/web/utils/helpers/websocket.js | 53 ++++++++++++++++- tests/units/test_event_namespace.py | 58 +++++++++++++++++++ 3 files changed, 119 insertions(+), 8 deletions(-) diff --git a/docs/api-reference/channels.md b/docs/api-reference/channels.md index 98361f47b20..38074f5ee1a 100644 --- a/docs/api-reference/channels.md +++ b/docs/api-reference/channels.md @@ -106,11 +106,17 @@ class Frames(rx.channels.Channel): await session.send("frame", {"rows": len(buffers[0]) // 8}, buffers) ``` -A message may carry up to 64 attachments, and a frame may not exceed -`REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE` (1 MB by default); raise it if clients -send larger payloads. Both limits are enforced where the message is built — -`session.send` raises and `channel.emit` throws — because a frame that broke -them on the wire would cost the app its whole websocket. +A message may carry up to 64 attachments, in either direction: +`session.send` raises and `channel.emit` throws rather than build a frame the +other end would refuse. + +Size is capped in one direction only. A frame a client sends must fit +`REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE` (1 MB by default) or the backend closes +the connection, so `channel.emit` throws first and a message queued before the +channel opened is dropped with an `error` when the limit turns out to exclude +it. Raise the setting if your clients send larger payloads. Messages the +server sends are not capped by it — mind the section below on sharing the +connection. `connect`, `disconnect` and `error` are reserved message names: the client handle reports its own lifecycle under them, so `session.send` refuses them. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js index 14d31ca6565..406ab05159e 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js @@ -83,6 +83,39 @@ export const parseJsonLenient = (text, fallback) => { } }; +/** + * Whether a serialized frame is over the backend's inbound message limit. + * + * Mirrors the check the backend applies before closing the connection: the + * limit counts UTF-8 bytes, and UTF-8 is 1-4 bytes per character, so a text + * frame is only measured exactly when the cheap bounds cannot settle it. + * @param frame The serialized text or binary frame. + * @param limit The limit in bytes. + * @returns Whether the frame exceeds it. + */ +const exceedsMessageLimit = (frame, limit) => { + if (typeof frame !== "string") { + return frame.byteLength > limit; + } + if (frame.length > limit) { + return true; + } + if (frame.length * 4 <= limit) { + return false; + } + return new TextEncoder().encode(frame).byteLength > limit; +}; + +/** + * The size a serialized frame occupies on the wire, for diagnostics. + * @param frame The serialized text or binary frame. + * @returns The size in bytes. + */ +const frameByteLength = (frame) => + typeof frame === "string" + ? new TextEncoder().encode(frame).byteLength + : frame.byteLength; + /** * Serialize an outgoing frame. * @param frame The frame array to serialize. @@ -272,10 +305,11 @@ class ReflexChannel extends LocalEmitter { ? encodeChannelFrame(event, data, this.name, buffers) : stringifyFrame([event, data, this.name]); const limit = this._transport?._maxMessageSize; - if (limit && frame.byteLength > limit) { + if (limit && exceedsMessageLimit(frame, limit)) { throw new Error( - `Channel message "${event}" is ${frame.byteLength} bytes, over the ` + - `${limit} the backend accepts (REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE).`, + `Channel message "${event}" is ${frameByteLength(frame)} bytes, over ` + + `the ${limit} the backend accepts ` + + "(REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE).", ); } if (this.connected && this._transport) { @@ -330,7 +364,20 @@ class ReflexChannel extends LocalEmitter { this.connected = true; const queued = this._queue; this._queue = []; + const limit = this._transport._maxMessageSize; for (const frame of queued) { + // A message emitted before the channel opened was queued without a + // limit to check it against; now there is one, and sending it anyway + // would cost the app its websocket. + if (limit && exceedsMessageLimit(frame, limit)) { + this._emitLocal("error", { + code: "message_too_large", + message: + `A queued channel message is ${frameByteLength(frame)} bytes, ` + + `over the ${limit} the backend accepts; it was dropped.`, + }); + continue; + } // Back onto the transport's own queue if it went away mid-flush. this._transport._send(frame); } diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 8e8e94ca906..41223904e65 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -1108,3 +1108,61 @@ def test_client_limits_match_the_protocol(): assert {name.strip().strip('"') for name in reserved.group(1).split(",")} == set( RESERVED_EVENTS ) + + +@pytest.mark.skipif(not NODE, reason="Requires node to run the client") +def test_client_refuses_frames_the_backend_would_close_over(tmp_path: Path): + """The client enforces the size limit itself, whenever it learns of it. + + The backend answers an oversized frame by closing the connection, taking + the app's state updates with it, so every path that can produce one has to + be stopped on the client: emitting while open, emitting before the channel + opens (checked again when the limit arrives), and multibyte text, whose + UTF-8 size is what the backend measures. + """ + script = tmp_path / "limits.mjs" + script.write_text(f""" +import {{ getChannel }} from {json.dumps(WEBSOCKET_JS_TEMPLATE.as_uri())}; + +const result = {{ sent: 0, errors: [] }}; +const transport = {{ _maxMessageSize: 1024, _send: () => {{ result.sent += 1; }} }}; + +const open = getChannel("open"); +open._transport = transport; +open.connected = true; +try {{ + open.emit("push", {{ blob: "x".repeat(5000) }}); +}} catch (error) {{ + result.tooBig = error.message; +}} +try {{ + // 400 characters, 1200 UTF-8 bytes: only a byte-accurate check catches it. + open.emit("push", "\\u20ac".repeat(400)); +}} catch (error) {{ + result.multibyte = error.message; +}} +open.emit("push", {{ small: true }}); + +// Emitted with no transport, so with no limit to check against yet. +const queued = getChannel("queued"); +queued.on("error", (error) => result.errors.push(error.code)); +queued.emit("push", {{ blob: "y".repeat(5000) }}); +queued._transport = transport; +queued._receive("_opened", null, []); + +console.log(JSON.stringify(result)); +""") + result = subprocess.run( + [NODE, str(script)], capture_output=True, text=True, check=False + ) + + assert result.returncode == 0, result.stderr + report = json.loads(result.stdout) + assert "1024" in report["tooBig"] + # 400 characters: a length check would have passed it, a byte check does not. + multibyte = re.search(r"is (\d+) bytes", report["multibyte"]) + assert multibyte is not None, report["multibyte"] + assert 1024 < int(multibyte.group(1)) < 4 * 400 + assert report["errors"] == ["message_too_large"] + # Only the message that fits was ever handed to the transport. + assert report["sent"] == 1 From e3b06397c0f8b731f16a1cbde6894e683f19f266 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 12 Sep 2026 21:08:31 +0200 Subject: [PATCH 34/44] remove the hidden channel header limit, surface the uvicorn websocket warning, and de-flake the reconnect test --- .../.templates/web/utils/helpers/websocket.js | 2 +- reflex/event_namespace.py | 7 +++-- reflex/utils/exec.py | 21 +++++++++++---- .../tests_playwright/test_channels.py | 10 +++++-- tests/units/test_event_namespace.py | 23 ++++++++++++++++ tests/units/utils/test_exec.py | 26 +++++++++++++++++++ 6 files changed, 77 insertions(+), 12 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js index 406ab05159e..0e1e271a440 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js @@ -378,7 +378,7 @@ class ReflexChannel extends LocalEmitter { }); continue; } - // Back onto the transport's own queue if it went away mid-flush. + // The transport re-queues it if its socket closed mid-flush. this._transport._send(frame); } this._emitLocal("connect"); diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index b4e870006f0..c44fedd826f 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -51,9 +51,6 @@ # can view them as typed arrays without copying. _FRAME_ALIGNMENT = 8 -# Bound on the JSON header of a binary channel frame. -_MAX_FRAME_HEADER_SIZE = 64 * 1024 - # Application-level socket event names, resolved once for the hot paths. _EVENT = str(constants.SocketEvent.EVENT) _PING = str(constants.SocketEvent.PING) @@ -130,7 +127,9 @@ def decode_channel_frame(frame: bytes) -> tuple[str, Any, str, list[bytes]]: msg = "Binary frame is too short to hold a header length." raise ValueError(msg) header_size = int.from_bytes(frame[:4], "little") - if header_size > _MAX_FRAME_HEADER_SIZE or 4 + header_size > len(frame): + if 4 + header_size > len(frame): + # The frame is the only bound the header needs: the transport rejects + # one over the size limit before decoding it. msg = f"Binary frame declares an unusable header size {header_size}." raise ValueError(msg) try: diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 7ea345a4bb7..1a0e4e62c04 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -420,10 +420,8 @@ def run_frontend_prod(host: str, port: int): @once -def _warn_user_about_uvicorn(): - logger.warning( - "Using Uvicorn for backend as it is installed. This behavior will change in 0.8.0 to use Granian by default." - ) +def _warn_about_uvicorn_websockets(): + """Warn when the selected uvicorn cannot serve websockets.""" if ( importlib.util.find_spec("websockets") is None and importlib.util.find_spec("wsproto") is None @@ -435,6 +433,13 @@ def _warn_user_about_uvicorn(): ) +def _warn_user_about_uvicorn(): + logger.warning( + "Using Uvicorn for backend as it is installed. This behavior will change in 0.8.0 to use Granian by default." + ) + _warn_about_uvicorn_websockets() + + def should_use_granian(): """Whether to use Granian for backend. @@ -442,7 +447,13 @@ def should_use_granian(): True if Granian should be used. """ if environment.REFLEX_USE_GRANIAN.is_set(): - return environment.REFLEX_USE_GRANIAN.get() + use_granian = environment.REFLEX_USE_GRANIAN.get() + if not use_granian: + # Asking for uvicorn explicitly is the likeliest way to end up + # without a websocket library, so this check cannot live on the + # auto-detect branch alone. + _warn_about_uvicorn_websockets() + return use_granian if ( importlib.util.find_spec("uvicorn") is None or importlib.util.find_spec("gunicorn") is None diff --git a/tests/integration/tests_playwright/test_channels.py b/tests/integration/tests_playwright/test_channels.py index 1591ce085ed..3bf980f0c0f 100644 --- a/tests/integration/tests_playwright/test_channels.py +++ b/tests/integration/tests_playwright/test_channels.py @@ -22,13 +22,18 @@ def ChannelApp(): PROBE_SETUP = """ if (typeof window !== "undefined") { const probeChannel = getChannel("probe"); - window.__probe = { received: [], errors: [], connected: false, connects: 0 }; + window.__probe = { + received: [], errors: [], connected: false, connects: 0, disconnects: 0, + }; probeChannel.on("connect", () => { window.__probe.connected = true; window.__probe.connects += 1; }); probeChannel.on("disconnect", () => { window.__probe.connected = false; + // Counted, not just flagged: the reconnect can restore the flag + // between two polls, but a count cannot be missed. + window.__probe.disconnects += 1; }); // Drop the socket the way the transport's own watchdog does on a dead // connection, so the test exercises the supported reconnect path. @@ -184,8 +189,9 @@ def test_channel_reopens_and_flushes_after_a_reconnect( _connected_probe(channel_app, page) connects = page.evaluate("window.__probe.connects") + disconnects = page.evaluate("window.__probe.disconnects") page.evaluate("window.__probe.drop()") - page.wait_for_function("window.__probe.connected === false") + page.wait_for_function(f"window.__probe.disconnects > {disconnects}") # Emitted while the socket is down: queued, not lost, and not rewritten by # what the caller did with the payload afterwards. page.evaluate("window.__probe.pushThenMutate(9)") diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 41223904e65..0d96582be09 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -1137,6 +1137,8 @@ def test_client_refuses_frames_the_backend_would_close_over(tmp_path: Path): }} try {{ // 400 characters, 1200 UTF-8 bytes: only a byte-accurate check catches it. + // An escape rather than the character itself, so this script stays ASCII + // whatever encoding the test's locale writes it in. open.emit("push", "\\u20ac".repeat(400)); }} catch (error) {{ result.multibyte = error.message; @@ -1166,3 +1168,24 @@ def test_client_refuses_frames_the_backend_would_close_over(tmp_path: Path): assert report["errors"] == ["message_too_large"] # Only the message that fits was ever handed to the transport. assert report["sent"] == 1 + + +def test_large_metadata_is_bounded_only_by_the_message_limit(): + """Metadata is limited by the frame size, not by a second hidden cap. + + The same metadata sent without attachments travels as a text frame, which + only the message limit applies to; a binary frame that rejected it would + close the connection over a payload the client had no way to know was too + big -- the handshake advertises one limit. + """ + metadata = {"spec": "x" * (128 * 1024)} + frame = encode_channel_frame("payload", metadata, "probe", [b"\x00\x01"]) + + event, data, channel, buffers = decode_channel_frame(frame) + + assert (event, data, channel, buffers) == ( + "payload", + metadata, + "probe", + [b"\x00\x01"], + ) diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py index 674e28151db..c3550ea632a 100644 --- a/tests/units/utils/test_exec.py +++ b/tests/units/utils/test_exec.py @@ -1,6 +1,7 @@ """Tests for development backend launchers in ``reflex.utils.exec``.""" import builtins +import logging import multiprocessing import os import sys @@ -262,3 +263,28 @@ def test_uvicorn_worker_carries_the_socket_policy(monkeypatch: pytest.MonkeyPatc >= exec_utils.uvicorn_websocket_options().items() ) assert ReflexUvicornWorker.CONFIG_KWARGS["ws_per_message_deflate"] is False + + +@pytest.mark.parametrize("use_granian", ["0", "1"]) +def test_forcing_uvicorn_warns_about_a_missing_websocket_library( + mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, caplog, use_granian: str +): + """Choosing uvicorn explicitly still reports that it cannot serve websockets. + + That choice is the likeliest way to end up without a websocket library, so + the diagnostic cannot live only on the branch that auto-detects uvicorn. + """ + monkeypatch.setenv("REFLEX_USE_GRANIAN", use_granian) + mocker.patch.object( + exec_utils.importlib.util, + "find_spec", + side_effect=lambda name: ( + None if name in ("websockets", "wsproto") else object() + ), + ) + + with caplog.at_level(logging.WARNING): + assert exec_utils.should_use_granian() is (use_granian == "1") + + warned = "has no websocket protocol library" in caplog.text + assert warned is (use_granian == "0") From e54f4727243129304284478f80d5e95814eb162f Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 12 Sep 2026 21:25:54 +0200 Subject: [PATCH 35/44] keep the uvicorn warnings once-cached and test the channel limits through the transport --- reflex/utils/exec.py | 1 + tests/units/test_event_namespace.py | 28 +++++++++++++------- tests/units/utils/test_exec.py | 41 ++++++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 1a0e4e62c04..ab5b8f741b4 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -433,6 +433,7 @@ def _warn_about_uvicorn_websockets(): ) +@once def _warn_user_about_uvicorn(): logger.warning( "Using Uvicorn for backend as it is installed. This behavior will change in 0.8.0 to use Granian by default." diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 0d96582be09..d714be950fc 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -1170,22 +1170,30 @@ def test_client_refuses_frames_the_backend_would_close_over(tmp_path: Path): assert report["sent"] == 1 -def test_large_metadata_is_bounded_only_by_the_message_limit(): +@pytest.mark.asyncio +async def test_large_metadata_is_bounded_only_by_the_message_limit( + namespace: WebsocketEventNamespace, mock_app: Mock +): """Metadata is limited by the frame size, not by a second hidden cap. The same metadata sent without attachments travels as a text frame, which only the message limit applies to; a binary frame that rejected it would close the connection over a payload the client had no way to know was too - big -- the handshake advertises one limit. + big -- the handshake advertises one limit. Driven through the receive path, + because that is where such a frame would be turned into a close. """ + channel = RecordingChannel(accepts_binary=True) + mock_app._channels = {"probe": channel} + # Far past any header-shaped cap, far under the 1 MB message limit. metadata = {"spec": "x" * (128 * 1024)} - frame = encode_channel_frame("payload", metadata, "probe", [b"\x00\x01"]) + websocket = FakeWebSocket() + websocket.feed( + [OPEN_MESSAGE, None, "probe"], + encode_channel_frame("payload", metadata, "probe", [b"\x00\x01"]), + ) - event, data, channel, buffers = decode_channel_frame(frame) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() - assert (event, data, channel, buffers) == ( - "payload", - metadata, - "probe", - [b"\x00\x01"], - ) + assert websocket.close_code is None + assert channel.messages == [("payload", metadata, [b"\x00\x01"])] diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py index c3550ea632a..07a824f204c 100644 --- a/tests/units/utils/test_exec.py +++ b/tests/units/utils/test_exec.py @@ -11,6 +11,7 @@ from pytest_mock import MockerFixture from reflex_base.environment import environment from reflex_base.utils import serializers +from reflex_base.utils.decorator import once from reflex.utils import exec as exec_utils @@ -265,9 +266,47 @@ def test_uvicorn_worker_carries_the_socket_policy(monkeypatch: pytest.MonkeyPatc assert ReflexUvicornWorker.CONFIG_KWARGS["ws_per_message_deflate"] is False +@pytest.fixture +def fresh_uvicorn_warnings(mocker: MockerFixture) -> None: + """Give each test its own `once` caches for the uvicorn warnings. + + They are cached for the life of the process, so a warning another test + already triggered would otherwise silently not be emitted again. + """ + for name in ("_warn_about_uvicorn_websockets", "_warn_user_about_uvicorn"): + warned = getattr(exec_utils, name) + # Re-wrapping would paper over a dropped `once`, so require it first: + # a single run asks `should_use_granian()` several times. + assert hasattr(warned, "__wrapped__"), f"{name} must stay `once`-cached" + mocker.patch.object(exec_utils, name, once(warned.__wrapped__)) + + +def test_auto_detected_uvicorn_is_announced_once( + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + caplog, + fresh_uvicorn_warnings: None, +): + """A single run asks repeatedly; the notice belongs in the log once.""" + monkeypatch.delenv("REFLEX_USE_GRANIAN", raising=False) + mocker.patch.object( + exec_utils.importlib.util, "find_spec", side_effect=lambda name: object() + ) + + with caplog.at_level(logging.WARNING): + assert exec_utils.should_use_granian() is False + assert exec_utils.should_use_granian() is False + + assert caplog.text.count("This behavior will change in 0.8.0") == 1 + + @pytest.mark.parametrize("use_granian", ["0", "1"]) def test_forcing_uvicorn_warns_about_a_missing_websocket_library( - mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, caplog, use_granian: str + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + caplog, + fresh_uvicorn_warnings: None, + use_granian: str, ): """Choosing uvicorn explicitly still reports that it cannot serve websockets. From 02dae5601eed0b678703a049db21634f83b625e7 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 12 Sep 2026 21:40:57 +0200 Subject: [PATCH 36/44] close a session whose token is gone instead of logging its payload per frame --- reflex/event_namespace.py | 15 +++++++++--- tests/units/test_event_namespace.py | 38 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index c44fedd826f..b3d33a66d04 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -339,9 +339,12 @@ async def handle_event( """ # Determine the token for this SID if (token := self.sid_to_token.get(sid)) is None: - logger.warning( - f"Received event from session {sid} with no associated token. This may indicate a bug. Event data: {data}" - ) + # The mapping is dropped when a token moves to another socket or + # its record goes stale, so a live connection can reach this and + # keep sending. Log it per frame at debug, without the + # client-controlled payload: at warning level it would be a log + # flood and an injection vector both. + logger.debug(f"Ignoring event from session {sid} with no linked token.") return # Both transports JSON-decode the frame, so a Reflex client's event @@ -885,6 +888,12 @@ async def _handle_frame( # Ordered by frequency: events are the hot path, heartbeat pongs # arrive once per ping interval. if event == _EVENT: + if sid not in self.sid_to_token: + # The token moved to another socket or its record went + # stale: nothing this session sends can be served, and a + # reconnect is how it gets a working one back. + logger.debug(f"Closing session {sid}: its token is gone.") + return 1008 await self.handle_event(sid, data, scope) elif event == PONG_MESSAGE: # Receiving it already refreshed the liveness deadline. diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index d714be950fc..681864df081 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -1197,3 +1197,41 @@ async def test_large_metadata_is_bounded_only_by_the_message_limit( assert websocket.close_code is None assert channel.messages == [("payload", metadata, [b"\x00\x01"])] + + +@pytest.mark.asyncio +async def test_event_from_a_session_whose_token_went_away_closes_it( + namespace: WebsocketEventNamespace, mock_app: Mock, caplog +): + """A session that loses its token mid-connection is closed, not left logging. + + The token manager drops the mapping when a token moves to another socket + or its record goes stale, while that socket stays open and sending. + Everything it sends is unservable, so answering each frame with a warning + that embeds the client's payload is both a log flood and an injection + vector. + """ + + class LosesItsToken(FakeWebSocket): + """Drops the token mapping once the connection is already serving.""" + + async def receive(self) -> dict[str, Any]: + message = await super().receive() + namespace.sid_to_token.clear() + return message + + websocket = LosesItsToken() + websocket.feed( + ["ping"], + ["event", {"name": "state.on_click", "payload": {"x": "\n[fake] log line"}}], + ) + + with caplog.at_level(logging.DEBUG): + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert websocket.close_code == 1008 + mock_app.event_processor.enqueue.assert_not_awaited() + # Nothing the client sent reached the log, at any level. + assert "[fake] log line" not in caplog.text + assert [r for r in caplog.records if r.levelno >= logging.WARNING] == [] From 016a14dd26af82564fd4c21abdcbaea8275356da Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 12 Sep 2026 21:47:19 +0200 Subject: [PATCH 37/44] close a revoked session before dispatching any frame, channel frames included --- reflex/event_namespace.py | 16 ++++++++----- tests/units/test_event_namespace.py | 35 ++++++++++++++++++++--------- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index b3d33a66d04..558813ae2d8 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -888,12 +888,6 @@ async def _handle_frame( # Ordered by frequency: events are the hot path, heartbeat pongs # arrive once per ping interval. if event == _EVENT: - if sid not in self.sid_to_token: - # The token moved to another socket or its record went - # stale: nothing this session sends can be served, and a - # reconnect is how it gets a working one back. - logger.debug(f"Closing session {sid}: its token is gone.") - return 1008 await self.handle_event(sid, data, scope) elif event == PONG_MESSAGE: # Receiving it already refreshed the liveness deadline. @@ -986,6 +980,16 @@ async def heartbeat() -> None: if received["type"] == "websocket.disconnect": break last_received = time.monotonic() + if sid not in self._token_manager.sid_to_token: + # The token moved to another socket or its record went + # stale. Nothing this session sends can be served -- not + # events, not channel messages, which would otherwise keep + # invoking handlers under a token that has moved on -- and + # a reconnect is how it gets a working session back. + logger.debug(f"Closing session {sid}: its token is gone.") + close_code = 1008 + await websocket.close(code=close_code) + break text = received.get("text") if text is not None: close_code = await self._handle_frame( diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 681864df081..4c0d2c33b90 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -1200,17 +1200,32 @@ async def test_large_metadata_is_bounded_only_by_the_message_limit( @pytest.mark.asyncio -async def test_event_from_a_session_whose_token_went_away_closes_it( - namespace: WebsocketEventNamespace, mock_app: Mock, caplog +@pytest.mark.parametrize( + "frame", + [ + pytest.param(["event", {"name": "state.on_click", "payload": {}}], id="event"), + pytest.param(["push", {"x": 1}, "probe"], id="channel"), + pytest.param(None, id="channel-binary"), + ], +) +async def test_frames_from_a_session_whose_token_went_away_close_it( + namespace: WebsocketEventNamespace, mock_app: Mock, caplog, frame: Any ): - """A session that loses its token mid-connection is closed, not left logging. + """A session that loses its token mid-connection is closed, whatever it sends. The token manager drops the mapping when a token moves to another socket - or its record goes stale, while that socket stays open and sending. - Everything it sends is unservable, so answering each frame with a warning - that embeds the client's payload is both a log flood and an injection - vector. + or its record goes stale, while that socket stays open. Nothing it sends + can be served after that: an event has no token to enqueue under, and a + channel message would keep invoking handlers under a token that has moved + on. The payload stays out of the log as well -- at warning level, per + frame, it would be a log flood and an injection vector both. """ + channel = RecordingChannel(accepts_binary=True) + mock_app._channels = {"probe": channel} + if frame is None: + frame = encode_channel_frame( + "push", {"x": "\n[fake] log line"}, "probe", [b"!"] + ) class LosesItsToken(FakeWebSocket): """Drops the token mapping once the connection is already serving.""" @@ -1221,10 +1236,7 @@ async def receive(self) -> dict[str, Any]: return message websocket = LosesItsToken() - websocket.feed( - ["ping"], - ["event", {"name": "state.on_click", "payload": {"x": "\n[fake] log line"}}], - ) + websocket.feed([OPEN_MESSAGE, None, "probe"], frame) with caplog.at_level(logging.DEBUG): await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] @@ -1232,6 +1244,7 @@ async def receive(self) -> dict[str, Any]: assert websocket.close_code == 1008 mock_app.event_processor.enqueue.assert_not_awaited() + assert channel.messages == [] # Nothing the client sent reached the log, at any level. assert "[fake] log line" not in caplog.text assert [r for r in caplog.records if r.levelno >= logging.WARNING] == [] From f76c716c29aaddc277ebd7fa51a8544406853427 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 12 Sep 2026 22:11:29 +0200 Subject: [PATCH 38/44] revoke the token after the channel opens so the test proves non-dispatch --- tests/units/test_event_namespace.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 4c0d2c33b90..3d101d2629c 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -1228,11 +1228,20 @@ async def test_frames_from_a_session_whose_token_went_away_close_it( ) class LosesItsToken(FakeWebSocket): - """Drops the token mapping once the connection is already serving.""" + """Drops the token mapping once the channel is open and serving. + + Not on the first frame: that one opens the channel, and losing the + token before it is dispatched would close the connection over the + open itself, leaving nothing for the frame under test to prove. + """ + + delivered = 0 async def receive(self) -> dict[str, Any]: message = await super().receive() - namespace.sid_to_token.clear() + self.delivered += 1 + if self.delivered == 2: + namespace.sid_to_token.clear() return message websocket = LosesItsToken() @@ -1242,6 +1251,8 @@ async def receive(self) -> dict[str, Any]: await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] await _drain_tasks() + # The channel was open and serving when its token went away. + assert len(channel.opened) == 1 assert websocket.close_code == 1008 mock_app.event_processor.enqueue.assert_not_awaited() assert channel.messages == [] From 07cce5005a1f8d6dcfc69c8800080c8c8a7cd1c0 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 12 Sep 2026 22:32:17 +0200 Subject: [PATCH 39/44] budget channel handler tracebacks like any other client-triggered error --- reflex/event_namespace.py | 81 +++++++++++++++++++---------- tests/units/test_event_namespace.py | 38 ++++++++++++++ 2 files changed, 91 insertions(+), 28 deletions(-) diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index 558813ae2d8..086dc597ca8 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -432,6 +432,47 @@ async def handle_ping(self, sid: str) -> None: # Emit the test event. await self.emit(_PING, "pong", to=sid) + def _within_error_budget(self, sid: str) -> bool: + """Whether another error-level record for this session fits the budget. + + Error-level logging driven by client traffic -- a reported frontend + error, a channel handler a message made raise -- is budgeted per + session and per time window, so no client can flood the backend logs + or starve the reports of other sessions. + + Args: + sid: The session id. + + Returns: + Whether the record may be written. + """ + # Rate limit per session so a client cannot flood the backend logs. + error_count = self._client_error_counts.get(sid, 0) + if error_count >= self._MAX_CLIENT_ERRORS_PER_SID: + return False + + # Also bound total entries per time window: per-SID budgets reset on + # reconnect, so they alone do not stop scripted reconnect loops. + now = time.monotonic() + if now - self._client_error_window_start > self._CLIENT_ERROR_WINDOW_SECONDS: + self._client_error_window_start = now + self._client_error_window_count = 0 + if self._client_error_window_count >= self._MAX_CLIENT_ERRORS_PER_WINDOW: + if self._client_error_window_count == self._MAX_CLIENT_ERRORS_PER_WINDOW: + # Warn once per window so suppression is visible in the logs + # and a flooding client cannot silently starve reports from + # other sessions. + self._client_error_window_count += 1 + logger.warning( + f"Received more than {self._MAX_CLIENT_ERRORS_PER_WINDOW} " + f"client-triggered errors in {self._CLIENT_ERROR_WINDOW_SECONDS:.0f}s; " + "suppressing further reports for this window." + ) + return False + self._client_error_window_count += 1 + self._client_error_counts[sid] = error_count + 1 + return True + async def handle_client_error(self, sid: str, data: Any) -> None: """Handle errors reported by the frontend. @@ -465,32 +506,9 @@ async def handle_client_error(self, sid: str, data: Any) -> None: logger.debug(f"Ignoring client_error report from unknown SID {sid}.") return - # Rate limit per session so a client cannot flood the backend logs. - error_count = self._client_error_counts.get(sid, 0) - if error_count >= self._MAX_CLIENT_ERRORS_PER_SID: + if not self._within_error_budget(sid): return - # Also bound total entries per time window: per-SID budgets reset on - # reconnect, so they alone do not stop scripted reconnect loops. - now = time.monotonic() - if now - self._client_error_window_start > self._CLIENT_ERROR_WINDOW_SECONDS: - self._client_error_window_start = now - self._client_error_window_count = 0 - if self._client_error_window_count >= self._MAX_CLIENT_ERRORS_PER_WINDOW: - if self._client_error_window_count == self._MAX_CLIENT_ERRORS_PER_WINDOW: - # Warn once per window so suppression is visible in the logs - # and a flooding client cannot silently starve reports from - # other sessions. - self._client_error_window_count += 1 - logger.warning( - f"Received more than {self._MAX_CLIENT_ERRORS_PER_WINDOW} " - f"client_error reports in {self._CLIENT_ERROR_WINDOW_SECONDS:.0f}s; " - "suppressing further reports for this window." - ) - return - self._client_error_window_count += 1 - self._client_error_counts[sid] = error_count + 1 - error_type = format.sanitize_client_log_value(data.get("error_type", "unknown")) if error_type == constants.ClientErrorType.DISPATCH_MISSING: substate = format.sanitize_client_log_value(data.get("substate", "")) @@ -779,10 +797,17 @@ async def _handle_channel_message( return await session.channel.on_message(session, event, data, buffers) except Exception: - logger.exception( - f"Error handling {event!r} on channel {channel_name!r} " - f"for session {sid}." - ) + # A client can keep sending whatever made the handler raise, so + # the traceback is budgeted like any other client-triggered error. + if self._within_error_budget(sid): + logger.exception( + f"Error handling {event!r} on channel {channel_name!r} " + f"for session {sid}." + ) + else: + logger.debug( + f"Suppressed a repeated channel handler error for session {sid}." + ) async def _handle_binary_frame( self, sid: str, frame: bytes, max_size: int diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 3d101d2629c..05fdf63422c 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -1259,3 +1259,41 @@ async def receive(self) -> dict[str, Any]: # Nothing the client sent reached the log, at any level. assert "[fake] log line" not in caplog.text assert [r for r in caplog.records if r.levelno >= logging.WARNING] == [] + + +@pytest.mark.asyncio +async def test_a_failing_channel_handler_cannot_flood_the_logs( + namespace: WebsocketEventNamespace, mock_app: Mock, caplog +): + """Tracebacks from a handler a client keeps breaking are budgeted. + + The connection survives a raising handler, which is what lets a client + send the same message again; without a budget each repeat would write + another traceback. + """ + + class BoomChannel(Channel): + name = "boom" + + async def on_message(self, session, event, data, buffers) -> None: + """Fail the way a handler meeting unexpected metadata would.""" + raise KeyError(data) + + mock_app._channels = {"boom": BoomChannel()} + attempts = namespace._MAX_CLIENT_ERRORS_PER_SID * 4 + websocket = FakeWebSocket() + websocket.feed( + [OPEN_MESSAGE, None, "boom"], + *([["go", {"n": 1}, "boom"]] * attempts), + ["ping"], + ) + + with caplog.at_level(logging.DEBUG): + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + tracebacks = [r for r in caplog.records if r.levelno >= logging.ERROR] + assert len(tracebacks) == namespace._MAX_CLIENT_ERRORS_PER_SID + # The connection is still serving: a channel bug is not the client's fault. + assert websocket.close_code is None + assert ["ping", "pong"] in websocket.sent From ea517ec4d665aeddfab9f245aa155db0dfd5cd02 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 12 Sep 2026 22:41:22 +0200 Subject: [PATCH 40/44] share the inbound size policy and the channel frame builder between their two call sites --- .../.templates/web/utils/helpers/websocket.js | 23 +++--- reflex/event_namespace.py | 72 +++++++++++++------ 2 files changed, 66 insertions(+), 29 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js index 0e1e271a440..1e114b12f7e 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js @@ -83,6 +83,19 @@ export const parseJsonLenient = (text, fallback) => { } }; +/** + * Serialize one channel message: binary when it carries attachments. + * @param event The message name. + * @param data The JSON metadata. + * @param channel The channel name. + * @param buffers Binary attachments (ArrayBuffers or typed arrays). + * @returns The serialized frame, text or binary. + */ +const channelFrame = (event, data, channel, buffers) => + buffers?.length + ? encodeChannelFrame(event, data, channel, buffers) + : stringifyFrame([event, data, channel]); + /** * Whether a serialized frame is over the backend's inbound message limit. * @@ -301,9 +314,7 @@ class ReflexChannel extends LocalEmitter { // Serialize now, connected or not: a queued frame must carry what was // emitted, not whatever the caller's payload and typed arrays hold by the // time the channel opens. - const frame = buffers?.length - ? encodeChannelFrame(event, data, this.name, buffers) - : stringifyFrame([event, data, this.name]); + const frame = channelFrame(event, data, this.name, buffers); const limit = this._transport?._maxMessageSize; if (limit && exceedsMessageLimit(frame, limit)) { throw new Error( @@ -636,11 +647,7 @@ export class ReflexWebSocket extends LocalEmitter { * @param buffers Binary attachments (ArrayBuffers or typed arrays). */ emitChannel(channel, event, data, buffers) { - this._send( - buffers?.length - ? encodeChannelFrame(event, data, channel, buffers) - : stringifyFrame([event, data, channel]), - ); + this._send(channelFrame(event, data, channel, buffers)); } /** diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index 086dc597ca8..557bafc3c8f 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -76,6 +76,30 @@ def utf8_size(data: str | bytes) -> int: return len(data) if data.isascii() else len(data.encode()) +def exceeds_message_limit(data: str | bytes, max_size: int) -> bool: + """Whether a received message is over the policy limit. + + The limit counts UTF-8 bytes, and UTF-8 encodes 1-4 bytes per character: + more characters than the limit is certainly over, a quarter or fewer + certainly under, so only the range between is encoded to count exactly + (bounding that copy to 4x the limit). Mirrors ``exceedsMessageLimit`` in + .templates/web/utils/helpers/websocket.js. + + Args: + data: The received message, text or binary. + max_size: The limit in bytes. + + Returns: + Whether the message exceeds it. + """ + if isinstance(data, bytes): + return len(data) > max_size + length = len(data) + return length > max_size or ( + length * 4 > max_size and len(data.encode("utf-8")) > max_size + ) + + def encode_channel_frame( event: str, data: Any, channel: str, buffers: Sequence[bytes] ) -> bytes: @@ -823,11 +847,8 @@ async def _handle_binary_frame( The websocket close code the session must end with, or None to keep serving it. """ - if len(frame) > max_size: - logger.debug(f"Closing session {sid}: message over {max_size} bytes.") - return 1009 - if otel.enabled: - otel.record_message_size(len(frame), "receive") + if (close_code := self._accept_inbound(sid, frame, max_size)) is not None: + return close_code try: event, data, channel_name, buffers = decode_channel_frame(frame) except ValueError: @@ -838,6 +859,29 @@ async def _handle_binary_frame( await self._handle_channel_message(sid, channel_name, event, data, buffers) return None + @staticmethod + def _accept_inbound(sid: str, payload: str | bytes, max_size: int) -> int | None: + """Apply the message size policy to a received frame, and account for it. + + ASGI delivers complete messages, so the server has already buffered + the frame; its protocol-level caps (enforced during frame reassembly) + bound that allocation. This applies the Reflex policy limit on top. + + Args: + sid: The session id. + payload: The received frame, text or binary. + max_size: The message size limit in bytes. + + Returns: + The close code the session must end with, or None to dispatch it. + """ + if exceeds_message_limit(payload, max_size): + logger.debug(f"Closing session {sid}: message over {max_size} bytes.") + return 1009 + if otel.enabled: + otel.record_message_size(utf8_size(payload), "receive") + return None + @staticmethod def _origin_allowed(origin: str | None) -> bool: """Check a connection's Origin header against the CORS config. @@ -869,22 +913,8 @@ async def _handle_frame( The websocket close code the session must end with, or None to keep serving it. """ - # ASGI delivers complete messages, so the server has already buffered - # the frame; its protocol-level caps (enforced during frame - # reassembly) bound that allocation. This check applies the Reflex - # policy limit on top. - # The limit is in bytes; UTF-8 encodes 1-4 bytes per character, so - # more characters than the limit is certainly over, and a quarter or - # fewer certainly under -- only encode to count the exact bytes in - # between (bounding the copy to 4x the limit). - text_length = len(text) - if text_length > max_size or ( - text_length * 4 > max_size and len(text.encode("utf-8")) > max_size - ): - logger.debug(f"Closing session {sid}: message over {max_size} bytes.") - return 1009 - if otel.enabled: - otel.record_message_size(utf8_size(text), "receive") + if (close_code := self._accept_inbound(sid, text, max_size)) is not None: + return close_code try: message = json.loads(text) except (json.JSONDecodeError, RecursionError): From b8580ed1337487671569d5964ea362296005d8fa Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 12 Sep 2026 22:49:41 +0200 Subject: [PATCH 41/44] route every client-triggered handler traceback through the error budget --- reflex/event_namespace.py | 58 +++++++++++++++++------------ tests/units/test_event_namespace.py | 40 ++++++++++++++------ 2 files changed, 64 insertions(+), 34 deletions(-) diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index 557bafc3c8f..89c2e5b6229 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -497,6 +497,24 @@ def _within_error_budget(self, sid: str) -> bool: self._client_error_counts[sid] = error_count + 1 return True + def _log_handler_failure( + self, sid: str, message: str, error: BaseException + ) -> None: + """Report a handler that raised, within the session's error budget. + + A client can keep sending whatever made the handler raise, so the + traceback is budgeted like any other client-triggered error. + + Args: + sid: The session id. + message: What failed. + error: The exception to attach. + """ + if self._within_error_budget(sid): + logger.error(message, exc_info=error) + else: + logger.debug(f"Suppressed a repeated handler error for session {sid}.") + async def handle_client_error(self, sid: str, data: Any) -> None: """Handle errors reported by the frontend. @@ -701,10 +719,10 @@ async def _open_channel_session(self, sid: str, channel_name: str) -> None: sessions[channel_name] = session try: await channel.on_open(session) - except Exception: + except Exception as exc: self._drop_channel_session(sid, channel_name) - logger.exception( - f"Error opening channel {channel_name!r} for session {sid}." + self._log_handler_failure( + sid, f"Error opening channel {channel_name!r} for session {sid}.", exc ) await self._send_channel_error( sid, channel_name, "open_failed", "The channel failed to open." @@ -761,9 +779,8 @@ async def _close_channel_sessions(self, sid: str) -> None: for channel_name, session in sessions.items(): await self._notify_channel_close(sid, channel_name, session) - @staticmethod async def _notify_channel_close( - sid: str, channel_name: str, session: ChannelSession + self, sid: str, channel_name: str, session: ChannelSession ) -> None: """Run a channel's close hook, logging a failure instead of raising. @@ -774,9 +791,9 @@ async def _notify_channel_close( """ try: await session.channel.on_close(session) - except Exception: - logger.exception( - f"Error closing channel {channel_name!r} for session {sid}." + except Exception as exc: + self._log_handler_failure( + sid, f"Error closing channel {channel_name!r} for session {sid}.", exc ) async def _handle_channel_message( @@ -820,18 +837,13 @@ async def _handle_channel_message( ) return await session.channel.on_message(session, event, data, buffers) - except Exception: - # A client can keep sending whatever made the handler raise, so - # the traceback is budgeted like any other client-triggered error. - if self._within_error_budget(sid): - logger.exception( - f"Error handling {event!r} on channel {channel_name!r} " - f"for session {sid}." - ) - else: - logger.debug( - f"Suppressed a repeated channel handler error for session {sid}." - ) + except Exception as exc: + self._log_handler_failure( + sid, + f"Error handling {event!r} on channel {channel_name!r} " + f"for session {sid}.", + exc, + ) async def _handle_binary_frame( self, sid: str, frame: bytes, max_size: int @@ -960,11 +972,11 @@ async def _handle_frame( # instead of logging per frame. logger.debug(f"Closing session {sid}: undeserializable event.") return 1002 - except Exception: + except Exception as exc: # A failing handler is a server-side bug: log it loudly; the # connection survives. - logger.exception( - f"Error handling socket event {event!r} for session {sid}." + self._log_handler_failure( + sid, f"Error handling socket event {event!r} for session {sid}.", exc ) return None diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 05fdf63422c..b82da9d78fe 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -1262,14 +1262,15 @@ async def receive(self) -> dict[str, Any]: @pytest.mark.asyncio -async def test_a_failing_channel_handler_cannot_flood_the_logs( - namespace: WebsocketEventNamespace, mock_app: Mock, caplog +@pytest.mark.parametrize("hook", ["on_open", "on_message", "on_close"]) +async def test_a_failing_channel_hook_cannot_flood_the_logs( + namespace: WebsocketEventNamespace, mock_app: Mock, caplog, hook: str ): - """Tracebacks from a handler a client keeps breaking are budgeted. + """Tracebacks from any handler a client keeps breaking are budgeted. The connection survives a raising handler, which is what lets a client - send the same message again; without a budget each repeat would write - another traceback. + send the frame again; without a budget each repeat writes another + traceback, whichever of the three hooks it lands in. """ class BoomChannel(Channel): @@ -1277,16 +1278,32 @@ class BoomChannel(Channel): async def on_message(self, session, event, data, buffers) -> None: """Fail the way a handler meeting unexpected metadata would.""" - raise KeyError(data) + if hook == "on_message": + raise KeyError(data) + + async def on_open(self, session) -> None: + """Fail while setting the session up.""" + if hook == "on_open": + raise KeyError(session.sid) + + async def on_close(self, session) -> None: + """Fail while tearing the session down.""" + if hook == "on_close": + raise KeyError(session.sid) mock_app._channels = {"boom": BoomChannel()} attempts = namespace._MAX_CLIENT_ERRORS_PER_SID * 4 + # Reopening is what a client does after a failed open, and open/close is + # the only way to reach on_close repeatedly. + frames: list[Any] = [] + for _ in range(attempts): + frames.append([OPEN_MESSAGE, None, "boom"]) + if hook == "on_message": + frames.append(["go", {"n": 1}, "boom"]) + elif hook == "on_close": + frames.append([CLOSE_MESSAGE, None, "boom"]) websocket = FakeWebSocket() - websocket.feed( - [OPEN_MESSAGE, None, "boom"], - *([["go", {"n": 1}, "boom"]] * attempts), - ["ping"], - ) + websocket.feed(*frames, ["ping"]) with caplog.at_level(logging.DEBUG): await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] @@ -1294,6 +1311,7 @@ async def on_message(self, session, event, data, buffers) -> None: tracebacks = [r for r in caplog.records if r.levelno >= logging.ERROR] assert len(tracebacks) == namespace._MAX_CLIENT_ERRORS_PER_SID + assert all(record.exc_info for record in tracebacks) # The connection is still serving: a channel bug is not the client's fault. assert websocket.close_code is None assert ["ping", "pong"] in websocket.sent From 46cecf595bb48a6200bb0047b60acc19306133b0 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 13 Sep 2026 00:23:01 +0200 Subject: [PATCH 42/44] keep error counters, closes and queued channel frames from outliving their connection --- .../.templates/web/utils/helpers/websocket.js | 32 ++- reflex/event_namespace.py | 173 ++++++++----- tests/units/test_app.py | 4 +- tests/units/test_event_namespace.py | 227 ++++++++++++++++-- 4 files changed, 349 insertions(+), 87 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js index 1e114b12f7e..12523025559 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js @@ -9,7 +9,6 @@ const PING_MESSAGE = "_ping"; const PONG_MESSAGE = "_pong"; const OPEN_MESSAGE = "_open"; const OPENED_MESSAGE = "_opened"; -const CLOSE_MESSAGE = "_close"; const CHANNEL_ERROR_MESSAGE = "_error"; // Backend protocol version that speaks channels. A backend older than this @@ -96,6 +95,24 @@ const channelFrame = (event, data, channel, buffers) => ? encodeChannelFrame(event, data, channel, buffers) : stringifyFrame([event, data, channel]); +/** + * Whether a parsed frame names a channel, which only a channel message does. + * @param message The parsed frame. + * @returns Whether it carries a channel name after the event and its payload. + */ +const isChannelMessage = (message) => + Array.isArray(message) && message.length > 2; + +/** + * Whether a serialized frame carries a channel message. + * @param frame The serialized text or binary frame. + * @returns True for a binary frame, or a text frame naming a channel. + */ +const isChannelFrame = (frame) => + // Only channel messages carry attachments, so only they go out binary. + typeof frame !== "string" || + isChannelMessage(parseJsonLenient(frame, undefined)); + /** * Whether a serialized frame is over the backend's inbound message limit. * @@ -372,6 +389,11 @@ class ReflexChannel extends LocalEmitter { */ _receive(event, data, buffers) { if (event === OPENED_MESSAGE) { + if (this._transport === null) { + // A straggler from a transport this channel is no longer on; the queue + // belongs to whichever transport it attaches to next. + return; + } this.connected = true; const queued = this._queue; this._queue = []; @@ -711,6 +733,12 @@ export class ReflexWebSocket extends LocalEmitter { disableChannels( "This backend predates channel support; upgrade Reflex to use channels.", ); + // A channel frame queued against an earlier connection would reach a + // backend that closes the socket over it, costing the app its state + // updates too. + this._sendQueue = this._sendQueue.filter( + (frame) => !isChannelFrame(frame), + ); } const queue = this._sendQueue; this._sendQueue = []; @@ -720,7 +748,7 @@ export class ReflexWebSocket extends LocalEmitter { this._emitLocal("connect"); return; } - if (message.length > 2) { + if (isChannelMessage(message)) { channels.get(message[2])?._receive(event, payload, []); return; } diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index 89c2e5b6229..6c2bd59c1d5 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -4,6 +4,7 @@ import asyncio import contextlib +import dataclasses import json import logging import time @@ -190,19 +191,80 @@ def decode_channel_frame(frame: bytes) -> tuple[str, Any, str, list[bytes]]: return event, data, channel, buffers +@dataclasses.dataclass +class _ErrorBudget: + """Bounds error-level logging driven by one kind of client traffic. + + Per session, because one client must not fill the log; and per time + window, because per-session budgets reset on reconnect and so do not stop + scripted reconnect loops. + """ + + # What the budget covers, for the message that announces suppression. + label: str + + max_per_session: int + max_per_window: int + window_seconds: float + + counts: dict[str, int] = dataclasses.field(default_factory=dict) + window_start: float = 0.0 + window_count: int = 0 + + def allows(self, sid: str) -> bool: + """Whether another record for this session fits the budget. + + Args: + sid: The session id. + + Returns: + Whether the record may be written. + """ + session_count = self.counts.get(sid, 0) + if session_count >= self.max_per_session: + return False + now = time.monotonic() + if now - self.window_start > self.window_seconds: + self.window_start = now + self.window_count = 0 + if self.window_count >= self.max_per_window: + if self.window_count == self.max_per_window: + # Warn once per window so suppression is visible in the logs + # and a flooding client cannot silently starve reports from + # other sessions. + self.window_count += 1 + logger.warning( + f"More than {self.max_per_window} {self.label} in " + f"{self.window_seconds:.0f}s; suppressing further reports " + "for this window." + ) + return False + self.window_count += 1 + self.counts[sid] = session_count + 1 + return True + + def forget(self, sid: str) -> None: + """Drop a disconnected session's counter. + + Args: + sid: The session id. + """ + self.counts.pop(sid, None) + + class BaseEventNamespace(ABC): """Transport-agnostic handler for client event sessions.""" # The application object. app: App - # Maximum error-level log entries a single session may produce via the - # client_error event before further reports from it are dropped. + # Maximum error-level log entries a single session may produce, per kind + # of client-triggered error, before further ones from it are dropped. _MAX_CLIENT_ERRORS_PER_SID = 5 - # Process-wide bound on error-level client_error log entries per time - # window; per-SID budgets alone reset on reconnect, so scripted - # reconnects could otherwise flood the logs. + # Process-wide bound on those entries per time window; per-SID budgets + # alone reset on reconnect, so scripted reconnects could otherwise flood + # the logs. _CLIENT_ERROR_WINDOW_SECONDS = 60.0 _MAX_CLIENT_ERRORS_PER_WINDOW = 20 @@ -219,12 +281,29 @@ def __init__(self, namespace: str, app: App): # Use TokenManager for distributed duplicate tab prevention self._token_manager = TokenManager.create() - # Number of client_error reports logged per SID, for rate limiting. - self._client_error_counts: dict[str, int] = {} + # Client-reported errors and server-side handler failures are both + # driven by client traffic, but a client chooses how many reports it + # sends while a handler traceback means a real bug, so one cannot be + # allowed to suppress the other. + self._client_error_budget = self._error_budget("client_error reports") + self._handler_error_budget = self._error_budget("handler errors") + + @classmethod + def _error_budget(cls, label: str) -> _ErrorBudget: + """Build a budget for one kind of client-triggered error logging. + + Args: + label: What the budget covers, for the suppression message. - # Start time and count of the current process-wide client_error window. - self._client_error_window_start = 0.0 - self._client_error_window_count = 0 + Returns: + The budget. + """ + return _ErrorBudget( + label=label, + max_per_session=cls._MAX_CLIENT_ERRORS_PER_SID, + max_per_window=cls._MAX_CLIENT_ERRORS_PER_WINDOW, + window_seconds=cls._CLIENT_ERROR_WINDOW_SECONDS, + ) @property def token_to_sid(self) -> Mapping[str, str]: @@ -300,7 +379,8 @@ def handle_disconnect(self, sid: str) -> asyncio.Task | None: """ if otel.enabled: otel.record_connection(-1) - self._client_error_counts.pop(sid, None) + self._client_error_budget.forget(sid) + self._handler_error_budget.forget(sid) # Get token before cleaning up disconnect_token = self.sid_to_token.get(sid) if disconnect_token: @@ -456,47 +536,6 @@ async def handle_ping(self, sid: str) -> None: # Emit the test event. await self.emit(_PING, "pong", to=sid) - def _within_error_budget(self, sid: str) -> bool: - """Whether another error-level record for this session fits the budget. - - Error-level logging driven by client traffic -- a reported frontend - error, a channel handler a message made raise -- is budgeted per - session and per time window, so no client can flood the backend logs - or starve the reports of other sessions. - - Args: - sid: The session id. - - Returns: - Whether the record may be written. - """ - # Rate limit per session so a client cannot flood the backend logs. - error_count = self._client_error_counts.get(sid, 0) - if error_count >= self._MAX_CLIENT_ERRORS_PER_SID: - return False - - # Also bound total entries per time window: per-SID budgets reset on - # reconnect, so they alone do not stop scripted reconnect loops. - now = time.monotonic() - if now - self._client_error_window_start > self._CLIENT_ERROR_WINDOW_SECONDS: - self._client_error_window_start = now - self._client_error_window_count = 0 - if self._client_error_window_count >= self._MAX_CLIENT_ERRORS_PER_WINDOW: - if self._client_error_window_count == self._MAX_CLIENT_ERRORS_PER_WINDOW: - # Warn once per window so suppression is visible in the logs - # and a flooding client cannot silently starve reports from - # other sessions. - self._client_error_window_count += 1 - logger.warning( - f"Received more than {self._MAX_CLIENT_ERRORS_PER_WINDOW} " - f"client-triggered errors in {self._CLIENT_ERROR_WINDOW_SECONDS:.0f}s; " - "suppressing further reports for this window." - ) - return False - self._client_error_window_count += 1 - self._client_error_counts[sid] = error_count + 1 - return True - def _log_handler_failure( self, sid: str, message: str, error: BaseException ) -> None: @@ -510,7 +549,7 @@ def _log_handler_failure( message: What failed. error: The exception to attach. """ - if self._within_error_budget(sid): + if self._handler_error_budget.allows(sid): logger.error(message, exc_info=error) else: logger.debug(f"Suppressed a repeated handler error for session {sid}.") @@ -548,7 +587,7 @@ async def handle_client_error(self, sid: str, data: Any) -> None: logger.debug(f"Ignoring client_error report from unknown SID {sid}.") return - if not self._within_error_budget(sid): + if not self._client_error_budget.allows(sid): return error_type = format.sanitize_client_log_value(data.get("error_type", "unknown")) @@ -871,6 +910,22 @@ async def _handle_binary_frame( await self._handle_channel_message(sid, channel_name, event, data, buffers) return None + @staticmethod + async def _close_quietly(websocket: WebSocket, code: int) -> None: + """Close a connection, tolerating one the heartbeat already closed. + + The heartbeat closes from its own task, so a close code decided while + a frame was in flight can arrive at a socket that is already gone. + + Args: + websocket: The client websocket connection. + code: The close code. + """ + try: + await websocket.close(code=code) + except RuntimeError: + logger.debug("Connection was already closed.", exc_info=True) + @staticmethod def _accept_inbound(sid: str, payload: str | bytes, max_size: int) -> int | None: """Apply the message size policy to a received frame, and account for it. @@ -1040,7 +1095,7 @@ async def heartbeat() -> None: ) if sid not in self._token_manager.sid_to_token: # No token was linked; not a Reflex client. - await websocket.close(code=1008) + await self._close_quietly(websocket, 1008) return while True: received = await websocket.receive() @@ -1054,8 +1109,7 @@ async def heartbeat() -> None: # invoking handlers under a token that has moved on -- and # a reconnect is how it gets a working session back. logger.debug(f"Closing session {sid}: its token is gone.") - close_code = 1008 - await websocket.close(code=close_code) + await self._close_quietly(websocket, 1008) break text = received.get("text") if text is not None: @@ -1073,7 +1127,7 @@ async def heartbeat() -> None: logger.debug(f"Closing session {sid}: received a binary frame.") close_code = 1003 if close_code is not None: - await websocket.close(code=close_code) + await self._close_quietly(websocket, close_code) break except WebSocketDisconnect: pass @@ -1084,6 +1138,9 @@ async def heartbeat() -> None: # shutdown must not leave the token linked to a dead session. cleanup_task = self.handle_disconnect(sid) await self._close_channel_sessions(sid) + # A close hook that raised logged through the handler budget, + # which recreated the counter handle_disconnect had just dropped. + self._handler_error_budget.forget(sid) if cleanup_task is not None: # Await the token cleanup so an immediate reconnect is not # treated as a duplicate tab; shielded so cancellation (e.g. diff --git a/tests/units/test_app.py b/tests/units/test_app.py index c7d73e16a63..3c99356bc0c 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -4400,7 +4400,7 @@ async def test_client_error_reporting_is_rate_limited_per_sid( task = event_namespace.on_disconnect("known_sid") if task is not None: await task - assert "known_sid" not in event_namespace._client_error_counts + assert "known_sid" not in event_namespace._client_error_budget.counts @pytest.mark.asyncio @@ -4433,7 +4433,7 @@ async def test_client_error_reporting_bounded_across_reconnects( == 1 ) # Once the window elapses, errors are reported again (not silenced forever). - event_namespace._client_error_window_start -= ( + event_namespace._client_error_budget.window_start -= ( EventNamespace._CLIENT_ERROR_WINDOW_SECONDS + 1 ) event_namespace.sid_to_token["sid_fresh"] = "token_fresh" diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index b82da9d78fe..4f818d77b87 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -73,6 +73,8 @@ def __init__( self.accepted_subprotocol: str | None = None self.accepted = False self.close_code: int | None = None + # Starlette refuses a second close; set to model one already sent. + self.refuse_close = False self._incoming: asyncio.Queue = asyncio.Queue() async def accept(self, subprotocol: str | None = None): @@ -89,7 +91,14 @@ async def send_bytes(self, data: bytes): self.sent.append(data) async def close(self, code: int = 1000): - """Record the close call.""" + """Record the close call. + + Raises: + RuntimeError: If the connection was already closed. + """ + if self.refuse_close: + message = 'Cannot call "send" once a close message has been sent.' + raise RuntimeError(message) self.close_code = code async def receive(self) -> dict[str, Any]: @@ -591,9 +600,12 @@ def test_protocol_message_names_match_the_client(): "PONG_MESSAGE": PONG_MESSAGE, "OPEN_MESSAGE": OPEN_MESSAGE, "OPENED_MESSAGE": OPENED_MESSAGE, - "CLOSE_MESSAGE": CLOSE_MESSAGE, "CHANNEL_ERROR_MESSAGE": CHANNEL_ERROR_MESSAGE, } + # CLOSE_MESSAGE is missing on purpose: a browser handle is shared by every + # component that asks for the channel, so it never closes one by itself and + # the backend frees the session on disconnect. + assert "CLOSE_MESSAGE" not in declarations class RecordingChannel(Channel): @@ -878,6 +890,32 @@ async def test_channel_frame_with_non_string_name_closes_connection( NODE = shutil.which("node") or "" +# The client template as a JS string literal. A file URL, not a path: an +# absolute Windows path is neither a valid ESM specifier nor a valid JS string +# literal (its separators are escapes). +CLIENT_MODULE = json.dumps(WEBSOCKET_JS_TEMPLATE.as_uri()) + + +def _run_client_script(tmp_path: Path, source: str, *args: str) -> Any: + """Run a script against the client template, returning the JSON it printed. + + Args: + tmp_path: Where to write the script. + source: The script source, importing from CLIENT_MODULE. + args: Command line arguments for the script. + + Returns: + The parsed JSON the script wrote to stdout. + """ + script = tmp_path / "client.mjs" + script.write_text(source) + result = subprocess.run( + [NODE, str(script), *args], capture_output=True, text=True, check=False + ) + + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + @pytest.mark.skipif(not NODE, reason="Requires node to run the client codec") def test_binary_frame_codec_matches_the_client(tmp_path: Path): @@ -888,12 +926,10 @@ def test_binary_frame_codec_matches_the_client(tmp_path: Path): """ buffers = [b"\x01\x02\x03", b"", bytes(range(24))] frame = encode_channel_frame("payload", {"fig": "f1", "n": 3}, "probe", buffers) - script = tmp_path / "codec.mjs" - # A file URL, not a path: an absolute Windows path is neither a valid ESM - # specifier nor a valid JS string literal (its separators are escapes). - template = json.dumps(WEBSOCKET_JS_TEMPLATE.as_uri()) - script.write_text(f""" -import {{ encodeChannelFrame, decodeChannelFrame }} from {template}; + decoded = _run_client_script( + tmp_path, + f""" +import {{ encodeChannelFrame, decodeChannelFrame }} from {CLIENT_MODULE}; const fromPython = Uint8Array.from(Buffer.from(process.argv[2], "base64")); const [event, data, channel, buffers] = decodeChannelFrame(fromPython.buffer); @@ -907,17 +943,10 @@ def test_binary_frame_codec_matches_the_client(tmp_path: Path): aligned: buffers.every((b) => b.byteOffset % 8 === 0), encoded: Buffer.from(encoded).toString("base64"), }})); -""") - result = subprocess.run( - [NODE, str(script), base64.b64encode(frame).decode()], - capture_output=True, - text=True, - check=False, +""", + base64.b64encode(frame).decode(), ) - assert result.returncode == 0, result.stderr - decoded = json.loads(result.stdout) - assert decoded["event"] == "payload" assert decoded["data"] == {"fig": "f1", "n": 3} assert decoded["channel"] == "probe" @@ -1094,6 +1123,68 @@ async def test_handshake_advertises_the_message_limit( assert websocket.sent[0][1]["max_message_size"] == 4096 +@pytest.mark.asyncio +async def test_closing_a_connection_the_heartbeat_already_closed_is_not_an_error( + namespace: WebsocketEventNamespace, +): + """A close code decided over a frame may find the socket already gone. + + The heartbeat closes an unresponsive session from its own task, so the + receive loop's own close can land on a connection starlette has already + said goodbye on. Teardown still has to finish. + """ + websocket = FakeWebSocket() + # No channels, so a binary frame is a close-worthy protocol error. + websocket.feed(b"\x00") + websocket.refuse_close = True + + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert "tok1" not in namespace.token_to_sid + + +@pytest.mark.asyncio +async def test_handler_error_counters_do_not_outlive_their_sessions( + namespace: WebsocketEventNamespace, mock_app: Mock +): + """Per-session error counters are dropped when the session goes away. + + A close hook runs after the disconnect bookkeeping, so a failing one logs + through the budget once the counter it uses has already been dropped -- + recreating it for a session that no longer exists. + """ + + class FailingChannel(Channel): + name = "probe" + + async def on_message( + self, session: ChannelSession, event: str, data: Any, buffers: list[bytes] + ) -> None: + """Accept anything; only the close hook matters here.""" + + async def on_close(self, session: ChannelSession) -> None: + """Fail the way a buggy close hook would. + + Args: + session: The session being closed. + + Raises: + RuntimeError: Always. + """ + message = "boom" + raise RuntimeError(message) + + mock_app._channels = {"probe": FailingChannel()} + for index in range(3): + websocket = FakeWebSocket(query_string=f"token=tok{index}".encode()) + websocket.feed([OPEN_MESSAGE, None, "probe"]) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert namespace._handler_error_budget.counts == {} + + def test_client_limits_match_the_protocol(): """The client enforces the same caps the backend closes the connection over. @@ -1120,9 +1211,10 @@ def test_client_refuses_frames_the_backend_would_close_over(tmp_path: Path): opens (checked again when the limit arrives), and multibyte text, whose UTF-8 size is what the backend measures. """ - script = tmp_path / "limits.mjs" - script.write_text(f""" -import {{ getChannel }} from {json.dumps(WEBSOCKET_JS_TEMPLATE.as_uri())}; + report = _run_client_script( + tmp_path, + f""" +import {{ getChannel }} from {CLIENT_MODULE}; const result = {{ sent: 0, errors: [] }}; const transport = {{ _maxMessageSize: 1024, _send: () => {{ result.sent += 1; }} }}; @@ -1153,13 +1245,9 @@ def test_client_refuses_frames_the_backend_would_close_over(tmp_path: Path): queued._receive("_opened", null, []); console.log(JSON.stringify(result)); -""") - result = subprocess.run( - [NODE, str(script)], capture_output=True, text=True, check=False +""", ) - assert result.returncode == 0, result.stderr - report = json.loads(result.stdout) assert "1024" in report["tooBig"] # 400 characters: a length check would have passed it, a byte check does not. multibyte = re.search(r"is (\d+) bytes", report["multibyte"]) @@ -1170,6 +1258,95 @@ def test_client_refuses_frames_the_backend_would_close_over(tmp_path: Path): assert report["sent"] == 1 +@pytest.mark.skipif(not NODE, reason="Requires node to run the client") +def test_client_drops_queued_channel_frames_on_a_backend_without_channels( + tmp_path: Path, +): + """A downgraded backend must not receive the channel frames still queued. + + A frame the transport queued for a channel (its socket closed mid-flush) + outlives the connection it was meant for. Handing it to a backend that + predates channels answers with a close, taking the state updates queued + behind it down too. + """ + report = _run_client_script( + tmp_path, + f""" +import {{ ReflexWebSocket, encodeChannelFrame }} from {CLIENT_MODULE}; + +// Built without dialing: only the handshake path is under test. +const sent = []; +const transport = Object.create(ReflexWebSocket.prototype); +transport._callbacks = {{}}; +transport._connectTimer = null; +transport._watchdogTimer = null; +transport.connected = false; +transport._ws = {{ send: (frame) => sent.push(frame), readyState: 1 }}; +transport._sendQueue = [ + JSON.stringify(["state_event", {{ token: "t" }}]), + JSON.stringify(["push", {{ n: 1 }}, "probe"]), + encodeChannelFrame("frame", {{ n: 2 }}, "probe", [new Uint8Array([1, 2, 3])]), +]; + +transport._onMessage( + JSON.stringify([ + "_handshake", + {{ ping_interval: 25, ping_timeout: 120, protocol: 1 }}, + ]), +); +transport._clearWatchdog(); + +console.log( + JSON.stringify({{ + flushed: sent.map((frame) => + typeof frame === "string" ? frame : "binary" + ), + queued: transport._sendQueue.length, + }}), +); +""", + ) + + # The app's own event still goes out; neither channel frame does. + assert report["flushed"] == ['["state_event",{"token":"t"}]'] + assert report["queued"] == 0 + + +@pytest.mark.skipif(not NODE, reason="Requires node to run the client") +def test_client_ignores_an_opened_for_a_channel_that_moved_on(tmp_path: Path): + """An _opened from a transport the channel has left changes nothing. + + Flushing against the transport it names would mean reaching for one the + channel no longer holds; the queue is owed to whichever transport it + attaches to next. + """ + report = _run_client_script( + tmp_path, + f""" +import {{ getChannel }} from {CLIENT_MODULE}; + +const sent = []; +const channel = getChannel("probe"); +channel.emit("push", {{ n: 1 }}); +// Detached: the transport that this _opened answers is gone. +channel._receive("_opened", null, []); +const afterStale = {{ connected: channel.connected, queued: channel._queue.length }}; + +channel._transport = {{ _maxMessageSize: null, _send: (f) => sent.push(f) }}; +channel._receive("_opened", null, []); + +console.log( + JSON.stringify({{ afterStale, connected: channel.connected, sent: sent.length }}), +); +""", + ) + + assert report["afterStale"] == {"connected": False, "queued": 1} + # The message survives for the transport the channel does attach to. + assert report["connected"] is True + assert report["sent"] == 1 + + @pytest.mark.asyncio async def test_large_metadata_is_bounded_only_by_the_message_limit( namespace: WebsocketEventNamespace, mock_app: Mock From 609b2f515fc9d00e9a63a056d00a7782c77323b0 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 13 Sep 2026 01:13:40 +0200 Subject: [PATCH 43/44] serialize a channel fan-out once for every recipient and size ASCII frames without a copy --- .../.templates/web/utils/helpers/websocket.js | 13 ++- reflex/channels.py | 82 +++++++++++++------ reflex/event_namespace.py | 32 +++++--- tests/units/test_channels.py | 20 +++-- tests/units/test_event_namespace.py | 34 ++++++++ 5 files changed, 133 insertions(+), 48 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js index 12523025559..87c6cc23187 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js @@ -20,6 +20,11 @@ const CHANNEL_PROTOCOL_VERSION = 2; // view one as a Float64Array without copying. const FRAME_ALIGNMENT = 8; +// Reused across frames: both are stateless, and a channel streaming binary +// would otherwise allocate one per message. +const TEXT_ENCODER = new TextEncoder(); +const TEXT_DECODER = new TextDecoder(); + // Messages a channel buffers while it is not open, oldest dropped first. const MAX_QUEUED_CHANNEL_MESSAGES = 64; @@ -133,7 +138,7 @@ const exceedsMessageLimit = (frame, limit) => { if (frame.length * 4 <= limit) { return false; } - return new TextEncoder().encode(frame).byteLength > limit; + return TEXT_ENCODER.encode(frame).byteLength > limit; }; /** @@ -143,7 +148,7 @@ const exceedsMessageLimit = (frame, limit) => { */ const frameByteLength = (frame) => typeof frame === "string" - ? new TextEncoder().encode(frame).byteLength + ? TEXT_ENCODER.encode(frame).byteLength : frame.byteLength; /** @@ -181,7 +186,7 @@ const padding = (offset) => */ export const encodeChannelFrame = (event, data, channel, buffers) => { const views = buffers.map(asBytes); - const header = new TextEncoder().encode( + const header = TEXT_ENCODER.encode( stringifyFrame([event, data, channel, views.map((v) => v.byteLength)]), ); let size = 4 + header.byteLength; @@ -215,7 +220,7 @@ export const decodeChannelFrame = (frame) => { return undefined; } const header = parseJsonLenient( - new TextDecoder().decode(new Uint8Array(frame, 4, headerSize)), + TEXT_DECODER.decode(new Uint8Array(frame, 4, headerSize)), undefined, ); if (!Array.isArray(header) || !Array.isArray(header[3])) { diff --git a/reflex/channels.py b/reflex/channels.py index ba6e0871591..77d5655f294 100644 --- a/reflex/channels.py +++ b/reflex/channels.py @@ -40,9 +40,37 @@ async def on_message(self, session, event, data, buffers): # message from the transport event it is named after. RESERVED_EVENTS = frozenset({"connect", "disconnect", "error"}) -# Sends one channel message to a connected session: (sid, channel, event, -# data, buffers). Supplied by the transport when the session opens. -ChannelSender = Callable[[str, str, str, Any, Sequence[bytes]], Awaitable[None]] +# Sends one channel message to connected sessions: (sids, channel, event, +# data, buffers). Supplied by the transport when the session opens. It takes +# every recipient at once so a fan-out serializes the frame only once. +ChannelSender = Callable[ + [Sequence[str], str, str, Any, Sequence[bytes]], Awaitable[None] +] + + +def _validate_message(event: str, buffers: Sequence[bytes]) -> None: + """Check a message against what a frame may carry, before any is sent. + + Args: + event: The message name. + buffers: The binary attachments. + + Raises: + ValueError: If the message name is reserved, or it carries more + attachments than a frame may hold. + """ + if event in RESERVED_EVENTS: + msg = ( + f"Channel message name {event!r} is reserved: the client-side " + "handle reports its own lifecycle under it." + ) + raise ValueError(msg) + if len(buffers) > MAX_MESSAGE_BUFFERS: + msg = ( + f"Channel message {event!r} carries {len(buffers)} attachments, " + f"over the {MAX_MESSAGE_BUFFERS} a frame may hold." + ) + raise ValueError(msg) def validate_channel_name(name: Any) -> None: @@ -99,23 +127,10 @@ async def send( Raises: ValueError: If the message name is reserved, or it carries more - attachments than a frame may hold. Raised before anything is - sent, so a fan-out fails whole rather than reaching some - clients. + attachments than a frame may hold. """ - if event in RESERVED_EVENTS: - msg = ( - f"Channel message name {event!r} is reserved: the client-side " - "handle reports its own lifecycle under it." - ) - raise ValueError(msg) - if len(buffers) > MAX_MESSAGE_BUFFERS: - msg = ( - f"Channel message {event!r} carries {len(buffers)} attachments, " - f"over the {MAX_MESSAGE_BUFFERS} a frame may hold." - ) - raise ValueError(msg) - await self._send(self.sid, self.channel.name, event, data, buffers) + _validate_message(event, buffers) + await self._send((self.sid,), self.channel.name, event, data, buffers) def join(self, room: str) -> None: """Add this session to a room for fan-out. @@ -214,13 +229,24 @@ async def send_to_room( event: The message name. data: The JSON-serializable metadata. buffers: Binary attachments delivered alongside the metadata. + + Raises: + ValueError: If the message name is reserved, or it carries more + attachments than a frame may hold. Raised before anything is + sent, so a fan-out fails whole rather than reaching some + clients. """ members = self._rooms.get(room) if not members: return - # A send can close a session and mutate the room, so iterate a copy. - for session in tuple(members): - await session.send(event, data, buffers) + _validate_message(event, buffers) + # Every session of a channel is opened by the app's one transport, so + # any member can carry the frame for all of them -- serialized once + # rather than once per recipient. The recipients are collected first: + # a send can close a session and mutate the room. + await next(iter(members))._send( + [session.sid for session in members], self.name, event, data, buffers + ) async def send_to_token( self, @@ -239,12 +265,20 @@ async def send_to_token( Returns: Whether a session received the message. + + Raises: + ValueError: If the message name is reserved, or it carries more + attachments than a frame may hold. Raised before anything is + sent, so a fan-out fails whole rather than reaching some + clients. """ sessions = self._sessions.get(client_token) if not sessions: return False - for session in tuple(sessions): - await session.send(event, data, buffers) + _validate_message(event, buffers) + await next(iter(sessions))._send( + [session.sid for session in sessions], self.name, event, data, buffers + ) return True def open_session( diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index 6c2bd59c1d5..d4c970fe89a 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -82,9 +82,9 @@ def exceeds_message_limit(data: str | bytes, max_size: int) -> bool: The limit counts UTF-8 bytes, and UTF-8 encodes 1-4 bytes per character: more characters than the limit is certainly over, a quarter or fewer - certainly under, so only the range between is encoded to count exactly - (bounding that copy to 4x the limit). Mirrors ``exceedsMessageLimit`` in - .templates/web/utils/helpers/websocket.js. + certainly under, so only the range between is measured exactly -- and an + ASCII payload, which is most of them, is measured without a copy. Mirrors + ``exceedsMessageLimit`` in .templates/web/utils/helpers/websocket.js. Args: data: The received message, text or binary. @@ -96,9 +96,7 @@ def exceeds_message_limit(data: str | bytes, max_size: int) -> bool: if isinstance(data, bytes): return len(data) > max_size length = len(data) - return length > max_size or ( - length * 4 > max_size and len(data.encode("utf-8")) > max_size - ) + return length > max_size or (length * 4 > max_size and utf8_size(data) > max_size) def encode_channel_frame( @@ -686,16 +684,19 @@ async def emit(self, event: str, data: Any = None, to: str | None = None) -> Non async def _send_channel_message( self, - sid: str, + sids: Sequence[str], channel: str, event: str, data: Any, buffers: Sequence[bytes], ) -> None: - """Send one channel message to a connected client session. + """Send one channel message to connected client sessions. + + The frame is serialized once for every recipient: a room broadcast + costs one encode and one buffer, not one per member. Args: - sid: The session id to send to. + sids: The session ids to send to. channel: The channel name. event: The message name. data: The JSON-serializable metadata. @@ -706,7 +707,8 @@ async def _send_channel_message( if buffers else format.json_dumps([event, data, channel]) ) - await self._deliver(sid, payload, event) + for sid in sids: + await self._deliver(sid, payload, event) async def _send_channel_error( self, sid: str, channel: str, code: str, message: str @@ -720,7 +722,11 @@ async def _send_channel_error( message: The human-readable explanation. """ await self._send_channel_message( - sid, channel, CHANNEL_ERROR_MESSAGE, {"code": code, "message": message}, () + (sid,), + channel, + CHANNEL_ERROR_MESSAGE, + {"code": code, "message": message}, + (), ) async def _open_channel_session(self, sid: str, channel_name: str) -> None: @@ -734,7 +740,7 @@ async def _open_channel_session(self, sid: str, channel_name: str) -> None: # Opening twice would orphan the first session in its rooms; the # client only opens once per connection, so answer and move on. await self._send_channel_message( - sid, channel_name, OPENED_MESSAGE, None, () + (sid,), channel_name, OPENED_MESSAGE, None, () ) return channel = self.app._channels.get(channel_name) @@ -767,7 +773,7 @@ async def _open_channel_session(self, sid: str, channel_name: str) -> None: sid, channel_name, "open_failed", "The channel failed to open." ) return - await self._send_channel_message(sid, channel_name, OPENED_MESSAGE, None, ()) + await self._send_channel_message((sid,), channel_name, OPENED_MESSAGE, None, ()) async def _close_channel_session(self, sid: str, channel_name: str) -> None: """Close one open channel session. diff --git a/tests/units/test_channels.py b/tests/units/test_channels.py index c8100c573b4..214071b8518 100644 --- a/tests/units/test_channels.py +++ b/tests/units/test_channels.py @@ -1,5 +1,6 @@ """Tests for the channel API in reflex/channels.py.""" +from collections.abc import Sequence from typing import Any import pytest @@ -21,7 +22,7 @@ class CollectingChannel(Channel): def __init__(self): """Initialize the channel and its recorded sends.""" super().__init__() - self.sent: list[tuple[str, str, str, Any, list[bytes]]] = [] + self.sent: list[tuple[list[str], str, str, Any, list[bytes]]] = [] async def on_message( self, session: ChannelSession, event: str, data: Any, buffers: list[bytes] @@ -31,14 +32,14 @@ async def on_message( async def _record( self, - sid: str, + sids: Sequence[str], channel: str, event: str, data: Any, buffers: Any, ) -> None: """Stand in for the transport's send callable.""" - self.sent.append((sid, channel, event, data, list(buffers))) + self.sent.append((list(sids), channel, event, data, list(buffers))) def session(self, sid: str, client_token: str = "tok") -> ChannelSession: """Open a session wired to the recording sender. @@ -84,12 +85,16 @@ async def test_session_send_reaches_the_transport(): await session.send("payload", {"fig": "f1"}, [b"\x00"]) - assert channel.sent == [("sid1", "probe", "payload", {"fig": "f1"}, [b"\x00"])] + assert channel.sent == [(["sid1"], "probe", "payload", {"fig": "f1"}, [b"\x00"])] @pytest.mark.asyncio async def test_room_fan_out_reaches_members_only(): - """Sending to a room reaches its members and no one else.""" + """A room's members are handed to the transport together, and nobody else. + + Together, because the frame is then serialized once however many members + the room has, rather than once per member. + """ channel = CollectingChannel() first = channel.session("sid1") second = channel.session("sid2") @@ -99,7 +104,8 @@ async def test_room_fan_out_reaches_members_only(): await channel.send_to_room("fig:1", "push", {"n": 1}) - assert sorted(sent[0] for sent in channel.sent) == ["sid1", "sid2"] + assert len(channel.sent) == 1 + assert sorted(channel.sent[0][0]) == ["sid1", "sid2"] @pytest.mark.asyncio @@ -136,7 +142,7 @@ async def test_send_to_token_reports_delivery(): assert await channel.send_to_token("tok1", "push", {"n": 1}) is True assert await channel.send_to_token("missing", "push") is False - assert [sent[0] for sent in channel.sent] == ["sid1"] + assert [sent[0] for sent in channel.sent] == [["sid1"]] @pytest.mark.asyncio diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 4f818d77b87..0c4f80a6fa1 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -1123,6 +1123,40 @@ async def test_handshake_advertises_the_message_limit( assert websocket.sent[0][1]["max_message_size"] == 4096 +@pytest.mark.asyncio +async def test_a_room_broadcast_serializes_one_frame_for_every_member( + namespace: WebsocketEventNamespace, monkeypatch: pytest.MonkeyPatch +): + """Fan-out costs one encode and one buffer, not one per recipient.""" + channel = RecordingChannel(accepts_binary=True) + sent: list[tuple[str | None, Any]] = [] + encodes = 0 + real_encode = event_namespace.encode_channel_frame + + def counting_encode(*args: Any) -> bytes: + nonlocal encodes + encodes += 1 + return real_encode(*args) + + async def record(to: str | None, payload: Any, label: str) -> None: + # Stands in for the write to each client's socket. + await asyncio.sleep(0) + sent.append((to, payload)) + + monkeypatch.setattr(event_namespace, "encode_channel_frame", counting_encode) + monkeypatch.setattr(namespace, "_deliver", record) + for sid in ("sid1", "sid2", "sid3"): + channel.open_session(sid, "tok", namespace._send_channel_message).join("fig:1") + + await channel.send_to_room("fig:1", "frame", {"seq": 1}, [b"\x00" * 32]) + + assert encodes == 1 + assert len(sent) == 3 + assert {to for to, _ in sent} == {"sid1", "sid2", "sid3"} + # The same buffer reached all three, rather than a copy each. + assert sent[0][1] is sent[1][1] is sent[2][1] + + @pytest.mark.asyncio async def test_closing_a_connection_the_heartbeat_already_closed_is_not_an_error( namespace: WebsocketEventNamespace, From 04939b4be4935499cf0823d30931e3ca91cd9fb9 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 13 Sep 2026 12:26:26 +0200 Subject: [PATCH 44/44] say where the deflate flag applies and give the threading example its import --- docs/api-reference/channels.md | 3 +++ news/+socket-deflate-flag.performance.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/api-reference/channels.md b/docs/api-reference/channels.md index 38074f5ee1a..7313afbe997 100644 --- a/docs/api-reference/channels.md +++ b/docs/api-reference/channels.md @@ -61,6 +61,9 @@ REFLEX_SOCKET_TIMEOUT` the server closes it as unresponsive. Hand long work to `asyncio.to_thread` (or a task) and answer when it finishes: ```python +import asyncio + + async def on_message(self, session, event, data, buffers): rows = await asyncio.to_thread(expensive_query, data["filter"]) if session.open: diff --git a/news/+socket-deflate-flag.performance.md b/news/+socket-deflate-flag.performance.md index 0feb9f1a3a0..534e67a4111 100644 --- a/news/+socket-deflate-flag.performance.md +++ b/news/+socket-deflate-flag.performance.md @@ -1 +1 @@ -Add `REFLEX_SOCKET_PER_MESSAGE_DEFLATE` to control websocket permessage-deflate compression (uvicorn only, on by default). Turning it off is worthwhile for apps sending binary data over a channel: compressing it costs milliseconds of event loop time per message and barely shrinks it. +Add `REFLEX_SOCKET_PER_MESSAGE_DEFLATE` to control websocket permessage-deflate compression (on by default wherever Uvicorn serves, including the Gunicorn production worker; Granian never negotiates it). Turning it off is worthwhile for apps sending binary data over a channel: compressing it costs milliseconds of event loop time per message and barely shrinks it.