From 24d8a2bec0059d37eb826a2a9db65b676e9b3fbd Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 22 Aug 2026 22:58:01 +0200 Subject: [PATCH 01/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] =?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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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. From 82798dccbfb7682bbf8a71517c932b5ab149a4dd Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Tue, 22 Sep 2026 01:16:07 +0200 Subject: [PATCH 45/62] fix(benchmarks): resolve the connection scope through a socket.io stand-in The socket.io namespace now reads the connection's ASGI scope through the server on every event, so the on_event benchmark needs an `app.sio` to resolve it. --- tests/benchmarks/test_event_processing.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/benchmarks/test_event_processing.py b/tests/benchmarks/test_event_processing.py index 6a714dfabae..580b069b1c0 100644 --- a/tests/benchmarks/test_event_processing.py +++ b/tests/benchmarks/test_event_processing.py @@ -214,8 +214,8 @@ def on_event_harness(): """Set up an EventNamespace with a connected socket for benchmarking on_event. The event processor's enqueue is mocked out so the benchmark isolates the - per-event router_data preparation (which reuses the connection-scoped - data gathered once in on_connect). + per-event router_data preparation (which reuses the headers decoded once + into the connection's ASGI scope). Yields: An async callable that feeds the given number of events through @@ -264,6 +264,9 @@ async def run_events(num_events: int) -> None: }, ) + # The namespace reads the connection scope through the Socket.IO server. + app.sio = mock.Mock(get_environ=mock.Mock(return_value=environ)) + loop = asyncio.new_event_loop() loop.run_until_complete(namespace.on_connect(sid, environ)) yield run_events, loop From 64fc1cec14884f408062fc331504a908d2537997 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Tue, 22 Sep 2026 01:36:49 +0200 Subject: [PATCH 46/62] perf(events): cache the whole connection-scoped router_data on the ASGI scope Restores main's connect-time router_data gathering (#7068) on the transport-agnostic path: the session id, decoded headers and resolved client IP are derived once per connection instead of re-running the client lookup and the x-forwarded-for unrolling on every event. --- reflex/event_namespace.py | 80 ++++++++++++++++++++++++--------------- tests/units/test_app.py | 22 ++++++----- 2 files changed, 62 insertions(+), 40 deletions(-) diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index 5c420d44c06..50c0bc2680b 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -60,6 +60,37 @@ # The heartbeat frame is static; serialize it once. _PING_FRAME = json.dumps([PING_MESSAGE]) +# ASGI scope key holding the connection-scoped router_data. +_STATIC_ROUTER_DATA = "_reflex_static_router_data" + + +def build_static_router_data(sid: str, asgi_scope: Mapping[str, Any]) -> dict[str, Any]: + """Build the router_data entries that are constant for a connection. + + Args: + sid: The session id. + asgi_scope: The ASGI scope of the client connection. + + Returns: + The connection-scoped router_data entries. + """ + headers = {k.decode("utf-8"): v.decode("utf-8") for (k, v) in asgi_scope["headers"]} + + # Get the client IP. + if client := asgi_scope.get("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() + return { + constants.RouteVar.SESSION_ID: sid, + constants.RouteVar.HEADERS: headers, + constants.RouteVar.CLIENT_IP: client_ip, + } + def utf8_size(data: str | bytes) -> int: """Size of a serialized message in UTF-8 bytes. @@ -472,44 +503,31 @@ async def handle_event( 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). - 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") - 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, + # Headers, client IP, and session id cannot change for the lifetime of + # the connection; derive them once and cache them on its ASGI scope, + # which is per-connection state, instead of on every event. + static_router_data = asgi_scope.get(_STATIC_ROUTER_DATA) + if static_router_data is None: + static_router_data = asgi_scope[_STATIC_ROUTER_DATA] = ( + build_static_router_data(sid, asgi_scope) ) - .partition(",")[0] - .strip() - ) + router_data = event.router_data try: + router_data.update(static_router_data) + # The cached headers reach the event, and from there + # `state.router_data`, a plain mutable dict: sharing the mapping + # would let a handler mutating `self.router_data["headers"]` + # corrupt the connection cache for every later event on this + # socket. The shallow copy is far cheaper than the per-event + # header decode it replaced, so the cache still pays off. + router_data[constants.RouteVar.HEADERS] = static_router_data[ + constants.RouteVar.HEADERS + ].copy() # 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" diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 859b35a8d1c..5dae1aebf46 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -4923,12 +4923,11 @@ async def test_on_event_does_not_share_the_cached_headers( enqueue_mock = cast(AsyncMock, event_namespace.app.event_processor.enqueue) _, event = enqueue_mock.call_args[0] event_headers = event.router_data[constants.RouteVar.HEADERS] - cached_headers = environ["asgi.scope"]["_reflex_headers"] + cached_headers = environ["asgi.scope"]["_reflex_static_router_data"][ + constants.RouteVar.HEADERS + ] - assert cached_headers == { - "origin": "http://localhost:3000", - "user-agent": "test-agent", - } + assert event_headers == cached_headers assert event_headers is not cached_headers # Mutating what the handler sees must not reach the connection cache. event_headers["user-agent"] = "mutated" @@ -4961,10 +4960,15 @@ async def test_on_event_builds_router_data_without_connect( await event_namespace.on_event("sid1", _client_event_payload()) await event_namespace.on_event("sid1", _client_event_payload()) - # The raw headers are decoded once and cached on the connection scope. - assert environ["asgi.scope"]["_reflex_headers"] == { - "origin": "http://localhost:3000", - "user-agent": "test-agent", + # The connection-scoped entries are derived once and cached on the scope. + assert environ["asgi.scope"]["_reflex_static_router_data"] == { + constants.RouteVar.SESSION_ID: "sid1", + constants.RouteVar.CLIENT_IP: "127.0.0.1", + constants.RouteVar.HEADERS: { + "origin": "http://localhost:3000", + "user-agent": "test-agent", + "asgi-scope-client": "127.0.0.1", + }, } enqueue_mock = cast(AsyncMock, event_namespace.app.event_processor.enqueue) assert enqueue_mock.call_count == 2 From c379fe0c7126e32ad15d448dafd475911404ff90 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Tue, 22 Sep 2026 01:48:15 +0200 Subject: [PATCH 47/62] bench: stand in for the socket.io environ lookup with a plain function A mock recording a call per event dominated the measurement of the router_data preparation the benchmark exists to track. --- tests/benchmarks/test_event_processing.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/benchmarks/test_event_processing.py b/tests/benchmarks/test_event_processing.py index 580b069b1c0..14cb59661af 100644 --- a/tests/benchmarks/test_event_processing.py +++ b/tests/benchmarks/test_event_processing.py @@ -265,7 +265,9 @@ async def run_events(num_events: int) -> None: ) # The namespace reads the connection scope through the Socket.IO server. - app.sio = mock.Mock(get_environ=mock.Mock(return_value=environ)) + # A plain function stands in for the lookup, so the measurement is not + # dominated by a mock recording a call per event. + app.sio = mock.Mock(get_environ=lambda *_args: environ) loop = asyncio.new_event_loop() loop.run_until_complete(namespace.on_connect(sid, environ)) From dfa3377d98ece3a0a53f313ac72ca351827d99ba Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Tue, 22 Sep 2026 02:03:57 +0200 Subject: [PATCH 48/62] perf(socketio): keep the connection scope on the namespace The per-event path reached the ASGI scope through the Socket.IO server on every frame; the namespace already tracks sessions for disconnect, so hold the scope there and fall back to the server only for a session on_connect never saw. --- reflex/socketio_namespace.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/reflex/socketio_namespace.py b/reflex/socketio_namespace.py index 7528718b726..6d3465cf45b 100644 --- a/reflex/socketio_namespace.py +++ b/reflex/socketio_namespace.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from collections.abc import MutableMapping from types import SimpleNamespace from typing import TYPE_CHECKING, Any @@ -76,6 +77,9 @@ def __init__(self, namespace: str, app: App): """ AsyncNamespace.__init__(self, namespace) BaseEventNamespace.__init__(self, namespace, app) + # ASGI scope per session, so the per-event path reaches the connection + # state it caches without going through the Socket.IO server. + self._scopes: dict[str, MutableMapping[str, Any]] = {} async def on_connect(self, sid: str, environ: dict): """Event for when the websocket is connected. @@ -84,6 +88,8 @@ async def on_connect(self, sid: str, environ: dict): sid: The Socket.IO session id. environ: The request information, including HTTP headers. """ + if (scope := environ.get("asgi.scope")) is not None: + self._scopes[sid] = scope await self.handle_connect( sid, environ.get("QUERY_STRING", ""), @@ -99,6 +105,7 @@ def on_disconnect(self, sid: str) -> asyncio.Task | None: Returns: An asyncio Task for cleaning up the token, or None. """ + self._scopes.pop(sid, None) return self.handle_disconnect(sid) async def on_event(self, sid: str, data: Any): @@ -111,14 +118,19 @@ async def on_event(self, sid: str, data: Any): 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"]) + scope = self._scopes.get(sid) + if scope is None: + # The connection was not seen by on_connect (e.g. the namespace + # was registered after the socket connected); ask the server. + 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) + scope = self._scopes[sid] = environ["asgi.scope"] + await self.handle_event(sid, data, scope) async def on_ping(self, sid: str): """Event for testing the API endpoint. From ea3667f26c5a58c9acb3815b6afc62374ffecfb9 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Wed, 23 Sep 2026 19:37:32 +0200 Subject: [PATCH 49/62] channels: a closed session cannot rejoin a room forget_session drops a session's rooms once, so a handler that resumes after the socket went away and calls join() put it back where nothing removed it again and every later fan-out kept addressing the dead connection. join() is now a no-op for a session whose `open` flag is false, matching what the flag already tells handlers. --- reflex/channels.py | 8 +++++++- tests/units/test_channels.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/reflex/channels.py b/reflex/channels.py index 77d5655f294..03901b88f6b 100644 --- a/reflex/channels.py +++ b/reflex/channels.py @@ -133,11 +133,17 @@ async def send( 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. + """Add this session to a room for fan-out, unless it is closed. + + A handler resuming after the socket went away would otherwise put the + session back in the room map, where nothing removes it again and every + fan-out keeps addressing the dead connection. Args: room: The room name. """ + if not self.open: + return self._rooms.add(room) self.channel._rooms.setdefault(room, set()).add(self) diff --git a/tests/units/test_channels.py b/tests/units/test_channels.py index 214071b8518..d5f602e42f5 100644 --- a/tests/units/test_channels.py +++ b/tests/units/test_channels.py @@ -249,6 +249,25 @@ async def on_message( NamelessChannel() +@pytest.mark.asyncio +async def test_join_after_close_leaves_no_member_behind(): + """A handler resuming after the socket went away cannot rejoin a room. + + The transport drops a closed session's rooms once; a later join would put + it back where nothing removes it again, and every fan-out would keep + addressing the dead connection. + """ + channel = CollectingChannel() + session = channel.session("sid1") + channel.forget_session(session) + + session.join("room") + + assert channel._rooms == {} + await channel.send_to_room("room", "tick", {"n": 1}) + assert channel.sent == [] + + @pytest.mark.asyncio @pytest.mark.parametrize("event", sorted(RESERVED_EVENTS)) async def test_send_rejects_reserved_message_names(event: str): From 54b61ad5f977918e603e0546090162396a9ee423 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Wed, 23 Sep 2026 19:37:38 +0200 Subject: [PATCH 50/62] channels: hold the reserved message names in both directions `connect`, `disconnect` and `error` name the client handle's own lifecycle, and only the server's send path refused them: a client could emit one, and the transport handed it to on_message as an application message. A handler that relays what it receives would then answer it with a send the channel API refuses. `channel.emit` now throws on a reserved name and the transport answers an inbound one with a `reserved_event` error instead of dispatching it. --- docs/api-reference/channels.md | 5 +- .../.templates/web/utils/helpers/websocket.js | 6 ++ reflex/event_namespace.py | 13 +++- tests/units/test_event_namespace.py | 64 +++++++++++++++++++ 4 files changed, 86 insertions(+), 2 deletions(-) diff --git a/docs/api-reference/channels.md b/docs/api-reference/channels.md index 7313afbe997..2b942e6b032 100644 --- a/docs/api-reference/channels.md +++ b/docs/api-reference/channels.md @@ -122,7 +122,10 @@ 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. +handle reports its own lifecycle under them, so neither end puts one on the +wire. `session.send` and `channel.emit` refuse them, and a message that arrives +under one is answered with a `reserved_event` error instead of reaching +`on_message`. ## 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 fcb948bf386..6da139856a4 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 @@ -302,6 +302,12 @@ class ReflexChannel extends LocalEmitter { * @param buffers Binary attachments (ArrayBuffers or typed arrays). */ emit(event, data, buffers = []) { + if (LIFECYCLE_EVENTS.has(event)) { + throw new Error( + `Channel message name "${event}" is reserved: this handle reports its ` + + "own lifecycle under it, and the backend refuses it.", + ); + } if (buffers?.length > MAX_MESSAGE_BUFFERS) { throw new Error( `Channel message "${event}" carries ${buffers.length} attachments, ` + diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index 50c0bc2680b..acff0877f4c 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -20,7 +20,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.channels import MAX_MESSAGE_BUFFERS, RESERVED_EVENTS, ChannelSession from reflex.istate.data import SessionData from reflex.istate.manager.token import BaseStateToken from reflex.state import StateUpdate @@ -898,6 +898,17 @@ async def _handle_channel_message( if event == CLOSE_MESSAGE: await self._close_channel_session(sid, channel_name) return + if event in RESERVED_EVENTS: + # The reservation holds in both directions, so a handler that + # relays what it receives cannot be made to attempt a send the + # channel API refuses. + await self._send_channel_error( + sid, + channel_name, + "reserved_event", + f"Channel message name {event!r} is reserved.", + ) + return if buffers and not session.channel.accepts_binary: await self._send_channel_error( sid, diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index d78395cbb43..eefaf52d2f7 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -821,6 +821,34 @@ async def test_channel_binary_rejected_when_not_accepted( assert channel.messages == [] +@pytest.mark.asyncio +@pytest.mark.parametrize("binary", [False, True]) +@pytest.mark.parametrize("event", sorted(RESERVED_EVENTS)) +async def test_channel_reserved_inbound_name_is_rejected( + namespace: WebsocketEventNamespace, mock_app: Mock, event: str, binary: bool +): + """A client may not send a message under a reserved name, on either frame kind. + + A handler that relays what it receives would answer such a message with a + send the channel API refuses, so the name never reaches it. + """ + channel = RecordingChannel(accepts_binary=True) + mock_app._channels = {"probe": channel} + websocket = FakeWebSocket() + websocket.feed( + [OPEN_MESSAGE, None, "probe"], + encode_channel_frame(event, {"fig": "f1"}, "probe", [b"\x00"]) + if binary + else [event, {"fig": "f1"}, "probe"], + ) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert channel.messages == [] + assert channel_frames(websocket)[-1][1]["code"] == "reserved_event" + assert websocket.close_code is None + + @pytest.mark.asyncio async def test_malformed_binary_frame_closes_connection( namespace: WebsocketEventNamespace, mock_app: Mock @@ -1312,6 +1340,42 @@ 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_refuses_to_emit_reserved_message_names(tmp_path: Path): + """The handle's own lifecycle names cannot be put on the wire. + + Emitting one is a consumer confusing `channel.on("connect")` with a + message, and the backend answers it with an error; throwing reports the + mistake where it was made. + """ + report = _run_client_script( + tmp_path, + f""" +import {{ getChannel }} from {CLIENT_MODULE}; + +const result = {{ sent: 0, refused: [] }}; +const channel = getChannel("probe"); +channel._transport = {{ _maxMessageSize: 0, _send: () => {{ result.sent += 1; }} }}; +channel.connected = true; +for (const name of {json.dumps(sorted(RESERVED_EVENTS))}) {{ + try {{ + channel.emit(name, {{ fig: "f1" }}); + }} catch (error) {{ + result.refused.push(name); + }} +}} +channel.emit("push", {{ fig: "f1" }}); + +console.log(JSON.stringify(result)); +""", + ) + + assert report["refused"] == sorted(RESERVED_EVENTS) + # Only the message under a usable name reached the transport, and nothing + # was queued behind it either. + 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, From 9ced7308331edb85f8817a11b6c08a2b09446f11 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Wed, 23 Sep 2026 19:37:44 +0200 Subject: [PATCH 51/62] socketio: refuse a connect that linked no client token handle_connect returns without linking a SID when the query string carries no token, and logs it at debug because "the transport closes the session" -- which the plain websocket transport does and the Socket.IO one did not, leaving an unauthenticated connection open for its ping interval to keep alive. on_connect now refuses it, undoing its own connection accounting and scope first, since Socket.IO runs no disconnect handler for a refused connect. --- reflex/socketio_namespace.py | 11 +++- tests/units/test_socketio_namespace.py | 79 ++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 tests/units/test_socketio_namespace.py diff --git a/reflex/socketio_namespace.py b/reflex/socketio_namespace.py index 6d3465cf45b..7e79a58788a 100644 --- a/reflex/socketio_namespace.py +++ b/reflex/socketio_namespace.py @@ -81,12 +81,15 @@ def __init__(self, namespace: str, app: App): # state it caches without going through the Socket.IO server. self._scopes: dict[str, MutableMapping[str, Any]] = {} - async def on_connect(self, sid: str, environ: dict): + async def on_connect(self, sid: str, environ: dict) -> bool | None: """Event for when the websocket is connected. Args: sid: The Socket.IO session id. environ: The request information, including HTTP headers. + + Returns: + False to refuse a session that linked no token, else None. """ if (scope := environ.get("asgi.scope")) is not None: self._scopes[sid] = scope @@ -95,6 +98,12 @@ async def on_connect(self, sid: str, environ: dict): environ.get("QUERY_STRING", ""), environ.get("HTTP_SEC_WEBSOCKET_PROTOCOL"), ) + if sid in self.sid_to_token: + return None + # Not a Reflex client: nothing it sends can be served. Socket.IO runs + # no disconnect handler for a refused connect, so undo this one here. + self.on_disconnect(sid) + return False def on_disconnect(self, sid: str) -> asyncio.Task | None: """Event for when the websocket disconnects. diff --git a/tests/units/test_socketio_namespace.py b/tests/units/test_socketio_namespace.py new file mode 100644 index 00000000000..774305abe0b --- /dev/null +++ b/tests/units/test_socketio_namespace.py @@ -0,0 +1,79 @@ +"""Tests for the Socket.IO event transport in reflex/socketio_namespace.py.""" + +from unittest.mock import AsyncMock, Mock + +import pytest +from reflex_base import otel + +from reflex.socketio_namespace import EventNamespace + +from .conftest import metric_points + + +@pytest.fixture +def mock_app() -> Mock: + """A mock app for the event namespace. + + Returns: + The mock app. + """ + app = Mock() + app._state = None + app._channels = {} + app.router = Mock(return_value=None) + app.event_processor.enqueue = AsyncMock() + return app + + +@pytest.fixture +def namespace(mock_app: Mock, mocker) -> EventNamespace: + """A Socket.IO event namespace with a mock app and a local token manager. + + Redis is disabled so token linking cannot leak into a shared Redis. + + Returns: + The namespace. + """ + mocker.patch("reflex.utils.prerequisites.check_redis_used", return_value=False) + return EventNamespace("/_event", mock_app) + + +@pytest.mark.asyncio +async def test_connect_with_token_is_accepted(namespace: EventNamespace): + """A client that authenticates keeps its connection and its ASGI scope.""" + scope = {"type": "websocket", "headers": []} + accepted = await namespace.on_connect( + "sid1", {"QUERY_STRING": "token=tok1", "asgi.scope": scope} + ) + + assert accepted is not False + assert namespace.sid_to_token["sid1"] == "tok1" + assert namespace._scopes["sid1"] is scope + + +@pytest.mark.asyncio +async def test_connect_without_token_is_refused(namespace: EventNamespace): + """A connection that never links a token is closed, not left open. + + Nothing it sends can be served, and Socket.IO does not run the disconnect + handler for a refused connect, so the namespace has to undo its own + bookkeeping here. + """ + refused = await namespace.on_connect( + "sid1", {"QUERY_STRING": "", "asgi.scope": {"type": "websocket"}} + ) + + assert refused is False + assert "sid1" not in namespace.sid_to_token + assert namespace._scopes == {} + + +@pytest.mark.asyncio +async def test_refused_connect_is_not_counted_as_a_connection( + namespace: EventNamespace, otel_metrics +): + """A refused connection leaves the gauge where it was, not one above it.""" + await namespace.on_connect("sid1", {"QUERY_STRING": ""}) + + (connections,) = metric_points(otel_metrics, otel.METRIC_WEBSOCKET_CONNECTIONS) + assert connections.value == 0 From c6a6b940681f9ce8bd9fcf1ef1baf7c9a95cf744 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Wed, 23 Sep 2026 19:37:50 +0200 Subject: [PATCH 52/62] tests: leave no reimported worker module behind, report a failed rebind test_uvicorn_worker_carries_the_socket_policy dropped the worker module from sys.modules and never put it back, so the rest of the session inherited a module whose CONFIG_KWARGS were baked from the test's environment. It now restores the cache it found. test_embedded_server_retries_taken_port polled for a full 15s and then failed on a bare assert whenever the rebind did not happen. It stops polling once the server thread is gone and names what went wrong. --- tests/units/test_testing.py | 15 ++++++++++++--- tests/units/utils/test_exec.py | 20 +++++++++++++++----- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/tests/units/test_testing.py b/tests/units/test_testing.py index 3b63e7d0038..1c3cec9673d 100644 --- a/tests/units/test_testing.py +++ b/tests/units/test_testing.py @@ -257,12 +257,21 @@ async def app(scope, receive, send): thread.start() try: deadline = time.monotonic() + 15 - while time.monotonic() < deadline: + # A server that gave up has nothing left to wait for; without that the + # only report of a broken retry would be a full-timeout bare assert. + while time.monotonic() < deadline and thread.is_alive(): if server.port != probed_port and server.is_listening(): break time.sleep(0.05) - assert server.port != probed_port - assert server.is_listening() + assert server.port != probed_port, ( + f"the server never left the probed port {probed_port} " + f"(serving: {thread.is_alive()}): it either bound a port another " + "socket holds, or failed with a bind error the retry does not " + "recognize -- the thread's traceback says which" + ) + assert server.is_listening(), ( + f"the server rebound to port {server.port} without serving on it" + ) finally: blocker.close() server.should_exit = True diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py index 38d04ae3272..48ccff3a316 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 importlib import logging import multiprocessing import os @@ -451,15 +452,24 @@ def test_uvicorn_worker_carries_the_socket_policy(monkeypatch: pytest.MonkeyPatc 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 + name = "reflex.utils.uvicorn_worker" + # The class body reads the environment at import time, so import it fresh + # -- then put the cache back, or a later importer would be handed a module + # whose settings were baked from this test's environment. + cached = sys.modules.pop(name, None) + try: + worker = importlib.import_module(name) + finally: + if cached is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = cached assert ( - ReflexUvicornWorker.CONFIG_KWARGS.items() + worker.ReflexUvicornWorker.CONFIG_KWARGS.items() >= exec_utils.uvicorn_websocket_options().items() ) - assert ReflexUvicornWorker.CONFIG_KWARGS["ws_per_message_deflate"] is False + assert worker.ReflexUvicornWorker.CONFIG_KWARGS["ws_per_message_deflate"] is False @pytest.fixture From 8933a2f56fe2e4e8237a36d32e6dc974d3111887 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Fri, 2 Oct 2026 23:39:25 +0200 Subject: [PATCH 53/62] Budget the lazily loaded socket.io-client chunk socket.io-client is now imported only when the Socket.IO transport is configured, so the playground export splits it into a chunk of its own: 18 chunks instead of 17, with a 10 KB smaller initial bundle. --- packages/reflex-bench/budgets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/reflex-bench/budgets.json b/packages/reflex-bench/budgets.json index cf4206c9a59..b7d2042660e 100644 --- a/packages/reflex-bench/budgets.json +++ b/packages/reflex-bench/budgets.json @@ -5,7 +5,7 @@ "initial_gzip": 330000, "initial_brotli": 265000, "total_gzip": 350000, - "chunks": 17, + "chunks": 18, "node_modules": 170000000 } } From cc7e1f0325560c9e3457e991a631c68495ebec47 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Fri, 9 Oct 2026 00:33:52 +0200 Subject: [PATCH 54/62] Fix the review findings on the plain WebSocket transport Transport: each connection queues its frames for a writer task of its own, so a client that stops reading no longer blocks the coroutines emitting to it, and is dropped past a 16 MiB backlog. Closing goes through the session's receive loop, which granian requires to complete a close. Frames are compact JSON, escape unpaired surrogates and size attachments in bytes; a huge integer literal is a malformed frame. The event route is served with and without a trailing slash, which the client now dials, Socket.IO clients are refused before accept, and emit() without a recipient broadcasts. Client: handlers run over a copy and cannot abort the transport's own bookkeeping, the ping watchdog reports a dead link at once, a channel connects once, a throwing WebSocket constructor becomes a connect error, channel names and attachments are validated, an oversized event is dropped instead of costing the socket, and a socket that cannot be created is reported as a connection error rather than a rejection that reconnects without end. Channels reserve every underscore-prefixed name in both directions and validate fan-out with nobody listening; registering one where channels cannot run warns and skips it. A custom sio without a transport setting falls back to Socket.IO with a deprecation, and reflex.app re-exports the Socket.IO classes lazily. A plain-string cors_allowed_origins is read as a list. Auto-detection picks granian when uvicorn has no websocket library, and granian caps the message size at what it receives. AppHarness owns its port from construction, reports readiness itself, fails fast on a busy requested port or a failed lifespan and logs its errors. The benchmark clients detect the transport the backend speaks. Docs cover the transport upgrade, proxy paths and channel task pattern. --- docker-example/production-compose/Caddyfile | 2 +- docker-example/production/Caddyfile | 2 +- docs/api-reference/channels.md | 63 +- docs/changelog/upgrading/upgrading-to-0-10.md | 31 +- docs/hosting/self-hosting.md | 6 +- news/6932.breaking.md | 1 + news/6932.feature.md | 2 +- .../news/+cors-origins-string.bugfix.md | 1 + .../.templates/web/utils/helpers/websocket.js | 154 ++++- .../reflex_base/.templates/web/utils/state.js | 7 + .../reflex-base/src/reflex_base/config.py | 15 + packages/reflex-bench/README.md | 19 +- .../src/reflex_bench/drivers/browser.py | 14 +- .../src/reflex_bench/drivers/echo_server.py | 141 +++-- .../src/reflex_bench/drivers/events.py | 584 ++++++++++++++---- .../src/reflex_bench/suites/wire.py | 154 +++-- reflex/app.py | 111 +++- reflex/channels.py | 65 +- reflex/event_namespace.py | 432 +++++++++---- reflex/socketio_namespace.py | 14 +- reflex/testing.py | 283 ++++++--- reflex/utils/exec.py | 47 +- tests/benchmarks/support/idle_clients.py | 50 +- tests/benchmarks/support/socket_client.py | 257 +++++--- tests/benchmarks/test_event_transport.py | 6 +- .../tests_playwright/test_stateless_app.py | 13 +- tests/units/compiler/state_js.test.mjs | 30 +- tests/units/compiler/websocket_js.test.mjs | 164 ++++- .../reflex_bench/drivers/test_browser.py | 35 +- .../units/reflex_bench/drivers/test_events.py | 360 ++++++++--- tests/units/reflex_bench/suites/test_wire.py | 402 ++++++++---- .../reflex_bench/suites/test_wire_app.py | 7 +- tests/units/test_app.py | 22 +- tests/units/test_channels.py | 31 +- tests/units/test_config.py | 27 + tests/units/test_event_namespace.py | 366 ++++++++++- tests/units/test_socketio_namespace.py | 25 + tests/units/test_testing.py | 277 ++++++--- tests/units/utils/test_exec.py | 51 ++ 39 files changed, 3246 insertions(+), 1025 deletions(-) create mode 100644 news/6932.breaking.md create mode 100644 packages/reflex-base/news/+cors-origins-string.bugfix.md diff --git a/docker-example/production-compose/Caddyfile b/docker-example/production-compose/Caddyfile index 32ac014e6da..9298ff0f31e 100644 --- a/docker-example/production-compose/Caddyfile +++ b/docker-example/production-compose/Caddyfile @@ -2,7 +2,7 @@ encode gzip -@backend_routes path /_event/* /ping /_health /_upload /_upload/* +@backend_routes path /_event /_event/* /ping /_health /_upload /_upload/* handle @backend_routes { reverse_proxy app:8000 } diff --git a/docker-example/production/Caddyfile b/docker-example/production/Caddyfile index e0bfb2705ef..ab80a6ed823 100644 --- a/docker-example/production/Caddyfile +++ b/docker-example/production/Caddyfile @@ -2,7 +2,7 @@ encode gzip -@backend_routes path /_event/* /ping /_health /_upload /_upload/* +@backend_routes path /_event /_event/* /ping /_health /_upload /_upload/* handle @backend_routes { reverse_proxy localhost:8000 } diff --git a/docs/api-reference/channels.md b/docs/api-reference/channels.md index 2b942e6b032..b40b6b52cd0 100644 --- a/docs/api-reference/channels.md +++ b/docs/api-reference/channels.md @@ -15,7 +15,8 @@ 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"`. +`transport="polling"`: there, registering a channel logs a warning and skips +it, and its browser-side handle reports a `channels_unsupported` error. ## Defining a channel @@ -47,7 +48,9 @@ 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. +which runs at backend startup with the live app. Registration never stops an +app that cannot serve channels from starting, so a package needs no check of +its own. Every field of an inbound message is client-controlled and unvalidated. A handler that raises is logged and the connection keeps serving, so a failing @@ -57,17 +60,31 @@ 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: +REFLEX_SOCKET_TIMEOUT` the server closes it as unresponsive. That holds for +anything the handler awaits, `asyncio.to_thread` included, so run long work in +a task of its own 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: - await session.send("rows", {"count": len(rows)}, [rows.tobytes()]) +class Rows(rx.channels.Channel): + name = "rows" + + def __init__(self): + super().__init__() + # Held, so the event loop does not drop a running task. + self._tasks = set() + + async def on_message(self, session, event, data, buffers): + task = asyncio.create_task(self._answer(session, data["filter"])) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + + async def _answer(self, session, query_filter): + rows = await asyncio.to_thread(expensive_query, query_filter) + if session.open: + await session.send("rows", {"count": len(rows)}, [rows.tobytes()]) ``` ## Sending to clients @@ -81,9 +98,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. +Work running in a task of its own can outlive its client; `session.open` +reports whether the client is still connected before you commit to expensive +or long-lived work. Inside `on_message` itself it stays true: the connection +cannot close while its receive loop is waiting for the handler. 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 @@ -117,15 +135,17 @@ 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. +it. Raise the setting if your clients send larger payloads; Granian, the +default server, receives frames of at most 16 MiB, so Reflex caps the setting +there and you need uvicorn for anything larger. 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 neither end puts one on the -wire. `session.send` and `channel.emit` refuse them, and a message that arrives -under one is answered with a `reserved_event` error instead of reaching -`on_message`. +handle reports its own lifecycle under them. So is every name starting with +`_`, which the channel protocol uses for its own messages. Neither end puts a +reserved name on the wire: `session.send` and `channel.emit` refuse them, and a +message that arrives under one is answered with a `reserved_event` error +instead of reaching `on_message`. ## Using a channel from the frontend @@ -141,10 +161,15 @@ 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. +// Attachments are an array of ArrayBuffers or typed arrays. channel.emit("frame", { seq: 1 }, [new Float64Array([1, 2, 3])]); ``` +`channel.emit` throws on anything the backend would refuse — a name that is +not a string or is reserved, attachments that are not an array of binary +buffers — rather than cost the app its socket. Read a `Blob` with +`await blob.arrayBuffer()` before attaching it. + 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. diff --git a/docs/changelog/upgrading/upgrading-to-0-10.md b/docs/changelog/upgrading/upgrading-to-0-10.md index 00213e46009..c9d13d16a9b 100644 --- a/docs/changelog/upgrading/upgrading-to-0-10.md +++ b/docs/changelog/upgrading/upgrading-to-0-10.md @@ -1,6 +1,6 @@ --- title: "Upgrading to Reflex 0.10" -meta_description: "Changes to review when upgrading an app from Reflex 0.9 to 0.10, with the fix for each: backend vars read on a state class, class-level defaults, background tasks, and state stores shared with 0.9 instances." +meta_description: "Changes to review when upgrading an app from Reflex 0.9 to 0.10, with the fix for each: backend vars read on a state class, class-level defaults, background tasks, the event transport, and state stores shared with 0.9 instances." --- # Upgrading to Reflex 0.10 @@ -109,6 +109,35 @@ class Child(Parent): self.bump() ``` +## The event transport + +`transport="websocket"`, the default, now means a plain WebSocket speaking +JSON frames rather than Socket.IO over a websocket +([#6932](https://github.com/reflex-dev/reflex/pull/6932)). Socket.IO remains +available as `transport="socketio"` (or `"polling"`) with the optional extra, +`pip install 'reflex[socketio]'`. + +| If your app | Do this | +| --- | --- | +| Passes `rx.App(sio=...)` | Set `transport="socketio"` in `rxconfig.py` and install `reflex[socketio]`. Without a transport setting the app still falls back to Socket.IO, with a deprecation warning. | +| Uses `app.sio` for its own Socket.IO events | The same: under the default transport `app.sio` is `None`. | +| Imports `socketio`, or `AsyncServer` or `EventNamespace` from `reflex.app` | Install `reflex[socketio]`: `python-socketio` is no longer a dependency of `reflex`. | +| Runs the backend on uvicorn | Install `reflex[uvicorn]`, which brings the `websockets` library uvicorn needs to serve the event socket. Without a websocket library, Reflex runs Granian instead. | +| Routes the backend through a reverse proxy | Send both `/_event` and `/_event/*` to the backend; the client dials `/_event/`. | +| Raised `REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE` | It counts UTF-8 bytes rather than characters, and Granian caps it at 16 MiB; run uvicorn for larger messages. | +| Uses `AppHarness.backend` or `AppHarnessProd.frontend_server` in tests | Both are an embedded Granian server rather than a `uvicorn.Server`: wait for startup with `wait_started()` (`started` is deprecated), and drop uses of `servers`, `config`, `serve()` and the other uvicorn internals. `reflex[testing]` no longer installs uvicorn; add it yourself if your tests import it. | + +Browser tabs left open across the upgrade, and frontends exported with 0.9 and +hosted separately, cannot reconnect: reload the tabs and re-export the +frontend. + +Under the default transport `app.event_namespace` is a +`WebsocketEventNamespace`. Its `emit(event, data, to=sid)` reaches one session, +or every session when `to` is omitted; Socket.IO's rooms, `skip_sid` and +callbacks are not available. An event whose payload is not a JSON object closes +the connection instead of being parsed from a string, and a Socket.IO +connection that carries no client token is refused. + ## Sharing a state store between 0.9 and 0.10 instances Instances of 0.9 and 0.10 cannot share a Redis or disk state store. A 0.10 diff --git a/docs/hosting/self-hosting.md b/docs/hosting/self-hosting.md index cd32acdada1..5b129f7c016 100644 --- a/docs/hosting/self-hosting.md +++ b/docs/hosting/self-hosting.md @@ -58,8 +58,10 @@ REFLEX_API_URL=http://localhost:8000 reflex run --env prod --frontend-only --fro ``` Alternatively, put a reverse proxy in front of both processes and route the -backend routes (`/_event`, `/ping`, `/_upload`, `/_health`) to the backend -port, as the `Caddyfile`s in the container examples below do. +backend routes (`/_event` and `/_event/*`, `/ping`, `/_upload`, `/_health`) to +the backend port, as the `Caddyfile`s in the container examples below do. The +event websocket is dialed at `/_event/`; it must be proxied as a websocket +upgrade. Instances of Reflex 0.9 and 0.10 cannot share a state store, whether Redis or the disk state manager's directory. A 0.10 instance loads state saved by 0.9, diff --git a/news/6932.breaking.md b/news/6932.breaking.md new file mode 100644 index 00000000000..f00ef48a542 --- /dev/null +++ b/news/6932.breaking.md @@ -0,0 +1 @@ +`transport="websocket"`, the default, now means a plain WebSocket rather than Socket.IO, and `python-socketio` and `uvicorn` moved to the `reflex[socketio]` and `reflex[uvicorn]` extras. Apps that pass `rx.App(sio=...)`, use `app.sio`, run uvicorn, proxy `/_event`, or use `AppHarness.backend` need changes: see [Upgrading to Reflex 0.10](https://reflex.dev/docs/changelog/upgrading/upgrading-to-0-10/#the-event-transport). diff --git a/news/6932.feature.md b/news/6932.feature.md index 5ec940f2479..48b92a24827 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. 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. +The client and server now talk over a plain WebSocket with a lightweight JSON event protocol instead of Socket.IO, and the browser loads `socket.io-client` only when `transport="socketio"` or `"polling"` is configured. diff --git a/packages/reflex-base/news/+cors-origins-string.bugfix.md b/packages/reflex-base/news/+cors-origins-string.bugfix.md new file mode 100644 index 00000000000..f7fb4195326 --- /dev/null +++ b/packages/reflex-base/news/+cors-origins-string.bugfix.md @@ -0,0 +1 @@ +A plain-string `cors_allowed_origins` is read as a comma-separated list, as `REFLEX_CORS_ALLOWED_ORIGINS` is, instead of admitting every origin it contains as a substring. 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 4b688312153..dfa9ff594b4 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 @@ -37,9 +37,27 @@ const MAX_QUEUED_CHANNEL_MESSAGES = 64; 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). +// may not use them (must match reflex.channels.RESERVED_EVENTS). Names +// starting with an underscore are reserved too: the channel protocol uses them. const LIFECYCLE_EVENTS = new Set(["connect", "disconnect", "error"]); +// Names a channel can be registered under (must match reflex.channels). +const CHANNEL_NAME_PATTERN = /^[A-Za-z0-9_./:-]{1,64}$/; + +/** + * Whether a channel message name is reserved for the handle or the protocol. + * @param event The message name. + * @returns Whether an application message may not use it. + */ +const isReservedEvent = (event) => + LIFECYCLE_EVENTS.has(event) || event.startsWith("_"); + +/** + * Report an error without throwing it into the code that found it. + * @param error The error to report. + */ +const report = (error) => (globalThis.reportError ?? console.error)(error); + /** * JSON.stringify replacer that sends undefined fields as null instead of * removing them. Also assigned as the socket.io encoder replacer. @@ -134,15 +152,29 @@ const frameByteLength = (frame) => */ const stringifyFrame = (frame) => JSON.stringify(frame, undefinedToNull); +/** + * Whether a value is binary data an attachment can carry. + * + * Checked by tag rather than instanceof, so buffers from another realm (an + * iframe, a worker) and shared buffers qualify. + * @param value The candidate attachment. + * @returns Whether it is an ArrayBuffer, SharedArrayBuffer or view of one. + */ +const isBinary = (value) => + ArrayBuffer.isView(value) || + ["[object ArrayBuffer]", "[object SharedArrayBuffer]"].includes( + Object.prototype.toString.call(value), + ); + /** * View any binary value as bytes without copying it. - * @param buffer An ArrayBuffer, typed array or DataView. + * @param buffer An ArrayBuffer, SharedArrayBuffer, 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); + ArrayBuffer.isView(buffer) + ? new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) + : new Uint8Array(buffer); /** * Bytes of padding needed to reach the next attachment boundary. @@ -266,8 +298,19 @@ class LocalEmitter { * @param args The handler arguments. */ _emitLocal(event, ...args) { - for (const fn of this._callbacks["$" + event] ?? []) { - fn(...args); + const handlers = this._callbacks["$" + event]; + if (handlers === undefined) { + return; + } + // A copy: a handler may remove itself, or add one, while this runs. + for (const fn of [...handlers]) { + try { + fn(...args); + } catch (error) { + // A failing handler must not skip the others, nor abort the + // transport bookkeeping that is reporting the event. + report(error); + } } } } @@ -303,13 +346,28 @@ class ReflexChannel extends LocalEmitter { * @param buffers Binary attachments (ArrayBuffers or typed arrays). */ emit(event, data, buffers = []) { - if (LIFECYCLE_EVENTS.has(event)) { + // The backend closes the app's socket over a frame it cannot read, so a + // malformed message is refused here, where the mistake is. + if (typeof event !== "string" || event === "") { + throw new TypeError( + `Channel message name must be a non-empty string, not ${String(event)}.`, + ); + } + if (isReservedEvent(event)) { throw new Error( `Channel message name "${event}" is reserved: this handle reports its ` + - "own lifecycle under it, and the backend refuses it.", + 'own lifecycle under it, names starting with "_" belong to the ' + + "channel protocol, and the backend refuses both.", + ); + } + if (!Array.isArray(buffers) || !buffers.every(isBinary)) { + throw new TypeError( + `Channel message "${event}" attachments must be an array of ` + + "ArrayBuffers or typed arrays; read a Blob with " + + "`await blob.arrayBuffer()` first.", ); } - if (buffers?.length > MAX_MESSAGE_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.`, @@ -376,9 +434,10 @@ 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. + if (this._transport === null || this.connected) { + // A straggler from a transport this channel is no longer on (the + // queue belongs to whichever it attaches to next), or the answer to + // an open sent twice. return; } this.connected = true; @@ -408,7 +467,7 @@ class ReflexChannel extends LocalEmitter { this._emitLocal("error", data); return; } - if (LIFECYCLE_EVENTS.has(event)) { + if (isReservedEvent(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)`); @@ -424,6 +483,12 @@ class ReflexChannel extends LocalEmitter { * @returns The channel handle. */ export const getChannel = (name) => { + if (typeof name !== "string" || !CHANNEL_NAME_PATTERN.test(name)) { + throw new TypeError( + `Invalid channel name ${String(name)}: expected 1-64 characters from ` + + "[A-Za-z0-9_./:-].", + ); + } let channel = channels.get(name); if (channel === undefined) { channel = new ReflexChannel(name); @@ -512,7 +577,6 @@ export class ReflexWebSocket extends LocalEmitter { // connect_error always fires and retries proceed. 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 @@ -560,6 +624,10 @@ export class ReflexWebSocket extends LocalEmitter { this._sessionRequested = true; this.open(); const ws = this._ws; + if (ws === null) { + // The dial failed outright; its connect_error is on the way. + return; + } if (ws.readyState === WebSocket.OPEN) { // Dialed ahead by open(). this._sendConnect(); @@ -584,9 +652,20 @@ export class ReflexWebSocket extends LocalEmitter { // Secure endpoints (https or already-wss) stay secure. url.protocol = url.protocol === "https:" || url.protocol === "wss:" ? "wss:" : "ws:"; + // Proxy rules written for Socket.IO route "/_event/*", which the bare + // path misses; the backend serves both. + url.pathname = url.pathname.replace(/\/?$/, "/"); url.search = new URLSearchParams(this.io.opts.query ?? {}).toString(); - this._closeReason = null; - const ws = new WebSocket(url, this.io.opts.protocols); + let ws; + try { + ws = new WebSocket(url, this.io.opts.protocols); + } catch (error) { + // Mixed content and CSP violations fail the constructor itself. Report + // it like a failed dial, after the caller has attached its handlers. + this._ws = null; + queueMicrotask(() => this._emitLocal("connect_error", error)); + return; + } // Channel attachments arrive as binary frames; take them as ArrayBuffers // so handlers can view them as typed arrays without a copy. ws.binaryType = "arraybuffer"; @@ -611,7 +690,7 @@ export class ReflexWebSocket extends LocalEmitter { this._clearWatchdog(); const wasConnected = this.connected; this.connected = false; - detachChannels(this, this._closeReason ?? "transport close"); + detachChannels(this, "transport close"); if (!wasConnected) { // Never handshaked: this was a failed connection attempt. this._emitLocal( @@ -619,7 +698,7 @@ export class ReflexWebSocket extends LocalEmitter { new Error("websocket connection failed"), ); } else { - this._emitLocal("disconnect", this._closeReason ?? "transport close", { + this._emitLocal("disconnect", "transport close", { code: event.code, reason: event.reason, }); @@ -667,16 +746,19 @@ export class ReflexWebSocket extends LocalEmitter { } // 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); - } + const wasConnected = this.connected; + this.connected = false; if (ws.readyState <= WebSocket.OPEN) { ws.onclose = null; ws.onmessage = null; ws.close(1000); } + // Report only once the transport's own state is settled: the handlers + // run application code. + detachChannels(this, reason); + if (wasConnected) { + this._emitLocal("disconnect", reason, details); + } } /** @@ -685,7 +767,21 @@ export class ReflexWebSocket extends LocalEmitter { * @param data The event payload. */ emit(event, data) { - this._send(stringifyFrame([event, data])); + const frame = stringifyFrame([event, data]); + const limit = this._maxMessageSize; + if (limit && exceedsMessageLimit(frame, limit)) { + // The backend closes the connection over an oversized frame, losing + // every update in flight with it; drop this one message instead. + report( + new Error( + `Message "${data?.name ?? event}" is ${frameByteLength(frame)} bytes, ` + + `over the ${limit} the backend accepts ` + + "(REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE); it was not sent.", + ), + ); + return; + } + this._send(frame); } /** @@ -788,9 +884,11 @@ export class ReflexWebSocket extends LocalEmitter { * @param reason The disconnect reason to report. */ _dropConnection(reason) { - if (this._ws && this.connected) { - this._closeReason = reason; - this._ws.close(); + if (this.connected) { + // Reported now rather than from onclose: on a dead link the browser + // fires that only when its closing handshake times out, up to a minute + // later, and the transport would claim to be connected until then. + this._teardown(reason, undefined); } } 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 bdd8f8cfe03..d9f4271259f 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 @@ -801,6 +801,13 @@ export const connect = async ( `Channels require transport="websocket", not "${transport}".`, ); } + } catch (error) { + // Reported rather than rejected: the unhandled rejection handler adds an + // event, which reconnects, which fails again without end -- as a client + // chunk the browser failed to load does on every retry. + console.error("Failed to create the event socket:", error); + setConnectErrors((connectErrors) => [...connectErrors.slice(-9), error]); + return; } finally { socket.connecting = false; } diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index f10c5635516..935a62c9695 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -385,6 +385,8 @@ def _post_init(self, **kwargs): self._normalize_frontend_compression_formats() + self._normalize_cors_allowed_origins() + # Normalize route prefixes to ensure they start with a slash. self._normalize_paths() @@ -578,6 +580,19 @@ def _normalize_disable_plugins(self): ) self.disable_plugins = normalized + def _normalize_cors_allowed_origins(self): + """Read a plain-string cors_allowed_origins as a comma-separated list. + + Left a string, membership in it is a substring test, so every origin + check would accept any origin the configured one contains. + """ + if isinstance(self.cors_allowed_origins, str): + self.cors_allowed_origins = [ + origin.strip() + for origin in self.cors_allowed_origins.split(",") + if origin.strip() + ] + def _normalize_frontend_compression_formats(self): """Normalize and validate configured frontend compression formats. diff --git a/packages/reflex-bench/README.md b/packages/reflex-bench/README.md index ddcf95d6880..8c8ba3748ca 100644 --- a/packages/reflex-bench/README.md +++ b/packages/reflex-bench/README.md @@ -339,9 +339,12 @@ the only one so far. (`examples/playground`) answers and how fast. Each benchmark instance starts it once as a production backend (`reflex run --env prod --backend-only`, one granian worker), and each sample drives it for a few seconds with -`reflex_bench.drivers.events`, a socket.io load generator that speaks reflex's -event websocket over `websockets` (no python-socketio). Sessions are browser -tabs: each connects with its own token, hydrates like a page load, then sends +`reflex_bench.drivers.events`, a load generator that speaks reflex's event +websocket over `websockets`: the plain protocol of reflex's default transport, +or Socket.IO (without python-socketio) for releases before it and apps with +`transport="socketio"`. Each pool of sessions detects which by dialing the +plain endpoint, which a Socket.IO backend refuses. Sessions are browser tabs: +each connects with its own token, hydrates like a page load, then sends `BenchState.set_seq*` events whose delta echoes a sequence number. The grid holds the common points, about 30 minutes with the default policy. @@ -592,8 +595,14 @@ app and reflex version, so every metric is exact and a sample is one run. counts, as does any delta of another state. The extra data keeps the echo frame (`reply`), the largest frame, and `delta_bytes`, the bytes of each substate's part of the deltas re-serialized compactly. -- **Keepalives do not count**: an engine.io ping and its pong are timing, not - payload, so a slow run moves the same bytes as a fast one. +- **Keepalives do not count**: a ping and its pong are timing, not payload, so + a slow run moves the same bytes as a fast one. +- **The backend's protocol**: the session speaks the plain protocol or + Socket.IO, whichever the backend does, so a comparison across reflex's switch + includes the framing. On the plain protocol the hydration rides in the + connect frame: `hydrate_and_load` with an empty payload, as on a reconnect, + so the full state rather than the difference from the compiled defaults a + browser's first load asks for. - **`wire.navigate`** sends what the frontend sends on a route change: one `on_load_internal` with the new route's `router_data`. `update_vars_internal` goes first only once the browser holds a value of a client storage var (the diff --git a/packages/reflex-bench/src/reflex_bench/drivers/browser.py b/packages/reflex-bench/src/reflex_bench/drivers/browser.py index 877b691416d..ea45522e373 100644 --- a/packages/reflex-bench/src/reflex_bench/drivers/browser.py +++ b/packages/reflex-bench/src/reflex_bench/drivers/browser.py @@ -58,8 +58,9 @@ HYDRATED = "#bench-hydrated" NAV_TIMEOUT_S = 120.0 _ANCHOR_READS = 3 -# A socket.io event frame: "42", an optional namespace, then ["name", ...]. -_SOCKETIO_EVENT = re.compile(r'42(?:/[^,]*,)?\["([^"]+)"') +# A message of the event websocket: ["name", ...], on Socket.IO after "42" and +# an optional namespace. +_MESSAGE = re.compile(r'(?:42(?:/[^,]*,)?)?\["([^"]+)"') # {"perf": ms since navigation start, "epoch": Date.now(), "alive": the # document's tag (see Tab.set_alive) or None}. @@ -133,7 +134,8 @@ class Tab: page: The Playwright page. cdp: A DevTools session of the page, e.g. for ``Network.setCacheDisabled``. ws_bytes: Payload bytes of the page's websocket frames, both directions. - ws_events: How often each socket.io event arrived, by name. + ws_events: How often each message of the event websocket arrived, by + name. transfer_bytes: Bytes received over the wire for the page's HTTP responses, headers included, as the encoded data length of the DevTools ``Network.loadingFinished`` events. @@ -201,14 +203,14 @@ def _on_frame_sent(self, payload: str | bytes) -> None: self.ws_bytes += _payload_size(payload) def _on_frame_received(self, payload: str | bytes) -> None: - """Count a received frame and the socket.io event it carries. + """Count a received frame and the message it carries. Args: payload: The frame's payload. """ self.ws_bytes += _payload_size(payload) - if isinstance(payload, str) and (event := _SOCKETIO_EVENT.match(payload)): - self.ws_events[event[1]] += 1 + if isinstance(payload, str) and (message := _MESSAGE.match(payload)): + self.ws_events[message[1]] += 1 def mark(self, id: str) -> Mark | None: """Read a mark. diff --git a/packages/reflex-bench/src/reflex_bench/drivers/echo_server.py b/packages/reflex-bench/src/reflex_bench/drivers/echo_server.py index 0752c581f76..d0ef159b44b 100644 --- a/packages/reflex-bench/src/reflex_bench/drivers/echo_server.py +++ b/packages/reflex-bench/src/reflex_bench/drivers/echo_server.py @@ -1,10 +1,12 @@ -"""A socket.io server that answers the event generator like the playground does, without reflex. - -It speaks the frames of reflex's event websocket (see -:mod:`reflex_bench.drivers.events`): the engine.io handshake, the namespace ack, -pings, and one delta per event. The hydration events set ``is_hydrated``; every -other event gets a delta that echoes its ``payload["seq"]``. The generator's -calibration (``selftest.events.calibrate``) runs against it in a separate +"""A server that answers the event generator like the playground does, without reflex. + +It speaks either protocol of reflex's event websocket (see +:mod:`reflex_bench.drivers.events`) and, like reflex, refuses the websocket on +the other one's path, so the generator detects which: the plain protocol's +connect frame and handshake or Socket.IO's engine.io open and namespace ack, +pings, and one delta per event. The hydration events set ``is_hydrated``; +every other event gets a delta that echoes its ``payload["seq"]``. The +generator's calibration (``selftest.events.calibrate``) runs it in a separate process, and the generator's tests subclass it to script delays and faults. """ @@ -15,30 +17,43 @@ import json import multiprocessing import os +import urllib.parse import uuid from collections.abc import AsyncIterator, Sequence +from http import HTTPStatus from multiprocessing.connection import Connection from multiprocessing.process import BaseProcess from typing import Any from websockets.asyncio.server import ServerConnection, serve from websockets.exceptions import ConnectionClosed +from websockets.http11 import Request, Response from reflex_bench.drivers.events import ( - CONNECT_FRAME, - DISCONNECT_FRAME, - EVENT_PREFIX, + BOOT_KEY, + CODECS, + CONNECT, + DISCONNECTED, + EVENT, + HANDSHAKE, + HYDRATE_AND_LOAD_EVENT, HYDRATE_EVENT, HYDRATED_VAR, KILL_GRACE_S, - NAMESPACE, + MAX_FRAME_BYTES, ON_LOAD_EVENT, PING, ROOT_STATE, - emit_frame, + SIO_CONNECT_FRAME, + SIO_OPEN_PREFIX, + SIO_PING, + WireProtocol, event_loop, ) +# The plain protocol's version, announced in its handshake. +PROTOCOL_VERSION = 2 + class EchoServer: """Answers the generator's frames as a reflex app does, one event at a time per connection.""" @@ -48,6 +63,7 @@ def __init__( *, delta_key: str, seq_var: str, + protocol: WireProtocol = "websocket", ping_interval_s: float = 25.0, ping_timeout_s: float = 120.0, ) -> None: @@ -56,13 +72,16 @@ def __init__( Args: delta_key: The state whose delta echoes the sequence number. seq_var: The var that echoes it. - ping_interval_s: Seconds between engine.io pings. + protocol: The protocol to speak. + ping_interval_s: Seconds between pings. ping_timeout_s: The ping timeout announced in the handshake. """ self.delta_key = delta_key self.seq_var = seq_var + self.codec = CODECS[protocol] self.ping_interval_s = ping_interval_s self.ping_timeout_s = ping_timeout_s + self._path = urllib.parse.urlsplit(self.codec.url("http://echo", "")).path @contextlib.asynccontextmanager async def serve(self, host: str, port: int) -> AsyncIterator[int]: @@ -83,36 +102,63 @@ async def serve(self, host: str, port: int) -> AsyncIterator[int]: compression=None, max_size=None, ping_interval=None, + process_request=self._route, ) as server: yield next(iter(server.sockets)).getsockname()[1] + def _route(self, connection: ServerConnection, request: Request) -> Response | None: + """Refuse the websocket on any path but the protocol's, as reflex does. + + Args: + connection: The connection. + request: Its upgrade request. + + Returns: + ``None`` to accept, or a 404. + """ + if urllib.parse.urlsplit(request.path).path == self._path: + return None + return connection.respond(HTTPStatus.NOT_FOUND, "Not Found\n") + async def handle(self, ws: ServerConnection) -> None: - """Serve one connection until it closes or leaves the namespace. + """Serve one connection until it closes or leaves the session. + + The codec decodes the client's frames too: Socket.IO's packets are the + same both ways, so its namespace join decodes as ``HANDSHAKE``. Args: ws: The connection. """ sid = uuid.uuid4().hex - await ws.send( - "0" - + json.dumps({ - "sid": sid, - "upgrades": [], - "pingInterval": int(self.ping_interval_s * 1000), - "pingTimeout": int(self.ping_timeout_s * 1000), - "maxPayload": 1_000_000, - }) - ) + codec = self.codec + if codec.greets: + await ws.send( + SIO_OPEN_PREFIX + + json.dumps({ + "sid": sid, + "upgrades": [], + "pingInterval": int(self.ping_interval_s * 1000), + "pingTimeout": int(self.ping_timeout_s * 1000), + "maxPayload": 1_000_000, + }) + ) pinger = asyncio.create_task(self._ping(ws)) try: async for message in ws: - if isinstance(message, str) and message.startswith(EVENT_PREFIX): - name, *args = json.loads(message[len(EVENT_PREFIX) :]) - if name == "event": - await self.on_event(ws, args[0]) - elif message == CONNECT_FRAME: + args = codec.parse(message) + if args is None: + # Socket.IO's pong. + continue + name = args[0] + if name == EVENT: + await self.on_event(ws, args[1]) + elif name in {CONNECT, HANDSHAKE}: await self.on_connect(ws, sid) - elif message == DISCONNECT_FRAME: + # The plain protocol's connect carries the boot event. + auth = args[1] if len(args) > 1 else None + if isinstance(auth, dict) and BOOT_KEY in auth: + await self.on_event(ws, auth[BOOT_KEY]) + elif name == DISCONNECTED: return except ConnectionClosed: pass @@ -120,24 +166,38 @@ async def handle(self, ws: ServerConnection) -> None: pinger.cancel() async def _ping(self, ws: ServerConnection) -> None: - """Send engine.io pings; the generator answers them. + """Send pings; the generator answers them. Args: ws: The connection. """ + ping = SIO_PING if self.codec.protocol == "socketio" else self.codec.emit(PING) with contextlib.suppress(ConnectionClosed): while True: await asyncio.sleep(self.ping_interval_s) - await ws.send(PING) + await ws.send(ping) async def on_connect(self, ws: ServerConnection, sid: str) -> None: - """Acknowledge the namespace connect. + """Acknowledge the connect. Args: ws: The connection. sid: The session id. """ - await ws.send(f"40{NAMESPACE}," + json.dumps({"sid": sid})) + if self.codec.protocol == "socketio": + await ws.send(SIO_CONNECT_FRAME + json.dumps({"sid": sid})) + return + await ws.send( + self.codec.emit( + HANDSHAKE, + { + "ping_interval": self.ping_interval_s, + "ping_timeout": self.ping_timeout_s, + "protocol": PROTOCOL_VERSION, + "max_message_size": MAX_FRAME_BYTES, + }, + ) + ) async def on_event(self, ws: ServerConnection, event: dict[str, Any]) -> None: """Answer one event. @@ -155,16 +215,17 @@ def reply(self, event: dict[str, Any]) -> str: event: The event. Returns: - ``hydrate`` and ``on_load_internal`` get the root state's - ``is_hydrated`` (false, then true); any other event gets its - sequence number echoed. + The hydration events get the root state's ``is_hydrated``: false + for ``hydrate``, true for ``on_load_internal`` and + ``hydrate_and_load``; any other event gets its sequence number + echoed. """ name = event["name"] - if name in {HYDRATE_EVENT, ON_LOAD_EVENT}: - delta = {ROOT_STATE: {HYDRATED_VAR: name == ON_LOAD_EVENT}} + if name in {HYDRATE_EVENT, ON_LOAD_EVENT, HYDRATE_AND_LOAD_EVENT}: + delta = {ROOT_STATE: {HYDRATED_VAR: name != HYDRATE_EVENT}} else: delta = {self.delta_key: {self.seq_var: event["payload"]["seq"]}} - return emit_frame("event", {"delta": delta, "events": []}) + return self.codec.emit(EVENT, {"delta": delta, "events": []}) def _serve_process( diff --git a/packages/reflex-bench/src/reflex_bench/drivers/events.py b/packages/reflex-bench/src/reflex_bench/drivers/events.py index 3ca1670373b..400ea6c13c4 100644 --- a/packages/reflex-bench/src/reflex_bench/drivers/events.py +++ b/packages/reflex-bench/src/reflex_bench/drivers/events.py @@ -1,11 +1,25 @@ -"""An open-loop event generator that speaks reflex's socket.io protocol over ``websockets``. +"""An open-loop event generator that speaks reflex's event websocket over ``websockets``. It measures what a user of a reflex app waits for: the time from the moment an event *should* have been sent until the state delta that answers it arrives. -**Protocol.** reflex mounts python-socketio at ``/_event`` with the websocket -transport only (engine.io 4, socket.io 5), from 0.8.23 to HEAD. One session is -one websocket, one token and one state:: +**Protocols.** One session is one websocket, one token and one state. reflex's +default transport is a plain JSON protocol at ``/_event``; releases before it, +from 0.8.23 on, and apps with ``transport="socketio"`` mount python-socketio at +``/_event/`` with the websocket transport only (engine.io 4, socket.io 5). A +:class:`Codec` frames each; :func:`detect_protocol` tells which one a backend +speaks, as a Socket.IO backend refuses the upgrade of ``/_event``. The plain +protocol:: + + client GET /_event?token= + client ["_connect",{"event":{"name":"...hydrate_and_load",...}}] the boot event + server ["_handshake",{"ping_interval":...,"protocol":2,...}] (a "new_token" may come first) + client ["event",{"name":...,"payload":...,"router_data":...,"token":...}] + server ["event",{"delta":{...},"events":[...],"final":...}] + server ["_ping"] client ["_pong"] + client closes the websocket (the server closes with a code) + +Socket.IO:: client GET /_event/?EIO=4&transport=websocket&token= server 0{"sid":...,"upgrades":[],"pingInterval":25000,"pingTimeout":...} @@ -16,11 +30,13 @@ server 2 client 3 engine.io ping and pong client 41/_event, leave the namespace -A session is primed like a page load, with ``hydrate`` and ``on_load_internal``, -until a delta sets ``is_hydrated``, then with the plan's :class:`LinkEvent` if -it has one (joining a shared state token), until a delta of the linked state -acknowledges it. Every event carries the token: 0.8.23 requires it, HEAD -ignores it. +A session is primed like a page load until a delta sets ``is_hydrated``: on the +plain protocol its connect frame carries ``hydrate_and_load`` as the boot event, +as the frontend's does on a reconnect (an empty payload: the full state); on +Socket.IO it sends ``hydrate`` and ``on_load_internal`` once joined. Then it +sends the plan's :class:`LinkEvent` if it has one (joining a shared state +token), until a delta of the linked state acknowledges it. Every event carries +the token: 0.8.23 requires it, later releases ignore it. **Open, closed and fan-out loop.** In the open loop each session sends on a fixed schedule whatever the server does, late events are sent at once (never @@ -44,7 +60,8 @@ session is primed, and merges what they measured into a :class:`LoadResult`. :func:`open_sessions` primes sessions on the caller's loop without a load, for measurements of idle sessions; :func:`hold_sessions` does the same in a spawned -process and keeps them open until told to close. +process and keeps them open until told to close. Each pool of sessions detects +the protocol once, unless its :class:`Endpoint` names it. """ from __future__ import annotations @@ -68,6 +85,7 @@ import traceback import urllib.parse import uuid +from abc import ABC, abstractmethod from collections.abc import ( AsyncIterator, Awaitable, @@ -80,10 +98,10 @@ from dataclasses import dataclass from multiprocessing.connection import Connection from multiprocessing.process import BaseProcess -from typing import Any, Literal +from typing import Any, ClassVar, Literal from websockets.asyncio.client import ClientConnection, connect -from websockets.exceptions import ConnectionClosed +from websockets.exceptions import ConnectionClosed, InvalidStatus from reflex_bench import stats @@ -91,22 +109,39 @@ import resource Mode = Literal["open", "closed", "fanout"] +WireProtocol = Literal["websocket", "socketio"] NAMESPACE = "/_event" ROOT_STATE = "reflex___state____state" HYDRATE_EVENT = f"{ROOT_STATE}.hydrate" +HYDRATE_AND_LOAD_EVENT = f"{ROOT_STATE}.hydrate_and_load" ON_LOAD_EVENT = ( f"{ROOT_STATE}.reflex___state____on_load_internal_state.on_load_internal" ) HYDRATED_VAR = "is_hydrated_rx_state_" -OPEN_PREFIX = "0" -CLOSE = "1" -PING = "2" -PONG = "3" -CONNECT_FRAME = f"40{NAMESPACE}," -DISCONNECT_FRAME = f"41{NAMESPACE}," -EVENT_PREFIX = f"42{NAMESPACE}," -CONNECT_ERROR_PREFIX = f"44{NAMESPACE}," +# Message names of the plain protocol (reflex.event_namespace). +EVENT = "event" +CONNECT = "_connect" +HANDSHAKE = "_handshake" +PING = "_ping" +PONG = "_pong" +# The key of the boot event in the connect frame (CompileVars.CONNECT_AUTH_EVENT). +BOOT_KEY = "event" +# Socket.IO packets the plain protocol has no message for decode to these names. +OPENED = "_sio_open" +REFUSED = "_sio_refused" +DISCONNECTED = "_sio_disconnect" +CONTROL = frozenset({CONNECT, HANDSHAKE, PING, PONG, OPENED, REFUSED, DISCONNECTED}) +# Socket.IO packets on reflex's namespace. +SIO_OPEN_PREFIX = "0" +SIO_CLOSE = "1" +SIO_PING = "2" +SIO_PONG = "3" +SIO_CONNECT_FRAME = f"40{NAMESPACE}," +SIO_DISCONNECT_FRAME = f"41{NAMESPACE}," +SIO_EVENT_PREFIX = f"42{NAMESPACE}," +SIO_CONNECT_ERROR_PREFIX = f"44{NAMESPACE}," +_COMPACT = (",", ":") PRIME_TIMEOUT_S = 30.0 PRIME_CONCURRENCY = 64 @@ -138,27 +173,15 @@ class LoadError(RuntimeError): """The load did not run: sessions could not start, a generator process failed, or it was stopped.""" -def emit_frame(*args: Any) -> str: - """Encode a socket.io event on reflex's namespace. - - Args: - *args: The event name and its arguments. - - Returns: - ``42/_event,[name, ...]``. - """ - return EVENT_PREFIX + json.dumps(list(args), separators=(",", ":")) - - -def event_frame( +def event( name: str, payload: Mapping[str, Any] | None, *, token: str, pathname: str = "/", query: Mapping[str, str] | None = None, -) -> str: - """Encode an event as the reflex frontend sends it. +) -> dict[str, Any]: + """Build an event as the reflex frontend sends it. Args: name: The full event handler name. @@ -169,36 +192,335 @@ def event_frame( ``/item/42`` of a ``/item/[item_id]`` page. Returns: - The frame. + The event. """ router_data = {"pathname": pathname, "asPath": pathname, "query": dict(query or {})} - return emit_frame( - "event", - {"name": name, "payload": payload, "router_data": router_data, "token": token}, - ) + return { + "name": name, + "payload": payload, + "router_data": router_data, + "token": token, + } + +def _message(text: str) -> list[Any] | None: + """Decode a JSON message ``[name, *args]``. -def event_url(backend_url: str, token: str) -> str: - """Build the websocket URL of a session. + Args: + text: The JSON. + + Returns: + The message, or ``None`` when the text is not one. + """ + try: + message = json.loads(text) + except ValueError: + return None + if isinstance(message, list) and message and isinstance(message[0], str): + return message + return None + + +def _ws_url(backend_url: str, path: str, query: Mapping[str, Any]) -> str: + """Build a websocket URL on a backend. Args: backend_url: The backend's URL, e.g. ``http://localhost:8000``. - token: The session's token. + path: The path below the backend's. + query: The query parameters. Returns: - E.g. ``ws://localhost:8000/_event/?EIO=4&transport=websocket&token=``. + The URL, ``wss`` for an ``https`` backend. """ parts = urllib.parse.urlsplit(backend_url) - query = urllib.parse.urlencode({"EIO": 4, "transport": "websocket", "token": token}) return urllib.parse.urlunsplit(( "wss" if parts.scheme == "https" else "ws", parts.netloc, - f"{parts.path.rstrip('/')}{NAMESPACE}/", - query, + f"{parts.path.rstrip('/')}{path}", + urllib.parse.urlencode(query), "", )) +class Codec(ABC): + """Frames reflex's event websocket in one protocol. + + Received frames decode to messages ``[name, *args]`` named as in the plain + protocol; Socket.IO's open, refusal and disconnect packets, which it has + no message for, decode to :data:`OPENED`, :data:`REFUSED` and + :data:`DISCONNECTED`. + """ + + protocol: ClassVar[WireProtocol] + # Whether the server greets with an engine.io open before the connect. + greets: ClassVar[bool] + # The hydration events to send once the server acknowledged the connect. + load_events: ClassVar[tuple[str, ...]] + # The answer to a ping. + pong: ClassVar[str] + # The frame that leaves the session before the websocket closes, if any. + leave: ClassVar[str | None] + + @abstractmethod + def url(self, backend_url: str, token: str) -> str: + """Build the websocket URL of a session. + + Args: + backend_url: The backend's URL, e.g. ``http://localhost:8000``. + token: The session's token. + + Returns: + The URL. + """ + + @abstractmethod + def emit(self, *args: Any) -> str: + """Encode a message. + + Args: + *args: The message name and its arguments. + + Returns: + The frame. + """ + + @abstractmethod + def parse(self, message: str | bytes) -> list[Any] | None: + """Decode a received frame. + + Args: + message: The frame. + + Returns: + The message ``[name, *args]``, or ``None`` for a frame the + protocol does not have. + """ + + @abstractmethod + def connect_frame( + self, *, token: str, pathname: str, query: Mapping[str, str] | None = None + ) -> str: + """Encode the frame that opens a session. + + Args: + token: The session's token. + pathname: The page route the session loads. + query: The route's parameters. + + Returns: + The frame; the plain protocol's carries the hydration as its boot + event. + """ + + def event_frame( + self, + name: str, + payload: Mapping[str, Any] | None, + *, + token: str, + pathname: str = "/", + query: Mapping[str, str] | None = None, + ) -> str: + """Encode an event as the reflex frontend sends it. + + Args: + name: The full event handler name. + payload: The handler's arguments. + token: The session's token. + pathname: The page route the event comes from. + query: The route's parameters. + + Returns: + The frame. + """ + return self.emit( + EVENT, event(name, payload, token=token, pathname=pathname, query=query) + ) + + +class WebsocketCodec(Codec): + """reflex's plain protocol: every frame is a JSON message ``[name, *args]``.""" + + protocol = "websocket" + greets = False + load_events = () + pong = json.dumps([PONG]) + leave = None + + def url(self, backend_url: str, token: str) -> str: + """Build the websocket URL of a session. + + Args: + backend_url: The backend's URL, e.g. ``http://localhost:8000``. + token: The session's token. + + Returns: + E.g. ``ws://localhost:8000/_event?token=``. + """ + return _ws_url(backend_url, NAMESPACE, {"token": token}) + + def emit(self, *args: Any) -> str: + """Encode a message. + + Args: + *args: The message name and its arguments. + + Returns: + ``[name, ...]``. + """ + return json.dumps(args, separators=_COMPACT) + + def parse(self, message: str | bytes) -> list[Any] | None: + """Decode a received frame. + + Args: + message: The frame. + + Returns: + The message, or ``None`` for a binary frame or one that is not a + message. + """ + return _message(message) if isinstance(message, str) else None + + def connect_frame( + self, *, token: str, pathname: str, query: Mapping[str, str] | None = None + ) -> str: + """Encode the connect frame, with ``hydrate_and_load`` as its boot event. + + Args: + token: The session's token. + pathname: The page route the session loads. + query: The route's parameters. + + Returns: + ``["_connect",{"event":{...}}]``. + """ + boot = event( + HYDRATE_AND_LOAD_EVENT, {}, token=token, pathname=pathname, query=query + ) + return self.emit(CONNECT, {BOOT_KEY: boot}) + + +class SocketIOCodec(Codec): + """python-socketio on reflex's namespace: engine.io 4 and socket.io 5 packets.""" + + protocol = "socketio" + greets = True + load_events = (HYDRATE_EVENT, ON_LOAD_EVENT) + pong = SIO_PONG + leave = SIO_DISCONNECT_FRAME + + def url(self, backend_url: str, token: str) -> str: + """Build the websocket URL of a session. + + Args: + backend_url: The backend's URL, e.g. ``http://localhost:8000``. + token: The session's token. + + Returns: + E.g. ``ws://localhost:8000/_event/?EIO=4&transport=websocket&token=``. + """ + query = {"EIO": 4, "transport": "websocket", "token": token} + return _ws_url(backend_url, f"{NAMESPACE}/", query) + + def emit(self, *args: Any) -> str: + """Encode a socket.io event on reflex's namespace. + + Args: + *args: The event name and its arguments. + + Returns: + ``42/_event,[name, ...]``. + """ + return SIO_EVENT_PREFIX + json.dumps(args, separators=_COMPACT) + + def parse(self, message: str | bytes) -> list[Any] | None: + """Decode a received packet. + + Args: + message: The frame. + + Returns: + A socket.io event's arguments; :data:`PING` for an engine.io ping, + :data:`HANDSHAKE` for the namespace ack, :data:`OPENED`, + :data:`REFUSED` and :data:`DISCONNECTED` with the packet's data; + ``None`` for anything else. + """ + if not isinstance(message, str): + return None + if message.startswith(SIO_EVENT_PREFIX): + return _message(message[len(SIO_EVENT_PREFIX) :]) + if message == SIO_PING: + return [PING] + if message.startswith(SIO_CONNECT_FRAME): + return [HANDSHAKE, message[len(SIO_CONNECT_FRAME) :]] + if message in {SIO_DISCONNECT_FRAME, SIO_CLOSE}: + return [DISCONNECTED] + if message.startswith(SIO_CONNECT_ERROR_PREFIX): + return [REFUSED, message[len(SIO_CONNECT_ERROR_PREFIX) :]] + if message.startswith(SIO_OPEN_PREFIX): + return [OPENED, message[len(SIO_OPEN_PREFIX) :]] + return None + + def connect_frame( + self, *, token: str, pathname: str, query: Mapping[str, str] | None = None + ) -> str: + """Encode the namespace join; the hydration events follow its ack. + + Args: + token: The session's token. + pathname: The page route the session loads. + query: The route's parameters. + + Returns: + ``40/_event,``. + """ + return SIO_CONNECT_FRAME + + +CODECS: dict[WireProtocol, Codec] = { + codec.protocol: codec for codec in (WebsocketCodec(), SocketIOCodec()) +} + + +async def detect_protocol(backend_url: str) -> WireProtocol: + """Find the protocol of a backend's event websocket. + + The plain protocol's endpoint accepts the websocket before the client + sends anything; a Socket.IO backend refuses the upgrade of ``/_event``, + its mount being ``/_event/``. + + Args: + backend_url: The backend's URL, e.g. ``http://localhost:8000``. + + Returns: + ``websocket`` or ``socketio``. + """ + try: + async with connect( + CODECS["websocket"].url(backend_url, str(uuid.uuid4())), + proxy=None, + open_timeout=PRIME_TIMEOUT_S, + ping_interval=None, + close_timeout=1, + ): + return "websocket" + except InvalidStatus: + return "socketio" + + +async def resolve_codec(endpoint: Endpoint) -> Codec: + """Find the codec of an endpoint's protocol, detecting it if the endpoint names none. + + Args: + endpoint: The endpoint. + + Returns: + The codec. + """ + return CODECS[endpoint.protocol or await detect_protocol(endpoint.backend_url)] + + def seq_payload(seq: int) -> dict[str, int]: """Build the payload of the playground's ``set_seq*`` handlers. @@ -300,16 +622,16 @@ class LinkEvent: class Endpoint: """Where sessions connect: a reflex backend and the page they load. - The protocol is the same from 0.8.23 to HEAD (every event carries the - token), so nothing here depends on the reflex version. - Attributes: backend_url: The backend's URL, e.g. ``http://localhost:8000``. pathname: The page route the sessions hydrate and send events from. + protocol: The protocol of its event websocket, or ``None`` to detect + it (:func:`detect_protocol`) once per pool of sessions. """ backend_url: str pathname: str = "/" + protocol: WireProtocol | None = None @dataclass(frozen=True, kw_only=True) @@ -524,11 +846,12 @@ class _Frames: """Encodes a session's events; only the payload changes between them.""" def __init__( - self, shape: EventShape, token: str, pathname: str, index: int + self, codec: Codec, shape: EventShape, token: str, pathname: str, index: int ) -> None: """Split the event frame around its payload. Args: + codec: The protocol's codec. shape: The event. token: The session's token. pathname: The page route. @@ -537,7 +860,7 @@ def __init__( """ self._payload = shape.payload self._client = {"client": index} if shape.client_var is not None else {} - template = event_frame(shape.name, None, token=token, pathname=pathname) + template = codec.event_frame(shape.name, None, token=token, pathname=pathname) head, marker, self._tail = template.partition('"payload":null') self._head = head + marker.removesuffix("null") @@ -584,7 +907,8 @@ def __init__( self.shape = shape self.link = link self.token = str(uuid.uuid4()) - self.frames = self._frames() + self.codec: Codec | None = None + self.frames: _Frames | None = None self.ws: ClientConnection | None = None self.error: str | None = None self.prime_ns: int | None = None @@ -610,7 +934,10 @@ def _frames(self) -> _Frames | None: """ if self.shape is None: return None - return _Frames(self.shape, self.token, self.endpoint.pathname, self.index) + assert self.codec is not None + return _Frames( + self.codec, self.shape, self.token, self.endpoint.pathname, self.index + ) def _fail(self, reason: str) -> None: """Mark the session failed and wake whatever waits on it. @@ -624,74 +951,82 @@ def _fail(self, reason: str) -> None: self._linked.set() self._idle.set() - async def prime(self, gate: asyncio.Semaphore) -> None: - """Connect, join the namespace, hydrate like a page load and link; failures are recorded. + async def prime(self, gate: asyncio.Semaphore, codec: Codec) -> None: + """Connect, open the session, hydrate like a page load and link; failures are recorded. Args: gate: Limits how many sessions connect at once. + codec: The endpoint's protocol. """ + self.codec = codec + self.frames = self._frames() async with gate: try: - await asyncio.wait_for(self._open(), PRIME_TIMEOUT_S) + await asyncio.wait_for(self._open(codec), PRIME_TIMEOUT_S) except TimeoutError: step = "linked" if self._hydrated.is_set() else "hydrated" self._fail(f"not {step} within {PRIME_TIMEOUT_S:g} s") except Exception as exc: self._fail(f"could not connect: {type(exc).__name__}: {exc}") - async def _open(self) -> None: - """Run the handshake, the hydration events and the link event. + async def _open(self, codec: Codec) -> None: + """Run the handshake, the hydration and the link event. + + Args: + codec: The endpoint's protocol. Raises: - ProtocolError: When the server does not open or refuses the namespace. + ProtocolError: When the server does not open or refuses the session. ConnectionError: When the session failed while hydrating or linking. """ started = time.perf_counter_ns() ws = self.ws = await connect( - event_url(self.endpoint.backend_url, self.token), + codec.url(self.endpoint.backend_url, self.token), proxy=None, open_timeout=None, max_size=MAX_FRAME_BYTES, ping_interval=None, close_timeout=1, ) - opened = await ws.recv() - if not (isinstance(opened, str) and opened.startswith(OPEN_PREFIX)): - msg = f"the server did not open an engine.io session: {opened[:40]!r}" - raise ProtocolError(msg) - await ws.send(CONNECT_FRAME) + pathname = self.endpoint.pathname + if codec.greets: + greeting = await ws.recv() + opened = codec.parse(greeting) + if opened is None or opened[0] != OPENED: + msg = f"the server did not open an engine.io session: {greeting[:40]!r}" + raise ProtocolError(msg) + await ws.send(codec.connect_frame(token=self.token, pathname=pathname)) while True: message = await ws.recv() - if isinstance(message, str): - if message.startswith(EVENT_PREFIX): - # python-socketio sends events of the connect handler first. - self._on_emit(json.loads(message[len(EVENT_PREFIX) :]), message, 0) - continue - if message.startswith(CONNECT_FRAME): - break - if message.startswith(CONNECT_ERROR_PREFIX): - reason = message[len(CONNECT_ERROR_PREFIX) :] - msg = f"the server refused {NAMESPACE}: {reason}" - raise ProtocolError(msg) - if message == PING: - await ws.send(PONG) - continue - msg = f"unexpected frame in the handshake: {message[:40]!r}" - raise ProtocolError(msg) - self._reader = asyncio.create_task(self._read()) - pathname = self.endpoint.pathname - await ws.send( - event_frame(HYDRATE_EVENT, {}, token=self.token, pathname=pathname) - ) - await ws.send( - event_frame(ON_LOAD_EVENT, {}, token=self.token, pathname=pathname) - ) + args = codec.parse(message) + if args is None: + msg = f"unexpected frame in the handshake: {message[:40]!r}" + raise ProtocolError(msg) + name = args[0] + if name == HANDSHAKE: + break + if name == PING: + await ws.send(codec.pong) + elif name == REFUSED: + msg = f"the server refused {NAMESPACE}: {args[1]}" + raise ProtocolError(msg) + elif name in CONTROL: + msg = f"unexpected frame in the handshake: {message[:40]!r}" + raise ProtocolError(msg) + else: + # Events of the connect come first, e.g. a new token. + self._on_emit(args, message, 0) + self._reader = asyncio.create_task(self._read(ws, codec)) + for name in codec.load_events: + await ws.send( + codec.event_frame(name, {}, token=self.token, pathname=pathname) + ) await self._hydrated.wait() if self.error is not None: raise ConnectionError(self.error) if (link := self.link) is not None: await ws.send( - event_frame( + codec.event_frame( link.name, link.payload, token=self.token, pathname=pathname ) ) @@ -700,43 +1035,46 @@ async def _open(self) -> None: raise ConnectionError(self.error) self.prime_ns = time.perf_counter_ns() - started - async def _read(self) -> None: - """Receive frames until the websocket closes: answers, pings, tokens and disconnects.""" - ws = self.ws - assert ws is not None - start = len(EVENT_PREFIX) + async def _read(self, ws: ClientConnection, codec: Codec) -> None: + """Receive frames until the websocket closes: answers, pings, tokens and disconnects. + + Args: + ws: The websocket. + codec: Its protocol. + """ + parse = codec.parse try: async for message in ws: now = time.perf_counter_ns() - if isinstance(message, str) and message.startswith(EVENT_PREFIX): - self._on_emit(json.loads(message[start:]), message, now) - elif message == PING: - await ws.send(PONG) - elif message in {DISCONNECT_FRAME, CLOSE}: + args = parse(message) + name = None if args is None else args[0] + if name == PING: + await ws.send(codec.pong) + elif name == DISCONNECTED: self._fail(f"the server disconnected the session ({message!r})") return - else: + elif args is None or name in CONTROL: self._fail(f"unexpected frame: {message[:40]!r}") return + else: + self._on_emit(args, message, now) except ConnectionClosed as exc: if not self._closing: self._fail(f"the websocket closed: {exc}") - except ValueError as exc: - self._fail(f"{type(exc).__name__}: {exc}") else: if not self._closing: self._fail("the websocket closed") - def _on_emit(self, args: list[Any], frame: str, now: int) -> None: - """Handle a socket.io event from the server. + def _on_emit(self, args: list[Any], frame: str | bytes, now: int) -> None: + """Handle a message from the server. Args: - args: The event name and arguments. + args: The message name and arguments. frame: The raw frame. now: When it arrived. """ name = args[0] - if name == "event": + if name == EVENT: delta = args[1].get("delta") if not delta: return @@ -750,7 +1088,7 @@ def _on_emit(self, args: list[Any], frame: str, now: int) -> None: or state.get(shape.client_var) == self.index ) ): - if self.reply_frame is None: + if self.reply_frame is None and isinstance(frame, str): self.reply_frame = frame if self.fanout is None: self._answer(seq, now) @@ -922,13 +1260,14 @@ async def _drain(self, until: int) -> None: await asyncio.wait_for(self._idle.wait(), remaining) async def close(self) -> None: - """Leave the namespace and close the websocket.""" + """Leave the session and close the websocket.""" self._closing = True - ws = self.ws + ws, codec = self.ws, self.codec if ws is not None: - if self.error is None: + assert codec is not None + if self.error is None and codec.leave is not None: with contextlib.suppress(ConnectionClosed): - await ws.send(DISCONNECT_FRAME) + await ws.send(codec.leave) await ws.close() if self._reader is not None: self._reader.cancel() @@ -959,6 +1298,7 @@ def __init__( shape: The event they send, or ``None`` for idle sessions. link: The event they send once hydrated, or ``None``. """ + self.endpoint = endpoint self.sessions = [_Session(index, endpoint, shape, link) for index in indices] @property @@ -971,12 +1311,18 @@ def errors(self) -> list[str]: return [f"session {s.index}: {s.error}" for s in self.sessions if s.error] async def prime(self) -> None: - """Connect, hydrate and link every session, up to ``PRIME_CONCURRENCY`` at a time.""" + """Find the protocol, then connect, hydrate and link every session, up to ``PRIME_CONCURRENCY`` at a time.""" + try: + codec = await resolve_codec(self.endpoint) + except Exception as exc: + for session in self.sessions: + session._fail(f"could not connect: {type(exc).__name__}: {exc}") + return gate = asyncio.Semaphore(PRIME_CONCURRENCY) - await asyncio.gather(*(session.prime(gate) for session in self.sessions)) + await asyncio.gather(*(session.prime(gate, codec) for session in self.sessions)) async def close(self) -> None: - """Leave the namespace and close every websocket.""" + """Leave the sessions and close every websocket.""" await asyncio.gather(*(session.close() for session in self.sessions)) @@ -1623,8 +1969,8 @@ class SessionHold: """Idle sessions in a spawned process: connected, hydrated and answering pings. The sessions send no events after priming, like browser tabs left open. - :meth:`ready` waits until every session is primed, :meth:`close` leaves the - namespace and closes each websocket, and :meth:`kill` ends the process from + :meth:`ready` waits until every session is primed, :meth:`close` leaves + each session and closes its websocket, and :meth:`kill` ends the process from any thread (a benchmark's ``conclude`` after a timed-out ``sample``). A hold whose parent exits closes its sessions and ends. """ @@ -1683,7 +2029,7 @@ def ready(self) -> None: raise LoadError(msg) def close(self) -> list[str]: - """Leave the namespace and close every websocket, then end the process. + """Leave every session and close its websocket, then end the process. Returns: Why sessions failed while they were held, e.g. because the server diff --git a/packages/reflex-bench/src/reflex_bench/suites/wire.py b/packages/reflex-bench/src/reflex_bench/suites/wire.py index 15d7d15c98b..b90b408c7ed 100644 --- a/packages/reflex-bench/src/reflex_bench/suites/wire.py +++ b/packages/reflex-bench/src/reflex_bench/suites/wire.py @@ -6,7 +6,10 @@ payload bytes of every websocket frame it sends and receives: - ``wire.hydrate``: from the connect until the delta that sets ``is_hydrated``, - the page load of the playground's index route. + the page load of the playground's index route. On the plain protocol the + connect frame carries ``hydrate_and_load`` with an empty payload, as the + frontend's does on a reconnect: the full state, not the difference from the + compiled defaults that a browser's first load asks for. - ``wire.event[shape=...]``: after that, one event of a playground shape (:data:`~reflex_bench.suites.events.SHAPES`): the request frame, and the reply until the delta that echoes its sequence number, every frame in @@ -25,7 +28,10 @@ as the control that changes no collection. Every handler also sets ``last_seq``, so the deltas differ only by the collection they carry. -A byte count is a text frame's UTF-8 length; the HTTP handshake that opens the +The session speaks whichever protocol the backend does +(:func:`~reflex_bench.drivers.events.detect_protocol`), so a comparison across +reflex's switch from Socket.IO to the plain protocol includes the framing. A +byte count is a text frame's UTF-8 length; the HTTP handshake that opens the websocket is not a frame. Sizes are deterministic for a given app and reflex version, so every metric is exact and a sample is one run. The playground is the fixture of ``hydrate`` and ``event``; the generated app's source hash keys @@ -52,25 +58,23 @@ from reflex_bench.context import Context from reflex_bench.drivers.app_process import AppProcess, run_cli from reflex_bench.drivers.events import ( - CLOSE, - CONNECT_ERROR_PREFIX, - CONNECT_FRAME, - DISCONNECT_FRAME, - EVENT_PREFIX, - HYDRATE_EVENT, + CODECS, + DISCONNECTED, + EVENT, + HANDSHAKE, HYDRATED_VAR, MAX_FRAME_BYTES, ON_LOAD_EVENT, - OPEN_PREFIX, + OPENED, PING, - PONG, + REFUSED, ROOT_STATE, + Codec, Endpoint, EventShape, ProtocolError, - event_frame, event_loop, - event_url, + resolve_codec, seq_payload, ) from reflex_bench.fixtures import describe_fixture @@ -143,10 +147,12 @@ class Hydration: """What a page load moved, from the connect until ``is_hydrated``. Attributes: - sent_bytes: Frame bytes sent: the namespace join and the hydration - events. - received_bytes: Frame bytes received: the engine.io open, the namespace - ack, a ``new_token`` if any, and the deltas. + sent_bytes: Frame bytes sent: the connect frame, which carries the + hydration as its boot event, or on Socket.IO the namespace join + and the hydration events. + received_bytes: Frame bytes received: the handshake (on Socket.IO the + engine.io open and the namespace ack), a ``new_token`` if any, and + the deltas. sent_frames: The frames sent. received_frames: The frames received. largest_frame_bytes: The largest frame received. @@ -189,15 +195,23 @@ class Exchange: class WireSession: """One browser tab on a metered websocket: it hydrates, then sends events one at a time.""" - def __init__(self, ws: Transport, token: str, pathname: str = "/") -> None: + def __init__( + self, + ws: Transport, + token: str, + pathname: str = "/", + codec: Codec = CODECS["websocket"], + ) -> None: """Wrap a connected websocket; nothing is sent yet. Args: ws: The websocket, connected with ``token`` in its URL. token: The session's token. pathname: The page route the session loads. + codec: The protocol the websocket speaks. """ self._ws = ws + self.codec = codec self.token = token self.pathname = pathname self.query: dict[str, str] = {} @@ -221,61 +235,59 @@ async def _send(self, frame: str) -> int: await self._ws.send(frame) return size - async def _receive(self) -> tuple[str | bytes, int, list[Any] | None]: + async def _receive(self) -> tuple[str | bytes, int, list[Any]]: """Receive a frame and count it; pings are answered, disconnects raise. A keepalive ping and its pong are timing, not payload: neither counts. Returns: - The frame, its bytes and, for a socket.io event, its arguments. + The frame, its bytes and the message it decodes to. Raises: ProtocolError: When the server disconnects the session, refuses - the namespace, asks the page to reload or sends a frame the - session does not speak. + it, asks the page to reload or sends a frame the session does + not speak. """ + codec = self.codec while True: message = await self._ws.recv() - if message == PING: - await self._ws.send(PONG) + args = codec.parse(message) + if args is not None and args[0] == PING: + await self._ws.send(codec.pong) continue size = frame_bytes(message) self.received_bytes += size self.received_frames += 1 - if isinstance(message, str): - if message.startswith(EVENT_PREFIX): - args = json.loads(message[len(EVENT_PREFIX) :]) - if args[0] == "new_token": - # The token was in use; reflex hands out a new one. - self.token = args[1] - elif args[0] == "reload": - msg = "the server asked the page to reload" - raise ProtocolError(msg) - return message, size, args - if message in {DISCONNECT_FRAME, CLOSE}: - msg = f"the server disconnected the session ({message!r})" - raise ProtocolError(msg) - if message.startswith(CONNECT_ERROR_PREFIX): - reason = message[len(CONNECT_ERROR_PREFIX) :] - msg = f"the server refused the namespace: {reason}" - raise ProtocolError(msg) - if message.startswith((OPEN_PREFIX, CONNECT_FRAME)): - return message, size, None - msg = f"unexpected frame: {message[:40]!r}" - raise ProtocolError(msg) + if args is None: + msg = f"unexpected frame: {message[:40]!r}" + raise ProtocolError(msg) + name = args[0] + if name == "new_token": + # The token was in use; reflex hands out a new one. + self.token = args[1] + elif name == "reload": + msg = "the server asked the page to reload" + raise ProtocolError(msg) + elif name == DISCONNECTED: + msg = f"the server disconnected the session ({message!r})" + raise ProtocolError(msg) + elif name == REFUSED: + msg = f"the server refused the namespace: {args[1]}" + raise ProtocolError(msg) + return message, size, args @staticmethod - def _delta(args: list[Any] | None, sizes: dict[str, int]) -> dict[str, Any]: - """Take the delta of an event frame, adding its substates' bytes. + def _delta(args: list[Any], sizes: dict[str, int]) -> dict[str, Any]: + """Take the delta of an event message, adding its substates' bytes. Args: - args: The frame's arguments, or ``None`` for a control frame. + args: The message. sizes: Bytes per substate, added to. Returns: - The delta, empty when the frame carries none. + The delta, empty when the message carries none. """ - if args is None or args[0] != "event": + if args[0] != EVENT: return {} delta = args[1].get("delta") or {} for substate, values in delta.items(): @@ -285,28 +297,36 @@ def _delta(args: list[Any] | None, sizes: dict[str, int]) -> dict[str, Any]: return delta async def hydrate(self) -> Hydration: - """Join the namespace and hydrate like a page load. + """Open the session and hydrate it like a page load. Returns: What the page load moved. Raises: - ProtocolError: When the server does not open an engine.io session. + ProtocolError: When a Socket.IO server does not open an engine.io + session. """ sent0, sent_frames0 = self.sent_bytes, self.sent_frames received0, received_frames0 = self.received_bytes, self.received_frames - opened, largest, _ = await self._receive() - if not (isinstance(opened, str) and opened.startswith(OPEN_PREFIX)): - msg = f"the server did not open an engine.io session: {opened[:40]!r}" - raise ProtocolError(msg) - await self._send(CONNECT_FRAME) + codec = self.codec + largest = 0 + if codec.greets: + greeting, largest, args = await self._receive() + if args[0] != OPENED: + msg = f"the server did not open an engine.io session: {greeting[:40]!r}" + raise ProtocolError(msg) + await self._send( + codec.connect_frame( + token=self.token, pathname=self.pathname, query=self.query + ) + ) sizes: dict[str, int] = {} while True: - message, size, args = await self._receive() + _, size, args = await self._receive() largest = max(largest, size) - if isinstance(message, str) and message.startswith(CONNECT_FRAME): - # Joined; a new_token, when the server sends one, came before. - for name in (HYDRATE_EVENT, ON_LOAD_EVENT): + if args[0] == HANDSHAKE: + # Opened; a new_token, when the server sends one, came before. + for name in codec.load_events: await self._send(self._event(name, {})) continue delta = self._delta(args, sizes) @@ -331,7 +351,7 @@ def _event(self, name: str, payload: dict[str, Any]) -> str: Returns: The frame. """ - return event_frame( + return self.codec.event_frame( name, payload, token=self.token, pathname=self.pathname, query=self.query ) @@ -409,7 +429,7 @@ async def _reply( def measure(endpoint: Endpoint, work: Callable[[WireSession], Awaitable[_T]]) -> _T: - """Connect one session, run work on it, and leave the namespace. + """Connect one session, run work on it, and leave the session. Args: endpoint: The backend and the page route. @@ -424,8 +444,9 @@ def measure(endpoint: Endpoint, work: Callable[[WireSession], Awaitable[_T]]) -> async def run() -> _T: token = str(uuid.uuid4()) + codec = await resolve_codec(endpoint) async with connect( - event_url(endpoint.backend_url, token), + codec.url(endpoint.backend_url, token), # A browser sends the page's origin; the router's page host and # full paths in the deltas come from it. additional_headers={"Origin": endpoint.backend_url}, @@ -435,15 +456,16 @@ async def run() -> _T: ping_interval=None, close_timeout=1, ) as ws: - session = WireSession(ws, token, endpoint.pathname) + session = WireSession(ws, token, endpoint.pathname, codec) try: return await asyncio.wait_for(work(session), WIRE_TIMEOUT_S) except TimeoutError: msg = f"the session did not finish within {WIRE_TIMEOUT_S:g} s" raise TimeoutError(msg) from None finally: - with contextlib.suppress(ConnectionClosed): - await ws.send(DISCONNECT_FRAME) + if codec.leave is not None: + with contextlib.suppress(ConnectionClosed): + await ws.send(codec.leave) loop = event_loop() try: diff --git a/reflex/app.py b/reflex/app.py index 93735c83f61..1328b5e50b3 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -39,7 +39,7 @@ 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 import console, memo_paths from reflex_base.utils.imports import ImportVar from reflex_base.utils.types import ASGIApp, Receive, Scope, Send from reflex_base.vars.dep_tracking import is_dependency @@ -109,11 +109,12 @@ # Define custom types. ComponentCallable = Callable[[], Component | tuple[Component, ...] | str | Var] + _SocketIOServer = AsyncServer else: ComponentCallable = Callable[[], Component | tuple[Component, ...] | str] - # Runtime placeholder so annotations resolve without the optional - # python-socketio dependency installed. - AsyncServer = Any + # Lets annotations resolve without the optional python-socketio package; + # the public name resolves lazily, through __getattr__. + _SocketIOServer = Any Reducer = Callable[[Event], Coroutine[Any, Any, StateUpdate]] @@ -442,7 +443,7 @@ class App(MiddlewareMixin, LifespanMixin): head_components: list[Component] = dataclasses.field(default_factory=list) - sio: AsyncServer | None = None + sio: _SocketIOServer | None = None html_lang: str | None = None @@ -532,27 +533,35 @@ def event_namespace(self) -> BaseEventNamespace | None: def register_channel(self, channel: Channel) -> None: """Register a side channel multiplexed onto the event websocket. + Channels run only over the plain WebSocket transport with state + enabled; elsewhere the channel is skipped with a warning. + Args: channel: The channel to serve. Raises: - RuntimeError: If the app cannot serve channels, or the name is taken. + RuntimeError: If the name is taken. """ name = type(channel).name + # Packages register their channels from a plugin, unaware of how the + # app is set up; where channels cannot run, the browser-side handle + # reports it, so the app keeps serving rather than failing to start. 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)." + logger.warning( + f"Channel {name!r} is not served: it needs the event websocket, " + "which exists only when state is enabled (rx.App(enable_state=True), " + "the default)." ) - raise RuntimeError(msg) + return 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".' + logger.warning( + f"Channel {name!r} is not served: it requires the plain WebSocket " + "transport; remove the transport setting in rxconfig.py or set " + 'transport="websocket".' ) - raise RuntimeError(msg) + return if name in self._channels: msg = f"A channel named {name!r} is already registered." raise RuntimeError(msg) @@ -642,19 +651,33 @@ def _setup_state(self) -> None: namespace = config.get_event_namespace() event_path = config.prepend_backend_path(str(constants.Endpoint.EVENT)) - 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": + if self.sio is not None and config.transport == "websocket": + if "transport" in config._non_default_attributes: msg = ( "A custom `sio` server requires the Socket.IO transport; " 'set transport="socketio" (or "polling") in rxconfig.py.' ) raise RuntimeError(msg) + # The default transport used to be Socket.IO over a websocket, so + # an app passing its own server never had to choose one. + console.deprecate( + feature_name="A custom `sio` server without a transport setting", + reason='set transport="socketio" in rxconfig.py; the default ' + "transport is now a plain WebSocket", + deprecation_version="0.10.0", + removal_version="1.0", + ) + # Persisted, so the compiled frontend and the workers use it too. + config._set_persistent(transport="socketio") + + if config.transport in ("socketio", "polling"): + # Legacy Socket.IO transport, kept behind the optional dependency. try: - from reflex.socketio_namespace import ( - EventNamespace, - create_socketio_app, - ) + from reflex.socketio_namespace import create_socketio_app + + # Through the module, so an app that replaced the class gets + # its own. + event_namespace_class = sys.modules[__name__].EventNamespace except ImportError as ex: msg = ( f"transport={config.transport!r} requires the python-socketio " @@ -665,7 +688,7 @@ def _setup_state(self) -> None: socket_app = create_socketio_app(self, config) # Create the event namespace and attach the main app. Not related to any paths. - self._event_namespace = EventNamespace(namespace, self) + self._event_namespace = event_namespace_class(namespace, self) # Register the event namespace with the socket. self.sio.register_namespace(self._event_namespace) # pyright: ignore[reportOptionalMemberAccess] @@ -676,9 +699,12 @@ def _setup_state(self) -> None: # 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) - ) + # Also with a trailing slash, the form the client dials: proxy + # rules written for Socket.IO route "/_event/*". + for path in (event_path, event_path.rstrip("/") + "/"): + self._api.router.routes.append( + WebSocketRoute(path, self._event_namespace.handle_websocket) + ) # Check the exception handlers self._validate_exception_handlers() @@ -2025,8 +2051,18 @@ async def health(_request: Request) -> JSONResponse: return JSONResponse(content=health_status, status_code=status_code) +# Socket.IO names this module exported before python-socketio became optional: +# name -> (module, attribute). +_SOCKETIO_EXPORTS = { + "AsyncNamespace": ("socketio", "AsyncNamespace"), + "AsyncServer": ("socketio", "AsyncServer"), + "EngineIOApp": ("socketio", "ASGIApp"), + "EventNamespace": ("reflex.socketio_namespace", "EventNamespace"), +} + + def __getattr__(name: str) -> Any: - """Resolve the optional Socket.IO EventNamespace export lazily. + """Resolve the optional Socket.IO exports lazily. Args: name: The attribute name. @@ -2035,11 +2071,20 @@ def __getattr__(name: str) -> Any: The resolved attribute. Raises: - AttributeError: If the attribute is unknown. + AttributeError: If the attribute is unknown, or needs python-socketio + and it is not installed. """ - if name == "EventNamespace": - from reflex.socketio_namespace import EventNamespace - - return EventNamespace - msg = f"module {__name__!r} has no attribute {name!r}" - raise AttributeError(msg) + if (target := _SOCKETIO_EXPORTS.get(name)) is None: + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) + module_name, attribute = target + try: + module = importlib.import_module(module_name) + except ImportError as ex: + # An AttributeError, so hasattr() and getattr() with a default work. + msg = ( + f"{__name__}.{name} needs the optional python-socketio package; " + "install it with: pip install 'reflex[socketio]'" + ) + raise AttributeError(msg) from ex + return getattr(module, attribute) diff --git a/reflex/channels.py b/reflex/channels.py index 03901b88f6b..e86a1405219 100644 --- a/reflex/channels.py +++ b/reflex/channels.py @@ -27,6 +27,8 @@ async def on_message(self, session, event, data, buffers): from collections.abc import Awaitable, Callable, Sequence from typing import Any, ClassVar +from typing_extensions import Buffer + # 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}") @@ -37,18 +39,31 @@ async def on_message(self, session, event, data, buffers): # 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. +# message from the transport event it is named after. Names starting with an +# underscore are reserved too: the channel protocol's own messages use them. RESERVED_EVENTS = frozenset({"connect", "disconnect", "error"}) # 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] + [Sequence[str], str, str, Any, Sequence[Buffer]], Awaitable[None] ] -def _validate_message(event: str, buffers: Sequence[bytes]) -> None: +def is_reserved_event(event: str) -> bool: + """Whether a message name is reserved for the handle's lifecycle or the protocol. + + Args: + event: The message name. + + Returns: + Whether an application message may not use it. + """ + return event in RESERVED_EVENTS or event.startswith("_") + + +def _validate_message(event: str, buffers: Sequence[Buffer]) -> None: """Check a message against what a frame may carry, before any is sent. Args: @@ -56,13 +71,18 @@ def _validate_message(event: str, buffers: Sequence[bytes]) -> None: buffers: The binary attachments. Raises: - ValueError: If the message name is reserved, or it carries more - attachments than a frame may hold. + ValueError: If the message name is not a usable string or is + reserved, or the message carries more attachments than a frame + may hold. """ - if event in RESERVED_EVENTS: + if not isinstance(event, str) or not event: + msg = f"Channel message name {event!r} is not a non-empty string." + raise ValueError(msg) + if is_reserved_event(event): msg = ( f"Channel message name {event!r} is reserved: the client-side " - "handle reports its own lifecycle under it." + "handle reports its own lifecycle under it, and names starting " + "with '_' belong to the channel protocol." ) raise ValueError(msg) if len(buffers) > MAX_MESSAGE_BUFFERS: @@ -116,7 +136,7 @@ class ChannelSession: open: bool = True async def send( - self, event: str, data: Any = None, buffers: Sequence[bytes] = () + self, event: str, data: Any = None, buffers: Sequence[Buffer] = () ) -> None: """Send one message to this client. @@ -126,8 +146,9 @@ async def send( 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. + ValueError: If the message name is not a usable string or is + reserved, or the message carries more attachments than a + frame may hold. """ _validate_message(event, buffers) await self._send((self.sid,), self.channel.name, event, data, buffers) @@ -226,7 +247,7 @@ async def send_to_room( room: str, event: str, data: Any = None, - buffers: Sequence[bytes] = (), + buffers: Sequence[Buffer] = (), ) -> None: """Send one message to every session in a room. @@ -237,15 +258,15 @@ async def send_to_room( 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. + ValueError: If the message name is not a usable string or is + reserved, or 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. """ + _validate_message(event, buffers) members = self._rooms.get(room) if not members: return - _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: @@ -259,7 +280,7 @@ async def send_to_token( client_token: str, event: str, data: Any = None, - buffers: Sequence[bytes] = (), + buffers: Sequence[Buffer] = (), ) -> bool: """Send one message to every session of a client token on this worker. @@ -273,15 +294,15 @@ async def send_to_token( 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. + ValueError: If the message name is not a usable string or is + reserved, or 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. """ + _validate_message(event, buffers) sessions = self._sessions.get(client_token) if not sessions: return False - _validate_message(event, buffers) await next(iter(sessions))._send( [session.sid for session in sessions], self.name, event, data, buffers ) diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index f422b87608c..da45198408c 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import collections import contextlib import dataclasses import json @@ -19,8 +20,9 @@ from reflex_base.environment import environment from reflex_base.event import _EVENT_FIELDS, Event from starlette.websockets import WebSocket, WebSocketDisconnect +from typing_extensions import Buffer -from reflex.channels import MAX_MESSAGE_BUFFERS, RESERVED_EVENTS, ChannelSession +from reflex.channels import MAX_MESSAGE_BUFFERS, ChannelSession, is_reserved_event from reflex.istate.data import SessionData from reflex.istate.manager.token import BaseStateToken from reflex.state import StateUpdate @@ -58,8 +60,11 @@ _PING = str(constants.SocketEvent.PING) _CLIENT_ERROR = str(constants.SocketEvent.CLIENT_ERROR) +# Frames carry no separator whitespace: it is pure overhead on the wire. +_COMPACT = (",", ":") + # The heartbeat frame is static; serialize it once. -_PING_FRAME = json.dumps([PING_MESSAGE]) +_PING_FRAME = json.dumps([PING_MESSAGE], separators=_COMPACT) # ASGI scope key holding the connection-scoped router_data. _STATIC_ROUTER_DATA = "_reflex_static_router_data" @@ -124,6 +129,26 @@ def connect_boot_event(auth: Any) -> Any: ) +def _dumps(obj: Any) -> str: + """Serialize a frame compactly, as text that is valid UTF-8. + + Args: + obj: The frame to serialize. + + Returns: + The JSON text. + """ + text = format.json_dumps(obj, separators=_COMPACT) + if not text.isascii(): + try: + text.encode() + except UnicodeEncodeError: + # An unpaired surrogate (a non-UTF-8 file name decodes to one) + # cannot travel as text; escape it, as ensure_ascii would. + return text.encode(errors="backslashreplace").decode() + return text + + def _parse_frame(text: str) -> Any: """Parse a text frame. @@ -135,9 +160,10 @@ def _parse_frame(text: str) -> Any: """ try: return json.loads(text) - except (json.JSONDecodeError, RecursionError): - # Deeply nested JSON exhausts the decoder's stack rather than failing - # to parse; both are just a malformed frame here. + except (ValueError, RecursionError): + # Besides a JSONDecodeError, the decoder raises a plain ValueError for + # an integer past the digit limit and exhausts its stack on deeply + # nested JSON; all are just a malformed frame here. return None @@ -180,7 +206,7 @@ def exceeds_message_limit(data: str | bytes, max_size: int) -> bool: def encode_channel_frame( - event: str, data: Any, channel: str, buffers: Sequence[bytes] + event: str, data: Any, channel: str, buffers: Sequence[Buffer] ) -> bytes: """Serialize a channel message carrying binary attachments. @@ -197,20 +223,17 @@ def encode_channel_frame( 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] + # In bytes: len() counts items, which is not the size of a typed array. + sizes = [memoryview(buffer).nbytes for buffer in buffers] + header = _dumps([event, data, channel, sizes]).encode() + parts: list[Buffer] = [len(header).to_bytes(4, "little"), header] offset = 4 + len(header) - for buffer in buffers: + for buffer, size in zip(buffers, sizes, strict=True): padding = -offset % _FRAME_ALIGNMENT if padding: parts.append(bytes(padding)) parts.append(buffer) - offset += padding + len(buffer) + offset += padding + size return b"".join(parts) @@ -269,6 +292,106 @@ def decode_channel_frame(frame: bytes) -> tuple[str, Any, str, list[bytes]]: return event, data, channel, buffers +# Size of the frames one connection may have waiting behind the one being +# written. Past it the client has stopped reading, and is dropped: it would +# otherwise buffer without bound until its heartbeat times out. +_MAX_SEND_BACKLOG = 16 * 1024 * 1024 + + +class _Connection: + """One client websocket, as the transport sends to and closes it. + + Sending only queues a frame for the connection's own writer task, so a + client that stops reading cannot stall the coroutines emitting to it. + Closing ends the session's receive loop, which closes the socket itself: + a server may not complete a close sent while a receive is pending on it. + """ + + def __init__(self, websocket: WebSocket): + """Start the connection's writer. + + Args: + websocket: The client websocket connection. + """ + self.websocket = websocket + # When a frame last arrived, for the heartbeat's liveness check. + self.last_received = time.monotonic() + # The code to close with, once the connection is ending. + self.close_code: int | None = None + self._frames: collections.deque[str | bytes] = collections.deque() + # Size of the frames waiting behind the one being written. + self._backlog = 0 + self._ready = asyncio.Event() + self._lifetime: asyncio.Timeout | None = None + self._writer = asyncio.create_task(self._write()) + + def send(self, frame: str | bytes) -> None: + """Queue a frame for the client, closing a connection that stopped reading. + + Args: + frame: The serialized text or binary frame. + """ + if self.close_code is not None: + return + frames = self._frames + frames.append(frame) + self._backlog += len(frame) + # One frame may exceed the bound; frames piling up behind it may not. + if self._backlog > _MAX_SEND_BACKLOG and len(frames) > 1: + logger.debug("Closing a session whose client stopped reading.") + self.close(1008) + return + self._ready.set() + + async def _write(self) -> None: + """Write the queued frames in order until the connection ends.""" + websocket = self.websocket + frames = self._frames + try: + while True: + await self._ready.wait() + self._ready.clear() + while frames: + frame = frames.popleft() + self._backlog -= len(frame) + if isinstance(frame, str): + await websocket.send_text(frame) + else: + await websocket.send_bytes(frame) + except Exception: + # The connection went away mid-write; its receive loop cleans up. + logger.debug("Failed to write to a client websocket.", exc_info=True) + + def attach(self, lifetime: asyncio.Timeout) -> None: + """Let close() end the receive loop running under a lifetime. + + Args: + lifetime: The timeout the session's receive loop runs under. + """ + self._lifetime = lifetime + if self.close_code is not None: + lifetime.reschedule(0) + + def close(self, code: int) -> None: + """End the session with a close code, dropping further sends. + + Args: + code: The websocket close code. + """ + if self.close_code is not None: + return + self.close_code = code + lifetime = self._lifetime + if lifetime is not None and not lifetime.expired(): + # Fires at the receive loop's next await, which ends it. + lifetime.reschedule(0) + + def stop(self) -> None: + """Stop writing, dropping the frames not written yet.""" + self._lifetime = None + self._writer.cancel() + + @dataclasses.dataclass class _ErrorBudget: """Bounds error-level logging driven by one kind of client traffic. @@ -407,12 +530,12 @@ def sid_to_token(self) -> dict[str, str]: @abstractmethod async def emit(self, event: str, data: Any = None, to: str | None = None) -> None: - """Emit an event to a connected client session. + """Emit an event to a connected client session, or to all of them. Args: event: The event name. data: The event payload. - to: The session id to emit to. + to: The session id to emit to; every connected session if None. """ async def handle_connect( @@ -730,44 +853,43 @@ def __init__(self, namespace: str, app: App): app: The application object. """ super().__init__(namespace, app) - self._sockets: dict[str, WebSocket] = {} + self._connections: dict[str, _Connection] = {} # Open channel sessions per connection, by session id and channel name. self._channel_sessions: dict[str, dict[str, ChannelSession]] = {} - async def _deliver(self, to: str | None, payload: str | bytes, label: str) -> None: - """Write one serialized frame to a connected client session. + def _deliver(self, to: str | None, payload: str | bytes, label: str) -> None: + """Queue one serialized frame for a connected client session. Args: 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: + connection = self._connections.get(to) if to is not None else None + if connection 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 {label!r} to unknown session {to!r}.") return if otel.enabled: otel.record_message_size(utf8_size(payload), "transmit") - try: - 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 {label!r} to session {to!r}.", exc_info=True) + connection.send(payload) async def emit(self, event: str, data: Any = None, to: str | None = None) -> None: - """Emit an event to a connected client session. + """Emit an event to a connected client session, or to all of them. Args: event: The event name. data: The event payload. - to: The session id to emit to. + to: The session id to emit to; every connected session if None. """ - await self._deliver(to, format.json_dumps([event, data]), event) + payload = _dumps([event, data]) + if to is not None: + self._deliver(to, payload, event) + return + # Like Socket.IO's emit without a recipient: a broadcast. + for sid in self._connections: + self._deliver(sid, payload, event) async def _send_channel_message( self, @@ -775,7 +897,7 @@ async def _send_channel_message( channel: str, event: str, data: Any, - buffers: Sequence[bytes], + buffers: Sequence[Buffer], ) -> None: """Send one channel message to connected client sessions. @@ -792,10 +914,10 @@ async def _send_channel_message( payload = ( encode_channel_frame(event, data, channel, buffers) if buffers - else format.json_dumps([event, data, channel]) + else _dumps([event, data, channel]) ) for sid in sids: - await self._deliver(sid, payload, event) + self._deliver(sid, payload, event) async def _send_channel_error( self, sid: str, channel: str, code: str, message: str @@ -960,7 +1082,7 @@ async def _handle_channel_message( if event == CLOSE_MESSAGE: await self._close_channel_session(sid, channel_name) return - if event in RESERVED_EVENTS: + if is_reserved_event(event): # The reservation holds in both directions, so a handler that # relays what it receives cannot be made to attempt a send the # channel API refuses. @@ -1016,10 +1138,10 @@ async def _handle_binary_frame( @staticmethod async def _close_quietly(websocket: WebSocket, code: int) -> None: - """Close a connection, tolerating one the heartbeat already closed. + """Close a connection, tolerating one that is 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. + The client can close its side while the server decides to close, and + starlette refuses a close after the connection has ended. Args: websocket: The client websocket connection. @@ -1203,6 +1325,12 @@ async def handle_websocket(self, websocket: WebSocket) -> None: # Reject cross-origin connections before accepting. await websocket.close(code=1008) return + if b"EIO" in urllib.parse.parse_qs(websocket.scope.get("query_string", b"")): + # A Socket.IO client -- a tab or bundle from before this transport + # -- cannot speak it; refuse it at once rather than leave it + # waiting for an engine.io handshake. + 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. @@ -1223,98 +1351,35 @@ async def handle_websocket(self, websocket: WebSocket) -> None: boot_event = connect_boot_event( connect_frame[1] if len(connect_frame) > 1 else None ) - 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(_PING_FRAME) - except Exception: - # Socket went away; the receive loop handles cleanup. - return - + connection = _Connection(websocket) + self._connections[sid] = connection heartbeat_task = asyncio.create_task( - heartbeat(), name=f"reflex_heartbeat|{sid}" + self._heartbeat(connection, ping_interval, ping_timeout), + name=f"reflex_heartbeat|{sid}", ) + lifetime = asyncio.timeout(None) try: - await self.handle_connect( - sid, - websocket.scope.get("query_string", b"").decode(), - subprotocols[0] if subprotocols else None, - update_state=boot_event is None, - ) - if sid not in self._token_manager.sid_to_token: - # No token was linked; not a Reflex client. - await self._close_quietly(websocket, 1008) - return - # The handshake acknowledges the connect 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, - "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, - }, - ]) - ) - if ( - boot_event is not None - and ( - close_code := await self._handle_boot_event( - sid, boot_event, websocket.scope - ) + async with lifetime: + connection.attach(lifetime) + await self._serve_session( + sid, + connection, + boot_event, + subprotocols[0] if subprotocols else None, + ping_interval, + ping_timeout, + max_message_size, ) - is not None - ): - await self._close_quietly(websocket, close_code) - return - while True: - received = await websocket.receive() - 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.") - await self._close_quietly(websocket, 1008) - break - text = received.get("text") - 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 self._close_quietly(websocket, close_code) - break + except TimeoutError: + # The lifetime firing is how connection.close() ends the loop. + if not lifetime.expired(): + raise except WebSocketDisconnect: pass finally: heartbeat_task.cancel() - self._sockets.pop(sid, None) + connection.stop() + self._connections.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) @@ -1323,9 +1388,120 @@ async def heartbeat() -> None: # 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. - # server shutdown) cannot abort it. Errors are logged by the - # task's done callback. + # Await the token cleanup so the client's reconnect after the + # close 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) + if connection.close_code is not None: + await self._close_quietly(websocket, connection.close_code) + + @staticmethod + async def _heartbeat( + connection: _Connection, ping_interval: float, ping_timeout: float + ) -> None: + """Ping a session's client, closing the session once it stops answering. + + Args: + connection: The client connection. + ping_interval: Seconds between pings. + ping_timeout: Seconds past the interval a client may stay silent. + """ + while True: + await asyncio.sleep(ping_interval) + if ( + time.monotonic() - connection.last_received + > ping_interval + ping_timeout + ): + connection.close(1001) + return + connection.send(_PING_FRAME) + + async def _serve_session( + self, + sid: str, + connection: _Connection, + boot_event: Any, + subprotocol: str | None, + ping_interval: float, + ping_timeout: float, + max_message_size: int, + ) -> None: + """Open a session and dispatch its frames until it ends. + + Args: + sid: The session id. + connection: The client connection. + boot_event: The boot event its connect frame carried, if any. + subprotocol: The websocket subprotocol offered by the client. + ping_interval: Seconds between heartbeat pings. + ping_timeout: Seconds past the interval a client may stay silent. + max_message_size: The message size limit in bytes. + """ + websocket = connection.websocket + await self.handle_connect( + sid, + websocket.scope.get("query_string", b"").decode(), + subprotocol, + update_state=boot_event is None, + ) + if sid not in self._token_manager.sid_to_token: + # No token was linked; not a Reflex client. + connection.close(1008) + return + # The handshake acknowledges the connect and carries the heartbeat + # settings for the client's connection watchdog. + connection.send( + _dumps([ + HANDSHAKE_MESSAGE, + { + "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, + }, + ]) + ) + if ( + boot_event is not None + and ( + close_code := await self._handle_boot_event( + sid, boot_event, websocket.scope + ) + ) + is not None + ): + connection.close(close_code) + return + while connection.close_code is None: + received = await websocket.receive() + if received["type"] == "websocket.disconnect": + return + connection.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.") + connection.close(1008) + return + text = received.get("text") + 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: + connection.close(close_code) diff --git a/reflex/socketio_namespace.py b/reflex/socketio_namespace.py index 821fb834ffa..f5d5f124ae9 100644 --- a/reflex/socketio_namespace.py +++ b/reflex/socketio_namespace.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging from collections.abc import MutableMapping from types import SimpleNamespace from typing import TYPE_CHECKING, Any @@ -14,7 +15,7 @@ from socketio import AsyncNamespace, AsyncServer from reflex.event_namespace import BaseEventNamespace, connect_boot_event, utf8_size -from reflex.utils import format +from reflex.utils import exceptions, format if TYPE_CHECKING: import asyncio @@ -23,6 +24,8 @@ from reflex.app import App +logger = logging.getLogger(__name__) + def _sio_dumps(obj: Any, **kwargs: Any) -> str: """Serialize an outgoing Socket.IO packet, recording its size when telemetry is on. @@ -158,7 +161,14 @@ async def on_event(self, sid: str, data: Any): msg = "Socket.IO environ is not initialized." raise RuntimeError(msg) scope = self._scopes[sid] = environ["asgi.scope"] - await self.handle_event(sid, data, scope) + try: + await self.handle_event(sid, data, scope) + except exceptions.EventDeserializationError: + # Client-controlled input a Reflex client never sends; end the + # session, as the plain transport does, rather than log a + # traceback per frame. + logger.debug(f"Disconnecting session {sid}: undeserializable event.") + await self.disconnect(sid) async def on_ping(self, sid: str): """Event for testing the API endpoint. diff --git a/reflex/testing.py b/reflex/testing.py index 8eca402d980..8eaca339142 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -20,17 +20,22 @@ import threading import time import types -from collections.abc import Callable, Coroutine, Sequence +from collections.abc import Awaitable, Callable, Coroutine, Sequence from importlib.util import find_spec from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self, TypeVar from granian.constants import Interfaces +from granian.errors import FatalError +from granian.log import LogLevels +from granian.net import SocketHolder # pyright: ignore[reportPrivateImportUsage] +from granian.server.embed import Server as EmbeddedGranian from reflex_base.components.memo import MEMOS from reflex_base.config import get_config, reload_config from reflex_base.environment import environment from reflex_base.registry import RegistrationContext -from reflex_base.utils.types import ASGIApp +from reflex_base.utils import console +from reflex_base.utils.types import ASGIApp, Message, Receive, Scope, Send import reflex import reflex.reflex @@ -75,46 +80,101 @@ FRONTEND_POPEN_ARGS["start_new_session"] = True +class _PreboundGranian(EmbeddedGranian): + """Embedded granian server serving a listening socket bound beforehand.""" + + def __init__(self, listener: socket.socket, *args, **kwargs) -> None: + """Create the server around a bound socket. + + Args: + listener: the listening socket; granian owns it once serving starts. + *args: positional arguments for the granian server. + **kwargs: keyword arguments for the granian server. + """ + super().__init__(*args, **kwargs) + self._listener = listener + + def _init_shared_socket(self): + """Hand the listening socket to granian instead of binding a new one.""" + fd = self._listener.detach() + # SocketHolder takes its pickled state, whose shape differs per platform. + if sys.platform == "win32": + state = (fd,) + elif sys.platform.startswith(("linux", "freebsd")): + state = (fd, False, self.backlog) + else: + state = (fd, False) + self._shd = SocketHolder(*state) + self._sfd = fd + + def release_socket(self, served: bool) -> None: + """Close the socket unless the worker serving it already did. + + Args: + served: whether a worker took the socket over. + """ + holder, self._shd = self._shd, None + # Dropping the holder closes it on Windows, where workers serve a clone. + if holder is not None and not served and sys.platform != "win32": + os.close(self._sfd) + + 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. + and state instances with the running server. The listening socket is + bound on construction, without SO_REUSEPORT, so the server owns its port + (OS-assigned for port 0) before it serves. """ def __init__(self, app: ASGIApp, host: str = "127.0.0.1", port: int = 0) -> None: - """Prepare the server without starting it. + """Bind the listening socket without serving yet. 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. + port: the port to bind to; 0 lets the OS pick a free one. """ self.app = app self.host = host - self.port = port or self._pick_free_port(host) + self._listener = self._listen(host, port) + self.port: int = self._listener.getsockname()[1] # Monkeypatchable async shutdown hook, mirroring uvicorn.Server.shutdown. self.shutdown: Callable[..., Coroutine[Any, Any, None]] = self._noop_shutdown self._should_exit = threading.Event() + self._serving = False + # Set once the server serves or stops. + self._settled = threading.Event() self._loop: asyncio.AbstractEventLoop | None = None - self._server: Any = None + self._server: _PreboundGranian | None = None @staticmethod - def _pick_free_port(host: str) -> int: - """Ask the OS for a free port and release it again. + def _listen(host: str, port: int) -> socket.socket: + """Bind a listening socket that no other socket can share. Args: - host: the address the port has to be free on. + host: the address to bind to. + port: the port to bind to. Returns: - The port number. + The listening socket. """ - with socket.socket() as probe: - probe.bind((host, 0)) - return probe.getsockname()[1] + sock = socket.socket() + try: + # On posix SO_REUSEADDR only allows rebinding a port in TIME_WAIT; + # on Windows it would let other sockets share the port. + sock.setsockopt( + socket.SOL_SOCKET, + getattr(socket, "SO_EXCLUSIVEADDRUSE", socket.SO_REUSEADDR), + 1, + ) + sock.bind((host, port)) + sock.listen() + except OSError: + sock.close() + raise + return sock @staticmethod async def _noop_shutdown(*args, **kwargs) -> None: @@ -133,17 +193,38 @@ def getsockname(self) -> tuple[str, int]: """ return (self.host, self.port) - def is_listening(self) -> bool: - """Whether the server accepts connections. + def wait_started(self, timeout: float | None = None) -> None: + """Block until the server serves, after the app's lifespan startup. + + Args: + timeout: how long to wait in seconds; None waits indefinitely. + + Raises: + TimeoutError: when the server does not serve within the timeout. + RuntimeError: when the server stopped without serving. + """ + if not self._settled.wait(timeout): + msg = f"Server on port {self.port} did not start within {timeout}s." + raise TimeoutError(msg) + if not self._serving: + msg = f"Server on port {self.port} stopped without serving; see the log above." + raise RuntimeError(msg) + + @property + def started(self) -> bool: + """Whether the server serves, like uvicorn's `Server.started`. Returns: - True if a TCP connection to the bound address succeeds. + True once the app's lifespan startup completed. """ - try: - socket.create_connection((self.host, self.port), timeout=0.1).close() - except OSError: - return False - return True + console.deprecate( + feature_name="AppHarness.backend.started", + reason="AppHarness.start() returns once the backend serves; " + "call `wait_started()` to wait for a server yourself", + deprecation_version="0.10.0", + removal_version="1.0", + ) + return self._serving @property def should_exit(self) -> bool: @@ -161,66 +242,85 @@ def should_exit(self, value: bool) -> None: 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) + loop.call_soon_threadsafe(server.stop) def run(self) -> None: """Serve the app until `should_exit` is set; used as a thread target.""" - asyncio.run(self._serve()) + try: + asyncio.run(self._serve()) + finally: + # A no-op once granian took the socket over. + self._listener.close() + self._settled.set() - async def _serve(self) -> None: - from granian.server.embed import Server + def _mark_serving(self) -> None: + """Record that the server serves.""" + self._serving = True + self._settled.set() + + def _asgi(self, scope: Scope, receive: Receive, send: Send) -> Awaitable[None]: + """Call the app, tracking its lifespan startup. + + Args: + scope: the ASGI scope. + receive: the ASGI receive callable. + send: the ASGI send callable. + + Returns: + The app's ASGI awaitable. + """ + if scope["type"] == "lifespan": + return self._lifespan(scope, receive, send) + return self.app(scope, receive, send) + + async def _lifespan(self, scope: Scope, receive: Receive, send: Send) -> None: + """Run the app's lifespan, marking the server serving after startup. + + Args: + scope: the ASGI lifespan scope. + receive: the ASGI receive callable. + send: the ASGI send callable. + """ + failed = False + + async def tracking_send(message: Message) -> None: + nonlocal failed + await send(message) + if message["type"] == "lifespan.startup.complete": + self._mark_serving() + elif message["type"] == "lifespan.startup.failed": + failed = True try: - # Another process can claim the probed port before granian binds - # it; retry the bind on a fresh port. - for attempts_left in reversed(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 self.app(scope, receive, tracking_send) + finally: + # Like granian, serve without lifespan support unless startup failed. + if not failed: + self._mark_serving() + + async def _serve(self) -> None: + server = _PreboundGranian( + self._listener, + self._asgi, + address=self.host, + port=self.port, + interface=Interfaces.ASGI, + # Keeps lifespan failures and unhandled app errors visible. + log_level=LogLevels.error, + ) + self._server = server + self._loop = asyncio.get_running_loop() + try: + # A stop requested before this point found no server to interrupt. + # A failed lifespan startup is logged by granian and reported by + # wait_started(). + if not self._should_exit.is_set(): + with contextlib.suppress(FatalError): await server.serve() - except (OSError, RuntimeError) as ex: - # Granian surfaces bind failures as RuntimeError; the - # 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(): - break - if not attempts_left: - raise - logger.warning( - f"Port {self.port} unavailable ({ex}); retrying on a fresh port." - ) - self.port = self._pick_free_port(self.host) - continue - # serve() returned: shutdown, or a server failure after - # startup -- never restart on a different port. - break finally: + server.release_socket(served=self._serving) await self.shutdown() @@ -734,26 +834,24 @@ async def _poll_for_async( return False def _poll_for_servers(self, timeout: TimeoutType = None) -> _EmbeddedServer: - """Poll the backend server until it is listening. + """Wait for the backend server to serve. Args: - timeout: how long to wait for the listening server. + timeout: how long to wait for the server. Returns: the backend server, exposing `getsockname()` for its bound address Raises: - RuntimeError: when the backend hasn't started running + RuntimeError: when the backend hasn't started running, or stopped + without serving TimeoutError: when the server is not ready """ if self.backend is None: msg = "Backend is not running." raise RuntimeError(msg) - backend = self.backend - if not self._poll_for(target=backend.is_listening, timeout=timeout): - msg = "Backend is not listening." - raise TimeoutError(msg) - return backend + self.backend.wait_started(DEFAULT_TIMEOUT if timeout is None else timeout) + return self.backend def frontend( self, @@ -969,12 +1067,6 @@ class AppHarnessProd(AppHarness): frontend_thread: threading.Thread | 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 = _EmbeddedServer(frontend_app) - self.frontend_server.run() - def _start_frontend(self): # Set up the frontend. with chdir(self.app_path): @@ -999,21 +1091,20 @@ def _start_frontend(self): loglevel=reflex.constants.LogLevel.INFO, env=reflex.constants.Env.PROD, ) + self.frontend_server = _EmbeddedServer( + reflex.utils.exec._frontend_prod_app() + ) print("Frontend starting...") # for pytest diagnosis #noqa: T201 - self.frontend_thread = threading.Thread(target=self._run_frontend) + self.frontend_thread = threading.Thread(target=self.frontend_server.run) self.frontend_thread.start() def _wait_frontend(self): - self._poll_for( - lambda: ( - self.frontend_server is not None and self.frontend_server.is_listening() - ) - ) - if self.frontend_server is None or not self.frontend_server.is_listening(): + if self.frontend_server is None: msg = "Frontend did not start" raise RuntimeError(msg) + self.frontend_server.wait_started(DEFAULT_TIMEOUT) config = get_config() self.frontend_url = "http://{}:{}".format( *self.frontend_server.getsockname() diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 80994305bf5..36c2b9f2242 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -484,13 +484,22 @@ def run_frontend_prod(host: str, port: int): ) +def _uvicorn_can_serve_websockets() -> bool: + """Whether a websocket protocol library uvicorn can use is installed. + + Returns: + True if websockets or wsproto is importable. + """ + return ( + importlib.util.find_spec("websockets") is not None + or importlib.util.find_spec("wsproto") is not None + ) + + @once 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 - ): + if not _uvicorn_can_serve_websockets(): logger.warning( "Uvicorn has no websocket protocol library installed, so the default " "WebSocket transport will not connect. Install `reflex[uvicorn]` or " @@ -523,6 +532,8 @@ def should_use_granian(): if ( importlib.util.find_spec("uvicorn") is None or importlib.util.find_spec("gunicorn") is None + # Granian is always installed and serves the event socket itself. + or not _uvicorn_can_serve_websockets() ): return True _warn_user_about_uvicorn() @@ -832,6 +843,32 @@ def _granian_log_dictconfig() -> dict[str, Any] | None: return {"handlers": {"console": json_handler, "access": json_handler}} +# The largest websocket frame Granian receives; it drops larger ones, with no +# setting to raise the limit. +_GRANIAN_MAX_FRAME_SIZE = 16 * 1024 * 1024 + + +def _cap_message_size_for_granian(): + """Lower the message size limit to what Granian can receive. + + The limit is advertised to clients, which size what they send by it; a + message Granian drops is lost with the socket. Lowered in the environment, + so the workers it spawns read the capped value too. + """ + configured = environment.REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE.get() + if configured <= _GRANIAN_MAX_FRAME_SIZE: + return + logger.warning( + f"REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE is {configured} bytes, but Granian " + f"drops websocket frames over {_GRANIAN_MAX_FRAME_SIZE} bytes; capping " + "it there. Run uvicorn (`reflex[uvicorn]`, REFLEX_USE_GRANIAN=0) for " + "larger messages." + ) + os.environ[environment.REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE.name] = str( + _GRANIAN_MAX_FRAME_SIZE + ) + + def run_granian_backend(host: str, port: int, loglevel: LogLevel): """Run the backend in development mode using Granian. @@ -841,6 +878,7 @@ def run_granian_backend(host: str, port: int, loglevel: LogLevel): loglevel: The log level. """ logger.debug("Using Granian for backend") + _cap_message_size_for_granian() if environment.REFLEX_STRICT_HOT_RELOAD.get(): import multiprocessing @@ -1101,6 +1139,7 @@ def run_granian_backend_prod( loglevel: The log level. app_target: The ASGI app target to run. Defaults to the reflex app instance. """ + _cap_message_size_for_granian() from granian.constants import Interfaces from granian.log import LogLevels from granian.server import Server as Granian diff --git a/tests/benchmarks/support/idle_clients.py b/tests/benchmarks/support/idle_clients.py index f21321d13b8..599923ac4be 100644 --- a/tests/benchmarks/support/idle_clients.py +++ b/tests/benchmarks/support/idle_clients.py @@ -1,20 +1,39 @@ -"""Hold idle Socket.IO sessions from a separate process. +"""Hold idle event websocket sessions from a separate process. Memory-per-session benchmarks run the backend inside the test process, so client-side allocations would pollute resident-memory measurements. This module connects a number of sessions, primes each with one event so the backend materializes its state, reports readiness on stdout, and holds the -connections until stdin closes. +connections, answering the server's pings, until stdin closes. """ from __future__ import annotations +import contextlib import json import sys +import threading -import socketio +from websockets.exceptions import ConnectionClosed -from tests.benchmarks.support.socket_client import _connect +from tests.benchmarks.support.socket_client import EventClient, _connect + +# Seconds between checks for pings while the sessions are held. +_KEEPALIVE_INTERVAL = 1.0 + + +def _keep_alive(clients: list[EventClient], stop: threading.Event) -> None: + """Answer the server's pings until stopped. + + Args: + clients: The held sessions. + stop: Set when the sessions are released. + """ + while not stop.wait(_KEEPALIVE_INTERVAL): + for client in clients: + # A session the server ended stays ended; keep the others alive. + with contextlib.suppress(ConnectionClosed, ConnectionError): + client.poll() def main() -> int: @@ -25,22 +44,25 @@ def main() -> int: """ url, token_prefix, count, payload_json = sys.argv[1:5] payload = json.loads(payload_json) - clients: list[socketio.SimpleClient] = [] + clients: list[EventClient] = [] try: for index in range(int(count)): - client = socketio.SimpleClient(logger=False) - _connect(client, url, f"{token_prefix}-{index}", "/_event", timeout=10) - client.emit("event", payload) - response = client.receive(timeout=10) - while response and response[0] != "event": - response = client.receive(timeout=10) + client = _connect(url, f"{token_prefix}-{index}", timeout=10) clients.append(client) + client.emit("event", payload) + client.receive(10, "event") print("ready", flush=True) - sys.stdin.read() + stop = threading.Event() + keeper = threading.Thread(target=_keep_alive, args=(clients, stop)) + keeper.start() + try: + sys.stdin.read() + finally: + stop.set() + keeper.join() finally: for client in clients: - if client.connected: - client.disconnect() + client.close() return 0 diff --git a/tests/benchmarks/support/socket_client.py b/tests/benchmarks/support/socket_client.py index e64c782c128..66fb9b8e94e 100644 --- a/tests/benchmarks/support/socket_client.py +++ b/tests/benchmarks/support/socket_client.py @@ -1,19 +1,35 @@ -"""Protocol-aware Socket.IO client used by scheduled load tests.""" +"""Blocking client of Reflex's event websocket used by scheduled load tests. + +It speaks the plain WebSocket protocol of Reflex's default transport, and the +Socket.IO packets of a backend that refuses that endpoint (``transport="socketio"`` +apps and the python-socketio baseline server), so both sides of a comparison +pay the same client cost. +""" from __future__ import annotations import asyncio import concurrent.futures +import contextlib import dataclasses import functools import json import time from collections.abc import Callable, Mapping from typing import Any -from urllib.parse import quote -import socketio -from socketio.exceptions import TimeoutError as SocketTimeoutError +from reflex_bench.drivers.events import ( + CODECS, + CONNECT, + DISCONNECTED, + HANDSHAKE, + PING, + REFUSED, + SIO_CONNECT_FRAME, + Codec, +) +from websockets.exceptions import ConnectionClosed, InvalidStatus +from websockets.sync.client import ClientConnection, connect # Silence window that marks the connection quiescent while draining the extra # deltas the first event on a fresh token emits (hydrate + on_load_internal). @@ -40,62 +56,155 @@ class ReconnectResult: errors: tuple[str, ...] -def _payload_size(response: Any) -> int: - """Approximate the wire size of a received update payload. +class EventClient: + """One session on Reflex's event websocket, used from one thread at a time.""" + + def __init__(self, ws: ClientConnection, codec: Codec): + """Wrap a connected websocket. + + Args: + ws: The websocket. + codec: The protocol it speaks. + """ + self._ws = ws + self._codec = codec + + def emit(self, name: str, data: Any) -> None: + """Send a message. + + Args: + name: The message name. + data: Its payload. + """ + self._ws.send(self._codec.emit(name, data)) + + def receive(self, timeout: float, name: str | None = None) -> list[Any]: + """Wait for the next message, answering pings on the way. + + Args: + timeout: Seconds to wait; 0 takes only what already arrived. + name: The message name to wait for, skipping others; any if None. + + Returns: + The message, ``[name, *args]``. + + Raises: + ConnectionError: When the server refuses or ends the session. + """ + codec = self._codec + deadline = time.monotonic() + timeout + while True: + message = self._ws.recv(timeout=deadline - time.monotonic()) + args = codec.parse(message) + if args is None: + # A frame without a message, e.g. a binary one. + continue + if args[0] == PING: + self._ws.send(codec.pong) + elif args[0] in {REFUSED, DISCONNECTED}: + msg = f"the server ended the session: {message!r}" + raise ConnectionError(msg) + elif name is None or args[0] == name: + return args + + def poll(self) -> None: + """Handle the frames that already arrived, answering pings.""" + with contextlib.suppress(TimeoutError): + while True: + self.receive(0) + + def close(self) -> None: + """Leave the session and close the websocket.""" + if (leave := self._codec.leave) is not None: + with contextlib.suppress(ConnectionClosed): + self._ws.send(leave) + self._ws.close() + + +def _open(url: str, token: str, codec: Codec, timeout: float) -> ClientConnection: + """Open the websocket of a session. Args: - response: Decoded ``[event, payload]`` message from the client. + url: Backend URL. + token: Reflex client token. + codec: The protocol. + timeout: Maximum connection wait. Returns: - Compact JSON byte length of the payload, excluding Socket.IO framing. + The websocket. """ - payload = response[1] if len(response) > 1 else None - return len(json.dumps(payload, separators=(",", ":"), default=str)) + return connect( + codec.url(url, token), + additional_headers={"Origin": url}, + compression=None, + proxy=None, + open_timeout=timeout, + ping_interval=None, + close_timeout=1, + max_size=None, + ) -def _connect( - client: socketio.SimpleClient, - url: str, - token: str, - namespace: str, - timeout: float, -) -> None: - """Connect a client the way the Reflex frontend does. +def _connect(url: str, token: str, timeout: float) -> EventClient: + """Connect a client the way the Reflex frontend does, without a boot event. + + A backend that refuses the plain protocol's endpoint gets Socket.IO. Args: - client: Blocking Socket.IO client. - url: Backend Socket.IO URL. + url: Backend URL. token: Reflex client token. - namespace: Reflex Socket.IO namespace. - timeout: Maximum connection wait. + timeout: Maximum wait for the connection and its handshake. + + Returns: + The connected client. """ - separator = "&" if "?" in url else "?" - client.connect( - f"{url}{separator}token={quote(token)}", - transports=["websocket"], - socketio_path="_event", - headers={"Origin": url}, - namespace=namespace, - wait_timeout=max(1, int(timeout)), - ) + codec = CODECS["websocket"] + try: + ws = _open(url, token, codec, timeout) + except InvalidStatus: + codec = CODECS["socketio"] + ws = _open(url, token, codec, timeout) + client = EventClient(ws, codec) + try: + ws.send( + SIO_CONNECT_FRAME + if codec.protocol == "socketio" + else codec.emit(CONNECT, {}) + ) + client.receive(timeout, HANDSHAKE) + except BaseException: + ws.close() + raise + return client + + +def _payload_size(response: Any) -> int: + """Approximate the wire size of a received update payload. + + Args: + response: Decoded ``[event, payload]`` message from the client. + + Returns: + Compact JSON byte length of the payload, excluding the protocol's framing. + """ + payload = response[1] if len(response) > 1 else None + return len(json.dumps(payload, separators=(",", ":"), default=str)) -def _drain(client: socketio.SimpleClient, timeout: float) -> None: +def _drain(client: EventClient, timeout: float) -> None: """Consume buffered responses until the socket is quiet for ``timeout``. Args: - client: Blocking Socket.IO client. + client: Event websocket client. timeout: Silence window that marks the connection quiescent. """ - while True: - try: - client.receive(timeout=timeout) - except SocketTimeoutError: - return + with contextlib.suppress(TimeoutError): + while True: + client.receive(timeout) def _prime( - client: socketio.SimpleClient, + client: EventClient, event_name: str, payload: Mapping[str, Any], timeout: float, @@ -109,8 +218,8 @@ def _prime( events, which would otherwise report each latency pipelined two events deep. Args: - client: Blocking Socket.IO client. - event_name: Socket event name used for requests. + client: Event websocket client. + event_name: Message name used for requests. payload: Event payload emitted to trigger hydration. timeout: Maximum wait bounding the drain silence window. """ @@ -126,20 +235,18 @@ async def run_socket_client( *, event_name: str = "event", response_name: str = "event", - namespace: str = "/_event", timeout: float = 10, executor: concurrent.futures.Executor | None = None, ) -> ClientLoadResult: """Send events sequentially and measure matching socket responses. Args: - url: Backend Socket.IO URL. + url: Backend URL. token: Reflex client token. payload: Event payload emitted for each operation. events: Number of operations. - event_name: Socket event name used for requests. - response_name: Socket event name carrying state updates. - namespace: Reflex Socket.IO namespace. + event_name: Message name used for requests. + response_name: Message name carrying state updates. timeout: Maximum response wait per operation. executor: Optional executor that owns the blocking socket client. @@ -157,7 +264,6 @@ async def run_socket_client( events, event_name, response_name, - namespace, timeout, ), ) @@ -170,40 +276,36 @@ def _run_socket_client_sync( events: int, event_name: str, response_name: str, - namespace: str, timeout: float, ) -> ClientLoadResult: - """Run a load client with the websocket-client transport. + """Run a load client on a blocking websocket. Args: - url: Backend Socket.IO URL. + url: Backend URL. token: Reflex client token. payload: Event payload emitted for each operation. events: Number of operations. - event_name: Socket event name used for requests. - response_name: Socket event name carrying state updates. - namespace: Reflex Socket.IO namespace. + event_name: Message name used for requests. + response_name: Message name carrying state updates. timeout: Maximum response wait per operation. Returns: Per-operation latency and error observations. """ - client = socketio.SimpleClient(logger=False) + client: EventClient | None = None latencies: list[float] = [] payload_sizes: list[int] = [] errors: list[str] = [] try: - _connect(client, url, token, namespace, timeout) + client = _connect(url, token, timeout) _prime(client, event_name, payload, timeout) for _ in range(events): started = time.perf_counter_ns() client.emit(event_name, dict(payload)) try: - response = client.receive(timeout=timeout) - while response and response[0] != response_name: - response = client.receive(timeout=timeout) - except SocketTimeoutError: + response = client.receive(timeout, response_name) + except TimeoutError: errors.append("response_timeout") continue latencies.append((time.perf_counter_ns() - started) / 1_000_000) @@ -211,8 +313,8 @@ def _run_socket_client_sync( except Exception as err: errors.append(f"{type(err).__name__}: {err}") finally: - if client.connected: - client.disconnect() + if client is not None: + client.close() return ClientLoadResult( token, tuple(latencies), tuple(errors), tuple(payload_sizes) @@ -226,19 +328,17 @@ async def run_reconnect_client( *, event_name: str = "event", response_name: str = "event", - namespace: str = "/_event", timeout: float = 10, executor: concurrent.futures.Executor | None = None, ) -> ReconnectResult: """Connect, send one event, and measure connect and first-response time. Args: - url: Backend Socket.IO URL. + url: Backend URL. token: Reflex client token. payload: Event payload emitted after connecting. - event_name: Socket event name used for the request. - response_name: Socket event name carrying state updates. - namespace: Reflex Socket.IO namespace. + event_name: Message name used for the request. + response_name: Message name carrying state updates. timeout: Maximum connection and response wait. executor: Optional executor that owns the blocking socket client. @@ -255,7 +355,6 @@ async def run_reconnect_client( payload, event_name, response_name, - namespace, timeout, ), ) @@ -267,45 +366,41 @@ def _run_reconnect_client_sync( payload: Mapping[str, Any], event_name: str, response_name: str, - namespace: str, timeout: float, ) -> ReconnectResult: - """Run one reconnect client with the websocket-client transport. + """Run one reconnect client on a blocking websocket. Args: - url: Backend Socket.IO URL. + url: Backend URL. token: Reflex client token. payload: Event payload emitted after connecting. - event_name: Socket event name used for the request. - response_name: Socket event name carrying state updates. - namespace: Reflex Socket.IO namespace. + event_name: Message name used for the request. + response_name: Message name carrying state updates. timeout: Maximum connection and response wait. Returns: Connect and first-response observations. """ - client = socketio.SimpleClient(logger=False) + client: EventClient | None = None connect_ms = 0.0 first_response_ms = 0.0 errors: list[str] = [] try: started = time.perf_counter_ns() - _connect(client, url, token, namespace, timeout) + client = _connect(url, token, timeout) connect_ms = (time.perf_counter_ns() - started) / 1_000_000 started = time.perf_counter_ns() client.emit(event_name, dict(payload)) - response = client.receive(timeout=timeout) - while response and response[0] != response_name: - response = client.receive(timeout=timeout) + client.receive(timeout, response_name) first_response_ms = (time.perf_counter_ns() - started) / 1_000_000 - except SocketTimeoutError: + except TimeoutError: errors.append("response_timeout") except Exception as err: errors.append(f"{type(err).__name__}: {err}") finally: - if client.connected: - client.disconnect() + if client is not None: + client.close() return ReconnectResult(token, connect_ms, first_response_ms, tuple(errors)) diff --git a/tests/benchmarks/test_event_transport.py b/tests/benchmarks/test_event_transport.py index be165266557..c00996ddd87 100644 --- a/tests/benchmarks/test_event_transport.py +++ b/tests/benchmarks/test_event_transport.py @@ -18,7 +18,7 @@ import pytest_asyncio from pytest_codspeed import BenchmarkFixture -from reflex.event_namespace import CONNECT_MESSAGE, WebsocketEventNamespace +from reflex.event_namespace import CONNECT_MESSAGE, WebsocketEventNamespace, _Connection from reflex.state import StateUpdate NUM_MESSAGES = 100 @@ -203,7 +203,8 @@ async def websocket_outbound(): app = _make_app() namespace = WebsocketEventNamespace(NAMESPACE, app) # pyright: ignore[reportArgumentType] websocket = FakeWebSocket([]) - namespace._sockets["sid-1"] = websocket # pyright: ignore[reportArgumentType] + connection = _Connection(websocket) # pyright: ignore[reportArgumentType] + namespace._connections["sid-1"] = connection await namespace.link_token_to_sid("sid-1", TOKEN) async def run() -> None: @@ -211,6 +212,7 @@ async def run() -> None: await namespace.emit_update(_UPDATE, TOKEN) yield run + connection.stop() @pytest_asyncio.fixture diff --git a/tests/integration/tests_playwright/test_stateless_app.py b/tests/integration/tests_playwright/test_stateless_app.py index de88ef12f53..d7897873f10 100644 --- a/tests/integration/tests_playwright/test_stateless_app.py +++ b/tests/integration/tests_playwright/test_stateless_app.py @@ -5,8 +5,9 @@ import httpx import pytest from playwright.sync_api import Page, expect -from reflex_base.config import get_config from reflex_base.constants import Endpoint +from websockets.exceptions import InvalidStatus +from websockets.sync.client import connect from reflex.testing import AppHarness @@ -48,11 +49,13 @@ 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.is_listening() + stateless_app.backend.wait_started(timeout=0) - config = get_config() - res = httpx.get(config.api_url + config.prepend_backend_path(str(Endpoint.EVENT))) - assert res.status_code == 404 + # Plain HTTP gets a 404 from a websocket route too: dial the handshake. + event_url = Endpoint.EVENT.get_url() + for url in (event_url, event_url.rstrip("/") + "/"): + with pytest.raises(InvalidStatus), connect(url, open_timeout=10): + pass res2 = httpx.get(Endpoint.PING.get_url()) assert res2.status_code == 200 diff --git a/tests/units/compiler/state_js.test.mjs b/tests/units/compiler/state_js.test.mjs index e7ff61a6f86..5dc07648d93 100644 --- a/tests/units/compiler/state_js.test.mjs +++ b/tests/units/compiler/state_js.test.mjs @@ -19,6 +19,7 @@ async function setup({ hidden = false, storedToken = "original-token", transport = "websocket", + importFails = false, } = {}) { const sockets = []; const microtasks = []; @@ -171,6 +172,10 @@ async function setup({ }, // The socket.io client is imported on demand. async importModuleDynamically(name) { + if (importFails) { + // A chunk missing after a redeploy; browsers cache the failure. + throw new TypeError("Failed to fetch dynamically imported module"); + } const imported = link(name); await imported.link(() => {}); await imported.evaluate(); @@ -180,7 +185,9 @@ async function setup({ await module.link(link); await module.evaluate(); const socket = { current: null }; + let connectErrors = []; return { + connectErrors: () => connectErrors, sockets, timers, firstHydrates, @@ -194,7 +201,10 @@ async function setup({ socket, { child: (delta) => updates.push(delta.count) }, transports, - () => {}, + (update) => { + connectErrors = + typeof update === "function" ? update(connectErrors) : update; + }, {}, () => {}, { current: {} }, @@ -304,7 +314,23 @@ test("blocked session storage does not throw from speculative setup", async () = }; app.flush(); assert.equal(app.sockets.length, 0); - await assert.rejects(app.connect(), /storage blocked/); + // Reported, not rejected: the unhandled rejection handler adds an event, + // which reconnects, which would fail again without end. + await app.connect(); + assert.equal(app.sockets.length, 0); + assert.match(String(app.connectErrors()[0]), /storage blocked/); + // A later attempt is not locked out by the failed one. + app.window.sessionStorage.getItem = () => "original-token"; + await app.connect(); + assert.equal(app.sockets.length, 1); +}); + +test("a socket.io client that fails to load is a connection error", async () => { + const app = await setup({ transport: "socketio", importFails: true }); + await app.connect(["socketio"]); + assert.equal(app.socket.current, null); + assert.equal(app.connectErrors().length, 1); + assert.match(String(app.connectErrors()[0]), /dynamically imported module/); }); for (const [transport, engineTransport] of [ diff --git a/tests/units/compiler/websocket_js.test.mjs b/tests/units/compiler/websocket_js.test.mjs index ed72e64fe98..f58ee8a6688 100644 --- a/tests/units/compiler/websocket_js.test.mjs +++ b/tests/units/compiler/websocket_js.test.mjs @@ -12,9 +12,10 @@ const source = await readFile( ); /** Evaluate the actual transport module against a scripted browser WebSocket. */ -async function setup() { +async function setup({ throwOnConstruct = false, closeLater = false } = {}) { const sockets = []; const timers = new Map(); + const reported = []; class WebSocket { static CONNECTING = 0; @@ -23,6 +24,10 @@ async function setup() { static CLOSED = 3; constructor(url, protocols) { + if (throwOnConstruct) { + // Mixed content or a CSP violation fails the constructor itself. + throw new Error("SecurityError: insecure WebSocket from https"); + } this.url = String(url); this.protocols = protocols; this.readyState = WebSocket.CONNECTING; @@ -32,10 +37,16 @@ async function setup() { send(frame) { assert.equal(this.readyState, WebSocket.OPEN); - this.sent.push(JSON.parse(frame)); + this.sent.push(typeof frame === "string" ? JSON.parse(frame) : frame); } close() { + if (closeLater) { + // A dead link: the browser reports the close only after its closing + // handshake times out, up to a minute later. + this.readyState = WebSocket.CLOSING; + return; + } this.readyState = WebSocket.CLOSED; this.onclose?.({ code: 1000, reason: "" }); } @@ -62,6 +73,8 @@ async function setup() { Uint8Array, WebSocket, queueMicrotask, + SharedArrayBuffer, + reportError: (error) => reported.push(error), setTimeout: (fn, ms) => { timers.set(fn, ms); return fn; @@ -88,7 +101,29 @@ async function setup() { for (const name of ["connect", "connect_error", "disconnect", "event"]) { transport.on(name, (...args) => events.push([name, ...args])); } - return { transport, sockets, timers, events }; + return { + transport, + sockets, + timers, + events, + reported, + getChannel: module.namespace.getChannel, + decodeChannelFrame: module.namespace.decodeChannelFrame, + }; +} + +/** + * Connect the transport and complete its handshake. + * @param app The test setup. + * @param handshake Overrides for the handshake payload. + * @returns The connected scripted socket. + */ +function connectTransport(app, handshake = {}) { + app.transport.connect(); + const socket = app.sockets.at(-1); + socket.open(); + socket.receive(["_handshake", { ...HANDSHAKE[1], ...handshake }]); + return socket; } const HANDSHAKE = [ @@ -172,3 +207,126 @@ test("a session that is never acknowledged fails as a connect error", async () = assert.equal(events.at(-1)[0], "connect_error"); assert.equal(timers.size, 0); }); + +test("the transport dials the event path with a trailing slash", async () => { + // Proxy rules written for Socket.IO route "/_event/*", which "/_event" misses. + const { transport, sockets } = await setup(); + transport.connect(); + assert.equal(new URL(sockets[0].url).pathname, "/_event/"); +}); + +test("a handler removing itself does not skip the next one", async () => { + const { transport } = await setup(); + const calls = []; + const once = () => { + calls.push("once"); + transport.off("custom", once); + }; + transport.on("custom", once); + transport.on("custom", () => calls.push("next")); + transport._emitLocal("custom"); + transport._emitLocal("custom"); + assert.deepEqual(calls, ["once", "next", "next"]); +}); + +test("a throwing channel handler cannot leave the transport half torn down", async () => { + const app = await setup(); + const socket = connectTransport(app); + const channel = app.getChannel("throws-on-disconnect"); + socket.receive(["_opened", null, "throws-on-disconnect"]); + channel.on("disconnect", () => { + throw new Error("component already unmounted"); + }); + app.transport.disconnect(); + assert.equal(app.transport.connected, false); + assert.equal(socket.readyState, socket.constructor.CLOSED); + assert.equal(app.events.at(-1)[0], "disconnect"); + assert.equal(app.reported.length, 1); + assert.match(app.reported[0].message, /already unmounted/); +}); + +test("the ping watchdog reports a dead link at once", async () => { + const app = await setup({ closeLater: true }); + connectTransport(app); + const channel = app.getChannel("watched"); + app.sockets[0].receive(["_opened", null, "watched"]); + const watchdog = [...app.timers.keys()].at(-1); + watchdog(); + // Not when the browser gives up on the closing handshake, a minute later. + assert.equal(app.transport.connected, false); + assert.deepEqual(app.events.at(-1).slice(0, 2), [ + "disconnect", + "ping timeout", + ]); + assert.equal(channel.connected, false); +}); + +test("a channel opened twice reports connect once", async () => { + const app = await setup(); + const socket = connectTransport(app); + const channel = app.getChannel("twice"); + let connects = 0; + channel.on("connect", () => connects++); + socket.receive(["_opened", null, "twice"]); + socket.receive(["_opened", null, "twice"]); + assert.equal(connects, 1); +}); + +test("a socket constructor that throws becomes a connect error", async () => { + const app = await setup({ throwOnConstruct: true }); + app.transport.connect(); + // Reported on a later tick, like any failed dial, once handlers exist. + assert.deepEqual(app.events, []); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(app.events.length, 1); + assert.equal(app.events[0][0], "connect_error"); + assert.match(app.events[0][1].message, /SecurityError/); + // A later attempt dials again rather than finding the transport stuck. + app.transport.connect(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(app.events.length, 2); +}); + +test("a channel refuses names and attachments the backend cannot take", async () => { + const app = await setup(); + connectTransport(app); + const channel = app.getChannel("strict"); + for (const name of [undefined, 42, "", "_open", "_custom", "connect"]) { + assert.throws(() => channel.emit(name, null)); + } + for (const buffers of [ + new ArrayBuffer(8), + [{ size: 3 }], + [[1, 2, 3]], + ["abc"], + ]) { + assert.throws(() => channel.emit("push", null, buffers), { + name: "TypeError", + }); + } + for (const name of [undefined, "has space", "x".repeat(65)]) { + assert.throws(() => app.getChannel(name), { name: "TypeError" }); + } +}); + +test("shared buffers are sent whole", async () => { + const app = await setup(); + const socket = connectTransport(app); + const channel = app.getChannel("shared"); + socket.receive(["_opened", null, "shared"]); + channel.emit("push", null, [new SharedArrayBuffer(8)]); + const [, , , buffers] = app.decodeChannelFrame(socket.sent.at(-1)); + assert.equal(buffers[0].byteLength, 8); +}); + +test("an oversized event is dropped instead of costing the socket", async () => { + const app = await setup(); + const socket = connectTransport(app, { max_message_size: 100 }); + const before = socket.sent.length; + app.transport.emit("event", { blob: "x".repeat(200) }); + assert.equal(socket.sent.length, before); + assert.equal(app.reported.length, 1); + assert.match(app.reported[0].message, /over the 100/); + app.transport.emit("event", { small: true }); + assert.equal(socket.sent.length, before + 1); +}); diff --git a/tests/units/reflex_bench/drivers/test_browser.py b/tests/units/reflex_bench/drivers/test_browser.py index 71ef592f0b7..eb9c644ece4 100644 --- a/tests/units/reflex_bench/drivers/test_browser.py +++ b/tests/units/reflex_bench/drivers/test_browser.py @@ -19,14 +19,14 @@ from collections.abc import Callable, Iterator from concurrent.futures import ThreadPoolExecutor from pathlib import Path -from typing import Any, TypeVar +from typing import Any, TypeVar, cast import psutil import pytest from reflex_bench import machine from reflex_bench.drivers import browser as browser_module from reflex_bench.drivers.app_process import Readiness -from reflex_bench.drivers.browser import Anchor, Browser +from reflex_bench.drivers.browser import Anchor, Browser, Tab from reflex_bench.scheduler import ABANDON_GRACE_S HTML = Path(__file__).parents[1] / "fixtures" / "html" @@ -162,6 +162,37 @@ def test_the_page_script_is_one_file_next_to_the_driver(): assert script.read_text(encoding="utf-8") == browser_module.BENCH_JS +class _Events: + """A page or DevTools session that ignores what a tab asks of it.""" + + def on(self, *args: Any) -> None: + """Ignore a listener.""" + + def send(self, *args: Any) -> None: + """Ignore a command.""" + + +def test_received_frames_count_the_messages_of_both_protocols(): + tab = Tab(cast(Any, _Events()), cast(Any, _Events())) + frames: list[str | bytes] = [ + '["event",{"delta":{}}]', + '["_ping"]', + '42/_event,["event",{"delta":{}}]', + '42["reload","/"]', + # Socket.IO's open, ack and ping, and binary frames carry no message. + '0{"sid":"a"}', + '40/_event,{"sid":"a"}', + "2", + b"\x00\x01", + ] + for frame in frames: + tab._on_frame_received(frame) + assert tab.ws_events == {"event": 2, "_ping": 1, "reload": 1} + assert tab.ws_bytes == sum( + len(frame.encode() if isinstance(frame, str) else frame) for frame in frames + ) + + @needs_chromium def test_marks_carry_both_clocks_and_map_onto_the_harness_clock( owner: ThreadPoolExecutor, browser: Browser, site: str diff --git a/tests/units/reflex_bench/drivers/test_events.py b/tests/units/reflex_bench/drivers/test_events.py index 2d3b59ecc18..41fc86a2b69 100644 --- a/tests/units/reflex_bench/drivers/test_events.py +++ b/tests/units/reflex_bench/drivers/test_events.py @@ -1,10 +1,11 @@ """Tests for reflex_bench.drivers.events. The generator runs against :class:`~reflex_bench.drivers.echo_server.EchoServer` -on a thread of the test process; subclasses script delays, dropped and reordered -answers, a new token, a missing hydration, a disconnect and a stall. Most tests -drive the sessions on the calling thread, as one generator process does; the -multi-process ones go through :class:`~reflex_bench.drivers.events.LoadRunner`. +on a thread of the test process, on the plain protocol unless a test names +Socket.IO; subclasses script delays, dropped and reordered answers, a new token, +a missing hydration, a disconnect and a stall. Most tests drive the sessions on +the calling thread, as one generator process does; the multi-process ones go +through :class:`~reflex_bench.drivers.events.LoadRunner`. """ from __future__ import annotations @@ -24,7 +25,7 @@ from reflex_bench.drivers.app_process import free_ports from reflex_bench.drivers.echo_server import EchoServer from reflex_bench.drivers.events import ( - EVENT_PREFIX, + CODECS, Endpoint, EventShape, GeneratorSaturated, @@ -33,6 +34,7 @@ LoadPlan, LoadResult, LoadRunner, + WireProtocol, open_sessions, run_load, ) @@ -48,8 +50,19 @@ delta_key=STATE, seq_var=SEQ_VAR, ) +WEBSOCKET = CODECS["websocket"] +SOCKETIO = CODECS["socketio"] +PROTOCOLS = pytest.mark.parametrize("protocol", ["websocket", "socketio"]) +# The hydration events of a session, by protocol. +HYDRATION = { + "websocket": [events.HYDRATE_AND_LOAD_EVENT], + "socketio": [events.HYDRATE_EVENT, events.ON_LOAD_EVENT], +} +# The events whose answer sets is_hydrated. +HYDRATING = {events.ON_LOAD_EVENT, events.HYDRATE_AND_LOAD_EVENT} # Captured from the playground: HEAD leaves empty fields out, 0.8.23 does not. -HEAD_REPLY = f'42/_event,["event",{{"delta":{{"{STATE}":{{"{SEQ_VAR}":7}}}}}}]' +HEAD_REPLY = f'["event",{{"delta":{{"{STATE}":{{"{SEQ_VAR}":7}}}}}}]' +SOCKETIO_REPLY = f'42/_event,["event",{{"delta":{{"{STATE}":{{"{SEQ_VAR}":7}}}}}}]' OLD_REPLY = ( f'42/_event,["event",{{"delta":{{"{STATE}":{{"{SEQ_VAR}":7}}}},' '"events":[],"final":true}]' @@ -136,12 +149,27 @@ def start(errors: list[str]) -> Awaitable[int | None]: class Scripted(EchoServer): """An echo server that records the seq events and asks a hook about each.""" - def __init__(self) -> None: - """Record nothing yet.""" - super().__init__(delta_key=STATE, seq_var=SEQ_VAR) + def __init__(self, protocol: WireProtocol = "websocket") -> None: + """Record nothing yet. + + Args: + protocol: The protocol to speak. + """ + super().__init__(delta_key=STATE, seq_var=SEQ_VAR, protocol=protocol) self.seqs: list[int] = [] self.tokens: list[str] = [] + async def end(self, ws: ServerConnection) -> None: + """End a session as reflex does: Socket.IO leaves the namespace, the plain protocol closes with a code. + + Args: + ws: The connection. + """ + if self.codec.protocol == "socketio": + await ws.send(events.SIO_DISCONNECT_FRAME) + else: + await ws.close(1008) + async def on_event(self, ws: ServerConnection, event: dict[str, Any]) -> None: """Record a seq event, then answer it unless the hook says otherwise. @@ -173,39 +201,118 @@ async def seq_event( return True -def test_event_url(): +def test_event_urls(): + assert ( + WEBSOCKET.url("http://localhost:8000", "tok") + == "ws://localhost:8000/_event?token=tok" + ) assert ( - events.event_url("http://localhost:8000", "tok") + SOCKETIO.url("http://localhost:8000", "tok") == "ws://localhost:8000/_event/?EIO=4&transport=websocket&token=tok" ) - assert events.event_url("https://app.example.com/", "tok").startswith( - "wss://app.example.com/_event/?" + assert ( + WEBSOCKET.url("https://app.example.com/", "tok") + == "wss://app.example.com/_event?token=tok" + ) + assert SOCKETIO.url("https://app.example.com/base/", "tok").startswith( + "wss://app.example.com/base/_event/?" ) def test_event_frames_always_carry_the_token(): - # 0.8.23 rejects an event without a token and HEAD ignores it, so every - # frame carries it. - frame = events.event_frame(f"{STATE}.set_seq", {"seq": 7}, token="tok") + # 0.8.23 rejects an event without a token and later releases ignore it, + # so every frame carries it. + frame = WEBSOCKET.event_frame(f"{STATE}.set_seq", {"seq": 7}, token="tok") assert frame == ( - f'42/_event,["event",{{"name":"{STATE}.set_seq","payload":{{"seq":7}},' + f'["event",{{"name":"{STATE}.set_seq","payload":{{"seq":7}},' '"router_data":{"pathname":"/","asPath":"/","query":{}},"token":"tok"}]' ) - frames = events._Frames(SHAPE, "tok", "/", index=3) - assert frames.event(7) == frame - name, event = json.loads(frames.event(8)[len(EVENT_PREFIX) :]) - assert (name, event["payload"]) == ("event", {"seq": 8}) - # A shape with a client var sends the session's index along. - contended = events._Frames(CONTENDED_SHAPE, "tok", "/", index=3) - _, event = json.loads(contended.event(8)[len(EVENT_PREFIX) :]) - assert event["payload"] == {"seq": 8, "client": 3} + socketio = SOCKETIO.event_frame(f"{STATE}.set_seq", {"seq": 7}, token="tok") + assert socketio == f"42/_event,{frame}" + for codec in (WEBSOCKET, SOCKETIO): + frames = events._Frames(codec, SHAPE, "tok", "/", index=3) + assert frames.event(7) == codec.event_frame( + f"{STATE}.set_seq", {"seq": 7}, token="tok" + ) + message = codec.parse(frames.event(8)) + assert message is not None + name, event = message + assert (name, event["payload"]) == ("event", {"seq": 8}) + # A shape with a client var sends the session's index along. + contended = events._Frames(codec, CONTENDED_SHAPE, "tok", "/", index=3) + message = codec.parse(contended.event(8)) + assert message is not None + assert message[1]["payload"] == {"seq": 8, "client": 3} + + +def test_the_plain_connect_frame_carries_the_hydration_as_its_boot_event(): + frame = WEBSOCKET.connect_frame( + token="tok", pathname="/item/42", query={"item_id": "42"} + ) + assert frame == ( + '["_connect",{"event":{"name":"reflex___state____state.hydrate_and_load",' + '"payload":{},"router_data":{"pathname":"/item/42","asPath":"/item/42",' + '"query":{"item_id":"42"}},"token":"tok"}}]' + ) + assert WEBSOCKET.load_events == () + assert not WEBSOCKET.greets + # Socket.IO joins the namespace and hydrates once acknowledged, as 0.8.23 needs. + assert SOCKETIO.connect_frame(token="tok", pathname="/") == "40/_event," + assert SOCKETIO.load_events == (events.HYDRATE_EVENT, events.ON_LOAD_EVENT) + assert SOCKETIO.greets + + +def test_both_codecs_decode_to_the_plain_protocols_messages(): + assert WEBSOCKET.parse('["_ping"]') == SOCKETIO.parse("2") == [events.PING] + assert WEBSOCKET.parse('["_handshake",{"protocol":2}]') == [ + events.HANDSHAKE, + {"protocol": 2}, + ] + assert SOCKETIO.parse('40/_event,{"sid":"a"}') == [events.HANDSHAKE, '{"sid":"a"}'] + assert SOCKETIO.parse('0{"sid":"a"}') == [events.OPENED, '{"sid":"a"}'] + assert SOCKETIO.parse('44/_event,{"message":"no"}') == [ + events.REFUSED, + '{"message":"no"}', + ] + assert SOCKETIO.parse("41/_event,") == SOCKETIO.parse("1") == [events.DISCONNECTED] + for codec in (WEBSOCKET, SOCKETIO): + assert codec.parse(codec.emit("new_token", "t")) == ["new_token", "t"] + assert codec.parse(b'["event",{}]') is None + for frame in ("{}", "[1]", "[]", "nope"): + assert WEBSOCKET.parse(frame) is None + assert SOCKETIO.parse("6") is None + assert SOCKETIO.parse("42/_event,nope") is None + assert (WEBSOCKET.pong, SOCKETIO.pong) == ('["_pong"]', "3") + assert (WEBSOCKET.leave, SOCKETIO.leave) == (None, "41/_event,") -def test_replies_of_both_versions_carry_the_same_delta(): - for reply in (HEAD_REPLY, OLD_REPLY): - name, update = json.loads(reply[len(EVENT_PREFIX) :]) - assert name == "event" - assert update["delta"] == {STATE: {SEQ_VAR: 7}} +@pytest.mark.parametrize( + ("reply", "codec"), + [(HEAD_REPLY, WEBSOCKET), (SOCKETIO_REPLY, SOCKETIO), (OLD_REPLY, SOCKETIO)], +) +def test_replies_of_every_version_carry_the_same_delta(reply: str, codec: Any): + message = codec.parse(reply) + assert message is not None + name, update = message + assert name == "event" + assert update["delta"] == {STATE: {SEQ_VAR: 7}} + + +@PROTOCOLS +def test_the_protocol_is_detected(protocol: WireProtocol): + # Each server refuses the websocket on the other protocol's path. + with serving(Scripted(protocol)) as url: + assert asyncio.run(events.detect_protocol(url)) == protocol + codec = asyncio.run(events.resolve_codec(Endpoint(url))) + assert codec is CODECS[protocol] + + +def test_a_named_protocol_is_not_detected(): + port = free_ports()[0] + endpoint = Endpoint(f"http://127.0.0.1:{port}", protocol="socketio") + assert asyncio.run(events.resolve_codec(endpoint)) is SOCKETIO + with pytest.raises(OSError): + asyncio.run(events.detect_protocol(endpoint.backend_url)) def test_a_refused_namespace_fails_the_session(): @@ -216,7 +323,7 @@ async def on_connect(self, ws, sid): """Send a connect_error instead of the ack.""" await ws.send('44/_event,{"message":"Unable to connect"}') - with serving(Refusing()) as url: + with serving(Refusing("socketio")) as url: result = run_inline(plan(url, sessions=1)) assert result.sent == 0 (error,) = result.session_errors @@ -224,6 +331,22 @@ async def on_connect(self, ws, sid): assert "Unable to connect" in error +def test_a_session_closed_in_the_handshake_fails(): + class Closing(Scripted): + """Closes the connect with a policy violation, as reflex does without a token.""" + + async def on_connect(self, ws, sid): + """Close instead of the handshake.""" + await ws.close(1008) + + with serving(Closing()) as url: + result = run_inline(plan(url, sessions=1)) + assert result.sent == 0 + (error,) = result.session_errors + assert "could not connect: ConnectionClosedError" in error + assert "1008" in error + + def test_an_unexpected_frame_fails_the_session(): # reflex never sends binary attachments; anything the generator does not # speak ends the session with a reason instead of being ignored. @@ -269,8 +392,9 @@ def test_schedule_spreads_sessions_evenly(): assert merged == [int(k * second / 30) for k in range(30)] -def test_every_event_is_answered(): - server = Scripted() +@PROTOCOLS +def test_every_event_is_answered(protocol: WireProtocol): + server = Scripted(protocol) with serving(server) as url: result = run_inline(plan(url, sessions=3, rate=150.0)) # 150 ev/s over the 0.5 s window, as planned. @@ -428,16 +552,17 @@ def test_an_unordered_shape_takes_answers_in_any_order(): assert result.answered == result.sent -def test_a_new_token_is_adopted(): +@PROTOCOLS +def test_a_new_token_is_adopted(protocol: WireProtocol): class Renaming(Scripted): """Hands every connection a new token, as reflex does for a duplicate tab.""" async def on_connect(self, ws, sid): - """Send the new token before the namespace ack, like python-socketio.""" - await ws.send(events.emit_frame("new_token", f"fresh-{sid}")) + """Send the new token before the handshake, as reflex does.""" + await ws.send(self.codec.emit("new_token", f"fresh-{sid}")) await super().on_connect(ws, sid) - server = Renaming() + server = Renaming(protocol) with serving(server) as url: result = run_inline(plan(url, sessions=1, rate=20.0)) assert result.unanswered == 0 @@ -445,30 +570,32 @@ async def on_connect(self, ws, sid): assert all(token.startswith("fresh-") for token in server.tokens) -def test_a_session_that_never_hydrates_fails(monkeypatch: pytest.MonkeyPatch): - class NeverHydrated(Scripted): - """Ignores on_load_internal, so is_hydrated never becomes true.""" - - async def on_event(self, ws, event): - """Answer everything but on_load_internal.""" - if event["name"] != events.ON_LOAD_EVENT: - await super().on_event(ws, event) - +@PROTOCOLS +def test_a_session_that_never_hydrates_fails( + monkeypatch: pytest.MonkeyPatch, protocol: WireProtocol +): monkeypatch.setattr(events, "PRIME_TIMEOUT_S", 0.5) - with serving(NeverHydrated()) as url: + with serving(NeverHydrated(protocol)) as url: result = run_inline(plan(url, sessions=1)) assert result.sent == 0 assert len(result.session_errors) == 1 assert "not hydrated within 0.5 s" in result.session_errors[0] -def test_a_disconnect_mid_run_fails_the_session(): +@pytest.mark.parametrize( + ("protocol", "reason"), + [ + ("websocket", "the websocket closed: received 1008"), + ("socketio", "the server disconnected the session"), + ], +) +def test_a_disconnect_mid_run_fails_the_session(protocol: WireProtocol, reason: str): class Disconnecting(Scripted): - """Ends the namespace of the first connection instead of answering seq 5.""" + """Ends the session of the first connection instead of answering seq 5.""" def __init__(self) -> None: """Know no connection yet.""" - super().__init__() + super().__init__(protocol) self.first: ServerConnection | None = None async def seq_event(self, ws, event, seq): @@ -480,14 +607,14 @@ async def seq_event(self, ws, event, seq): if self.first is None: self.first = ws if ws is self.first and seq == 5: - await ws.send(events.DISCONNECT_FRAME) + await self.end(ws) return False return True with serving(Disconnecting()) as url: result = run_inline(plan(url, sessions=2, rate=40.0, warmup_s=0.0)) assert len(result.session_errors) == 1 - assert "disconnected" in result.session_errors[0] + assert reason in result.session_errors[0] # One session stopped after seq 5, which stays unanswered; the other ran on. assert result.sent == 15 assert result.unanswered == 1 @@ -581,9 +708,13 @@ def test_closed_loop_reports_service_time_only(): class Board(EchoServer): """Answers like the playground's shared board: a join links a connection to a token, and a seq event reaches every linked connection.""" - def __init__(self) -> None: - """Link nothing yet.""" - super().__init__(delta_key=BOARD, seq_var=SEQ_VAR) + def __init__(self, protocol: WireProtocol = "websocket") -> None: + """Link nothing yet. + + Args: + protocol: The protocol to speak. + """ + super().__init__(delta_key=BOARD, seq_var=SEQ_VAR, protocol=protocol) self.linked: dict[str, list[ServerConnection]] = {} self.names: dict[ServerConnection, list[str]] = {} self.payloads: list[dict[str, Any]] = [] @@ -598,7 +729,7 @@ async def on_event(self, ws: ServerConnection, event: dict[str, Any]) -> None: self.names.setdefault(ws, []).append(event["name"]) if event["name"] == LINK.name: self.linked.setdefault(event["payload"]["token"], []).append(ws) - await ws.send(events.emit_frame("event", {"delta": {BOARD: {SEQ_VAR: 0}}})) + await ws.send(self.codec.emit("event", {"delta": {BOARD: {SEQ_VAR: 0}}})) elif event["name"] == BOARD_SHAPE.name: self.payloads.append(event["payload"]) delta = { @@ -609,7 +740,7 @@ async def on_event(self, ws: ServerConnection, event: dict[str, Any]) -> None: } for index, linked in enumerate(self.linked_to(ws)): await self.deliver( - linked, index, events.emit_frame("event", {"delta": delta}) + linked, index, self.codec.emit("event", {"delta": delta}) ) else: await super().on_event(ws, event) @@ -643,16 +774,20 @@ def board_plan(url: str, **overrides: Any) -> LoadPlan: return plan(url, **{"shape": BOARD_SHAPE, "link": LINK, **overrides}) -def test_linked_sessions_join_after_hydrating_and_before_the_load(): - server = Board() +@PROTOCOLS +def test_linked_sessions_join_after_hydrating_and_before_the_load( + protocol: WireProtocol, +): + server = Board(protocol) with serving(server) as url: result = run_inline(board_plan(url, mode="closed", rate=None, sessions=3)) assert result.session_errors == [] assert result.answered > 0 assert len(server.linked["board-1"]) == 3 + primed = [*HYDRATION[protocol], LINK.name] for names in server.names.values(): - assert names[:3] == [events.HYDRATE_EVENT, events.ON_LOAD_EVENT, LINK.name] - assert set(names[3:]) == {BOARD_SHAPE.name} + assert names[: len(primed)] == primed + assert set(names[len(primed) :]) == {BOARD_SHAPE.name} def test_a_session_whose_join_is_not_acknowledged_fails( @@ -933,27 +1068,46 @@ def test_a_generator_process_ends_when_its_parent_goes_away(): assert proc.exitcode is not None +class NeverHydrated(Scripted): + """Ignores the events that complete the hydration, so is_hydrated never becomes true.""" + + async def on_event(self, ws, event): + """Answer everything but those.""" + if event["name"] not in HYDRATING: + await super().on_event(ws, event) + + class Counting(Scripted): - """Counts the hydrations and the disconnects.""" + """Counts the hydrations and the sessions that ended; the protocol's probe is no session.""" - def __init__(self) -> None: - """Count nothing yet.""" - super().__init__() + def __init__(self, protocol: WireProtocol = "websocket") -> None: + """Count nothing yet. + + Args: + protocol: The protocol to speak. + """ + super().__init__(protocol) self.hydrated = 0 self.left = 0 + self._sessions: set[ServerConnection] = set() + + async def on_connect(self, ws, sid): + """Count a session.""" + self._sessions.add(ws) + await super().on_connect(ws, sid) async def on_event(self, ws, event): - """Count on_load_internal, then answer.""" - if event["name"] == events.ON_LOAD_EVENT: + """Count the event that completes a hydration, then answer.""" + if event["name"] in HYDRATING: self.hydrated += 1 await super().on_event(ws, event) async def handle(self, ws): - """Count a connection that ends.""" + """Count a session that ends.""" try: await super().handle(ws) finally: - self.left += 1 + self.left += ws in self._sessions def test_open_sessions_primes_and_closes_them(): @@ -978,14 +1132,6 @@ async def hold() -> list[str]: def test_open_sessions_reports_the_sessions_that_fail( monkeypatch: pytest.MonkeyPatch, ): - class NeverHydrated(Scripted): - """Ignores on_load_internal, so is_hydrated never becomes true.""" - - async def on_event(self, ws, event): - """Answer everything but on_load_internal.""" - if event["name"] != events.ON_LOAD_EVENT: - await super().on_event(ws, event) - monkeypatch.setattr(events, "PRIME_TIMEOUT_S", 0.5) with serving(NeverHydrated()) as url: loop = events.event_loop() @@ -1003,19 +1149,27 @@ async def hold() -> list[str]: class Holding(Counting): - """Pings often and records each connection's frames.""" + """Pings often and records each session's frames and close code.""" - def __init__(self) -> None: - """Record nothing yet.""" - super().__init__() + def __init__(self, protocol: WireProtocol = "websocket") -> None: + """Record nothing yet. + + Args: + protocol: The protocol to speak. + """ + super().__init__(protocol) self.ping_interval_s = 0.2 self.frames: list[list[str | bytes]] = [] + self.close_codes: list[int | None] = [] async def handle(self, ws): - """Serve a connection, recording its frames.""" + """Serve a connection, recording a session's frames and how it closed.""" frames: list[str | bytes] = [] - self.frames.append(frames) - await super().handle(cast(ServerConnection, _Recording(ws, frames))) + recording = cast(ServerConnection, _Recording(ws, frames)) + await super().handle(recording) + if recording in self._sessions: + self.frames.append(frames) + self.close_codes.append(ws.close_code) class _Recording: @@ -1071,8 +1225,10 @@ def wait_for(condition: Callable[[], bool], timeout: float = 10.0) -> bool: return True -def test_a_hold_keeps_hydrated_sessions_until_closed(): - server = Holding() +@PROTOCOLS +def test_a_hold_keeps_hydrated_sessions_until_closed(protocol: WireProtocol): + server = Holding(protocol) + codec = CODECS[protocol] with serving(server) as url: hold = events.hold_sessions(Endpoint(url), 3) try: @@ -1085,9 +1241,13 @@ def test_a_hold_keeps_hydrated_sessions_until_closed(): hold.kill() assert wait_for(lambda: server.left == 3) for frames in server.frames: - # The sessions answered the pings while held, then left the namespace. - assert events.PONG in frames - assert frames[-1] == events.DISCONNECT_FRAME + # The sessions answered the pings while held, then left. + assert codec.pong in frames + if codec.leave is not None: + assert frames[-1] == codec.leave + if codec.leave is None: + # The plain protocol leaves with a normal close. + assert server.close_codes == [1000] * 3 assert multiprocessing.active_children() == [] @@ -1104,16 +1264,14 @@ def test_a_hold_raises_when_sessions_cannot_connect(): def test_close_reports_sessions_lost_while_held(): class Dropping(Holding): - """Ends the namespace of the first hydrated connection half a second later.""" + """Closes the first hydrated connection half a second later.""" async def on_event(self, ws, event): - """Answer, then schedule the disconnect of the first connection.""" + """Answer, then schedule the end of the first connection.""" await super().on_event(ws, event) - if event["name"] == events.ON_LOAD_EVENT and self.hydrated == 1: + if event["name"] in HYDRATING and self.hydrated == 1: loop = asyncio.get_running_loop() - loop.call_later( - 0.5, lambda: loop.create_task(ws.send(events.DISCONNECT_FRAME)) - ) + loop.call_later(0.5, lambda: loop.create_task(self.end(ws))) with serving(Dropping()) as url: hold = events.hold_sessions(Endpoint(url), 2) @@ -1124,20 +1282,20 @@ async def on_event(self, ws, event): finally: hold.kill() assert len(errors) == 1 - assert "disconnected" in errors[0] + assert "the websocket closed: received 1008" in errors[0] assert multiprocessing.active_children() == [] def test_kill_ends_a_hold_that_is_still_priming(): - class NeverHydrated(Holding): - """Ignores on_load_internal, so no session finishes priming.""" + class NeverPrimed(Holding): + """Ignores the events that complete the hydration, so no session finishes priming.""" async def on_event(self, ws, event): - """Answer everything but on_load_internal.""" - if event["name"] != events.ON_LOAD_EVENT: + """Answer everything but those.""" + if event["name"] not in HYDRATING: await super().on_event(ws, event) - with serving(NeverHydrated()) as url: + with serving(NeverPrimed()) as url: hold = events.hold_sessions(Endpoint(url), 2) killer = threading.Timer(1.0, hold.kill) killer.start() diff --git a/tests/units/reflex_bench/suites/test_wire.py b/tests/units/reflex_bench/suites/test_wire.py index 8aa867ac1fb..4cff2809ed0 100644 --- a/tests/units/reflex_bench/suites/test_wire.py +++ b/tests/units/reflex_bench/suites/test_wire.py @@ -1,8 +1,9 @@ """Tests for reflex_bench.suites.wire. Nothing here starts reflex: the sessions run against scripted transports (or -the echo server on a loopback socket), and the benchmarks run through the -scheduler with the app and the websocket replaced by fakes. +the echo server on a loopback socket), on the plain protocol unless a test +names Socket.IO, and the benchmarks run through the scheduler with the app and +the websocket replaced by fakes. """ from __future__ import annotations @@ -13,7 +14,7 @@ import contextlib import dataclasses import json -from collections.abc import AsyncIterator, Callable +from collections.abc import AsyncIterator, Callable, Iterator, Sequence from pathlib import Path from typing import Any @@ -22,23 +23,31 @@ from reflex_bench.drivers.app_process import CliResult from reflex_bench.drivers.echo_server import EchoServer from reflex_bench.drivers.events import ( - CONNECT_FRAME, - DISCONNECT_FRAME, + CODECS, + CONNECT, + DISCONNECTED, + HANDSHAKE, + HYDRATE_AND_LOAD_EVENT, HYDRATE_EVENT, HYDRATED_VAR, ON_LOAD_EVENT, - PING, PONG, ROOT_STATE, + SIO_CONNECT_FRAME, + SIO_DISCONNECT_FRAME, + SIO_PING, + SIO_PONG, + Codec, Endpoint, ProtocolError, - emit_frame, - event_frame, + WireProtocol, ) from reflex_bench.scheduler import Planned, Policy, Scheduler, plan from reflex_bench.suites import events as events_suite from reflex_bench.suites import wire from reflex_bench.suites.events import SEQ_VAR +from websockets.exceptions import ConnectionClosedError +from websockets.frames import Close from tests.units.reflex_bench.drivers.test_events import serving from tests.units.reflex_bench.factories import make_subject @@ -47,8 +56,16 @@ BENCH_STATE = STATES.bench SHAPES = {name: shape_of(STATES) for name, shape_of in events_suite.SHAPES.items()} PLAYGROUND_STATE = "reflex___state____state.playground___state____playground_state" +WEBSOCKET = CODECS["websocket"] +SOCKETIO = CODECS["socketio"] +HANDSHAKE_FRAME = ( + '["_handshake",{"ping_interval":25.0,"ping_timeout":120.0,"protocol":2,' + '"max_message_size":10000000}]' +) OPEN = '0{"sid":"abc","upgrades":[],"pingInterval":25000,"pingTimeout":20000}' ACK = '40/_event,{"sid":"abc"}' +# How reflex ends a plain session: it closes the websocket with a code. +CLOSED = ConnectionClosedError(Close(1008, ""), None) CHANGES = ( "set_scalar", "append_item", @@ -62,28 +79,32 @@ def size(*frames: str) -> int: return sum(len(frame.encode()) for frame in frames) -def delta_frame(delta: dict[str, Any], **more: Any) -> str: - return emit_frame("event", {"delta": delta, **more}) +def delta_frame(delta: dict[str, Any], codec: Codec = WEBSOCKET, **more: Any) -> str: + return codec.emit("event", {"delta": delta, **more}) + + +# A frame of the scripted server, or the error its websocket raises instead. +Reply = str | bytes | Exception class FakeTransport: """A scripted server: every sent frame queues the replies a script gives.""" def __init__( - self, script: Callable[[str], list[str]], greeting: list[str] | None = None + self, + script: Callable[[str], Sequence[Reply]], + greeting: Sequence[Reply] = (), ) -> None: """Queue the server's greeting. Args: script: Maps a sent frame to the frames it triggers. - greeting: The frames waiting before anything is sent; the engine.io - open by default. + greeting: The frames waiting before anything is sent, e.g. the + engine.io open of Socket.IO; none by default. """ self.script = script self.sent: list[str] = [] - self.inbox: collections.deque[str | bytes] = collections.deque( - greeting or [OPEN] - ) + self.inbox: collections.deque[Reply] = collections.deque(greeting) async def send(self, message: str) -> None: """Record a frame and queue what the script answers. @@ -106,58 +127,74 @@ async def recv(self) -> str | bytes: if not self.inbox: msg = "the session waits for a frame the script never sends" raise AssertionError(msg) - return self.inbox.popleft() + reply = self.inbox.popleft() + if isinstance(reply, Exception): + raise reply + return reply def playground_script( - *, new_token: str | None = None, replies: dict[str, list[str]] | None = None -) -> Callable[[str], list[str]]: + *, + new_token: str | None = None, + replies: dict[str, list[Reply]] | None = None, + codec: Codec = WEBSOCKET, +) -> Callable[[str], list[Reply]]: """Script the playground's answers: a hydration in two deltas, then echoes. Args: - new_token: A token handed out before the namespace ack. + new_token: A token handed out before the handshake. replies: The frames answering an event, by handler name; other events get one echo of their sequence number, and ``on_load_internal`` the ``is_hydrated`` delta, with the router's page away from ``/``. + codec: The protocol, which decodes the session's frames too. Returns: The script. """ - def script(message: str) -> list[str]: - if message == CONNECT_FRAME: - handed = [emit_frame("new_token", new_token)] if new_token else [] - return [*handed, ACK] - if message in {PONG, DISCONNECT_FRAME}: - return [] - event = json.loads(message.partition(",")[2])[1] + def answer(event: dict[str, Any]) -> list[Reply]: name = event["name"] if replies and name in replies: return replies[name] + snapshot = delta_frame( + { + ROOT_STATE: {HYDRATED_VAR: False, "router_rx_state_": {"a": 1}}, + BENCH_STATE: {SEQ_VAR: 0, "part_a_rx_state_": 0}, + PLAYGROUND_STATE: {"count_rx_state_": 0, "items_rx_state_": ["alpha"]}, + }, + codec, + ) if name == HYDRATE_EVENT: - return [ - delta_frame({ - ROOT_STATE: {HYDRATED_VAR: False, "router_rx_state_": {"a": 1}}, - BENCH_STATE: {SEQ_VAR: 0, "part_a_rx_state_": 0}, - PLAYGROUND_STATE: { - "count_rx_state_": 0, - "items_rx_state_": ["alpha"], - }, - }) - ] + return [snapshot] + if name == HYDRATE_AND_LOAD_EVENT: + return [snapshot, navigation_reply(event["router_data"], codec)] if name == ON_LOAD_EVENT: - return [navigation_reply(event["router_data"])] + return [navigation_reply(event["router_data"], codec)] state, _, _handler = name.rpartition(".") - return [delta_frame({state: {SEQ_VAR: event["payload"]["seq"]}})] + return [delta_frame({state: {SEQ_VAR: event["payload"]["seq"]}}, codec)] + + def script(message: str) -> list[Reply]: + args = codec.parse(message) + if args is None or args[0] in {PONG, DISCONNECTED}: + # Socket.IO's pong decodes to nothing. + return [] + handed = [codec.emit("new_token", new_token)] if new_token else [] + if args[0] == CONNECT: + return [*handed, HANDSHAKE_FRAME, *answer(args[1]["event"])] + if args[0] == HANDSHAKE: + # Socket.IO's namespace join. + return [*handed, ACK] + return answer(args[1]) return script -def navigation_reply(router_data: dict[str, Any]) -> str: +def navigation_reply(router_data: dict[str, Any], codec: Codec = WEBSOCKET) -> str: """Build the delta that completes an ``on_load_internal``. Args: router_data: The event's router data. + codec: The protocol. Returns: ``is_hydrated`` set; away from ``/`` the root state's router too. @@ -167,7 +204,20 @@ def navigation_reply(router_data: dict[str, Any]) -> str: root["router_rx_state_"] = { "page": {"path": router_data["pathname"], "params": router_data["query"]} } - return delta_frame({ROOT_STATE: root}) + return delta_frame({ROOT_STATE: root}, codec) + + +def answers(script: Callable[[str], Sequence[Reply]], message: str) -> list[str]: + """Take the text frames a script answers a frame with. + + Args: + script: The script. + message: The frame sent. + + Returns: + Its text frames. + """ + return [frame for frame in script(message) if isinstance(frame, str)] def run(work: Any) -> Any: @@ -184,16 +234,21 @@ def test_hydration_counts_every_frame_until_hydrated(): ws = FakeTransport(playground_script(new_token="tok-2")) session = wire.WireSession(ws, "tok-1") hydration = run(session.hydrate()) - hydrate = event_frame(HYDRATE_EVENT, {}, token="tok-2", pathname="/") - on_load = event_frame(ON_LOAD_EVENT, {}, token="tok-2", pathname="/") - # The new token is adopted before the hydration events go out. - assert ws.sent == [CONNECT_FRAME, hydrate, on_load] - assert hydration.sent_bytes == size(CONNECT_FRAME, hydrate, on_load) - assert hydration.sent_frames == 3 - first, second = playground_script()(hydrate)[0], playground_script()(on_load)[0] - token_frame = emit_frame("new_token", "tok-2") - assert hydration.received_bytes == size(OPEN, token_frame, ACK, first, second) - assert hydration.received_frames == 5 + # The connect frame carries the hydration as its boot event. + connect = WEBSOCKET.connect_frame(token="tok-1", pathname="/") + assert ws.sent == [connect] + assert '"name":"reflex___state____state.hydrate_and_load"' in connect + assert hydration.sent_bytes == size(connect) + assert hydration.sent_frames == 1 + token_frame, handshake, first, second = answers( + playground_script(new_token="tok-2"), connect + ) + assert (token_frame, handshake) == ( + WEBSOCKET.emit("new_token", "tok-2"), + HANDSHAKE_FRAME, + ) + assert hydration.received_bytes == size(token_frame, handshake, first, second) + assert hydration.received_frames == 4 assert hydration.largest_frame_bytes == size(first) assert hydration.delta_bytes == { ROOT_STATE: size(json.dumps({HYDRATED_VAR: False, "router_rx_state_": {"a": 1}}, separators=(",", ":"))) @@ -201,44 +256,88 @@ def test_hydration_counts_every_frame_until_hydrated(): BENCH_STATE: size('{"last_seq_rx_state_":0,"part_a_rx_state_":0}'), PLAYGROUND_STATE: size('{"count_rx_state_":0,"items_rx_state_":["alpha"]}'), } # fmt: skip + # Later events carry the new token. assert session.token == "tok-2" -def test_a_ping_during_hydration_is_answered_and_not_counted(): - ws = FakeTransport(playground_script(), greeting=[OPEN, PING]) - hydration = run(wire.WireSession(ws, "tok").hydrate()) - assert ws.sent[:2] == [CONNECT_FRAME, PONG] - # Keepalives are timing, not payload: neither the ping nor the pong counts. +def test_a_socketio_hydration_joins_then_sends_the_hydration_events(): + script = playground_script(new_token="tok-2", codec=SOCKETIO) + ws = FakeTransport(script, greeting=[OPEN]) + session = wire.WireSession(ws, "tok-1", codec=SOCKETIO) + hydration = run(session.hydrate()) + hydrate = SOCKETIO.event_frame(HYDRATE_EVENT, {}, token="tok-2", pathname="/") + on_load = SOCKETIO.event_frame(ON_LOAD_EVENT, {}, token="tok-2", pathname="/") + # The new token is adopted before the hydration events go out. + assert ws.sent == [SIO_CONNECT_FRAME, hydrate, on_load] + assert hydration.sent_bytes == size(SIO_CONNECT_FRAME, hydrate, on_load) assert hydration.sent_frames == 3 - assert hydration.received_frames == 4 - assert hydration.sent_bytes == size(CONNECT_FRAME, *ws.sent[2:]) + (first,), (second,) = answers(script, hydrate), answers(script, on_load) + token_frame = SOCKETIO.emit("new_token", "tok-2") + assert hydration.received_bytes == size(OPEN, token_frame, ACK, first, second) + assert hydration.received_frames == 5 + assert hydration.largest_frame_bytes == size(first) + assert set(hydration.delta_bytes) == {ROOT_STATE, BENCH_STATE, PLAYGROUND_STATE} + assert session.token == "tok-2" + + +@pytest.mark.parametrize( + ("protocol", "ping", "pong", "sent_frames", "received_frames"), + [ + ("websocket", '["_ping"]', '["_pong"]', 1, 3), + ("socketio", SIO_PING, SIO_PONG, 3, 4), + ], +) +def test_a_ping_during_hydration_is_answered_and_not_counted( + protocol: WireProtocol, ping: str, pong: str, sent_frames: int, received_frames: int +): + codec = CODECS[protocol] + greeting = [OPEN, ping] if codec.greets else [ping] + ws = FakeTransport(playground_script(codec=codec), greeting=greeting) + hydration = run(wire.WireSession(ws, "tok", codec=codec).hydrate()) + assert ws.sent[1] == pong + # Keepalives are timing, not payload: neither the ping nor the pong counts. + assert hydration.sent_frames == sent_frames + assert hydration.received_frames == received_frames + assert hydration.sent_bytes == size(ws.sent[0], *ws.sent[2:]) @pytest.mark.parametrize( - ("frames", "match"), + ("protocol", "frames", "match"), [ - (['44/_event,{"message":"nope"}'], "refused"), - ([DISCONNECT_FRAME], "disconnected"), - (["1"], "disconnected"), - (["6"], "unexpected frame"), - ([emit_frame("reload", "/")], "reload"), + ("websocket", [CLOSED], "received 1008"), + ("websocket", ['{"not":"a message"}'], "unexpected frame"), + ("websocket", [b"\x00\x01"], "unexpected frame"), + ("websocket", [WEBSOCKET.emit("reload", "/")], "reload"), + ("socketio", ['44/_event,{"message":"nope"}'], "refused"), + ("socketio", [SIO_DISCONNECT_FRAME], "disconnected"), + ("socketio", ["1"], "disconnected"), + ("socketio", ["6"], "unexpected frame"), + ("socketio", [SOCKETIO.emit("reload", "/")], "reload"), ], ) def test_a_frame_that_ends_the_session_fails_the_hydration( - frames: list[str], match: str + protocol: WireProtocol, frames: list[Reply], match: str ): - ws = FakeTransport(lambda message: frames if message == CONNECT_FRAME else []) - with pytest.raises(ProtocolError, match=match): - run(wire.WireSession(ws, "tok").hydrate()) + codec = CODECS[protocol] + + def script(message: str) -> list[Reply]: + args = codec.parse(message) + return frames if args is not None and args[0] in {CONNECT, HANDSHAKE} else [] + + ws = FakeTransport(script, greeting=[OPEN] if codec.greets else ()) + with pytest.raises((ProtocolError, ConnectionClosedError), match=match): + run(wire.WireSession(ws, "tok", codec=codec).hydrate()) @pytest.mark.parametrize( ("first", "match"), [(ACK, "did not open"), ("3", "unexpected frame")] ) -def test_a_server_that_does_not_open_fails_the_hydration(first: str, match: str): +def test_a_socketio_server_that_does_not_open_fails_the_hydration( + first: str, match: str +): ws = FakeTransport(lambda message: [], greeting=[first]) with pytest.raises(ProtocolError, match=match): - run(wire.WireSession(ws, "tok").hydrate()) + run(wire.WireSession(ws, "tok", codec=SOCKETIO).hydrate()) async def hydrated_exchange( @@ -252,7 +351,9 @@ async def hydrated_exchange( def test_an_exchange_counts_the_request_and_its_reply(): ws = FakeTransport(playground_script()) exchange = run(hydrated_exchange(ws, "simple", seq=7)) - request = event_frame(SHAPES["simple"].name, {"seq": 7}, token="tok", pathname="/") + request = WEBSOCKET.event_frame( + SHAPES["simple"].name, {"seq": 7}, token="tok", pathname="/" + ) reply = delta_frame({BENCH_STATE: {SEQ_VAR: 7}}) assert ws.sent[-1] == request assert exchange.request_bytes == size(request) @@ -306,7 +407,7 @@ def test_a_navigation_sends_on_load_from_the_new_route_until_hydrated_again(): session = wire.WireSession(ws, "tok") run(session.hydrate()) exchange = run(session.navigate("/item/42", {"item_id": "42"})) - request = event_frame( + request = WEBSOCKET.event_frame( ON_LOAD_EVENT, {}, token="tok", pathname="/item/42", query={"item_id": "42"} ) reply = navigation_reply({"pathname": "/item/42", "query": {"item_id": "42"}}) @@ -326,7 +427,7 @@ def test_a_navigation_sends_on_load_from_the_new_route_until_hydrated_again(): ) # Later events come from the new route. run(session.exchange(SHAPES["simple"], 2)) - assert ws.sent[-1] == event_frame( + assert ws.sent[-1] == WEBSOCKET.event_frame( SHAPES["simple"].name, {"seq": 2}, token="tok", @@ -354,16 +455,45 @@ def test_a_navigation_with_on_load_events_is_summed_until_hydrated(): assert not ws.inbox -def test_a_disconnect_while_waiting_for_the_reply_fails(): +@pytest.mark.parametrize( + ("protocol", "end", "error", "match"), + [ + ("websocket", CLOSED, ConnectionClosedError, "received 1008"), + ("socketio", SIO_DISCONNECT_FRAME, ProtocolError, "disconnected"), + ], +) +def test_a_disconnect_while_waiting_for_the_reply_fails( + protocol: WireProtocol, end: Reply, error: type[Exception], match: str +): + codec = CODECS[protocol] simple = SHAPES["simple"].name - ws = FakeTransport(playground_script(replies={simple: [DISCONNECT_FRAME]})) - with pytest.raises(ProtocolError, match="disconnected"): - run(hydrated_exchange(ws, "simple")) + ws = FakeTransport( + playground_script(replies={simple: [end]}, codec=codec), + greeting=[OPEN] if codec.greets else (), + ) + async def exchange() -> wire.Exchange: + session = wire.WireSession(ws, "tok", codec=codec) + await session.hydrate() + return await session.exchange(SHAPES["simple"], 1) -def test_measure_connects_hydrates_and_leaves_the_namespace( - monkeypatch: pytest.MonkeyPatch, -): + with pytest.raises(error, match=match): + run(exchange()) + + +@contextlib.contextmanager +def connecting( + monkeypatch: pytest.MonkeyPatch, codec: Codec +) -> Iterator[tuple[list[FakeTransport], list[str], list[dict[str, str]]]]: + """Replace the websocket connect with scripted transports of a protocol. + + Args: + monkeypatch: Patches the connect. + codec: The protocol. + + Yields: + The transports, the URLs and the headers of every connect. + """ transports: list[FakeTransport] = [] urls: list[str] = [] headers: list[dict[str, str]] = [] @@ -372,30 +502,71 @@ def test_measure_connects_hydrates_and_leaves_the_namespace( async def connect(url: str, **kwargs: Any) -> AsyncIterator[FakeTransport]: urls.append(url) headers.append(kwargs["additional_headers"]) - transports.append(ws := FakeTransport(playground_script())) + greeting = [OPEN] if codec.greets else [] + transports.append( + ws := FakeTransport(playground_script(codec=codec), greeting=greeting) + ) yield ws monkeypatch.setattr(wire, "connect", connect) - hydration = wire.measure( - Endpoint("http://localhost:8000", "/page"), wire.WireSession.hydrate - ) + yield transports, urls, headers + + +def test_measure_connects_hydrates_and_closes(monkeypatch: pytest.MonkeyPatch): + with connecting(monkeypatch, WEBSOCKET) as (transports, urls, headers): + hydration = wire.measure( + Endpoint("http://localhost:8000", "/page", protocol="websocket"), + wire.WireSession.hydrate, + ) + (ws,) = transports + assert urls[0].startswith("ws://localhost:8000/_event?token=") + # A browser sends the page's origin; the router's page host comes from it. + assert headers == [{"Origin": "http://localhost:8000"}] + assert hydration.received_frames == 3 + # The plain protocol leaves by closing the websocket: the connect is all it sent. + (connect,) = ws.sent + assert '"pathname":"/page"' in connect + + +def test_measure_joins_and_leaves_the_socketio_namespace( + monkeypatch: pytest.MonkeyPatch, +): + with connecting(monkeypatch, SOCKETIO) as (transports, urls, _headers): + hydration = wire.measure( + Endpoint("http://localhost:8000", "/page", protocol="socketio"), + wire.WireSession.hydrate, + ) (ws,) = transports assert urls[0].startswith( "ws://localhost:8000/_event/?EIO=4&transport=websocket&token=" ) - # A browser sends the page's origin; the router's page host comes from it. - assert headers == [{"Origin": "http://localhost:8000"}] assert hydration.received_frames == 4 - assert ws.sent[-1] == DISCONNECT_FRAME + assert ws.sent[-1] == SIO_DISCONNECT_FRAME assert '"pathname":"/page"' in ws.sent[1] -def test_measure_against_the_echo_server_counts_real_frames(): - with serving(EchoServer(delta_key=BENCH_STATE, seq_var=SEQ_VAR)) as url: +@pytest.mark.parametrize( + ("protocol", "hydration_frames", "hydration_deltas"), + [ + ("websocket", 2, ['{"is_hydrated_rx_state_":true}']), + ( + "socketio", + 4, + ['{"is_hydrated_rx_state_":false}', '{"is_hydrated_rx_state_":true}'], + ), + ], +) +def test_measure_against_the_echo_server_detects_its_protocol_and_counts_real_frames( + protocol: WireProtocol, hydration_frames: int, hydration_deltas: list[str] +): + codec = CODECS[protocol] + echo = EchoServer(delta_key=BENCH_STATE, seq_var=SEQ_VAR, protocol=protocol) + with serving(echo) as url: async def work( session: wire.WireSession, ) -> tuple[wire.Hydration, wire.Exchange, wire.Exchange]: + assert session.codec is codec return ( await session.hydrate(), await session.exchange(SHAPES["simple"], 3), @@ -403,24 +574,20 @@ async def work( ) hydration, exchange, navigation = wire.measure(Endpoint(url), work) - reply = emit_frame("event", {"delta": {BENCH_STATE: {SEQ_VAR: 3}}, "events": []}) + reply = codec.emit("event", {"delta": {BENCH_STATE: {SEQ_VAR: 3}}, "events": []}) assert exchange.response_bytes == size(reply) assert exchange.reply == reply - hydrated = emit_frame( + hydrated = codec.emit( "event", {"delta": {ROOT_STATE: {HYDRATED_VAR: True}}, "events": []} ) assert navigation.reply == hydrated assert navigation.response_frames == 1 # The token is a uuid4, so the request has a fixed length. assert navigation.request_bytes == size( - event_frame(ON_LOAD_EVENT, {}, token="x" * 36, pathname="/counter") + codec.event_frame(ON_LOAD_EVENT, {}, token="x" * 36, pathname="/counter") ) - assert hydration.received_frames == 4 - assert hydration.delta_bytes == { - ROOT_STATE: size( - '{"is_hydrated_rx_state_":false}', '{"is_hydrated_rx_state_":true}' - ) - } + assert hydration.received_frames == hydration_frames + assert hydration.delta_bytes == {ROOT_STATE: size(*hydration_deltas)} def test_measure_times_out(monkeypatch: pytest.MonkeyPatch): @@ -434,7 +601,7 @@ async def hang(session: wire.WireSession) -> None: monkeypatch.setattr(wire, "connect", connect) monkeypatch.setattr(wire, "WIRE_TIMEOUT_S", 0.05) with pytest.raises(TimeoutError): - wire.measure(Endpoint("http://localhost:1"), hang) + wire.measure(Endpoint("http://localhost:1", protocol="websocket"), hang) ALLOWED_RX = { @@ -583,7 +750,7 @@ class Fakes: log: list[str] = dataclasses.field(default_factory=list) compiled: list[Path] = dataclasses.field(default_factory=list) envs: list[dict[str, str]] = dataclasses.field(default_factory=list) - replies: dict[str, list[str]] = dataclasses.field(default_factory=dict) + replies: dict[str, list[Reply]] = dataclasses.field(default_factory=dict) @pytest.fixture @@ -628,6 +795,9 @@ async def connect(url: str, **kwargs: Any) -> AsyncIterator[FakeTransport]: monkeypatch.setattr(wire, "playground_states", lambda ctx: STATES) monkeypatch.setattr(wire, "AppProcess", FakeApp) monkeypatch.setattr(wire, "connect", connect) + monkeypatch.setattr( + wire, "resolve_codec", lambda endpoint: asyncio.sleep(0, WEBSOCKET) + ) return state @@ -651,21 +821,19 @@ def test_hydrate_starts_a_backend_per_sample_and_stops_it(tmp_path: Path, fakes: "connect", "app stop", ] - script = playground_script() # The token is a uuid4, so the sent frames only have a fixed length. - hydrate = event_frame(HYDRATE_EVENT, {}, token="x" * 36) - on_load = event_frame(ON_LOAD_EVENT, {}, token="x" * 36) - first, second = script(hydrate)[0], script(on_load)[0] + connect = WEBSOCKET.connect_frame(token="x" * 36, pathname="/") + handshake, first, second = answers(playground_script(), connect) samples = { name: metric["samples"]["A"] for name, metric in entry["metrics"].items() } - assert samples["hydrate_received_bytes"] == [size(OPEN, ACK, first, second)] - assert samples["hydrate_frames"] == [4] - assert samples["hydrate_sent_bytes"] == [size(CONNECT_FRAME, hydrate, on_load)] + assert samples["hydrate_received_bytes"] == [size(handshake, first, second)] + assert samples["hydrate_frames"] == [3] + assert samples["hydrate_sent_bytes"] == [size(connect)] (extra,) = entry["sample_extra"] assert extra["largest_frame_bytes"] == size(first) assert set(extra["delta_bytes"]) == {ROOT_STATE, BENCH_STATE, PLAYGROUND_STATE} - assert extra["sent_frames"] == 3 + assert extra["sent_frames"] == 1 (env,) = fakes.envs assert env["REFLEX_STATE_MANAGER_MODE"] == "memory" assert env["GRANIAN_WORKERS"] == "1" @@ -688,11 +856,13 @@ def test_event_measures_one_exchange_per_shape(tmp_path: Path, fakes: Fakes): } assert samples["response_bytes"] == [size(empty, echo)] assert samples["response_frames"] == [2] - request = event_frame(background, {"seq": 1}, token="x" * 36, pathname="/") + request = WEBSOCKET.event_frame( + background, {"seq": 1}, token="x" * 36, pathname="/" + ) assert samples["request_bytes"] == [size(request)] (extra,) = entry["sample_extra"] assert extra["reply"] == echo - assert extra["hydration"]["received_frames"] == 4 + assert extra["hydration"]["received_frames"] == 3 assert fakes.log == [ "copy playground", "run_cli compile", @@ -712,7 +882,7 @@ def test_navigate_measures_the_route_change_after_hydration( samples = { name: metric["samples"]["A"] for name, metric in entry["metrics"].items() } - request = event_frame( + request = WEBSOCKET.event_frame( ON_LOAD_EVENT, {}, token="x" * 36, @@ -726,7 +896,7 @@ def test_navigate_measures_the_route_change_after_hydration( (extra,) = entry["sample_extra"] assert extra["reply"] == reply # Hydrated on "/" first: its on_load reply carries no router. - assert extra["hydration"]["received_frames"] == 4 + assert extra["hydration"]["received_frames"] == 3 assert extra["hydration"]["delta_bytes"][ROOT_STATE] == size( '{"is_hydrated_rx_state_":false,"router_rx_state_":{"a":1}}', '{"is_hydrated_rx_state_":true}', @@ -762,7 +932,9 @@ def test_delta_generates_its_own_app_and_keys_the_series_on_its_hash( assert entry["metrics"]["response_bytes"]["samples"]["A"] == [size(reply)] (extra,) = entry["sample_extra"] assert extra["request_bytes"] == size( - event_frame(f"{wire.WIRE_STATE}.append_item", {"seq": 1}, token="x" * 36) + WEBSOCKET.event_frame( + f"{wire.WIRE_STATE}.append_item", {"seq": 1}, token="x" * 36 + ) ) assert extra["response_frames"] == 1 assert extra["delta_bytes"] == { @@ -773,8 +945,8 @@ def test_delta_generates_its_own_app_and_keys_the_series_on_its_hash( def test_a_failed_sample_still_stops_the_backend(tmp_path: Path, fakes: Fakes): - fakes.replies[SHAPES["simple"].name] = [DISCONNECT_FRAME] + fakes.replies[SHAPES["simple"].name] = [CLOSED] entry = run_bench(tmp_path, "wire.event", shape="simple") assert entry["status"] == "failed" - assert "disconnected" in entry["error"] + assert "received 1008" in entry["error"] assert fakes.log[-1] == "app stop" diff --git a/tests/units/reflex_bench/suites/test_wire_app.py b/tests/units/reflex_bench/suites/test_wire_app.py index f50d26d4c52..1a253476efd 100644 --- a/tests/units/reflex_bench/suites/test_wire_app.py +++ b/tests/units/reflex_bench/suites/test_wire_app.py @@ -65,10 +65,9 @@ def test_navigate_for_real(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): pathname = wire.ROUTES[route][0] assert f'"path":"{pathname}"' in extra["reply"] assert set(extra["delta_bytes"]) == {ROOT_STATE} - # The hydration on "/" came first: the open, the namespace ack, the - # hydrate delta, on_load_internal's update of the always dirty - # rx.dynamic var of /widgets, and the delta setting is_hydrated. - assert extra["hydration"]["received_frames"] == 5 + # The hydration on "/" came first: the handshake, the snapshot of the + # connect's hydrate_and_load, and the delta setting is_hydrated. + assert extra["hydration"]["received_frames"] == 3 # The dynamic route argument is a var of the root state. assert '"item_id_rx_state_":"42"' in entries["item"]["sample_extra"][0]["reply"] diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 08e434ed43f..d3309b10a58 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -5495,22 +5495,29 @@ def test_register_channel_rejects_a_duplicate_name(): @pytest.mark.parametrize("state", [None, State]) -def test_register_channel_requires_the_event_websocket(state: type[State] | None): +def test_register_channel_skips_without_the_event_websocket( + state: type[State] | None, caplog: pytest.LogCaptureFixture +): """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. + skips the setup that creates the event namespace and its route. A package + registering its channel from a plugin cannot know that, so the app warns + and keeps serving rather than failing to start. """ with RegistrationContext.get().fork(): app = App(_state=state, enable_state=False) assert app.event_namespace is None - with pytest.raises(RuntimeError, match="needs the event websocket"): + with caplog.at_level(logging.WARNING): app.register_channel(_ProbeChannel()) + assert app._channels == {} + assert "needs the event websocket" in caplog.text -def test_register_channel_requires_the_websocket_transport( - monkeypatch: pytest.MonkeyPatch, + +def test_register_channel_skips_without_the_websocket_transport( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ): """Channels are a plain-WebSocket feature; Socket.IO cannot carry them. @@ -5521,5 +5528,8 @@ def test_register_channel_requires_the_websocket_transport( app = App(enable_state=True) monkeypatch.setattr(get_config(), "transport", "socketio") - with pytest.raises(RuntimeError, match="requires the plain WebSocket transport"): + with caplog.at_level(logging.WARNING): app.register_channel(_ProbeChannel()) + + assert app._channels == {} + assert "requires the plain WebSocket transport" in caplog.text diff --git a/tests/units/test_channels.py b/tests/units/test_channels.py index d5f602e42f5..462bbd9b226 100644 --- a/tests/units/test_channels.py +++ b/tests/units/test_channels.py @@ -269,9 +269,16 @@ async def test_join_after_close_leaves_no_member_behind(): @pytest.mark.asyncio -@pytest.mark.parametrize("event", sorted(RESERVED_EVENTS)) +@pytest.mark.parametrize( + "event", + [*sorted(RESERVED_EVENTS), "_open", "_opened", "_close", "_error", "_future"], +) async def test_send_rejects_reserved_message_names(event: str): - """A message may not impersonate the client handle's lifecycle events.""" + """A message may not impersonate the handle's lifecycle or the protocol. + + A relayed ``_error`` or ``_opened`` would reach every other member as a + lifecycle event, so the protocol's underscore names are reserved too. + """ channel = CollectingChannel() session = channel.session("sid1") @@ -279,3 +286,23 @@ async def test_send_rejects_reserved_message_names(event: str): await session.send(event, {"anything": True}) assert channel.sent == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("event", "buffers"), + [("_error", ()), ("push", [b""] * (MAX_MESSAGE_BUFFERS + 1)), (42, ())], +) +async def test_fan_out_validates_even_when_nobody_listens(event: Any, buffers: Any): + """A message that could never be sent fails whether or not anyone is in the room. + + Otherwise the mistake only surfaces once a client happens to join. + """ + channel = CollectingChannel() + + with pytest.raises(ValueError, match="Channel message"): + await channel.send_to_room("empty", event, None, buffers) + with pytest.raises(ValueError, match="Channel message"): + await channel.send_to_token("nobody", event, None, buffers) + + assert channel.sent == [] diff --git a/tests/units/test_config.py b/tests/units/test_config.py index 676430f596d..2929eeda386 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -167,6 +167,33 @@ def test_update_from_env_cors( ] +@pytest.mark.parametrize( + ("origins", "expected"), + [ + ("https://myapp.com", ["https://myapp.com"]), + ("https://a.com, https://b.dev", ["https://a.com", "https://b.dev"]), + ], +) +def test_cors_allowed_origins_string_lists_origins( + base_config_values: dict[str, Any], origins: str, expected: list[str] +): + """A plain string lists origins as the environment variable does. + + Left a string, membership is a substring test: https://myapp.co would pass + an origin check for https://myapp.com. + + Args: + base_config_values: Config values. + origins: The configured origins. + expected: The origins they list. + """ + config = rx.Config(**base_config_values, cors_allowed_origins=origins) + + assert list(config.cors_allowed_origins) == expected + assert "https://myapp.co" not in config.cors_allowed_origins + assert "https://b.de" not in config.cors_allowed_origins + + def test_update_from_env_frontend_compression_formats( base_config_values: dict[str, Any], monkeypatch: pytest.MonkeyPatch, diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index c5862ca53b7..55ee27d5f3f 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -1,9 +1,11 @@ """Tests for the plain WebSocket event transport in reflex/event_namespace.py.""" +import array import asyncio import base64 import json import logging +import os import re import shutil import subprocess @@ -16,6 +18,8 @@ import pytest from opentelemetry import trace from reflex_base import otel +from reflex_base.config import get_config +from reflex_base.registry import RegistrationContext from starlette.routing import WebSocketRoute from reflex import event_namespace @@ -37,11 +41,11 @@ PONG_MESSAGE, PROTOCOL_VERSION, WebsocketEventNamespace, + _Connection, _decode_asgi_headers, decode_channel_frame, encode_channel_frame, ) -from reflex.utils import format from .conftest import active_tracer, metric_points @@ -75,6 +79,8 @@ def __init__( } self.headers = {"origin": origin} if origin is not None else {} self.sent: list[Any] = [] + # The text frames as written, before parsing. + self.raw_sent: list[str] = [] self.accepted_subprotocol: str | None = None self.accepted = False self.close_code: int | None = None @@ -89,6 +95,7 @@ async def accept(self, subprotocol: str | None = None): async def send_text(self, text: str): """Record an outgoing frame.""" + self.raw_sent.append(text) self.sent.append(json.loads(text)) async def send_bytes(self, data: bytes): @@ -112,6 +119,8 @@ async def receive(self) -> dict[str, Any]: Returns: The ASGI websocket message. """ + # A real receive suspends on the network, which lets the writer run. + await asyncio.sleep(0) item = await self._incoming.get() if item is _DISCONNECT: return {"type": "websocket.disconnect", "code": 1000} @@ -250,7 +259,42 @@ async def test_session_opens_only_with_a_connect_frame( assert websocket.close_code == close_code assert websocket.sent == [] assert namespace.token_to_sid == {} - assert namespace._sockets == {} + assert namespace._connections == {} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("connect", [True, False]) +async def test_oversized_integer_closes_with_protocol_error( + namespace: WebsocketEventNamespace, connect: bool +): + """An integer literal past the parser's digit limit is a malformed frame. + + The JSON decoder raises a plain ValueError for it, not a JSONDecodeError; + escaping the handler, it would log an ASGI traceback per connection. + """ + frame = "[" + json.dumps(CONNECT_MESSAGE) + ", " + "1" * 4301 + "]" + websocket = FakeWebSocket() + if connect: + websocket.feed(frame, connect=False) + else: + websocket.feed('["ping", ' + "1" * 4301 + "]") + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert websocket.close_code == 1002 + + +@pytest.mark.asyncio +async def test_frames_are_serialized_compactly(namespace: WebsocketEventNamespace): + """Frames carry no separator whitespace, like the Socket.IO packets they replace.""" + websocket = FakeWebSocket() + websocket.feed(["ping"], [OPEN_MESSAGE, None, "nope"]) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert len(websocket.raw_sent) == 3 + for text in websocket.raw_sent: + assert text == json.dumps(json.loads(text), separators=(",", ":")) @pytest.mark.asyncio @@ -285,7 +329,11 @@ async def test_oversize_connect_frame_closes_connection( async def test_undeserializable_boot_event_closes_connection( namespace: WebsocketEventNamespace, mock_app: Mock ): - """A boot event that fails deserialization ends the session it opened.""" + """A boot event that fails deserialization ends the session it opened. + + The handshake queued for it is dropped with the session, so the client + sees a failed connect and retries with backoff. + """ websocket = FakeWebSocket() websocket.feed( [CONNECT_MESSAGE, {"event": "not an event"}], ["ping"], connect=False @@ -294,7 +342,7 @@ async def test_undeserializable_boot_event_closes_connection( await _drain_tasks() assert websocket.close_code == 1002 - assert [frame[0] for frame in websocket.sent] == [HANDSHAKE_MESSAGE] + assert websocket.sent == [] mock_app.event_processor.enqueue.assert_not_awaited() assert "tok1" not in namespace.token_to_sid @@ -322,6 +370,126 @@ async def test_boot_event_handler_error_keeps_connection( ) +class ReceiveBoundWebSocket(FakeWebSocket): + """Models granian: a close sent while a receive is pending never completes. + + Only the task that receives can close such a socket, once its receive is + no longer pending. + """ + + def __init__(self, **kwargs: Any): + """Initialize the fake websocket.""" + super().__init__(**kwargs) + self.receiving = False + + async def receive(self) -> dict[str, Any]: + """Return the next queued frame, marking the receive as pending. + + Returns: + The ASGI websocket message. + """ + self.receiving = True + try: + return await super().receive() + finally: + self.receiving = False + + async def close(self, code: int = 1000): + """Record the close, unless a receive is pending.""" + if self.receiving: + await asyncio.Event().wait() + await super().close(code) + + +class StalledWebSocket(FakeWebSocket): + """A peer that stopped reading: once its buffers fill, every write blocks.""" + + async def send_text(self, text: str): + """Block forever, like a write to a full socket buffer.""" + await asyncio.Event().wait() + + +def _open_session(websocket: FakeWebSocket) -> None: + """Queue a connect frame and nothing after it, so the session stays open. + + Args: + websocket: The fake websocket. + """ + websocket._incoming.put_nowait(json.dumps(CONNECT_FRAME)) + + +@pytest.mark.asyncio +async def test_a_silent_client_is_closed_by_its_own_session( + namespace: WebsocketEventNamespace, monkeypatch: pytest.MonkeyPatch +): + """The liveness timeout ends the session even where only its task can close. + + A close sent from the heartbeat task while the session waits in receive() + never completes on granian, so the session and its token would leak. + """ + monkeypatch.setenv("REFLEX_SOCKET_INTERVAL", "20ms") + monkeypatch.setenv("REFLEX_SOCKET_TIMEOUT", "20ms") + websocket = ReceiveBoundWebSocket() + _open_session(websocket) + + await asyncio.wait_for(namespace.handle_websocket(websocket), 2) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert websocket.close_code == 1001 + assert namespace.token_to_sid == {} + + +@pytest.mark.asyncio +async def test_a_client_that_stops_reading_does_not_block_senders( + namespace: WebsocketEventNamespace, monkeypatch: pytest.MonkeyPatch +): + """Emitting to a stalled client returns at once, and its backlog is bounded. + + The emit used to await the socket write, so every coroutine sending to the + client -- a shared state's fan-out under its lock, the lost-and-found + loop -- froze with it. + """ + monkeypatch.setattr(event_namespace, "_MAX_SEND_BACKLOG", 1000) + linked = asyncio.Event() + handle_connect = namespace.handle_connect + + async def connect_and_signal(*args: Any, **kwargs: Any) -> None: + await handle_connect(*args, **kwargs) + linked.set() + + monkeypatch.setattr(namespace, "handle_connect", connect_and_signal) + websocket = StalledWebSocket() + _open_session(websocket) + session = asyncio.create_task(namespace.handle_websocket(websocket)) # pyright: ignore[reportArgumentType] + await asyncio.wait_for(linked.wait(), 1) + sid = namespace.token_to_sid["tok1"] + + for _ in range(20): + await asyncio.wait_for(namespace.emit("event", {"x": "y" * 100}, to=sid), 1) + + # Over the backlog bound, the session is dropped rather than buffering on. + await asyncio.wait_for(session, 2) + await _drain_tasks() + assert websocket.close_code == 1008 + assert namespace.token_to_sid == {} + + +@pytest.mark.asyncio +async def test_a_stalled_client_still_times_out( + namespace: WebsocketEventNamespace, monkeypatch: pytest.MonkeyPatch +): + """The heartbeat's own ping cannot block the liveness check that closes it.""" + monkeypatch.setenv("REFLEX_SOCKET_INTERVAL", "20ms") + monkeypatch.setenv("REFLEX_SOCKET_TIMEOUT", "20ms") + websocket = StalledWebSocket() + _open_session(websocket) + + await asyncio.wait_for(namespace.handle_websocket(websocket), 2) # pyright: ignore[reportArgumentType] + await _drain_tasks() + + assert websocket.close_code == 1001 + + @pytest.mark.asyncio async def test_event_is_enqueued(namespace: WebsocketEventNamespace, mock_app: Mock): """An incoming event frame reaches the app's event processor.""" @@ -581,10 +749,13 @@ async def test_duplicate_token_gets_new_token(namespace: WebsocketEventNamespace """A second tab connecting with the same token receives a new_token frame.""" first = FakeWebSocket() second = FakeWebSocket() - namespace._sockets["sid1"] = first # pyright: ignore[reportArgumentType] - namespace._sockets["sid2"] = second # pyright: ignore[reportArgumentType] + namespace._connections["sid1"] = _Connection(first) # pyright: ignore[reportArgumentType] + namespace._connections["sid2"] = _Connection(second) # pyright: ignore[reportArgumentType] await namespace.link_token_to_sid("sid1", "tok1") await namespace.link_token_to_sid("sid2", "tok1") + await _drain_tasks() + for connection in namespace._connections.values(): + connection.stop() new_token_frames = [frame for frame in second.sent if frame[0] == "new_token"] assert len(new_token_frames) == 1 @@ -625,7 +796,7 @@ async def test_websocket_records_connections_and_message_sizes( } assert sizes == { "receive": len(json.dumps(CONNECT_FRAME)) + len(json.dumps(["ping"])), - "transmit": len(format.json_dumps(["ping", "pong"])), + "transmit": len(json.dumps(["ping", "pong"], separators=(",", ":"))), } @@ -657,7 +828,11 @@ async def enqueue(token, event): def test_default_transport_uses_websocket_namespace(): - """The default transport sets up the plain websocket namespace.""" + """The default transport sets up the plain websocket namespace. + + The path is served with and without a trailing slash: proxy rules written + for Socket.IO route "/_event/*", and the client dials that form. + """ app = App(enable_state=True) assert isinstance(app.event_namespace, WebsocketEventNamespace) assert app.sio is None @@ -665,11 +840,29 @@ def test_default_transport_uses_websocket_namespace(): websocket_routes = [ route for route in app._api.router.routes if isinstance(route, WebSocketRoute) ] - assert [route.path for route in websocket_routes] == ["/_event"] + assert [route.path for route in websocket_routes] == ["/_event", "/_event/"] + + +@pytest.mark.asyncio +async def test_a_socketio_client_is_refused_before_accept( + namespace: WebsocketEventNamespace, +): + """A tab or bundle from before the plain transport cannot speak it. + + Accepted, it would wait for an engine.io handshake that never comes until + the connect timeout; refused, it fails at once. + """ + websocket = FakeWebSocket(query_string=b"EIO=4&transport=websocket&token=tok1") + websocket.feed() + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + + assert not websocket.accepted + assert websocket.close_code == 1008 + assert namespace.token_to_sid == {} def test_socketio_transport_uses_socketio_namespace( - monkeypatch: pytest.MonkeyPatch, + monkeypatch: pytest.MonkeyPatch, forked_registration_context: RegistrationContext ): """transport="socketio" sets up the Socket.IO server and namespace.""" from reflex.socketio_namespace import EventNamespace @@ -683,7 +876,7 @@ def test_socketio_transport_uses_socketio_namespace( def test_polling_transport_uses_socketio_namespace( - monkeypatch: pytest.MonkeyPatch, + monkeypatch: pytest.MonkeyPatch, forked_registration_context: RegistrationContext ): """transport="polling" sets up the Socket.IO server with polling only.""" from reflex.socketio_namespace import EventNamespace @@ -695,15 +888,60 @@ def test_polling_transport_uses_socketio_namespace( 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.""" +def test_transport_chosen_in_a_test_does_not_outlive_it(): + """Runs after the transport tests above: the shared config is untouched. + + App() reloads the config into the active registration context, so the + tests that choose a transport through the environment build their apps + in a forked one. + """ + assert get_config().transport == "websocket" + + +def test_custom_sio_falls_back_to_the_socketio_transport( + monkeypatch: pytest.MonkeyPatch, + forked_registration_context: RegistrationContext, + mocker, +): + """A custom sio server under the default transport keeps working, deprecated. + + The default transport used to be Socket.IO over a websocket, so an app + passing its own server never had to choose one. + """ + from socketio import AsyncServer + + from reflex.socketio_namespace import EventNamespace + + deprecate = mocker.patch("reflex.app.console.deprecate") + # Recorded twice, so the variable the fallback persists is removed again. + monkeypatch.setenv("REFLEX_TRANSPORT", "websocket") + monkeypatch.delenv("REFLEX_TRANSPORT") + sio = AsyncServer(async_mode="asgi") + + app = App(sio=sio) + + assert app.sio is sio + assert isinstance(app.event_namespace, EventNamespace) + # Persisted, so the compiled frontend and the workers use it too. + assert get_config().transport == "socketio" + assert os.environ["REFLEX_TRANSPORT"] == "socketio" + deprecate.assert_called_once() + + +def test_custom_sio_with_an_explicit_websocket_transport_is_refused( + monkeypatch: pytest.MonkeyPatch, forked_registration_context: RegistrationContext +): + """Asking for the plain transport by name conflicts with a custom sio server.""" from socketio import AsyncServer + monkeypatch.setenv("REFLEX_TRANSPORT", "websocket") 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): +def test_custom_sio_with_socketio_transport( + monkeypatch: pytest.MonkeyPatch, forked_registration_context: RegistrationContext +): """A custom sio server works with the Socket.IO transport.""" from socketio import AsyncServer @@ -713,16 +951,93 @@ def test_custom_sio_with_socketio_transport(monkeypatch: pytest.MonkeyPatch): assert app.sio is sio -def test_app_event_namespace_reexport(): - """reflex.app.EventNamespace still resolves to the Socket.IO namespace.""" +def test_app_socketio_reexports(): + """reflex.app still names the Socket.IO classes it used to import.""" + import socketio + import reflex.app from reflex.socketio_namespace import EventNamespace assert reflex.app.EventNamespace is EventNamespace + assert reflex.app.AsyncServer is socketio.AsyncServer + assert reflex.app.AsyncNamespace is socketio.AsyncNamespace + assert reflex.app.EngineIOApp is socketio.ASGIApp with pytest.raises(AttributeError): _ = reflex.app.DoesNotExist +def test_app_socketio_reexports_without_the_extra(monkeypatch: pytest.MonkeyPatch): + """Without python-socketio the names are absent, not import errors. + + hasattr() and getattr() with a default only swallow AttributeError. + """ + import reflex.app + + monkeypatch.setitem(sys.modules, "socketio", None) + monkeypatch.delitem(sys.modules, "reflex.socketio_namespace", raising=False) + + for name in ("EventNamespace", "AsyncServer"): + assert not hasattr(reflex.app, name) + with pytest.raises(AttributeError, match=r"reflex\[socketio\]"): + getattr(reflex.app, name) + + +def test_a_replaced_event_namespace_class_is_used( + monkeypatch: pytest.MonkeyPatch, forked_registration_context: RegistrationContext +): + """An app can still substitute its own Socket.IO namespace class.""" + import reflex.app + from reflex.socketio_namespace import EventNamespace + + class AuthorizingNamespace(EventNamespace): + """A namespace an app customized.""" + + monkeypatch.setattr( + reflex.app, "EventNamespace", AuthorizingNamespace, raising=False + ) + monkeypatch.setenv("REFLEX_TRANSPORT", "socketio") + + assert isinstance(App(enable_state=True).event_namespace, AuthorizingNamespace) + + +@pytest.mark.asyncio +async def test_emit_without_a_recipient_reaches_every_session( + namespace: WebsocketEventNamespace, +): + """Like Socket.IO's, an emit addressed to nobody in particular broadcasts.""" + first, second = FakeWebSocket(), FakeWebSocket() + namespace._connections["sid1"] = _Connection(first) # pyright: ignore[reportArgumentType] + namespace._connections["sid2"] = _Connection(second) # pyright: ignore[reportArgumentType] + + await namespace.emit("custom", {"n": 1}) + await _drain_tasks() + for connection in namespace._connections.values(): + connection.stop() + + assert first.sent == second.sent == [["custom", {"n": 1}]] + + +@pytest.mark.asyncio +async def test_a_lone_surrogate_is_sent_as_an_escape( + namespace: WebsocketEventNamespace, +): + """A frame stays valid UTF-8 when state holds an unpaired surrogate. + + A non-UTF-8 file name decodes to one; written raw, the frame either fails + to send or arrives empty, and the update is silently lost. + """ + websocket = FakeWebSocket() + namespace._connections["sid1"] = _Connection(websocket) # pyright: ignore[reportArgumentType] + + await namespace.emit("event", {"file": "bad\udcffname"}, to="sid1") + await _drain_tasks() + namespace._connections["sid1"].stop() + + (text,) = websocket.raw_sent + text.encode() + assert json.loads(text) == ["event", {"file": "bad\udcffname"}] + + def test_protocol_message_names_match_the_client(): """The client speaks the same protocol message names as the server. @@ -968,7 +1283,7 @@ async def test_channel_binary_rejected_when_not_accepted( @pytest.mark.asyncio @pytest.mark.parametrize("binary", [False, True]) -@pytest.mark.parametrize("event", sorted(RESERVED_EVENTS)) +@pytest.mark.parametrize("event", [*sorted(RESERVED_EVENTS), "_opened", "_error"]) async def test_channel_reserved_inbound_name_is_rejected( namespace: WebsocketEventNamespace, mock_app: Mock, event: str, binary: bool ): @@ -1110,6 +1425,18 @@ def _run_client_script(tmp_path: Path, source: str, *args: str) -> Any: return json.loads(result.stdout) +def test_binary_frame_sizes_attachments_in_bytes(): + """An attachment is measured in bytes, whatever its item size. + + ``len()`` counts items, so an array of doubles would declare an eighth of + its size and every later attachment would be read from the wrong offset. + """ + doubles = array.array("d", [1.0, 2.0]) + frame = encode_channel_frame("rows", None, "probe", [doubles, b"SECOND!!"]) + + assert decode_channel_frame(frame)[3] == [doubles.tobytes(), b"SECOND!!"] + + @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. @@ -1331,9 +1658,8 @@ def counting_encode(*args: Any) -> bytes: 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) + def record(to: str | None, payload: Any, label: str) -> None: + # Stands in for queueing the frame on each client's connection. sent.append((to, payload)) monkeypatch.setattr(event_namespace, "encode_channel_frame", counting_encode) diff --git a/tests/units/test_socketio_namespace.py b/tests/units/test_socketio_namespace.py index 4d506352004..8f35c69200e 100644 --- a/tests/units/test_socketio_namespace.py +++ b/tests/units/test_socketio_namespace.py @@ -147,3 +147,28 @@ async def test_connect_whose_boot_event_fails_is_refused( assert namespace._scopes == {} (connections,) = metric_points(otel_metrics, otel.METRIC_WEBSOCKET_CONNECTIONS) assert connections.value == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "data", ['{"name": "state.on_click", "payload": {}, "router_data": {}}', 42] +) +async def test_undeserializable_event_disconnects_the_session( + namespace: EventNamespace, mock_app: Mock, caplog, data: object +): + """A malformed event ends the session, as on the plain transport. + + Escaping into python-socketio, the error logged a traceback per frame, + outside any error budget, and the session stayed open. + """ + await namespace.on_connect( + "sid1", {"QUERY_STRING": "token=tok1", "asgi.scope": {"headers": []}} + ) + namespace.disconnect = AsyncMock() + + with caplog.at_level(logging.DEBUG, logger="reflex.event_namespace"): + await namespace.on_event("sid1", data) + + namespace.disconnect.assert_awaited_once_with("sid1") + mock_app.event_processor.enqueue.assert_not_awaited() + assert all(record.levelno <= logging.DEBUG for record in caplog.records) diff --git a/tests/units/test_testing.py b/tests/units/test_testing.py index b025b262fe6..4cfb1ed1e16 100644 --- a/tests/units/test_testing.py +++ b/tests/units/test_testing.py @@ -1,20 +1,24 @@ """Unit tests for the included testing tools.""" +import asyncio +import contextlib import socket import sys import threading import time -from collections.abc import Callable +from collections.abc import Callable, Iterator from types import ModuleType, SimpleNamespace from unittest import mock -import granian.server.embed +import httpx import pytest import reflex_base.config from reflex_base.components.memo import MEMOS from reflex_base.constants import IS_WINDOWS from reflex_base.environment import environment from reflex_base.registry import RegistrationContext +from reflex_base.utils.types import ASGIApp +from starlette.applications import Starlette import reflex.constants import reflex.reflex as reflex_cli @@ -199,126 +203,211 @@ 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`. +def _asgi_app(on_startup: Callable[[], object] | None = None) -> ASGIApp: + """A minimal ASGI app with lifespan support that answers every request. Args: - monkeypatch: the pytest monkeypatch fixture. - on_serve: called once per `serve()`; raise from it to simulate a - failed bind. + on_startup: called (in a worker thread) during lifespan startup. + + Returns: + The ASGI app. """ - class FakeServer: - def __init__(self, *args, **kwargs): - self.interrupt_signal = False - self.main_loop_interrupt = threading.Event() + async def app(scope, receive, send): + if scope["type"] == "lifespan": + while (await receive())["type"] == "lifespan.startup": + if on_startup is not None: + await asyncio.to_thread(on_startup) + await send({"type": "lifespan.startup.complete"}) + await send({"type": "lifespan.shutdown.complete"}) + return + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"embedded"}) - # Async to match granian's Server.serve(). - async def serve(self): - on_serve() + return app - monkeypatch.setattr(granian.server.embed, "Server", FakeServer) +@contextlib.contextmanager +def _running(server: reflex_testing._EmbeddedServer) -> Iterator[threading.Thread]: + """Run the server in a thread, stopping it on exit. -def _bind_error() -> RuntimeError: - """A granian bind failure for an already-claimed port. + Args: + server: the embedded server. - Returns: - The error granian raises when the port is taken. + Yields: + The thread serving the app. """ - 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.""" - - 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. On Windows only - # SO_EXCLUSIVEADDRUSE makes the port unavailable to other binders. - blocker = socket.socket() - 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) thread.start() try: - deadline = time.monotonic() + 15 - # A server that gave up has nothing left to wait for; without that the - # only report of a broken retry would be a full-timeout bare assert. - while time.monotonic() < deadline and thread.is_alive(): - if server.port != probed_port and server.is_listening(): - break - time.sleep(0.05) - assert server.port != probed_port, ( - f"the server never left the probed port {probed_port} " - f"(serving: {thread.is_alive()}): it either bound a port another " - "socket holds, or failed with a bind error the retry does not " - "recognize -- the thread's traceback says which" - ) - assert server.is_listening(), ( - f"the server rebound to port {server.port} without serving on it" - ) + yield thread finally: - blocker.close() 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.""" - _patch_embedded_granian(monkeypatch, lambda: None) - server = reflex_testing._EmbeddedServer(app=mock.Mock()) - port_before = server.port - server.run() - assert server.port == port_before +def _assert_port_taken(host: str, port: int): + """Assert that no socket can bind the port, even one asking to share it. + Args: + host: the address of the port. + port: the port number. + """ + with socket.socket() as thief: + thief.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if reuse_port := getattr(socket, "SO_REUSEPORT", None): + thief.setsockopt(socket.SOL_SOCKET, reuse_port, 1) + with pytest.raises(OSError): + thief.bind((host, port)) + thief.listen() -def test_embedded_server_raises_after_retries_exhausted(monkeypatch): - """Exhausted bind retries re-raise the error instead of returning silently.""" - calls = [] - def on_serve() -> None: - calls.append(1) - raise _bind_error() +def _assert_port_free(host: str, port: int): + """Assert that the port can be bound again. + + Args: + host: the address of the port. + port: the port number. + """ + with socket.socket() as rebind: + rebind.bind((host, port)) - _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 +def _harness_with_backend( + tmp_path, server: reflex_testing._EmbeddedServer +) -> AppHarness: + """An AppHarness whose backend is the given server. -def test_embedded_server_shutdown_wins_over_exhausted_retries(monkeypatch): - """A stop requested during the last failed bind ends the server cleanly.""" - calls = [] - holder = {} + Args: + tmp_path: the app root. + server: the backend server. - def on_serve() -> None: - calls.append(1) - if len(calls) == 10: - holder["server"].should_exit = True - raise _bind_error() + Returns: + The harness. + """ + harness = AppHarness.create(root=tmp_path, app_name="embedded_app") + harness.backend = server + return harness - _patch_embedded_granian(monkeypatch, on_serve) - server = reflex_testing._EmbeddedServer(app=mock.Mock()) - holder["server"] = server + +def test_embedded_server_owns_its_port(): + """The server holds its port from construction on, so no other socket can share it.""" + server = reflex_testing._EmbeddedServer(_asgi_app()) + _assert_port_taken(server.host, server.port) + with _running(server): + server.wait_started(timeout=15) + _assert_port_taken(server.host, server.port) + assert httpx.get(f"http://{server.host}:{server.port}/").content == b"embedded" + + +def test_poll_for_servers_waits_for_the_backend_itself(tmp_path): + """Readiness follows the app's startup, not a connectable port. + + Args: + tmp_path: pytest tmp_path fixture + """ + startup = threading.Event() + server = reflex_testing._EmbeddedServer(_asgi_app(on_startup=startup.wait)) + harness = _harness_with_backend(tmp_path, server) + # Another listener on the port must not pass for the backend. + foreign = socket.socket() + with contextlib.suppress(OSError): + foreign.bind((server.host, server.port)) + foreign.listen() + with foreign, _running(server): + try: + with pytest.raises(TimeoutError): + harness._poll_for_servers(timeout=0.5) + finally: + startup.set() + assert harness._poll_for_servers(timeout=15).getsockname() == ( + server.host, + server.port, + ) + assert httpx.get(f"http://{server.host}:{server.port}/").content == b"embedded" + + +def test_embedded_server_refuses_a_taken_requested_port(): + """A requested port that is in use fails loudly instead of moving.""" + with socket.socket() as blocker: + blocker.bind(("127.0.0.1", 0)) + blocker.listen() + port = blocker.getsockname()[1] + with pytest.raises(OSError): + reflex_testing._EmbeddedServer(_asgi_app(), port=port) + server = reflex_testing._EmbeddedServer(_asgi_app(), port=port) + assert server.getsockname() == ("127.0.0.1", port) + server.should_exit = True + server.run() + + +def test_failed_backend_startup_logs_and_fails_fast(tmp_path, capsys): + """A failing lifespan startup is logged and fails readiness without the timeout. + + Args: + tmp_path: pytest tmp_path fixture + capsys: pytest capsys fixture + """ + + @contextlib.asynccontextmanager + async def lifespan(app): + msg = "lifespan boom" + raise RuntimeError(msg) + yield + + server = reflex_testing._EmbeddedServer(Starlette(lifespan=lifespan)) + harness = _harness_with_backend(tmp_path, server) + with _running(server): + started = time.monotonic() + with pytest.raises(RuntimeError, match="stopped without serving"): + harness._poll_for_servers(timeout=10) + assert time.monotonic() - started < 5 + out = capsys.readouterr().out + assert "ASGI lifespan startup failed" in out + assert "RuntimeError: lifespan boom" in out + # The socket is released although no worker ever served it. + _assert_port_free(server.host, server.port) + + +def test_embedded_server_stop_releases_port(): + """Stopping a serving server runs its shutdown hook and frees the port.""" + shutdown = mock.AsyncMock() + server = reflex_testing._EmbeddedServer(_asgi_app()) + server.shutdown = shutdown + with _running(server): + server.wait_started(timeout=15) + shutdown.assert_awaited_once() + _assert_port_free(server.host, server.port) + + +def test_embedded_server_stopped_before_run_never_serves(): + """A server stopped before it runs returns at once and releases its port.""" + shutdown = mock.AsyncMock() + server = reflex_testing._EmbeddedServer(_asgi_app()) + server.shutdown = shutdown + server.should_exit = True server.run() - assert len(calls) == 10 + shutdown.assert_awaited_once() + with pytest.raises(RuntimeError, match="stopped without serving"): + server.wait_started(timeout=0) + _assert_port_free(server.host, server.port) + + +def test_embedded_server_started_is_deprecated(monkeypatch): + """The uvicorn-style `started` flag still works, with a deprecation warning. + + Args: + monkeypatch: pytest monkeypatch fixture + """ + deprecate = mock.Mock() + monkeypatch.setattr(reflex_testing.console, "deprecate", deprecate) + server = reflex_testing._EmbeddedServer(_asgi_app()) + assert server.started is False + with _running(server): + server.wait_started(timeout=15) + assert server.started is True + assert deprecate.call_args.kwargs["feature_name"] == "AppHarness.backend.started" def test_app_harness_frontend_env_has_development_condition( diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py index 40c48eed96e..a7f73be5af3 100644 --- a/tests/units/utils/test_exec.py +++ b/tests/units/utils/test_exec.py @@ -540,6 +540,57 @@ def test_forcing_uvicorn_warns_about_a_missing_websocket_library( assert warned is (use_granian == "0") +def test_auto_detection_skips_uvicorn_without_a_websocket_library( + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + fresh_uvicorn_warnings: None, +): + """Uvicorn and gunicorn alone cannot serve the event socket; granian can. + + python-socketio no longer pulls in wsproto, so an install with uvicorn and + gunicorn but neither websocket library would start an app that never + connects, although the required granian would work. + """ + monkeypatch.delenv("REFLEX_USE_GRANIAN", raising=False) + mocker.patch.object( + exec_utils.importlib.util, + "find_spec", + side_effect=lambda name: ( + None if name in ("websockets", "wsproto") else object() + ), + ) + + assert exec_utils.should_use_granian() is True + + +@pytest.mark.parametrize( + ("configured", "effective"), + [(None, 1000 * 1000), ("50000000", 16 * 1024 * 1024), ("1000", 1000)], +) +def test_granian_caps_the_message_size_at_what_it_can_receive( + monkeypatch: pytest.MonkeyPatch, + caplog, + configured: str | None, + effective: int, +): + """Granian drops frames over 16 MiB, so a larger limit is never advertised. + + Clients size what they send by the limit the handshake advertises; above + what the server receives, an oversized message is lost with the socket. + """ + name = "REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE" + if configured is None: + monkeypatch.delenv(name, raising=False) + else: + monkeypatch.setenv(name, configured) + + with caplog.at_level(logging.WARNING): + exec_utils._cap_message_size_for_granian() + + assert environment.REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE.get() == effective + assert ("drops websocket frames over" in caplog.text) is (configured == "50000000") + + def test_get_routes_manifest_router_missing_manifest( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ): From edba0b55efb9fbbe7ab41036992a3da7642614ee Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Fri, 9 Oct 2026 01:12:00 +0200 Subject: [PATCH 55/62] Keep env.json TRANSPORT an engine.io name and fix the review comments TRANSPORT in env.json is a name socket.io-client accepts again, as on main, and a SOCKETIO flag picks the protocol: third-party code hands TRANSPORT to socket.io-client to share the app's Socket.IO connection. The docs app runs on transport="socketio" with reflex[socketio], which xy 0.0.7's charts need until xy rides a channel. A boot event in a Socket.IO CONNECT packet bypasses on_event, so one that fails to deserialize refuses the connect instead of disconnecting a session not yet acknowledged. Header values that are not UTF-8 decode as Latin-1 rather than failing every event on the connection. Idle benchmark clients poll within the ping timeout. reflex-base's news scopes the deflate setting to uvicorn and lists the default transport change as breaking. --- docs/app/pyproject.toml | 3 +- docs/app/rxconfig.py | 3 ++ .../news/+socket-deflate-flag.performance.md | 2 +- packages/reflex-base/news/6932.breaking.md | 1 + packages/reflex-base/news/6932.feature.md | 2 +- .../reflex_base/.templates/web/utils/state.js | 16 ++++---- reflex/event_namespace.py | 19 ++++++++- reflex/socketio_namespace.py | 29 +++++++++----- reflex/utils/build.py | 6 ++- tests/benchmarks/support/idle_clients.py | 17 ++++++-- tests/units/benchmarks/test_support.py | 20 ++++++++++ tests/units/compiler/state_js.test.mjs | 39 ++++++++++--------- tests/units/test_event_namespace.py | 17 ++++++++ tests/units/test_socketio_namespace.py | 22 +++++++++++ tests/units/utils/test_build.py | 37 +++++++++++++++++- uv.lock | 4 +- 16 files changed, 188 insertions(+), 49 deletions(-) create mode 100644 packages/reflex-base/news/6932.breaking.md diff --git a/docs/app/pyproject.toml b/docs/app/pyproject.toml index ddb040cdee0..9d2738ee1ca 100644 --- a/docs/app/pyproject.toml +++ b/docs/app/pyproject.toml @@ -15,7 +15,8 @@ dependencies = [ "psycopg[binary]; python_version < '3.15'", "psycopg; python_version >= '3.15'", "python-frontmatter", - "reflex", + # xy 0.0.7's charts ride a Socket.IO namespace; see rxconfig.py. + "reflex[socketio]", "reflex-docgen", "reflex-enterprise>=0.9.4", "reflex-hosting-cli", diff --git a/docs/app/rxconfig.py b/docs/app/rxconfig.py index 890bc07d606..b115c2265e3 100644 --- a/docs/app/rxconfig.py +++ b/docs/app/rxconfig.py @@ -16,6 +16,9 @@ "@fontsource-variable/jetbrains-mono@5.3.0", ], telemetry_enabled=False, + # xy 0.0.7 serves its charts' data plane as a namespace on the app's + # Socket.IO server; back to the default once xy rides a channel instead. + transport="socketio", plugins=[ reflex_xy.XYPlugin(), rx.plugins.TailwindV4Plugin(), diff --git a/packages/reflex-base/news/+socket-deflate-flag.performance.md b/packages/reflex-base/news/+socket-deflate-flag.performance.md index 43e2dd75807..670dcaa7922 100644 --- a/packages/reflex-base/news/+socket-deflate-flag.performance.md +++ b/packages/reflex-base/news/+socket-deflate-flag.performance.md @@ -1 +1 @@ -Add the `REFLEX_SOCKET_PER_MESSAGE_DEFLATE` environment variable, controlling whether the websocket server compresses messages with permessage-deflate. +Add the `REFLEX_SOCKET_PER_MESSAGE_DEFLATE` environment variable, controlling whether uvicorn compresses websocket messages with permessage-deflate; Granian never negotiates it, so the variable has no effect there. diff --git a/packages/reflex-base/news/6932.breaking.md b/packages/reflex-base/news/6932.breaking.md new file mode 100644 index 00000000000..3dcb16dfe38 --- /dev/null +++ b/packages/reflex-base/news/6932.breaking.md @@ -0,0 +1 @@ +`transport="websocket"`, the default, now means a plain WebSocket speaking JSON `[event_name, payload]` frames rather than Socket.IO over a websocket; set `transport="socketio"` to keep Socket.IO. See [Upgrading to Reflex 0.10](https://reflex.dev/docs/changelog/upgrading/upgrading-to-0-10/#the-event-transport). diff --git a/packages/reflex-base/news/6932.feature.md b/packages/reflex-base/news/6932.feature.md index 4f4ed3e1661..64ce4283ac2 100644 --- a/packages/reflex-base/news/6932.feature.md +++ b/packages/reflex-base/news/6932.feature.md @@ -1 +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. +`config.transport` gains a `"socketio"` value for Socket.IO over a websocket, and the browser loads socket.io-client only when `"socketio"` or `"polling"` is configured. 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 d9f4271259f..e633c46ddc4 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 @@ -216,7 +216,7 @@ if (typeof window !== "undefined") { queueMicrotask(() => { if ( socketStarted || - env.TRANSPORT !== "websocket" || + env.SOCKETIO || Object.keys(app.initialState ?? {}).length <= 1 || isBackendDisabled() || document.visibilityState === "hidden" @@ -759,24 +759,22 @@ export const connect = async ( // Create the socket. A new session's token is saved here, once the app has // mounted, even when a transport warmed up with it earlier. socketStarted = true; - const transport = transports[0]; try { const session_token = getToken(); if ( warmSocket && - (transport !== "websocket" || - warmSocket.io.opts.query.token !== session_token) + (env.SOCKETIO || warmSocket.io.opts.query.token !== session_token) ) { discardWarmSocket(); } - if (transport === "websocket") { + if (!env.SOCKETIO) { // Default transport: plain WebSocket speaking the Reflex event protocol. socket.current = warmSocket ?? createSocket(endpoint, session_token); warmSocket = null; cancelWarmup(); } else { - // Socket.IO transport ("socketio" over websocket, or "polling"); the - // client library is only loaded when this transport is configured. + // Socket.IO, over the engine.io transports given; 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. @@ -784,7 +782,7 @@ export const connect = async ( } socket.current = io(endpoint.href, { path: endpoint.pathname, - transports: [transport === "socketio" ? "websocket" : transport], + transports, protocols: [reflexEnvironment.version], autoUnref: false, autoConnect: false, @@ -798,7 +796,7 @@ export const connect = async ( parseJsonLenient(str, false); // Channels are a plain-WebSocket protocol feature. disableChannels( - `Channels require transport="websocket", not "${transport}".`, + 'Channels require transport="websocket" in rxconfig.py, not Socket.IO.', ); } } catch (error) { diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index da45198408c..73a9da44113 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -70,6 +70,23 @@ _STATIC_ROUTER_DATA = "_reflex_static_router_data" +def _decode_header_value(value: bytes) -> str: + """Decode a header value, as UTF-8 where it is valid. + + Args: + value: The raw header value. + + Returns: + The decoded value. + """ + try: + return value.decode() + except UnicodeDecodeError: + # Header octets need not be UTF-8; Latin-1 maps every byte, as ASGI + # servers and Starlette decode them. + return value.decode("latin-1") + + def _decode_asgi_headers(headers: Iterable[tuple[bytes, bytes]]) -> dict[str, str]: """Decode raw ASGI scope header pairs into a str-keyed dict. @@ -79,7 +96,7 @@ def _decode_asgi_headers(headers: Iterable[tuple[bytes, bytes]]) -> dict[str, st Returns: A dict mapping decoded header names to decoded values. """ - return {k.decode("utf-8"): v.decode("utf-8") for (k, v) in headers} + return {k.decode("latin-1"): _decode_header_value(v) for (k, v) in headers} def build_static_router_data(sid: str, asgi_scope: Mapping[str, Any]) -> dict[str, Any]: diff --git a/reflex/socketio_namespace.py b/reflex/socketio_namespace.py index f5d5f124ae9..d643c7a5faa 100644 --- a/reflex/socketio_namespace.py +++ b/reflex/socketio_namespace.py @@ -115,7 +115,9 @@ async def on_connect( return False if boot_event is not None: try: - await self.on_event(sid, boot_event) + # Not through on_event: a failure here refuses the connect + # rather than disconnecting a session not yet acknowledged. + await self._dispatch_event(sid, boot_event) except Exception as exc: # Refused rather than raised: Socket.IO would keep a raising # connect registered yet unanswered, and the client would wait @@ -142,6 +144,22 @@ def on_disconnect(self, sid: str) -> asyncio.Task | None: 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. + """ + try: + await self._dispatch_event(sid, data) + except exceptions.EventDeserializationError: + # Client-controlled input a Reflex client never sends; end the + # session, as the plain transport does, rather than log a + # traceback per frame. + logger.debug(f"Disconnecting session {sid}: undeserializable event.") + await self.disconnect(sid) + + async def _dispatch_event(self, sid: str, data: Any) -> None: + """Hand one front-end event to the event processor. + Args: sid: The Socket.IO session id. data: The event data. @@ -161,14 +179,7 @@ async def on_event(self, sid: str, data: Any): msg = "Socket.IO environ is not initialized." raise RuntimeError(msg) scope = self._scopes[sid] = environ["asgi.scope"] - try: - await self.handle_event(sid, data, scope) - except exceptions.EventDeserializationError: - # Client-controlled input a Reflex client never sends; end the - # session, as the plain transport does, rather than log a - # traceback per frame. - logger.debug(f"Disconnecting session {sid}: undeserializable event.") - await self.disconnect(sid) + await self.handle_event(sid, data, scope) async def on_ping(self, sid: str): """Event for testing the API endpoint. diff --git a/reflex/utils/build.py b/reflex/utils/build.py index 3673408051c..6399379e492 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -21,7 +21,11 @@ def set_env_json(): config = get_config() env: dict[str, object] = { **{endpoint.name: endpoint.get_url() for endpoint in constants.Endpoint}, - "TRANSPORT": config.transport, + # An engine.io transport name, as third-party code sharing the app's + # Socket.IO connection hands it to socket.io-client; SOCKETIO picks + # the protocol. + "TRANSPORT": "polling" if config.transport == "polling" else "websocket", + "SOCKETIO": config.transport != "websocket", "TEST_MODE": is_in_app_harness(), } for plugin in config.plugins: diff --git a/tests/benchmarks/support/idle_clients.py b/tests/benchmarks/support/idle_clients.py index 599923ac4be..de97c6ab3fb 100644 --- a/tests/benchmarks/support/idle_clients.py +++ b/tests/benchmarks/support/idle_clients.py @@ -14,12 +14,22 @@ import sys import threading +from reflex_base.environment import environment from websockets.exceptions import ConnectionClosed from tests.benchmarks.support.socket_client import EventClient, _connect -# Seconds between checks for pings while the sessions are held. -_KEEPALIVE_INTERVAL = 1.0 + +def _keepalive_interval() -> float: + """Seconds between checks for pings while the sessions are held. + + A pong goes out at the first check after its ping, so checking faster than + the server's ping timeout keeps every session inside its deadline. + + Returns: + The interval. + """ + return min(1.0, environment.REFLEX_SOCKET_TIMEOUT.get().total_seconds() / 2) def _keep_alive(clients: list[EventClient], stop: threading.Event) -> None: @@ -29,7 +39,8 @@ def _keep_alive(clients: list[EventClient], stop: threading.Event) -> None: clients: The held sessions. stop: Set when the sessions are released. """ - while not stop.wait(_KEEPALIVE_INTERVAL): + interval = _keepalive_interval() + while not stop.wait(interval): for client in clients: # A session the server ended stays ended; keep the others alive. with contextlib.suppress(ConnectionClosed, ConnectionError): diff --git a/tests/units/benchmarks/test_support.py b/tests/units/benchmarks/test_support.py index 7176c8672e4..e66872172e9 100644 --- a/tests/units/benchmarks/test_support.py +++ b/tests/units/benchmarks/test_support.py @@ -322,3 +322,23 @@ def crash(self) -> None: BaselineSocketServer(response={}), ): pass + + +@pytest.mark.parametrize(("timeout", "interval"), [(None, 1.0), ("100ms", 0.05)]) +def test_idle_clients_answer_pings_within_the_ping_timeout( + monkeypatch: pytest.MonkeyPatch, timeout: str | None, interval: float +): + """Held sessions poll for pings fast enough to beat the server's deadline. + + A pong goes out at the first poll after its ping, so the gap between + client frames is the ping interval plus the poll period; it has to stay + under the interval plus the ping timeout. + """ + from tests.benchmarks.support import idle_clients + + if timeout is None: + monkeypatch.delenv("REFLEX_SOCKET_TIMEOUT", raising=False) + else: + monkeypatch.setenv("REFLEX_SOCKET_TIMEOUT", timeout) + + assert idle_clients._keepalive_interval() == pytest.approx(interval) diff --git a/tests/units/compiler/state_js.test.mjs b/tests/units/compiler/state_js.test.mjs index 5dc07648d93..c90993a650d 100644 --- a/tests/units/compiler/state_js.test.mjs +++ b/tests/units/compiler/state_js.test.mjs @@ -19,6 +19,7 @@ async function setup({ hidden = false, storedToken = "original-token", transport = "websocket", + socketio = false, importFails = false, } = {}) { const sockets = []; @@ -113,7 +114,11 @@ async function setup({ }, mergician: { mergician() {} }, "$/env.json": { - default: { EVENT: "ws://localhost:8000/_event", TRANSPORT: transport }, + default: { + EVENT: "ws://localhost:8000/_event", + TRANSPORT: transport, + SOCKETIO: socketio, + }, }, "$/reflex.json": { default: { version: "test" } }, "universal-cookie": { default: class {} }, @@ -260,11 +265,12 @@ test("a warm transport carrying another session's token is replaced", async () = assert.equal(app.socket.current.io.opts.query.token, "replaced-token"); }); -for (const transport of ["websocket", "socketio"]) { - test(`reconnect over ${transport} uses the assigned token and requests a full hydrate`, async () => { - const app = await setup({ transport }); +for (const socketio of [false, true]) { + test(`reconnect ${socketio ? "over socket.io " : ""}uses the assigned token and requests a full hydrate`, async () => { + const app = await setup({ socketio }); app.flush(); - await app.connect([transport]); + await app.connect(); + assert.equal(app.socket.current.kind, socketio ? "socketio" : "websocket"); app.socket.current.connected = false; app.socket.current.reconnect(); assert.equal(app.sockets.length, 1); @@ -326,28 +332,23 @@ test("blocked session storage does not throw from speculative setup", async () = }); test("a socket.io client that fails to load is a connection error", async () => { - const app = await setup({ transport: "socketio", importFails: true }); - await app.connect(["socketio"]); + const app = await setup({ socketio: true, importFails: true }); + await app.connect(); assert.equal(app.socket.current, null); assert.equal(app.connectErrors().length, 1); assert.match(String(app.connectErrors()[0]), /dynamically imported module/); }); -for (const [transport, engineTransport] of [ - ["socketio", "websocket"], - ["polling", "polling"], -]) { - test(`a caller selecting ${transport} gets a socket.io session carrying the boot event`, async () => { - const app = await setup(); +for (const transport of ["websocket", "polling"]) { + test(`socket.io over ${transport} gets the engine.io transport and the boot event`, async () => { + // TRANSPORT is an engine.io name, handed to socket.io-client as is. + const app = await setup({ socketio: true, transport }); app.flush(); await app.connect([transport]); - assert.equal(app.sockets[0].disconnects, 1); + assert.equal(app.sockets.length, 1); assert.equal(app.socket.current.kind, "socketio"); assert.equal(app.socket.current.io.opts.autoConnect, false); - assert.deepEqual( - [...app.socket.current.io.opts.transports], - [engineTransport], - ); + assert.deepEqual([...app.socket.current.io.opts.transports], [transport]); assert.equal(app.socket.current.namespaceConnects, 1); assert.deepEqual(app.firstHydrates, [true]); assert.equal(app.socket.current.auth.event.router_data.pathname, "/page"); @@ -360,7 +361,7 @@ for (const config of [ { disabled: true }, { hidden: true }, // The socket.io client is only loaded on connect. - { transport: "socketio" }, + { socketio: true }, ]) { test(`skip speculative connections for ${JSON.stringify(config)}`, async () => { const app = await setup(config); diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 55ee27d5f3f..6aabe878cc8 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -1425,6 +1425,23 @@ def _run_client_script(tmp_path: Path, source: str, *args: str) -> Any: return json.loads(result.stdout) +def test_header_values_that_are_not_utf8_are_kept(): + """A header may carry any octets; one that is not UTF-8 must not fail the connection. + + The connection-scoped router data is built from the headers on the first + event, so a decode error dropped every event the connection sent. UTF-8 + values still decode as before. + """ + router_data = event_namespace.build_static_router_data( + "sid1", + {"headers": [(b"x-raw", b"\xff\xfe"), (b"x-name", "José".encode())]}, + ) + + headers = router_data["headers"] + assert headers["x-raw"] == b"\xff\xfe".decode("latin-1") + assert headers["x-name"] == "José" + + def test_binary_frame_sizes_attachments_in_bytes(): """An attachment is measured in bytes, whatever its item size. diff --git a/tests/units/test_socketio_namespace.py b/tests/units/test_socketio_namespace.py index 8f35c69200e..2eab4030e6e 100644 --- a/tests/units/test_socketio_namespace.py +++ b/tests/units/test_socketio_namespace.py @@ -172,3 +172,25 @@ async def test_undeserializable_event_disconnects_the_session( namespace.disconnect.assert_awaited_once_with("sid1") mock_app.event_processor.enqueue.assert_not_awaited() assert all(record.levelno <= logging.DEBUG for record in caplog.records) + + +@pytest.mark.asyncio +async def test_connect_with_a_malformed_boot_event_is_refused( + namespace: EventNamespace, mock_app: Mock +): + """A boot event that does not deserialize refuses the connect. + + Disconnecting from inside the connect handler, as a later malformed event + does, would acknowledge a session that never hydrated. + """ + namespace.disconnect = AsyncMock() + environ = {"QUERY_STRING": "token=tok1", "asgi.scope": {"headers": []}} + + refused = await namespace.on_connect("sid1", environ, {"event": "not an event"}) + # Let the token cleanup task run. + await asyncio.sleep(0) + + assert refused is False + namespace.disconnect.assert_not_awaited() + mock_app.event_processor.enqueue.assert_not_awaited() + assert "tok1" not in namespace.token_to_sid diff --git a/tests/units/utils/test_build.py b/tests/units/utils/test_build.py index 750d8890275..9e6a6ad9344 100644 --- a/tests/units/utils/test_build.py +++ b/tests/units/utils/test_build.py @@ -209,13 +209,16 @@ def check_final_tree(directory: Path, formats: tuple[str, ...]): def _patch_env_json( - mocker: MockerFixture, tmp_path: Path, plugins: list[Plugin] | None = None + mocker: MockerFixture, + tmp_path: Path, + plugins: list[Plugin] | None = None, + transport: str = "websocket", ): web_dir = tmp_path / ".web" web_dir.mkdir() mocker.patch("reflex.utils.build.prerequisites.get_web_dir", return_value=web_dir) config = mocker.Mock() - config.transport = "websocket" + config.transport = transport config.plugins = plugins or [] mocker.patch("reflex.utils.build.get_config", return_value=config) mocker.patch("reflex.utils.build.is_in_app_harness", return_value=False) @@ -269,3 +272,33 @@ def update_env_json(self, **context): assert env["SHARED"] == "second" assert env["ONLY_FIRST"] == 1 assert env["ONLY_SECOND"] == 2 + + +@pytest.mark.parametrize( + ("transport", "engine_transport", "socketio"), + [ + ("websocket", "websocket", False), + ("socketio", "websocket", True), + ("polling", "polling", True), + ], +) +def test_set_env_json_keeps_transport_an_engine_io_name( + tmp_path: Path, + mocker: MockerFixture, + transport: str, + engine_transport: str, + socketio: bool, +): + """TRANSPORT stays a name socket.io-client accepts; SOCKETIO picks the protocol. + + Third-party frontend code hands TRANSPORT to socket.io-client to share the + app's Socket.IO connection, as xy's charts do, and "socketio" is no + engine.io transport. + """ + web_dir = _patch_env_json(mocker, tmp_path, transport=transport) + + build.set_env_json() + + env = json.loads((web_dir / "env.json").read_text()) + assert env["TRANSPORT"] == engine_transport + assert env["SOCKETIO"] is socketio diff --git a/uv.lock b/uv.lock index ceee0f6ffe5..246c0ae02f5 100644 --- a/uv.lock +++ b/uv.lock @@ -4053,7 +4053,7 @@ dependencies = [ { name = "psycopg" }, { name = "psycopg", extra = ["binary"], marker = "python_full_version < '3.15'" }, { name = "python-frontmatter" }, - { name = "reflex" }, + { name = "reflex", extra = ["socketio"] }, { name = "reflex-components-internal" }, { name = "reflex-docgen" }, { name = "reflex-enterprise" }, @@ -4092,7 +4092,7 @@ requires-dist = [ { name = "psycopg", marker = "python_full_version >= '3.15'" }, { name = "psycopg", extras = ["binary"], marker = "python_full_version < '3.15'" }, { name = "python-frontmatter" }, - { name = "reflex", editable = "." }, + { name = "reflex", extras = ["socketio"], editable = "." }, { name = "reflex-components-internal", editable = "packages/reflex-components-internal" }, { name = "reflex-docgen", editable = "packages/reflex-docgen" }, { name = "reflex-enterprise", specifier = ">=0.9.4" }, From c4e14459a89245cb35bf418a6780ec8ae4f3fb1b Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 17 Oct 2026 13:02:00 +0200 Subject: [PATCH 56/62] Recover the event namespace hot-path regressions Decode ASGI headers with plain str decodes and fall back to per-header Latin-1 decoding only when some header is not UTF-8, which is faster than upstream's decoding. Replace the Socket.IO namespace's _dispatch_event coroutine with a sync scope lookup, removing a coroutine layer per event, and drop the router_data type check that its normalization already covers. --- reflex/event_namespace.py | 27 +++++++++---------- reflex/socketio_namespace.py | 40 ++++++++++++++++------------- tests/units/test_event_namespace.py | 9 +++++++ 3 files changed, 45 insertions(+), 31 deletions(-) diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index 8e3df400a02..28f441ee626 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -71,21 +71,21 @@ _STATIC_ROUTER_DATA = "_reflex_static_router_data" -def _decode_header_value(value: bytes) -> str: - """Decode a header value, as UTF-8 where it is valid. +def _decode_header_octets(octets: bytes) -> str: + """Decode a header name or value, as UTF-8 where it is valid. Args: - value: The raw header value. + octets: The raw header name or value. Returns: - The decoded value. + The decoded text. """ try: - return value.decode() + return octets.decode() except UnicodeDecodeError: # Header octets need not be UTF-8; Latin-1 maps every byte, as ASGI # servers and Starlette decode them. - return value.decode("latin-1") + return octets.decode("latin-1") def _decode_asgi_headers(headers: Iterable[tuple[bytes, bytes]]) -> dict[str, str]: @@ -97,7 +97,11 @@ def _decode_asgi_headers(headers: Iterable[tuple[bytes, bytes]]) -> dict[str, st Returns: A dict mapping decoded header names to decoded values. """ - return {k.decode("latin-1"): _decode_header_value(v) for (k, v) in headers} + # The common all-UTF-8 case decodes without a function call per header. + try: + return {k.decode(): v.decode() for k, v in headers} + except UnicodeDecodeError: + return {_decode_header_octets(k): _decode_header_octets(v) for k, v in headers} def build_static_router_data(sid: str, asgi_scope: Mapping[str, Any]) -> dict[str, Any]: @@ -719,12 +723,9 @@ async def handle_event( msg = f"Failed to deserialize event data: {data}." 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) - ): + # The dataclass does not validate field types. A router_data that is + # no dict fails its normalization below. + if not isinstance(event.name, str) or not isinstance(event.payload, dict): msg = "Event fields have invalid types." raise exceptions.EventDeserializationError(msg) diff --git a/reflex/socketio_namespace.py b/reflex/socketio_namespace.py index 2054a005c5d..83deff26f4b 100644 --- a/reflex/socketio_namespace.py +++ b/reflex/socketio_namespace.py @@ -119,7 +119,7 @@ async def on_connect( try: # Not through on_event: a failure here refuses the connect # rather than disconnecting a session not yet acknowledged. - await self._dispatch_event(sid, boot_event) + await self.handle_event(sid, boot_event, self._scope(sid)) except Exception as exc: # Refused rather than raised: Socket.IO would keep a raising # connect registered yet unanswered, and the client would wait @@ -151,7 +151,7 @@ async def on_event(self, sid: str, data: Any): data: The event data. """ try: - await self._dispatch_event(sid, data) + await self.handle_event(sid, data, self._scope(sid)) except exceptions.EventDeserializationError: # Client-controlled input a Reflex client never sends; end the # session, as the plain transport does, rather than log a @@ -159,29 +159,33 @@ async def on_event(self, sid: str, data: Any): logger.debug(f"Disconnecting session {sid}: undeserializable event.") await self.disconnect(sid) - async def _dispatch_event(self, sid: str, data: Any) -> None: - """Hand one front-end event to the event processor. + def _scope(self, sid: str) -> MutableMapping[str, Any]: + """Get the ASGI scope of a session's connection. + + A plain function, not a coroutine: it runs for every event. Args: sid: The Socket.IO session id. - data: The event data. + + Returns: + The ASGI scope. Raises: RuntimeError: If the Socket.IO is badly initialized. """ - scope = self._scopes.get(sid) - if scope is None: - # The connection was not seen by on_connect (e.g. the namespace - # was registered after the socket connected); ask the server. - 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) - scope = self._scopes[sid] = environ["asgi.scope"] - await self.handle_event(sid, data, scope) + if (scope := self._scopes.get(sid)) is not None: + return scope + # The connection was not seen by on_connect (e.g. the namespace was + # registered after the socket connected); ask the server. + 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) + scope = self._scopes[sid] = environ["asgi.scope"] + return scope async def on_ping(self, sid: str): """Event for testing the API endpoint. diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 6ad0986fc8d..d1da7c492f2 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -600,6 +600,8 @@ async def test_malformed_frame_closes_connection( 42, {"name": 123, "payload": {}, "router_data": {}}, {"name": "x", "payload": "nope", "router_data": {}}, + {"name": "x", "payload": {}, "router_data": []}, + {"name": "x", "payload": {}, "router_data": "nope"}, {"name": "x", "payload": {}, "router_data": {"query": "not-a-dict"}}, ], ) @@ -2155,3 +2157,10 @@ def test_decode_asgi_headers(): ]) == {"cookie": "b=2"} # Names and values are decoded as UTF-8. assert _decode_asgi_headers([(b"x-name", "café".encode())]) == {"x-name": "café"} + # Octets that are not UTF-8, in a name or a value, decode as Latin-1; the + # other headers still decode as UTF-8. + assert _decode_asgi_headers([ + (b"x-\xff", b"a"), + (b"x-raw", b"\xfe"), + ("x-café".encode(), "café".encode()), + ]) == {"x-ÿ": "a", "x-raw": "þ", "x-café": "café"} From 7ff416484825edd118ef4182ffcd29583f8e2103 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 17 Oct 2026 13:14:33 +0200 Subject: [PATCH 57/62] Stop channel sends to moved sockets and size-check queued frames A token claimed by another instance drops this socket's token link but not its connection, so channel sends (rooms, send_to_token, session.send) kept reaching it until it sent another frame. Deliver channel messages only to linked sessions and close a stale connection, whose teardown forgets its channel sessions. The client flushed its send queue on handshake without checking the limit that handshake advertises, so a frame queued before the first handshake or while a socket was closing could make the backend drop the connection. Check each queued frame and report a dropped one like emit does, or as message_too_large on its channel. --- .../.templates/web/utils/helpers/websocket.js | 67 +++++++++++++++---- reflex/event_namespace.py | 9 ++- tests/units/compiler/websocket_js.test.mjs | 42 ++++++++++++ tests/units/test_event_namespace.py | 63 +++++++++++++++++ 4 files changed, 167 insertions(+), 14 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 dfa9ff594b4..95c7e612b74 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 @@ -418,6 +418,20 @@ class ReflexChannel extends LocalEmitter { } } + /** + * Report a queued message dropped for being over the backend's limit. + * @param frame The serialized frame. + * @param limit The limit in bytes. + */ + _dropOversized(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.`, + }); + } + /** * Report that this deployment cannot carry channels at all. * @param reason Why channels are unavailable. @@ -449,12 +463,7 @@ class ReflexChannel extends LocalEmitter { // 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.`, - }); + this._dropOversized(frame, limit); continue; } // The transport re-queues it if its socket closed mid-flush. @@ -522,6 +531,37 @@ export const disableChannels = (reason) => { } }; +/** + * The error reported for an event message over the backend's limit. + * @param name The event name. + * @param frame The serialized frame. + * @param limit The limit in bytes. + * @returns The error. + */ +const oversizedEventError = (name, frame, limit) => + new Error( + `Message "${name}" is ${frameByteLength(frame)} bytes, over the ${limit} ` + + "the backend accepts (REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE); it was not sent.", + ); + +/** + * Report a frame from the transport's queue dropped for being over the + * backend's limit: to its channel, or as an error for an event. + * @param frame The serialized text or binary frame. + * @param limit The limit in bytes. + */ +const dropOversizedFrame = (frame, limit) => { + const [event, data, channel] = + typeof frame === "string" + ? parseJsonLenient(frame, []) + : (decodeChannelFrame(frame) ?? []); + if (channel === undefined) { + report(oversizedEventError(data?.name ?? event, frame, limit)); + } else { + channels.get(channel)?._dropOversized(frame, limit); + } +}; + /** * Open every channel on a transport that just finished its handshake. * @param transport The connected transport. @@ -772,13 +812,7 @@ export class ReflexWebSocket extends LocalEmitter { if (limit && exceedsMessageLimit(frame, limit)) { // The backend closes the connection over an oversized frame, losing // every update in flight with it; drop this one message instead. - report( - new Error( - `Message "${data?.name ?? event}" is ${frameByteLength(frame)} bytes, ` + - `over the ${limit} the backend accepts ` + - "(REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE); it was not sent.", - ), - ); + report(oversizedEventError(data?.name ?? event, frame, limit)); return; } this._send(frame); @@ -865,7 +899,14 @@ export class ReflexWebSocket extends LocalEmitter { } const queue = this._sendQueue; this._sendQueue = []; + const limit = this._maxMessageSize; for (const frame of queue) { + // Queued before this backend's limit was known: an oversized frame + // would cost the connection and every frame flushed after it. + if (limit && exceedsMessageLimit(frame, limit)) { + dropOversizedFrame(frame, limit); + continue; + } this._ws.send(frame); } this._emitLocal("connect"); diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index 28f441ee626..ee6460b171b 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -963,8 +963,15 @@ async def _send_channel_message( if buffers else _dumps([event, data, channel]) ) + linked = self._token_manager.sid_to_token for sid in sids: - self._deliver(sid, payload, event) + if sid in linked: + self._deliver(sid, payload, event) + elif (connection := self._connections.get(sid)) is not None: + # Its token moved to another socket, which drops the link but + # not the connection. Close it, as its next frame would; the + # teardown forgets its channel sessions. + connection.close(1008) async def _send_channel_error( self, sid: str, channel: str, code: str, message: str diff --git a/tests/units/compiler/websocket_js.test.mjs b/tests/units/compiler/websocket_js.test.mjs index f58ee8a6688..989252920b0 100644 --- a/tests/units/compiler/websocket_js.test.mjs +++ b/tests/units/compiler/websocket_js.test.mjs @@ -330,3 +330,45 @@ test("an oversized event is dropped instead of costing the socket", async () => app.transport.emit("event", { small: true }); assert.equal(socket.sent.length, before + 1); }); + +test("an event queued before the handshake is checked against the limit it brings", async () => { + const app = await setup(); + app.transport.connect(); + const socket = app.sockets.at(-1); + socket.open(); + // Emitted before the backend has said what it accepts. + app.transport.emit("event", { name: "state.big", blob: "x".repeat(200) }); + app.transport.emit("event", { small: true }); + socket.receive(["_handshake", { ...HANDSHAKE[1], max_message_size: 100 }]); + assert.deepEqual(socket.sent.slice(1), [["event", { small: true }]]); + assert.equal(app.reported.length, 1); + assert.match( + app.reported[0].message, + /"state\.big" is \d+ bytes, over the 100/, + ); +}); + +test("a channel message queued on a closing socket is checked against the next backend's limit", async () => { + const app = await setup(); + let socket = connectTransport(app); + const channel = app.getChannel("big"); + const errors = []; + channel.on("error", (error) => errors.push(error)); + socket.receive(["_opened", null, "big"]); + // The socket is closing, but the browser has not reported the close yet. + socket.readyState = 2; + channel.emit("push", { blob: "x".repeat(200) }); + socket.readyState = 3; + socket.onclose({ code: 1006, reason: "" }); + + // The reconnect lands on a backend that accepts less. + socket = connectTransport(app, { max_message_size: 100 }); + assert.deepEqual( + socket.sent.map((frame) => frame[0]), + ["_connect", "_open"], + ); + assert.deepEqual( + errors.map((error) => error.code), + ["message_too_large"], + ); +}); diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index d1da7c492f2..81fe7d2fd7c 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -10,6 +10,7 @@ import shutil import subprocess import sys +from collections.abc import Awaitable, Callable from pathlib import Path from types import SimpleNamespace from typing import Any @@ -46,6 +47,7 @@ decode_channel_frame, encode_channel_frame, ) +from reflex.utils.token_manager import SocketRecord from .conftest import active_tracer, metric_points @@ -1721,6 +1723,7 @@ def record(to: str | None, payload: Any, label: str) -> None: monkeypatch.setattr(event_namespace, "encode_channel_frame", counting_encode) monkeypatch.setattr(namespace, "_deliver", record) for sid in ("sid1", "sid2", "sid3"): + namespace._token_manager.sid_to_token[sid] = "tok" 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]) @@ -1732,6 +1735,66 @@ def record(to: str | None, payload: Any, label: str) -> None: assert sent[0][1] is sent[1][1] is sent[2][1] +@pytest.mark.asyncio +@pytest.mark.parametrize( + "send", + [ + pytest.param( + lambda channel, session: channel.send_to_room("all", "news"), id="room" + ), + pytest.param( + lambda channel, session: channel.send_to_token("tok1", "news"), id="token" + ), + pytest.param(lambda channel, session: session.send("news"), id="session"), + ], +) +async def test_a_session_whose_token_moved_is_closed_instead_of_sent_to( + namespace: WebsocketEventNamespace, + mock_app: Mock, + send: Callable[[Channel, ChannelSession], Awaitable[Any]], +): + """Channel messages stop at a socket whose token another instance claimed. + + The claim drops this socket's token link but not the connection, so until + the socket sent another frame, its channel sessions kept receiving room + broadcasts, token sends and session sends. + """ + opened = asyncio.Event() + + class RoomChannel(RecordingChannel): + name = "probe" + + async def on_open(self, session: ChannelSession) -> None: + """Join a room and signal the open.""" + await super().on_open(session) + session.join("all") + opened.set() + + channel = RoomChannel() + mock_app._channels = {"probe": channel} + websocket = FakeWebSocket() + _open_session(websocket) + websocket._incoming.put_nowait(json.dumps([OPEN_MESSAGE, None, "probe"])) + serving = asyncio.create_task(namespace.handle_websocket(websocket)) # pyright: ignore[reportArgumentType] + await asyncio.wait_for(opened.wait(), 1) + # What RedisTokenManager does on adopting another instance's record. + sid = namespace.token_to_sid["tok1"] + namespace._token_manager.token_to_socket["tok1"] = SocketRecord( + instance_id="other-instance", sid="other-sid" + ) + del namespace._token_manager.sid_to_token[sid] + + await send(channel, channel.opened[0]) + await _drain_tasks() + + assert "news" not in [frame[0] for frame in channel_frames(websocket)] + await asyncio.wait_for(serving, 1) + assert websocket.close_code == 1008 + # The teardown forgot the stale session and its rooms. + assert channel.closed == channel.opened + assert channel._rooms == {} + + @pytest.mark.asyncio async def test_closing_a_connection_the_heartbeat_already_closed_is_not_an_error( namespace: WebsocketEventNamespace, From 70a15898b12d65a37d0a6a9f40db690b460d4ef5 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 17 Oct 2026 15:06:25 +0200 Subject: [PATCH 58/62] Keep reflex-bench out of the non-Linux unit test runs The benchmark socket client builds on reflex-bench's wire codec, and two unit tests that also run on Windows imported it. Importing any part of reflex-bench makes coverage count the whole Linux-only package, whose own tests are ignored off Linux, which pulled Windows coverage under the threshold. Run those two tests on Linux only and import the client inside them. --- tests/units/benchmarks/test_support.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/units/benchmarks/test_support.py b/tests/units/benchmarks/test_support.py index e66872172e9..18b27bb63ad 100644 --- a/tests/units/benchmarks/test_support.py +++ b/tests/units/benchmarks/test_support.py @@ -2,6 +2,7 @@ import asyncio import json +import sys from types import SimpleNamespace import pytest @@ -15,7 +16,13 @@ PerformanceReport, percentile, ) -from tests.benchmarks.support.socket_client import run_clients + +# The benchmark socket client speaks the wire protocols through reflex-bench, +# which is Linux-only like its own tests. Imported anywhere else, it would also +# make coverage count that whole untested package, so it is imported in tests. +linux_only = pytest.mark.skipif( + sys.platform != "linux", reason="the benchmark socket client builds on reflex-bench" +) def test_percentile_interpolates_and_validates(): @@ -140,9 +147,12 @@ def test_pipeline_trace_preserves_repeated_stage_pairs(): } +@linux_only @pytest.mark.asyncio async def test_run_clients_leaves_default_executor_usable(): """The load helper does not close the event loop's default executor.""" + from tests.benchmarks.support.socket_client import run_clients + results = await run_clients( 2, lambda index, executor: asyncio.get_running_loop().run_in_executor( @@ -324,6 +334,7 @@ def crash(self) -> None: pass +@linux_only @pytest.mark.parametrize(("timeout", "interval"), [(None, 1.0), ("100ms", 0.05)]) def test_idle_clients_answer_pings_within_the_ping_timeout( monkeypatch: pytest.MonkeyPatch, timeout: str | None, interval: float From 1f6393e234a6bd7cf11208492e17214c69356d61 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 17 Oct 2026 17:13:53 +0200 Subject: [PATCH 59/62] Share the session failure policy across transports and dedupe the server Both transports now open sessions through BaseEventNamespace: one connect-and-boot sequence and one handler failure policy. A failed boot event ends the session on either transport (the plain WebSocket closes with 1011), so the client retries rather than waiting for a hydrate that never comes. Undeserializable input ends a session quietly, and other handler failures are logged within the error budget; Socket.IO's on_event, on_ping and on_client_error no longer raise into python-socketio. Socket.IO packets are serialized like plain frames, so an unpaired surrogate is escaped instead of breaking the send. Also: one fan-out body for channel room and token sends, a socket policy read once per connection for both transports, a supports_channels flag instead of re-reading the config, the embedded test server capped like the Granian launchers, CORS strings parsed as the environment variable is, and an unreachable uvicorn warning removed. The Socket.IO namespace tests move from test_app.py to test_socketio_namespace.py, and the namespace tests share fixtures and a serve() helper. --- news/+socketio-handler-failures.bugfix.md | 1 + .../reflex-base/src/reflex_base/config.py | 11 +- reflex/app.py | 18 +- reflex/channels.py | 72 +- reflex/event_namespace.py | 294 ++++--- reflex/socketio_namespace.py | 120 +-- reflex/testing.py | 44 +- reflex/utils/exec.py | 1 - tests/units/conftest.py | 51 ++ tests/units/test_app.py | 593 +------------- tests/units/test_event_namespace.py | 740 +++++++----------- tests/units/test_socketio_namespace.py | 488 ++++++++++-- 12 files changed, 1095 insertions(+), 1338 deletions(-) create mode 100644 news/+socketio-handler-failures.bugfix.md diff --git a/news/+socketio-handler-failures.bugfix.md b/news/+socketio-handler-failures.bugfix.md new file mode 100644 index 00000000000..c55f0a2ba7d --- /dev/null +++ b/news/+socketio-handler-failures.bugfix.md @@ -0,0 +1 @@ +The Socket.IO transport now escapes unpaired surrogates in outgoing packets, which a non-UTF-8 file name in state could otherwise turn into a failed send, and logs event handler failures within the per-session error budget instead of raising them into python-socketio. diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index d6451f27576..2b52c7f4a38 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -584,17 +584,16 @@ def _normalize_disable_plugins(self): self.disable_plugins = normalized def _normalize_cors_allowed_origins(self): - """Read a plain-string cors_allowed_origins as a comma-separated list. + """Read a plain-string cors_allowed_origins as its environment variable is read. Left a string, membership in it is a substring test, so every origin check would accept any origin the configured one contains. """ if isinstance(self.cors_allowed_origins, str): - self.cors_allowed_origins = [ - origin.strip() - for origin in self.cors_allowed_origins.split(",") - if origin.strip() - ] + field = self.__dataclass_fields__["cors_allowed_origins"] + self.cors_allowed_origins = interpret_env_var_value( + self.cors_allowed_origins, field.type, field.name + ) def _normalize_frontend_compression_formats(self): """Normalize and validate configured frontend compression formats. diff --git a/reflex/app.py b/reflex/app.py index 1e79f39bbc2..5845ada3c5a 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -561,7 +561,7 @@ def register_channel(self, channel: Channel) -> None: "the default)." ) return - if get_config().transport != "websocket": + if not self.event_namespace.supports_channels: logger.warning( f"Channel {name!r} is not served: it requires the plain WebSocket " "transport; remove the transport setting in rxconfig.py or set " @@ -685,10 +685,7 @@ def _setup_state(self) -> None: # its own. event_namespace_class = sys.modules[__name__].EventNamespace except ImportError as ex: - msg = ( - f"transport={config.transport!r} requires the python-socketio " - "package. Install it with: pip install 'reflex[socketio]'" - ) + msg = f"transport={config.transport!r} requires {_SOCKETIO_PACKAGE}." raise RuntimeError(msg) from ex socket_app = create_socketio_app(self, config) @@ -2061,6 +2058,12 @@ async def health(_request: Request) -> JSONResponse: return JSONResponse(content=health_status, status_code=status_code) +# What the Socket.IO transport needs installed. +_SOCKETIO_PACKAGE = ( + "the optional python-socketio package; install it with: " + "pip install 'reflex[socketio]'" +) + # Socket.IO names this module exported before python-socketio became optional: # name -> (module, attribute). _SOCKETIO_EXPORTS = { @@ -2092,9 +2095,6 @@ def __getattr__(name: str) -> Any: module = importlib.import_module(module_name) except ImportError as ex: # An AttributeError, so hasattr() and getattr() with a default work. - msg = ( - f"{__name__}.{name} needs the optional python-socketio package; " - "install it with: pip install 'reflex[socketio]'" - ) + msg = f"{__name__}.{name} needs {_SOCKETIO_PACKAGE}." raise AttributeError(msg) from ex return getattr(module, attribute) diff --git a/reflex/channels.py b/reflex/channels.py index e86a1405219..5f724da31f8 100644 --- a/reflex/channels.py +++ b/reflex/channels.py @@ -25,7 +25,7 @@ async def on_message(self, session, event, data, buffers): import re from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable, Sequence -from typing import Any, ClassVar +from typing import Any, ClassVar, TypeVar from typing_extensions import Buffer @@ -93,6 +93,25 @@ def _validate_message(event: str, buffers: Sequence[Buffer]) -> None: raise ValueError(msg) +_K = TypeVar("_K") +_V = TypeVar("_V") + + +def _discard(index: dict[_K, set[_V]], key: _K, member: _V) -> None: + """Remove a member from an index of sets, dropping the key once it is empty. + + Args: + index: The index. + key: The key whose set holds the member. + member: The member to remove. + """ + members = index.get(key) + if members is not None: + members.discard(member) + if not members: + del index[key] + + def validate_channel_name(name: Any) -> None: """Check a channel name against the wire format. @@ -175,11 +194,7 @@ def leave(self, room: str) -> None: 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] + _discard(self.channel._rooms, room, self) class Channel(ABC): @@ -263,17 +278,7 @@ async def send_to_room( frame may hold. Raised before anything is sent, so a fan-out fails whole rather than reaching some clients. """ - _validate_message(event, buffers) - members = self._rooms.get(room) - if not members: - return - # 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 - ) + await self._fan_out(self._rooms.get(room), event, data, buffers) async def send_to_token( self, @@ -299,10 +304,35 @@ async def send_to_token( frame may hold. Raised before anything is sent, so a fan-out fails whole rather than reaching some clients. """ + return await self._fan_out( + self._sessions.get(client_token), event, data, buffers + ) + + async def _fan_out( + self, + sessions: set[ChannelSession] | None, + event: str, + data: Any, + buffers: Sequence[Buffer], + ) -> bool: + """Send one message to a group of sessions, validated before any is sent. + + Args: + sessions: The recipients, if any. + event: The message name. + data: The JSON-serializable metadata. + buffers: Binary attachments delivered alongside the metadata. + + Returns: + Whether a session received the message. + """ _validate_message(event, buffers) - sessions = self._sessions.get(client_token) if not sessions: return False + # 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 group. await next(iter(sessions))._send( [session.sid for session in sessions], self.name, event, data, buffers ) @@ -341,8 +371,4 @@ def forget_session(self, session: ChannelSession) -> None: session.open = False 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] + _discard(self._sessions, session.client_token, session) diff --git a/reflex/event_namespace.py b/reflex/event_namespace.py index ee6460b171b..26708875b89 100644 --- a/reflex/event_namespace.py +++ b/reflex/event_namespace.py @@ -14,7 +14,7 @@ from abc import ABC, abstractmethod from collections.abc import Iterable, Mapping, MutableMapping, Sequence from datetime import timedelta -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple from reflex_base import constants, otel from reflex_base.config import get_config @@ -152,6 +152,59 @@ def connect_auth(auth: Any) -> tuple[Any, Any]: ) +class _SocketPolicy(NamedTuple): + """The event socket's heartbeat and message size settings.""" + + # Seconds between heartbeat pings. + ping_interval: float + + # Seconds past the interval a client may stay silent. + ping_timeout: float + + # The message size limit in bytes. + max_message_size: int + + @property + def silence_limit(self) -> float: + """Seconds a client may stay silent before its session ends. + + Returns: + The ping interval plus the ping timeout. + """ + return self.ping_interval + self.ping_timeout + + +def _socket_policy() -> _SocketPolicy: + """Read the event socket's settings, per connection. + + Returns: + The settings currently configured. + """ + return _SocketPolicy( + environment.REFLEX_SOCKET_INTERVAL.get().total_seconds(), + environment.REFLEX_SOCKET_TIMEOUT.get().total_seconds(), + environment.REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE.get(), + ) + + +def _closing(sid: str, reason: str, code: int) -> int: + """Log why a session is closing, at debug level. + + Every reason is client-driven, so logging louder would let a hostile + client flood the logs. + + Args: + sid: The session id. + reason: Why the session closes. + code: The websocket close code. + + Returns: + The close code, to close the session with. + """ + logger.debug("Closing session %s: %s.", sid, reason) + return code + + def _dumps(obj: Any) -> str: """Serialize a frame compactly, as text that is valid UTF-8. @@ -482,6 +535,9 @@ class BaseEventNamespace(ABC): # The application object. app: App + # Whether the transport carries channels (reflex.channels). + supports_channels: ClassVar[bool] = False + # 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 @@ -626,6 +682,74 @@ async def handle_connect( to=sid, ) + async def _open_session( + self, sid: str, auth: Any, query_string: str, subprotocol: str | None + ) -> tuple[bool, Any]: + """Link a connecting session, reading the boot event its connect carries. + + Args: + sid: The session id. + auth: The connect payload. + query_string: The raw query string of the connection request. + subprotocol: The websocket subprotocol offered by the client. + + Returns: + Whether the session linked a token, and its boot event if any. + """ + boot_event, frontend_schema = connect_auth(auth) + await self.handle_connect( + sid, + query_string, + subprotocol, + frontend_schema, + update_state=boot_event is None, + ) + return sid in self._token_manager.sid_to_token, boot_event + + async def _handle_boot_event( + self, sid: str, boot_event: Any, scope: MutableMapping[str, Any] + ) -> int | None: + """Dispatch the boot event of a connect as the session's first event. + + A session whose boot event failed is ended: kept open, it would leave + the client waiting for a hydrate that never comes instead of retrying. + + Args: + sid: The session id. + boot_event: The boot event. + scope: The ASGI scope of the client connection. + + Returns: + None once dispatched, else the close code to end the session with. + """ + try: + await self.handle_event(sid, boot_event, scope) + except Exception as exc: + return self._handler_failed(sid, "the boot event", exc) or 1011 + return None + + def _handler_failed(self, sid: str, what: str, error: Exception) -> int | None: + """Apply the failure policy to a handler that raised. + + Undeserializable input is client-controlled and a Reflex client never + sends it, so it ends the session quietly; anything else is a + server-side bug, logged within the session's error budget. + + Args: + sid: The session id. + what: What was being handled, for the log. + error: The exception the handler raised. + + Returns: + The close code the session must end with, or None to keep it. + """ + if isinstance(error, exceptions.EventDeserializationError): + return _closing(sid, f"{what} did not deserialize", 1002) + self._log_handler_failure( + sid, f"Error handling {what} for session {sid}.", error + ) + return None + def handle_disconnect(self, sid: str) -> asyncio.Task | None: """Handle a client session disconnecting. @@ -699,8 +823,8 @@ async def handle_event( Raises: EventDeserializationError: If the event data is malformed. """ - # Determine the token for this SID - if (token := self.sid_to_token.get(sid)) is None: + # Determine the token for this SID, without the property's call. + if (token := self._token_manager.sid_to_token.get(sid)) is None: # 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 @@ -892,6 +1016,8 @@ class WebsocketEventNamespace(BaseEventNamespace): Frames are JSON arrays ``[event_name, payload]``. """ + supports_channels = True + def __init__(self, namespace: str, app: App): """Initialize the websocket event namespace. @@ -904,23 +1030,25 @@ def __init__(self, namespace: str, app: App): # Open channel sessions per connection, by session id and channel name. self._channel_sessions: dict[str, dict[str, ChannelSession]] = {} - def _deliver(self, to: str | None, payload: str | bytes, label: str) -> None: - """Queue one serialized frame for a connected client session. + def _deliver(self, sids: Iterable[str], payload: str | bytes, label: str) -> None: + """Queue one serialized frame for connected client sessions. Args: - to: The session id to send to. + sids: The session ids to send to. payload: The serialized text or binary frame. label: The message name, for diagnostics. """ - connection = self._connections.get(to) if to is not None else None - if connection 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 {label!r} to unknown session {to!r}.") - return - if otel.enabled: - otel.record_message_size(utf8_size(payload), "transmit") - connection.send(payload) + size = utf8_size(payload) if otel.enabled else 0 + for sid in sids: + if (connection := self._connections.get(sid)) 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 {label!r} to unknown session {sid!r}.") + continue + if otel.enabled: + otel.record_message_size(size, "transmit") + connection.send(payload) async def emit(self, event: str, data: Any = None, to: str | None = None) -> None: """Emit an event to a connected client session, or to all of them. @@ -928,15 +1056,12 @@ async def emit(self, event: str, data: Any = None, to: str | None = None) -> Non Args: event: The event name. data: The event payload. - to: The session id to emit to; every connected session if None. + to: The session id to emit to; every connected session if None, + like Socket.IO's emit without a recipient. """ - payload = _dumps([event, data]) - if to is not None: - self._deliver(to, payload, event) - return - # Like Socket.IO's emit without a recipient: a broadcast. - for sid in self._connections: - self._deliver(sid, payload, event) + self._deliver( + self._connections if to is None else (to,), _dumps([event, data]), event + ) async def _send_channel_message( self, @@ -964,14 +1089,16 @@ async def _send_channel_message( else _dumps([event, data, channel]) ) linked = self._token_manager.sid_to_token + recipients = [] for sid in sids: if sid in linked: - self._deliver(sid, payload, event) + recipients.append(sid) elif (connection := self._connections.get(sid)) is not None: # Its token moved to another socket, which drops the link but # not the connection. Close it, as its next frame would; the # teardown forgets its channel sessions. connection.close(1008) + self._deliver(recipients, payload, event) async def _send_channel_error( self, sid: str, channel: str, code: str, message: str @@ -1185,8 +1312,7 @@ async def _handle_binary_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 + return _closing(sid, "malformed binary frame", 1002) await self._handle_channel_message(sid, channel_name, event, data, buffers) return None @@ -1223,8 +1349,7 @@ def _accept_inbound(sid: str, payload: str | bytes, max_size: int) -> int | None 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 + return _closing(sid, f"message over {max_size} bytes", 1009) if otel.enabled: otel.record_message_size(utf8_size(payload), "receive") return None @@ -1271,14 +1396,12 @@ async def _handle_frame( # 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 + return _closing(sid, "malformed frame", 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 + return _closing(sid, "malformed channel frame", 1002) await self._handle_channel_message(sid, message[2], event, data, []) return None try: @@ -1297,17 +1420,8 @@ async def _handle_frame( 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 as exc: - # A failing handler is a server-side bug: log it loudly; the - # connection survives. - self._log_handler_failure( - sid, f"Error handling socket event {event!r} for session {sid}.", exc - ) + return self._handler_failed(sid, f"socket event {event!r}", exc) return None async def _receive_connect( @@ -1327,8 +1441,9 @@ async def _receive_connect( try: received = await asyncio.wait_for(websocket.receive(), timeout) except TimeoutError: - logger.debug(f"Closing session {sid}: no connect frame.") - await self._close_quietly(websocket, 1008) + await self._close_quietly( + websocket, _closing(sid, "no connect frame", 1008) + ) return None if received["type"] == "websocket.disconnect": return None @@ -1340,33 +1455,9 @@ async def _receive_connect( if isinstance(message, list) and message and message[0] == CONNECT_MESSAGE: return message # A Reflex client opens every session with a connect frame. - logger.debug(f"Closing session {sid}: expected a connect frame.") - await self._close_quietly(websocket, 1002) - return None - - async def _handle_boot_event( - self, sid: str, boot_event: Any, scope: MutableMapping[str, Any] - ) -> int | None: - """Dispatch the boot event of a connect frame as the session's first event. - - Args: - sid: The session id. - boot_event: The boot event. - scope: The ASGI scope of the client connection. - - Returns: - The websocket close code the session must end with, or None to - keep serving it. - """ - try: - await self.handle_event(sid, boot_event, scope) - except exceptions.EventDeserializationError: - logger.debug(f"Closing session {sid}: undeserializable boot event.") - return 1002 - except Exception as exc: - self._log_handler_failure( - sid, f"Error handling the boot event for session {sid}.", exc - ) + await self._close_quietly( + websocket, _closing(sid, "expected a connect frame", 1002) + ) return None async def handle_websocket(self, websocket: WebSocket) -> None: @@ -1391,21 +1482,19 @@ async def handle_websocket(self, websocket: WebSocket) -> None: await websocket.accept(subprotocol=subprotocols[0] if subprotocols else None) sid = str(uuid.uuid4()) - ping_interval = environment.REFLEX_SOCKET_INTERVAL.get().total_seconds() - ping_timeout = environment.REFLEX_SOCKET_TIMEOUT.get().total_seconds() - max_message_size = environment.REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE.get() + policy = _socket_policy() # The client sends its connect frame on open, without waiting for the # handshake, so the boot event it carries saves that round trip. A # socket that never sends one ends within the heartbeat window. connect_frame = await self._receive_connect( - sid, websocket, ping_interval + ping_timeout, max_message_size + sid, websocket, policy.silence_limit, policy.max_message_size ) if connect_frame is None: return connection = _Connection(websocket) self._connections[sid] = connection heartbeat_task = asyncio.create_task( - self._heartbeat(connection, ping_interval, ping_timeout), + self._heartbeat(connection, policy), name=f"reflex_heartbeat|{sid}", ) lifetime = asyncio.timeout(None) @@ -1417,9 +1506,7 @@ async def handle_websocket(self, websocket: WebSocket) -> None: connection, connect_frame[1] if len(connect_frame) > 1 else None, subprotocols[0] if subprotocols else None, - ping_interval, - ping_timeout, - max_message_size, + policy, ) except TimeoutError: # The lifetime firing is how connection.close() ends the loop. @@ -1449,22 +1536,16 @@ async def handle_websocket(self, websocket: WebSocket) -> None: await self._close_quietly(websocket, connection.close_code) @staticmethod - async def _heartbeat( - connection: _Connection, ping_interval: float, ping_timeout: float - ) -> None: + async def _heartbeat(connection: _Connection, policy: _SocketPolicy) -> None: """Ping a session's client, closing the session once it stops answering. Args: connection: The client connection. - ping_interval: Seconds between pings. - ping_timeout: Seconds past the interval a client may stay silent. + policy: The socket's heartbeat settings. """ while True: - await asyncio.sleep(ping_interval) - if ( - time.monotonic() - connection.last_received - > ping_interval + ping_timeout - ): + await asyncio.sleep(policy.ping_interval) + if time.monotonic() - connection.last_received > policy.silence_limit: connection.close(1001) return connection.send(_PING_FRAME) @@ -1475,9 +1556,7 @@ async def _serve_session( connection: _Connection, auth: Any, subprotocol: str | None, - ping_interval: float, - ping_timeout: float, - max_message_size: int, + policy: _SocketPolicy, ) -> None: """Open a session and dispatch its frames until it ends. @@ -1486,20 +1565,13 @@ async def _serve_session( connection: The client connection. auth: The payload its connect frame carried, if any. subprotocol: The websocket subprotocol offered by the client. - ping_interval: Seconds between heartbeat pings. - ping_timeout: Seconds past the interval a client may stay silent. - max_message_size: The message size limit in bytes. + policy: The socket's heartbeat and message size settings. """ websocket = connection.websocket - boot_event, frontend_schema = connect_auth(auth) - await self.handle_connect( - sid, - websocket.scope.get("query_string", b"").decode(), - subprotocol, - frontend_schema, - update_state=boot_event is None, + linked, boot_event = await self._open_session( + sid, auth, websocket.scope.get("query_string", b"").decode(), subprotocol ) - if sid not in self._token_manager.sid_to_token: + if not linked: # No token was linked; not a Reflex client. connection.close(1008) return @@ -1509,12 +1581,12 @@ async def _serve_session( _dumps([ HANDSHAKE_MESSAGE, { - "ping_interval": ping_interval, - "ping_timeout": ping_timeout, + "ping_interval": policy.ping_interval, + "ping_timeout": policy.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, + "max_message_size": policy.max_message_size, }, ]) ) @@ -1540,21 +1612,19 @@ async def _serve_session( # 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.") - connection.close(1008) + connection.close(_closing(sid, "its token is gone", 1008)) return text = received.get("text") if text is not None: close_code = await self._handle_frame( - sid, text, websocket.scope, max_message_size + sid, text, websocket.scope, policy.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 + sid, frame, policy.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 + close_code = _closing(sid, "received a binary frame", 1003) if close_code is not None: connection.close(close_code) diff --git a/reflex/socketio_namespace.py b/reflex/socketio_namespace.py index 83deff26f4b..dd5fc166352 100644 --- a/reflex/socketio_namespace.py +++ b/reflex/socketio_namespace.py @@ -3,19 +3,24 @@ from __future__ import annotations import json -import logging -from collections.abc import MutableMapping +from collections.abc import Awaitable, MutableMapping from types import SimpleNamespace from typing import TYPE_CHECKING, Any from reflex_base import otel -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, connect_auth, utf8_size -from reflex.utils import exceptions, format +from reflex.event_namespace import ( + _CLIENT_ERROR, + _EVENT, + _PING, + BaseEventNamespace, + _dumps, + _socket_policy, + utf8_size, +) if TYPE_CHECKING: import asyncio @@ -24,23 +29,21 @@ from reflex.app import App -logger = logging.getLogger(__name__) - def _sio_dumps(obj: Any, **kwargs: Any) -> str: """Serialize an outgoing Socket.IO packet, recording its size when telemetry is on. - Uses Reflex's dumps, which emits the non-finite float tokens the frontend - revives. + Serialized like a plain transport frame: compact, with the non-finite + float tokens the frontend revives, and valid UTF-8. Args: obj: The packet payload. - **kwargs: Options forwarded to the JSON encoder. + **kwargs: Encoder options; socket.io only asks for compact separators. Returns: The JSON string. """ - data = format.json_dumps(obj, **kwargs) + data = _dumps(obj) if otel.enabled: otel.record_message_size(utf8_size(data), "transmit") return data @@ -101,35 +104,24 @@ async def on_connect( """ if (scope := environ.get("asgi.scope")) is not None: self._scopes[sid] = scope - boot_event, frontend_schema = connect_auth(auth) - await self.handle_connect( + linked, boot_event = await self._open_session( sid, + auth, environ.get("QUERY_STRING", ""), environ.get("HTTP_SEC_WEBSOCKET_PROTOCOL"), - frontend_schema, - update_state=boot_event is None, ) - if sid not in self.sid_to_token: - # Not a Reflex client: nothing it sends can be served. Socket.IO - # runs no disconnect handler for a refused connect, so undo this - # one here. - self.on_disconnect(sid) - return False - if boot_event is not None: - try: - # Not through on_event: a failure here refuses the connect - # rather than disconnecting a session not yet acknowledged. - await self.handle_event(sid, boot_event, self._scope(sid)) - except Exception as exc: - # Refused rather than raised: Socket.IO would keep a raising - # connect registered yet unanswered, and the client would wait - # for a hydrate that never comes instead of retrying. - self._log_handler_failure( - sid, f"Error handling the boot event for session {sid}.", exc - ) - self.on_disconnect(sid) - return False - return None + # A failed boot event refuses the connect rather than raising, which + # would keep the connect registered yet unanswered, or disconnecting, + # which would acknowledge a session that never hydrated. + if linked and ( + boot_event is None + or await self._handle_boot_event(sid, boot_event, self._scope(sid)) is None + ): + return None + # Socket.IO runs no disconnect handler for a refused connect, so undo + # this one here. + self.on_disconnect(sid) + return False def on_disconnect(self, sid: str) -> asyncio.Task | None: """Event for when the websocket disconnects. @@ -150,14 +142,30 @@ async def on_event(self, sid: str, data: Any): sid: The Socket.IO session id. data: The event data. """ + # Inline rather than through _run_handler, which costs a coroutine + # frame: this runs for every event. try: await self.handle_event(sid, data, self._scope(sid)) - except exceptions.EventDeserializationError: - # Client-controlled input a Reflex client never sends; end the - # session, as the plain transport does, rather than log a - # traceback per frame. - logger.debug(f"Disconnecting session {sid}: undeserializable event.") - await self.disconnect(sid) + except Exception as exc: + if self._handler_failed(sid, f"socket event {_EVENT!r}", exc): + await self.disconnect(sid) + + async def _run_handler(self, sid: str, event: str, handler: Awaitable[None]): + """Await a socket event handler under the shared failure policy. + + Raising into python-socketio would log a traceback per frame, outside + the budget that bounds client-triggered error logs. + + Args: + sid: The Socket.IO session id. + event: The socket event name, for the log. + handler: The handler's coroutine. + """ + try: + await handler + except Exception as exc: + if self._handler_failed(sid, f"socket event {event!r}", exc): + await self.disconnect(sid) def _scope(self, sid: str) -> MutableMapping[str, Any]: """Get the ASGI scope of a session's connection. @@ -193,7 +201,7 @@ async def on_ping(self, sid: str): Args: sid: The Socket.IO session id. """ - await self.handle_ping(sid) + await self._run_handler(sid, _PING, self.handle_ping(sid)) async def on_client_error(self, sid: str, data: Any = None): """Handle errors reported by the frontend. @@ -205,7 +213,7 @@ async def on_client_error(self, sid: str, data: Any = None): ``on_client_error(sid)``; the malformed-payload guard then drops it without raising. """ - await self.handle_client_error(sid, data) + await self._run_handler(sid, _CLIENT_ERROR, self.handle_client_error(sid, data)) class _HeaderMiddleware: @@ -267,24 +275,20 @@ def create_socketio_app(app: App, config: Config) -> ASGIApp: RuntimeError: If a custom ``sio`` server does not use asgi mode. """ if not app.sio: + polling = config.transport == "polling" + policy = _socket_policy() 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().total_seconds(), - ping_timeout=environment.REFLEX_SOCKET_TIMEOUT.get().total_seconds(), + # An empty list disables CORS handling; engineio reads "*" among + # the origins as allowing any. + cors_allowed_origins=[] if polling else list(config.cors_allowed_origins), + cors_credentials=not polling, + max_http_buffer_size=policy.max_message_size, + ping_interval=policy.ping_interval, + ping_timeout=policy.ping_timeout, json=_SOCKET_JSON_CODEC, allow_upgrades=False, - transports=["polling" if config.transport == "polling" else "websocket"], + transports=["polling" if polling else "websocket"], # Handlers here only parse and enqueue (or emit a pong), so run # them inline on the socket's receive loop instead of paying a # task creation and a loop hop per incoming message. diff --git a/reflex/testing.py b/reflex/testing.py index 8eaca339142..d0e3ad40395 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -193,6 +193,15 @@ def getsockname(self) -> tuple[str, int]: """ return (self.host, self.port) + @property + def url(self) -> str: + """The http URL the server serves on. + + Returns: + The URL, without a trailing slash. + """ + return f"http://{self.host}:{self.port}" + def wait_started(self, timeout: float | None = None) -> None: """Block until the server serves, after the app's lifespan startup. @@ -598,6 +607,8 @@ def _start_backend(self, port: int = 0): if self.app_asgi is None: msg = "App was not initialized." raise RuntimeError(msg) + # Granian serves it, so it advertises the frame size Granian receives. + reflex.utils.exec._cap_message_size_for_granian() self.backend = _EmbeddedServer(self.app_asgi, port=port) self.backend.shutdown = self._get_backend_shutdown_handler() @@ -620,9 +631,7 @@ def _start_frontend(self): with chdir(self.app_path): config = get_config() print("Polling for servers...") # for pytest diagnosis #noqa: T201 - config.api_url = "http://{}:{}".format( - *self._poll_for_servers(timeout=30).getsockname(), - ) + config.api_url = self._poll_for_servers(timeout=30).url print("Building frontend...") # for pytest diagnosis #noqa: T201 reflex.utils.build.setup_frontend(self.app_path) @@ -1072,9 +1081,7 @@ def _start_frontend(self): with chdir(self.app_path): config = get_config() print("Polling for servers...") # for pytest diagnosis #noqa: T201 - config.api_url = "http://{}:{}".format( - *self._poll_for_servers(timeout=30).getsockname(), - ) + config.api_url = self._poll_for_servers(timeout=30).url print("Building frontend...") # for pytest diagnosis #noqa: T201 get_config().loglevel = reflex.constants.LogLevel.INFO @@ -1106,31 +1113,12 @@ def _wait_frontend(self): raise RuntimeError(msg) self.frontend_server.wait_started(DEFAULT_TIMEOUT) config = get_config() - self.frontend_url = "http://{}:{}".format( - *self.frontend_server.getsockname() - ) + config.prepend_frontend_path("/") + self.frontend_url = self.frontend_server.url + config.prepend_frontend_path("/") config.deploy_url = self.frontend_url - def _start_backend(self): - if self.app_asgi is None: - msg = "App was not initialized." - raise RuntimeError(msg) + def _start_backend(self, port: int = 0): environment.REFLEX_SKIP_COMPILE.set(True) - self.backend = _EmbeddedServer(self.app_asgi) - self.backend.shutdown = self._get_backend_shutdown_handler() - - def _run_backend(context: contextvars.Context) -> None: - if self.backend is not None: - context.run(self.backend.run) - - print( # noqa: T201 - "Creating backend in a new thread..." - ) - self.backend_thread = threading.Thread( - target=_run_backend, args=(contextvars.copy_context(),) - ) - self.backend_thread.start() - print("Backend started.") # for pytest diagnosis #noqa: T201 + super()._start_backend(port) def _poll_for_servers(self, timeout: TimeoutType = None) -> _EmbeddedServer: try: diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 19b248b559d..989d3cbe483 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -512,7 +512,6 @@ 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(): diff --git a/tests/units/conftest.py b/tests/units/conftest.py index 87c64245b0b..edebf3d1cd5 100644 --- a/tests/units/conftest.py +++ b/tests/units/conftest.py @@ -102,6 +102,57 @@ def mock_app(app_module_mock: mock.Mock, app: App) -> App: return app +@pytest.fixture +def namespace_app(monkeypatch: pytest.MonkeyPatch) -> mock.Mock: + """A mock app for an event namespace, with a local token manager. + + Redis is disabled so token linking cannot leak into a shared Redis. + + Args: + monkeypatch: The pytest monkeypatch fixture. + + Returns: + The mock app. + """ + monkeypatch.setattr(prerequisites, "check_redis_used", lambda: False) + app = mock.Mock() + app._state = None + app._channels = {} + app.router = mock.Mock(return_value=None) + app.event_processor.enqueue = mock.AsyncMock() + return app + + +@pytest.fixture +def linked_state(namespace_app: mock.Mock) -> mock.Mock: + """The state a namespace app loads when a connect links its token. + + Each load is recorded on ``namespace_app.state_manager.modify_state``. + + Args: + namespace_app: The mock app. + + Returns: + The state. + """ + namespace_app._state = mock.Mock() + state = mock.Mock(router_data={}) + namespace_app.state_manager.modify_state = mock.Mock( + return_value=mock.AsyncMock(__aenter__=mock.AsyncMock(return_value=state)) + ) + return state + + +@pytest.fixture +def boot_event() -> dict[str, Any]: + """The hydrate event a frontend sends with its connect. + + Returns: + The event payload. + """ + return {"name": "state.hydrate_and_load", "payload": {}, "router_data": {}} + + @pytest.fixture(scope="session") def windows_platform() -> bool: """Check if system is windows. diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 55980efe7fe..35509ca637b 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -16,17 +16,15 @@ import threading import unittest.mock import uuid -from collections.abc import AsyncGenerator, Generator +from collections.abc import Generator from concurrent.futures import ThreadPoolExecutor from contextlib import nullcontext as does_not_raise from importlib.util import find_spec from pathlib import Path -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, Mock import pytest -import pytest_asyncio -import reflex_base from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor @@ -34,7 +32,6 @@ 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 @@ -65,7 +62,6 @@ from reflex.app import ( App, ComponentCallable, - EventNamespace, _ContextMiddleware, default_overlay_component, ) @@ -84,7 +80,6 @@ from reflex.istate.manager.token import BaseStateToken from reflex.istate.storage import Cookie, LocalStorage, SessionStorage from reflex.model import Model -from reflex.socketio_namespace import _sio_dumps, _sio_loads from reflex.state import ( BaseState, OnLoadInternalState, @@ -96,7 +91,7 @@ from reflex.utils import exec as exec_utils from reflex.utils.token_manager import RedisTokenManager, SocketRecord -from .conftest import active_tracer, chdir, metric_points +from .conftest import chdir from .states import GenState from .states.upload import ( ChildFileUploadState, @@ -4521,513 +4516,6 @@ def register_route(self, *, add_page, **_): assert app._plugin_routes_registered -@pytest.fixture -def event_namespace() -> EventNamespace: - """An EventNamespace with a mock app and one linked client session. - - Returns: - The event namespace. - """ - namespace = EventNamespace(namespace="/_event", app=Mock()) - namespace.sid_to_token["known_sid"] = "some_token" - return namespace - - -@pytest.fixture -def frontend_errors(event_namespace: EventNamespace) -> list[str]: - """Capture exceptions routed to the app's frontend exception handler. - - Args: - event_namespace: The event namespace. - - Returns: - The captured exception messages. - """ - errors: list[str] = [] - event_namespace.app.frontend_exception_handler = lambda exc: errors.append(str(exc)) - return errors - - -@pytest.fixture -def client_error_console() -> Generator[dict[str, list[str]], None, None]: - """Capture messages logged through the app's logger, keyed by level. - - Yields: - Captured messages keyed by log level. - """ - captured: dict[str, list[str]] = {"error": [], "warn": [], "debug": []} - level_keys = { - logging.ERROR: "error", - logging.WARNING: "warn", - logging.DEBUG: "debug", - } - - class _CaptureHandler(logging.Handler): - def emit(self, record: logging.LogRecord): - key = level_keys.get(record.levelno) - if key is not None: - captured[key].append(record.getMessage()) - - app_logger = logging.getLogger("reflex.event_namespace") - handler = _CaptureHandler(level=logging.DEBUG) - previous_level = app_logger.level - app_logger.addHandler(handler) - app_logger.setLevel(logging.DEBUG) - yield captured - app_logger.removeHandler(handler) - app_logger.setLevel(previous_level) - - -@pytest.mark.asyncio -async def test_client_error_dispatch_missing_reports_actionable_error( - event_namespace: EventNamespace, frontend_errors: list[str] -): - """A dispatch_function_missing error reports the substate and remediation steps. - - Args: - event_namespace: The event namespace. - frontend_errors: Captured frontend exception handler messages. - """ - await event_namespace.on_client_error( - "known_sid", - { - "error_type": constants.ClientErrorType.DISPATCH_MISSING, - "message": "Cannot process state update", - "substate": "reflex___state____state.my___state____my_state", - }, - ) - assert len(frontend_errors) == 1 - message = frontend_errors[0] - assert "reflex___state____state.my___state____my_state" in message - assert "skipped" in message.lower() - assert "rebuild" in message.lower() - - -@pytest.mark.asyncio -async def test_client_error_generic_reports_type_and_message( - event_namespace: EventNamespace, frontend_errors: list[str] -): - """A generic client error reports the error type and message. - - Args: - event_namespace: The event namespace. - frontend_errors: Captured frontend exception handler messages. - """ - await event_namespace.on_client_error( - "known_sid", - { - "error_type": constants.ClientErrorType.STATE_UPDATE, - "message": "boom", - }, - ) - assert len(frontend_errors) == 1 - message = frontend_errors[0] - assert constants.ClientErrorType.STATE_UPDATE in message - assert "boom" in message - - -@pytest.mark.asyncio -@pytest.mark.parametrize("payload", ["not a dict", None, ["list"], 42]) -async def test_client_error_malformed_payload_is_ignored( - event_namespace: EventNamespace, - frontend_errors: list[str], - payload: Any, -): - """Non-dict payloads are dropped without raising or reporting errors. - - Args: - event_namespace: The event namespace. - frontend_errors: Captured frontend exception handler messages. - payload: The malformed payload to send. - """ - await event_namespace.on_client_error("known_sid", payload) - assert not frontend_errors - - -@pytest.mark.asyncio -@pytest.mark.parametrize("sid", ["known_sid", "unknown_sid"]) -async def test_client_error_no_argument_emit_is_ignored( - event_namespace: EventNamespace, - frontend_errors: list[str], - client_error_console: dict[str, list[str]], - sid: str, -): - """A payload-less client_error emit is dropped like any malformed payload. - - python-socketio dispatches ``emit("client_error")`` without a payload as - ``on_client_error(sid)``, so a missing ``data`` argument must not raise a - TypeError before the anti-abuse guards run. - - Args: - event_namespace: The event namespace. - frontend_errors: Captured frontend exception handler messages. - client_error_console: Captured console messages. - sid: The Socket.IO session id to report from. - """ - await event_namespace.on_client_error(sid) - assert not frontend_errors - assert not client_error_console["error"] - assert not client_error_console["warn"] - - -@pytest.mark.asyncio -async def test_client_error_unknown_sid_does_not_report_error( - event_namespace: EventNamespace, - frontend_errors: list[str], - client_error_console: dict[str, list[str]], -): - """Errors from sockets without a linked token are not reported. - - Args: - event_namespace: The event namespace. - frontend_errors: Captured frontend exception handler messages. - client_error_console: Captured console messages. - """ - await event_namespace.on_client_error( - "unknown_sid", - { - "error_type": constants.ClientErrorType.STATE_UPDATE, - "message": "spam from unauthenticated socket", - }, - ) - assert not frontend_errors - assert not client_error_console["error"] - - -@pytest.mark.asyncio -async def test_client_error_values_are_sanitized_and_truncated( - event_namespace: EventNamespace, frontend_errors: list[str] -): - """Control characters are stripped and long messages truncated before reporting. - - Args: - event_namespace: The event namespace. - frontend_errors: Captured frontend exception handler messages. - """ - evil = "\x1b[31mINJECT\x1b[0m\nFAKE LOG LINE\t" + "A" * 5000 - await event_namespace.on_client_error( - "known_sid", - {"error_type": "custom_type", "message": evil}, - ) - assert len(frontend_errors) == 1 - message = frontend_errors[0] - assert "\x1b" not in message - assert "\n" not in message - assert "\t" not in message - assert len(message) < 700 - - -@pytest.mark.asyncio -async def test_client_error_reporting_is_rate_limited_per_sid( - event_namespace: EventNamespace, frontend_errors: list[str] -): - """A single session cannot flood the backend logs with error reports. - - Args: - event_namespace: The event namespace. - frontend_errors: Captured frontend exception handler messages. - """ - for _ in range(20): - await event_namespace.on_client_error( - "known_sid", - {"error_type": "custom_type", "message": "spam"}, - ) - assert len(frontend_errors) == EventNamespace._MAX_CLIENT_ERRORS_PER_SID - # Disconnecting removes the counter so the mapping cannot grow unboundedly. - task = event_namespace.on_disconnect("known_sid") - if task is not None: - await task - assert "known_sid" not in event_namespace._client_error_budget.counts - - -@pytest.mark.asyncio -async def test_client_error_reporting_bounded_across_reconnects( - event_namespace: EventNamespace, - frontend_errors: list[str], - client_error_console: dict[str, list[str]], -): - """Reconnecting with fresh SIDs does not grant an unlimited report budget. - - Args: - event_namespace: The event namespace. - frontend_errors: Captured frontend exception handler messages. - client_error_console: Captured console messages. - """ - for reconnect in range(50): - sid = f"sid_{reconnect}" - event_namespace.sid_to_token[sid] = f"token_{reconnect}" - for _ in range(5): - await event_namespace.on_client_error( - sid, {"error_type": "custom_type", "message": "spam"} - ) - assert len(frontend_errors) == EventNamespace._MAX_CLIENT_ERRORS_PER_WINDOW - # Suppression is not silent: one warning is logged when the cap trips, so - # a flooding client cannot invisibly starve reports from other sessions. - assert ( - len([ - msg for msg in client_error_console["warn"] if "suppressing" in msg.lower() - ]) - == 1 - ) - # Once the window elapses, errors are reported again (not silenced forever). - event_namespace._client_error_budget.window_start -= ( - EventNamespace._CLIENT_ERROR_WINDOW_SECONDS + 1 - ) - event_namespace.sid_to_token["sid_fresh"] = "token_fresh" - await event_namespace.on_client_error( - "sid_fresh", {"error_type": "custom_type", "message": "after window"} - ) - assert len(frontend_errors) == EventNamespace._MAX_CLIENT_ERRORS_PER_WINDOW + 1 - - -def test_client_error_event_name_matches_handler(): - """python-socketio dispatches events to on_ methods by naming - convention; this pins the handler to SocketEvent.CLIENT_ERROR. - """ - assert ( - f"on_{constants.SocketEvent.CLIENT_ERROR}" - == EventNamespace.on_client_error.__name__ - ) - - -def test_client_error_constants_match_frontend(): - """The socket event and error types are duplicated as literals in state.js. - - Nothing at runtime keeps the two definitions in sync, so pin them here. - """ - state_js = ( - Path(reflex_base.__file__).parent / ".templates/web/utils/state.js" - ).read_text() - assert ( - f'const CLIENT_ERROR_EVENT = "{constants.SocketEvent.CLIENT_ERROR}"' in state_js - ) - assert ( - f'const ERROR_TYPE_DISPATCH_MISSING = "{constants.ClientErrorType.DISPATCH_MISSING}"' - in state_js - ) - assert ( - f'const ERROR_TYPE_STATE_UPDATE = "{constants.ClientErrorType.STATE_UPDATE}"' - in state_js - ) - - -@pytest_asyncio.fixture -async def event_namespace_with_processor_mock() -> AsyncGenerator[EventNamespace, None]: - """An EventNamespace whose app has a mocked event processor. - - Yields: - The EventNamespace instance. - """ - app = App() - app._event_processor = Mock(enqueue=AsyncMock()) - event_namespace = EventNamespace("/event", app) - yield event_namespace - # The token manager is backed by redis when one is configured; drop the - # tokens these tests link so they do not show up in another test's - # enumeration of the shared instance. Awaited rather than run in a fresh - # loop via asyncio.run: the redis client is bound to the test's loop. - await event_namespace._token_manager.disconnect_all() - - -def _connect_environ(token: str) -> dict[str, Any]: - return { - "QUERY_STRING": f"token={token}", - "asgi.scope": { - "headers": [ - (b"origin", b"http://localhost:3000"), - (b"user-agent", b"test-agent"), - ], - "client": ("127.0.0.1", 1234), - }, - } - - -def _client_event_payload() -> dict[str, Any]: - return { - "name": "state.hydrate", - "router_data": {"pathname": "/", "query": {}, "asPath": "/"}, - "payload": {}, - } - - -@pytest.mark.asyncio -async def test_on_event_uses_connection_scoped_router_data( - token: str, - event_namespace_with_processor_mock: EventNamespace, -): - """on_event merges the connection-scoped router_data into every event. - - Headers, client IP, and session id cannot change for the lifetime of a - connection, so the decode is cached on the ASGI scope and never repeated - per event. - - Args: - token: A token. - event_namespace_with_processor_mock: The event namespace fixture. - """ - event_namespace = event_namespace_with_processor_mock - environ = _connect_environ(token) - await event_namespace.on_connect("sid1", environ) - event_namespace.app.sio = Mock(get_environ=Mock(return_value=environ)) - - await event_namespace.on_event("sid1", _client_event_payload()) - - enqueue_mock = cast(AsyncMock, event_namespace.app.event_processor.enqueue) - enqueue_mock.assert_called_once() - enqueued_token, event = enqueue_mock.call_args[0] - assert enqueued_token == token - assert event.router_data[constants.RouteVar.CLIENT_TOKEN] == token - assert event.router_data[constants.RouteVar.SESSION_ID] == "sid1" - assert event.router_data[constants.RouteVar.CLIENT_IP] == "127.0.0.1" - assert event.router_data[constants.RouteVar.HEADERS] == { - "origin": "http://localhost:3000", - "user-agent": "test-agent", - "asgi-scope-client": "127.0.0.1", - } - assert event.router_data[constants.RouteVar.PATH] == "/404" - assert event.router_data[constants.RouteVar.QUERY] == {} - - # A later event on the same connection reuses the decoded headers. - environ["asgi.scope"]["headers"] = [(b"origin", b"decoded-again")] - enqueue_mock.reset_mock() - await event_namespace.on_event("sid1", _client_event_payload()) - _, next_event = enqueue_mock.call_args[0] - assert ( - next_event.router_data[constants.RouteVar.HEADERS]["origin"] - == "http://localhost:3000" - ) - - -@pytest.mark.asyncio -async def test_link_token_to_sid_records_the_connecting_identity( - token: str, - event_namespace_with_processor_mock: EventNamespace, - mocker: MockerFixture, -): - """The session var carries the token the state was loaded under. - - Duplicate-token handling hands back a fresh token, and the state is loaded - under it. Leaving `rx_router_session.client_token` empty until the first event - would let anything reading it in between -- a background task, a - shared-state link -- address the wrong state tree. - - Args: - token: A token. - event_namespace_with_processor_mock: The event namespace fixture. - mocker: pytest-mock fixture. - """ - event_namespace = event_namespace_with_processor_mock - state = Mock() - state.router_data = {} - mocker.patch.object( - event_namespace.app.state_manager, - "modify_state", - Mock(return_value=AsyncMock(__aenter__=AsyncMock(return_value=state))), - ) - - # No duplicate: the connecting token is recorded. - await event_namespace.link_token_to_sid("sid1", token) - assert state.router_data[constants.RouteVar.CLIENT_TOKEN] == token - assert state.rx_router_session.client_token == token - assert state.rx_router_session.session_id == "sid1" - - # Duplicate: the *new* token is recorded, not the one the client sent. - # The duplicate branch emits the replacement token to the client, which - # needs a server the bare namespace does not have. - event_namespace.emit = AsyncMock() # pyright: ignore[reportAttributeAccessIssue] - new_token = "a-fresh-token" - mocker.patch.object( - event_namespace._token_manager, - "link_token_to_sid", - AsyncMock(return_value=new_token), - ) - await event_namespace.link_token_to_sid("sid2", token) - assert state.router_data[constants.RouteVar.CLIENT_TOKEN] == new_token - assert state.rx_router_session.client_token == new_token - assert state.rx_router_session.session_id == "sid2" - - -@pytest.mark.asyncio -async def test_on_event_does_not_share_the_cached_headers( - token: str, - event_namespace_with_processor_mock: EventNamespace, -): - """Each event gets its own headers mapping, not the cached one. - - The headers reach `state.router_data`, a plain mutable dict, so sharing - the cached mapping would let a handler mutating it corrupt the connection - cache for every later event on the socket. - - Args: - token: A token. - event_namespace_with_processor_mock: The event namespace fixture. - """ - event_namespace = event_namespace_with_processor_mock - environ = _connect_environ(token) - await event_namespace.on_connect("sid1", environ) - event_namespace.app.sio = Mock(get_environ=Mock(return_value=environ)) - - await event_namespace.on_event("sid1", _client_event_payload()) - enqueue_mock = cast(AsyncMock, event_namespace.app.event_processor.enqueue) - _, event = enqueue_mock.call_args[0] - event_headers = event.router_data[constants.RouteVar.HEADERS] - cached_headers = environ["asgi.scope"]["_reflex_static_router_data"][ - constants.RouteVar.HEADERS - ] - - assert event_headers == cached_headers - assert event_headers is not cached_headers - # Mutating what the handler sees must not reach the connection cache. - event_headers["user-agent"] = "mutated" - assert cached_headers["user-agent"] == "test-agent" - - enqueue_mock.reset_mock() - await event_namespace.on_event("sid1", _client_event_payload()) - _, next_event = enqueue_mock.call_args[0] - assert ( - next_event.router_data[constants.RouteVar.HEADERS]["user-agent"] == "test-agent" - ) - - -@pytest.mark.asyncio -async def test_on_event_builds_router_data_without_connect( - token: str, - event_namespace_with_processor_mock: EventNamespace, -): - """on_event derives the connection data from the environ if connect was missed. - - Args: - token: A token. - event_namespace_with_processor_mock: The event namespace fixture. - """ - event_namespace = event_namespace_with_processor_mock - await event_namespace._token_manager.link_token_to_sid(token, "sid1") - environ = _connect_environ(token) - event_namespace.app.sio = Mock(get_environ=Mock(return_value=environ)) - - await event_namespace.on_event("sid1", _client_event_payload()) - await event_namespace.on_event("sid1", _client_event_payload()) - - # The connection-scoped entries are derived once and cached on the scope. - assert environ["asgi.scope"]["_reflex_static_router_data"] == { - constants.RouteVar.SESSION_ID: "sid1", - constants.RouteVar.CLIENT_IP: "127.0.0.1", - constants.RouteVar.HEADERS: { - "origin": "http://localhost:3000", - "user-agent": "test-agent", - "asgi-scope-client": "127.0.0.1", - }, - } - enqueue_mock = cast(AsyncMock, event_namespace.app.event_processor.enqueue) - assert enqueue_mock.call_count == 2 - for call in enqueue_mock.call_args_list: - _, event = call[0] - assert event.router_data[constants.RouteVar.SESSION_ID] == "sid1" - assert ( - event.router_data[constants.RouteVar.HEADERS]["user-agent"] == "test-agent" - ) - - @pytest.mark.parametrize("compile_raises", [False, True]) def test_compile_releases_memo_naming_caches( mocker: MockerFixture, compile_raises: bool @@ -5076,54 +4564,6 @@ def test_call_app_wraps_with_otel_asgi_middleware(): assert wrapped == [api] -def test_sio_json_records_message_sizes(otel_metrics): - """Socket.IO packet serialization records UTF-8 sizes in both directions.""" - data = _sio_dumps({"a": "é"}, separators=(",", ":")) - assert data == '{"a":"é"}' - assert _sio_loads(data) == {"a": "é"} - assert _sio_loads(data.encode()) == {"a": "é"} - # ASCII payloads take the fast path that sizes without encoding a copy. - ascii_data = _sio_dumps({"b": "x"}, separators=(",", ":")) - assert _sio_loads(ascii_data) == {"b": "x"} - points = { - p.attributes[otel.ATTR_NETWORK_IO_DIRECTION]: p.sum - for p in metric_points(otel_metrics, otel.METRIC_WEBSOCKET_MESSAGE_SIZE) - } - size = len(data.encode()) - assert size == len(data) + 1 - assert points == { - "transmit": size + len(ascii_data), - "receive": 2 * size + len(ascii_data), - } - - -@pytest.mark.asyncio -async def test_on_event_uses_frontend_traceparent(otel_exporter): - """A traceparent in the event payload becomes the parent of the event span.""" - mock_app = unittest.mock.Mock() - mock_app.router.return_value = "/" - mock_app.sio.get_environ.return_value = { - "asgi.scope": {"headers": [], "client": ("127.0.0.1", 1)} - } - seen: list = [] - - async def enqueue(token, event): - await asyncio.sleep(0) - seen.append(trace.get_current_span().get_span_context()) - - mock_app.event_processor.enqueue = enqueue - ns = EventNamespace(namespace="/", app=mock_app) - ns._token_manager.sid_to_token["sid"] = "tok" - - traceparent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" - with active_tracer().start_as_current_span("websocket"): - await ns.on_event("sid", {"name": "state.h", "traceparent": traceparent}) - await ns.on_event("sid", {"name": "state.h"}) - remote, fresh = seen - assert f"{remote.trace_id:032x}" == "0af7651916cd43dd8448eb211c80319c" - assert not fresh.is_valid - - @pytest.mark.asyncio async def test_upload_handler_span_joins_the_request_trace( tmp_path: Path, @@ -5225,24 +4665,6 @@ async def send(message): # noqa: RUF029 assert handler_parent.span_id == request_context.span_id -@pytest.mark.asyncio -async def test_connect_disconnect_counts_connections(otel_metrics): - """Connect and disconnect adjust the open connection gauge.""" - mock_app = unittest.mock.Mock() - mock_app._state = None - ns = EventNamespace(namespace="/", app=mock_app) - ns.emit = unittest.mock.AsyncMock() - await ns.on_connect("sid1", {"QUERY_STRING": "token=t1"}) - await ns.on_connect("sid2", {"QUERY_STRING": "token=t2"}) - task = ns.on_disconnect("sid1") - if task is not None: - await task - (point,) = metric_points(otel_metrics, otel.METRIC_WEBSOCKET_CONNECTIONS) - assert point.value == 1 - # Release t2 so a shared token store (redis) does not leak into other tests. - await ns._token_manager.disconnect_all() - - def test_compile_installs_browser_plugin( compilable_app: tuple[App, Path], mocker: MockerFixture, @@ -5534,12 +4956,13 @@ def test_register_channel_skips_without_the_websocket_transport( ): """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. + The namespace's capability is patched rather than a Socket.IO app built: + that 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") + assert app.event_namespace is not None + monkeypatch.setattr(app.event_namespace, "supports_channels", False) with caplog.at_level(logging.WARNING): app.register_channel(_ProbeChannel()) diff --git a/tests/units/test_event_namespace.py b/tests/units/test_event_namespace.py index 81fe7d2fd7c..2bfe5e1bea5 100644 --- a/tests/units/test_event_namespace.py +++ b/tests/units/test_event_namespace.py @@ -10,7 +10,7 @@ import shutil import subprocess import sys -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterable from pathlib import Path from types import SimpleNamespace from typing import Any @@ -18,7 +18,7 @@ import pytest from opentelemetry import trace -from reflex_base import otel +from reflex_base import constants, otel from reflex_base.config import get_config from reflex_base.registry import RegistrationContext from starlette.routing import WebSocketRoute @@ -130,7 +130,7 @@ async def receive(self) -> dict[str, Any]: return {"type": "websocket.receive", "bytes": item} return {"type": "websocket.receive", "text": item} - def feed(self, *frames: Any, connect: bool = True): + def feed(self, *frames: Any, connect: bool = True, disconnect: bool = True): """Queue incoming frames (lists are JSON-encoded) and a disconnect. The session's connect frame goes first unless ``connect`` is False. @@ -139,35 +139,18 @@ def feed(self, *frames: Any, connect: bool = True): self._incoming.put_nowait( frame if isinstance(frame, (str, bytes)) else json.dumps(frame) ) - self._incoming.put_nowait(_DISCONNECT) + if disconnect: + 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._channels = {} - app.router = Mock(return_value=None) - app.event_processor.enqueue = AsyncMock() - return app - - -@pytest.fixture -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 cannot leak into a shared Redis. +def namespace(namespace_app: Mock) -> WebsocketEventNamespace: + """A namespace serving the mock app. Returns: The namespace. """ - mocker.patch("reflex.utils.prerequisites.check_redis_used", return_value=False) - return WebsocketEventNamespace("/_event", mock_app) + return WebsocketEventNamespace("/_event", namespace_app) async def _drain_tasks(): @@ -176,6 +159,31 @@ async def _drain_tasks(): await asyncio.sleep(0) +async def serve( + namespace: WebsocketEventNamespace, + *frames: Any, + connect: bool = True, + websocket: FakeWebSocket | None = None, +) -> FakeWebSocket: + """Serve one session that sends the given frames and hangs up, then clean up. + + Args: + namespace: The namespace serving it. + *frames: The frames the client sends, after its connect frame unless + ``connect`` is False. + connect: Whether the client opens with a connect frame. + websocket: The fake websocket to serve; a new one if None. + + Returns: + The websocket, recording what the server sent. + """ + websocket = websocket or FakeWebSocket() + websocket.feed(*frames, connect=connect) + await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] + await _drain_tasks() + return websocket + + @pytest.mark.asyncio async def test_handshake_and_token_link(namespace: WebsocketEventNamespace): """The server links the token from the query, then acknowledges with the handshake.""" @@ -200,7 +208,10 @@ async def test_handshake_and_token_link(namespace: WebsocketEventNamespace): @pytest.mark.asyncio async def test_boot_event_is_the_sessions_first_event( - namespace: WebsocketEventNamespace, mock_app: Mock + namespace: WebsocketEventNamespace, + namespace_app: Mock, + linked_state: Mock, + boot_event: dict[str, Any], ): """The boot event in the connect frame is processed right after the handshake. @@ -208,66 +219,55 @@ async def test_boot_event_is_the_sessions_first_event( state, so the connect does not load and save the state tree for that; a connect without a boot event still does. """ - mock_app._state = Mock() - modify_state = mock_app.state_manager.modify_state = Mock( - return_value=AsyncMock(__aenter__=AsyncMock(return_value=Mock(router_data={}))) - ) - boot_event = {"name": "state.hydrate_and_load", "payload": {}, "router_data": {}} + modify_state = namespace_app.state_manager.modify_state click = {"name": "state.on_click", "payload": {}, "router_data": {}} - websocket = FakeWebSocket() - websocket.feed( - [CONNECT_MESSAGE, {"event": boot_event}], ["event", click], connect=False + websocket = await serve( + namespace, + [CONNECT_MESSAGE, {"event": boot_event}], + ["event", click], + connect=False, ) - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] - await _drain_tasks() assert websocket.sent[0][0] == HANDSHAKE_MESSAGE assert [ - call.args[1].name for call in mock_app.event_processor.enqueue.await_args_list + call.args[1].name + for call in namespace_app.event_processor.enqueue.await_args_list ] == ["state.hydrate_and_load", "state.on_click"] modify_state.assert_not_called() - websocket = FakeWebSocket() - websocket.feed() - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] - await _drain_tasks() + websocket = await serve(namespace) modify_state.assert_called_once() @pytest.mark.asyncio async def test_state_schema_mismatch_is_reported_before_the_handshake( - namespace: WebsocketEventNamespace, mock_app: Mock + namespace: WebsocketEventNamespace, namespace_app: Mock, boot_event: dict[str, Any] ): """A frontend compiled for another state tree is told so before it hydrates. The session still hydrates; a frontend built for the backend's state tree is told nothing. """ - mock_app._state = Mock(_to_frontend_schema=Mock(return_value="backend-schema")) - boot_event = {"name": "state.hydrate_and_load", "payload": {}, "router_data": {}} - websocket = FakeWebSocket() - websocket.feed( + namespace_app._state = Mock(_to_frontend_schema=Mock(return_value="backend-schema")) + websocket = await serve( + namespace, [CONNECT_MESSAGE, {"event": boot_event, "state_schema": "frontend-schema"}], connect=False, ) - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] - await _drain_tasks() assert websocket.sent[0] == [ "state_mismatch", {"backend": "backend-schema", "reload_cooldown_ms": 10_000}, ] assert websocket.sent[1][0] == HANDSHAKE_MESSAGE - mock_app.event_processor.enqueue.assert_awaited_once() + namespace_app.event_processor.enqueue.assert_awaited_once() - websocket = FakeWebSocket() - websocket.feed( + websocket = await serve( + namespace, [CONNECT_MESSAGE, {"event": boot_event, "state_schema": "backend-schema"}], connect=False, ) - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] - await _drain_tasks() assert [frame[0] for frame in websocket.sent] == [HANDSHAKE_MESSAGE] @@ -290,10 +290,7 @@ async def test_session_opens_only_with_a_connect_frame( close_code: int | None, ): """A socket whose first frame is no connect frame never links a token.""" - websocket = FakeWebSocket() - websocket.feed(*frames, connect=False) - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] - await _drain_tasks() + websocket = await serve(namespace, *frames, connect=False) assert websocket.close_code == close_code assert websocket.sent == [] @@ -326,10 +323,7 @@ async def test_oversized_integer_closes_with_protocol_error( @pytest.mark.asyncio async def test_frames_are_serialized_compactly(namespace: WebsocketEventNamespace): """Frames carry no separator whitespace, like the Socket.IO packets they replace.""" - websocket = FakeWebSocket() - websocket.feed(["ping"], [OPEN_MESSAGE, None, "nope"]) - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] - await _drain_tasks() + websocket = await serve(namespace, ["ping"], [OPEN_MESSAGE, None, "nope"]) assert len(websocket.raw_sent) == 3 for text in websocket.raw_sent: @@ -366,43 +360,44 @@ async def test_oversize_connect_frame_closes_connection( @pytest.mark.asyncio async def test_undeserializable_boot_event_closes_connection( - namespace: WebsocketEventNamespace, mock_app: Mock + namespace: WebsocketEventNamespace, namespace_app: Mock ): """A boot event that fails deserialization ends the session it opened. The handshake queued for it is dropped with the session, so the client sees a failed connect and retries with backoff. """ - websocket = FakeWebSocket() - websocket.feed( - [CONNECT_MESSAGE, {"event": "not an event"}], ["ping"], connect=False + websocket = await serve( + namespace, [CONNECT_MESSAGE, {"event": "not an event"}], ["ping"], connect=False ) - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] - await _drain_tasks() assert websocket.close_code == 1002 assert websocket.sent == [] - mock_app.event_processor.enqueue.assert_not_awaited() + namespace_app.event_processor.enqueue.assert_not_awaited() assert "tok1" not in namespace.token_to_sid @pytest.mark.asyncio -async def test_boot_event_handler_error_keeps_connection( +async def test_boot_event_handler_error_ends_the_session( namespace: WebsocketEventNamespace, - mock_app: Mock, + namespace_app: Mock, caplog: pytest.LogCaptureFixture, + boot_event: dict[str, Any], ): - """A server-side failure on the boot event is logged and the session survives.""" - mock_app.event_processor.enqueue.side_effect = RuntimeError("server bug") - boot_event = {"name": "state.hydrate_and_load", "payload": {}, "router_data": {}} - websocket = FakeWebSocket() - websocket.feed([CONNECT_MESSAGE, {"event": boot_event}], ["ping"], connect=False) + """A server-side failure on the boot event is logged and ends the session. + + Kept open, the session would leave the client waiting for a hydrate that + never comes; closed, the client reconnects and retries, as on Socket.IO. + """ + namespace_app.event_processor.enqueue.side_effect = RuntimeError("server bug") with caplog.at_level(logging.ERROR, logger="reflex.event_namespace"): - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] - await _drain_tasks() + websocket = await serve( + namespace, [CONNECT_MESSAGE, {"event": boot_event}], ["ping"], connect=False + ) - assert websocket.close_code is None - assert ["ping", "pong"] in websocket.sent + assert websocket.close_code == 1011 + assert ["ping", "pong"] not in websocket.sent + assert "tok1" not in namespace.token_to_sid assert any( "Error handling the boot event" in record.getMessage() for record in caplog.records @@ -448,15 +443,6 @@ async def send_text(self, text: str): await asyncio.Event().wait() -def _open_session(websocket: FakeWebSocket) -> None: - """Queue a connect frame and nothing after it, so the session stays open. - - Args: - websocket: The fake websocket. - """ - websocket._incoming.put_nowait(json.dumps(CONNECT_FRAME)) - - @pytest.mark.asyncio async def test_a_silent_client_is_closed_by_its_own_session( namespace: WebsocketEventNamespace, monkeypatch: pytest.MonkeyPatch @@ -469,7 +455,7 @@ async def test_a_silent_client_is_closed_by_its_own_session( monkeypatch.setenv("REFLEX_SOCKET_INTERVAL", "20ms") monkeypatch.setenv("REFLEX_SOCKET_TIMEOUT", "20ms") websocket = ReceiveBoundWebSocket() - _open_session(websocket) + websocket.feed(disconnect=False) await asyncio.wait_for(namespace.handle_websocket(websocket), 2) # pyright: ignore[reportArgumentType] await _drain_tasks() @@ -498,7 +484,7 @@ async def connect_and_signal(*args: Any, **kwargs: Any) -> None: monkeypatch.setattr(namespace, "handle_connect", connect_and_signal) websocket = StalledWebSocket() - _open_session(websocket) + websocket.feed(disconnect=False) session = asyncio.create_task(namespace.handle_websocket(websocket)) # pyright: ignore[reportArgumentType] await asyncio.wait_for(linked.wait(), 1) sid = namespace.token_to_sid["tok1"] @@ -521,7 +507,7 @@ async def test_a_stalled_client_still_times_out( monkeypatch.setenv("REFLEX_SOCKET_INTERVAL", "20ms") monkeypatch.setenv("REFLEX_SOCKET_TIMEOUT", "20ms") websocket = StalledWebSocket() - _open_session(websocket) + websocket.feed(disconnect=False) await asyncio.wait_for(namespace.handle_websocket(websocket), 2) # pyright: ignore[reportArgumentType] await _drain_tasks() @@ -530,18 +516,25 @@ async def test_a_stalled_client_still_times_out( @pytest.mark.asyncio -async def test_event_is_enqueued(namespace: WebsocketEventNamespace, mock_app: Mock): +async def test_event_is_enqueued( + namespace: WebsocketEventNamespace, namespace_app: Mock +): """An incoming event frame reaches the app's event processor.""" - 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() + await serve( + namespace, + [ + "event", + { + "token": "tok1", + "name": "state.on_click", + "payload": {}, + "router_data": {}, + }, + ], + ) - mock_app.event_processor.enqueue.assert_awaited_once() - token, event = mock_app.event_processor.enqueue.await_args.args + namespace_app.event_processor.enqueue.assert_awaited_once() + token, event = namespace_app.event_processor.enqueue.await_args.args assert token == "tok1" assert event.name == "state.on_click" assert event.router_data["headers"]["host"] == "localhost" @@ -551,25 +544,21 @@ 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.""" - websocket = FakeWebSocket() - websocket.feed(["ping"], [PONG_MESSAGE]) - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] - await _drain_tasks() + websocket = await serve(namespace, ["ping"], [PONG_MESSAGE]) assert ["ping", "pong"] in websocket.sent @pytest.mark.asyncio async def test_client_error_reaches_exception_handler( - namespace: WebsocketEventNamespace, mock_app: Mock + namespace: WebsocketEventNamespace, namespace_app: Mock ): """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() - websocket.feed(["client_error", {"error_type": "boom", "message": "it broke"}]) - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] - await _drain_tasks() + namespace_app.frontend_exception_handler = lambda exc: errors.append(str(exc)) + await serve( + namespace, ["client_error", {"error_type": "boom", "message": "it broke"}] + ) assert len(errors) == 1 assert "it broke" in errors[0] @@ -581,10 +570,7 @@ async def test_malformed_frame_closes_connection( namespace: WebsocketEventNamespace, frame: str ): """A malformed frame closes the connection with 1002 (protocol error).""" - websocket = FakeWebSocket() - websocket.feed(frame, ["ping"]) - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] - await _drain_tasks() + websocket = await serve(namespace, frame, ["ping"]) assert websocket.close_code == 1002 # Nothing after the malformed frame is processed. @@ -609,20 +595,17 @@ async def test_malformed_frame_closes_connection( ) async def test_undeserializable_event_closes_connection( namespace: WebsocketEventNamespace, - mock_app: Mock, + namespace_app: Mock, payload: object, caplog: pytest.LogCaptureFixture, ): """An event frame that fails deserialization closes with 1002 and no warning.""" - websocket = FakeWebSocket() - websocket.feed(["event", payload], ["ping"]) with caplog.at_level(logging.DEBUG, logger="reflex.event_namespace"): - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] - await _drain_tasks() + websocket = await serve(namespace, ["event", payload], ["ping"]) assert websocket.close_code == 1002 assert ["ping", "pong"] not in websocket.sent - mock_app.event_processor.enqueue.assert_not_awaited() + namespace_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) @@ -630,19 +613,17 @@ async def test_undeserializable_event_closes_connection( @pytest.mark.asyncio async def test_handler_error_keeps_connection( namespace: WebsocketEventNamespace, - mock_app: Mock, + namespace_app: Mock, caplog: pytest.LogCaptureFixture, ): """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"], - ) + namespace_app.event_processor.enqueue.side_effect = RuntimeError("server bug") with caplog.at_level(logging.ERROR, logger="reflex.event_namespace"): - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] - await _drain_tasks() + websocket = await serve( + namespace, + ["event", {"name": "state.on_click", "payload": {}, "router_data": {}}], + ["ping"], + ) assert websocket.close_code is None assert ["ping", "pong"] in websocket.sent @@ -663,11 +644,12 @@ async def test_tokenless_connection_rejected( 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"]) with caplog.at_level(logging.DEBUG, logger="reflex.event_namespace"): - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] - await _drain_tasks() + websocket = await serve( + namespace, + ["ping"], + websocket=FakeWebSocket(query_string=b"", subprotocols=["0.0.1"]), + ) assert websocket.close_code == 1008 # A refused session is never acknowledged. @@ -680,11 +662,8 @@ 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() + await serve(namespace, websocket=FakeWebSocket(subprotocols=["0.0.1\x1b[31m"])) warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING] assert len(warnings) == 1 @@ -699,10 +678,7 @@ async def test_oversize_message_closes_connection( ): """A frame over the size limit closes the connection with 1009.""" monkeypatch.setenv("REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE", "20") - websocket = FakeWebSocket() - websocket.feed(["event", {"payload": "x" * 100}]) - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] - await _drain_tasks() + websocket = await serve(namespace, ["event", {"payload": "x" * 100}]) assert websocket.close_code == 1009 @@ -716,10 +692,7 @@ async def test_oversize_multibyte_message_closes_connection( # 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() + websocket = await serve(namespace, frame) assert websocket.close_code == 1009 @@ -732,10 +705,7 @@ async def test_multibyte_message_within_limit_is_processed( # 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() + websocket = await serve(namespace, '["ping","€"]') assert websocket.close_code is None assert ["ping", "pong"] in websocket.sent @@ -744,10 +714,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).""" - websocket = FakeWebSocket() - websocket.feed(b"\x00\x01") - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] - await _drain_tasks() + websocket = await serve(namespace, b"\x00\x01") assert websocket.close_code == 1003 @@ -777,10 +744,9 @@ async def test_allowed_origin_is_accepted(namespace: WebsocketEventNamespace, mo 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() + websocket = await serve( + namespace, websocket=FakeWebSocket(origin="https://allowed.example") + ) assert websocket.accepted @@ -823,10 +789,7 @@ async def test_websocket_records_connections_and_message_sizes( namespace: WebsocketEventNamespace, otel_metrics ): """A session adjusts the connection gauge and sizes frames in both directions.""" - websocket = FakeWebSocket() - websocket.feed(["ping"]) - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] - await _drain_tasks() + await serve(namespace, ["ping"]) (connections,) = metric_points(otel_metrics, otel.METRIC_WEBSOCKET_CONNECTIONS) # Connect and disconnect both happened, so the gauge is back at zero. @@ -843,7 +806,7 @@ async def test_websocket_records_connections_and_message_sizes( @pytest.mark.asyncio async def test_websocket_event_uses_frontend_traceparent( - namespace: WebsocketEventNamespace, mock_app: Mock, otel_exporter + namespace: WebsocketEventNamespace, namespace_app: Mock, otel_exporter ): """A traceparent in the event payload becomes the parent of the event span.""" seen: list = [] @@ -852,16 +815,14 @@ async def enqueue(token, event): await asyncio.sleep(0) seen.append(trace.get_current_span().get_span_context()) - mock_app.event_processor.enqueue = enqueue + namespace_app.event_processor.enqueue = enqueue traceparent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" - websocket = FakeWebSocket() - websocket.feed( - ["event", {"name": "state.h", "traceparent": traceparent}], - ["event", {"name": "state.h"}], - ) with active_tracer().start_as_current_span("websocket"): - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] - await _drain_tasks() + await serve( + namespace, + ["event", {"name": "state.h", "traceparent": traceparent}], + ["event", {"name": "state.h"}], + ) remote, fresh = seen assert f"{remote.trace_id:032x}" == "0af7651916cd43dd8448eb211c80319c" @@ -1138,6 +1099,18 @@ async def on_close(self, session: ChannelSession) -> None: self.closed.append(session) +@pytest.fixture +def probe_channel(namespace_app: Mock) -> RecordingChannel: + """A recording channel the mock app serves as "probe". + + Returns: + The channel. + """ + channel = RecordingChannel() + namespace_app._channels = {"probe": channel} + return channel + + def channel_frames(websocket: FakeWebSocket, channel: str = "probe") -> list[Any]: """Text frames the server sent on a channel. @@ -1207,34 +1180,30 @@ def test_channel_frame_rejects_too_many_attachments(): @pytest.mark.asyncio async def test_channel_open_and_message( - namespace: WebsocketEventNamespace, mock_app: Mock + namespace: WebsocketEventNamespace, + namespace_app: Mock, + probe_channel: RecordingChannel, ): """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() + websocket = await serve( + namespace, [OPEN_MESSAGE, None, "probe"], ["sub", {"fig": "f1"}, "probe"] + ) 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 [session.client_token for session in probe_channel.opened] == ["tok1"] + assert probe_channel.messages == [("sub", {"fig": "f1"}, [])] assert ["echo", {"fig": "f1"}, "probe"] in websocket.sent # The disconnect closed the session again. - assert channel.closed == channel.opened + assert probe_channel.closed == probe_channel.opened @pytest.mark.asyncio async def test_channel_unknown_name_reports_error( - namespace: WebsocketEventNamespace, mock_app: Mock + namespace: WebsocketEventNamespace, namespace_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() + namespace_app._channels = {} + websocket = await serve(namespace, [OPEN_MESSAGE, None, "nope"]) error = channel_frames(websocket, "nope")[0] assert error[0] == CHANNEL_ERROR_MESSAGE @@ -1243,55 +1212,48 @@ async def test_channel_unknown_name_reports_error( @pytest.mark.asyncio async def test_channel_message_before_open_reports_error( - namespace: WebsocketEventNamespace, mock_app: Mock + namespace: WebsocketEventNamespace, + namespace_app: Mock, + probe_channel: RecordingChannel, ): """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() + websocket = await serve(namespace, ["sub", {}, "probe"]) assert channel_frames(websocket)[0][1]["code"] == "channel_not_open" - assert channel.messages == [] + assert probe_channel.messages == [] @pytest.mark.asyncio async def test_channel_close_ends_the_session( - namespace: WebsocketEventNamespace, mock_app: Mock + namespace: WebsocketEventNamespace, + namespace_app: Mock, + probe_channel: RecordingChannel, ): """A _close frame runs on_close and stops dispatching to the channel.""" - channel = RecordingChannel() - mock_app._channels = {"probe": channel} - websocket = FakeWebSocket() - websocket.feed( + websocket = await serve( + namespace, [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 len(probe_channel.closed) == 1 + assert probe_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 + namespace: WebsocketEventNamespace, namespace_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( + namespace_app._channels = {"probe": channel} + websocket = await serve( + namespace, [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)] @@ -1305,28 +1267,26 @@ async def test_channel_binary_message_round_trip( @pytest.mark.asyncio async def test_channel_binary_rejected_when_not_accepted( - namespace: WebsocketEventNamespace, mock_app: Mock + namespace: WebsocketEventNamespace, + namespace_app: Mock, + probe_channel: RecordingChannel, ): """Binary attachments to a text-only channel answer _error.""" - channel = RecordingChannel(accepts_binary=False) - mock_app._channels = {"probe": channel} - websocket = FakeWebSocket() - websocket.feed( + websocket = await serve( + namespace, [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 == [] + assert probe_channel.messages == [] @pytest.mark.asyncio @pytest.mark.parametrize("binary", [False, True]) @pytest.mark.parametrize("event", [*sorted(RESERVED_EVENTS), "_opened", "_error"]) async def test_channel_reserved_inbound_name_is_rejected( - namespace: WebsocketEventNamespace, mock_app: Mock, event: str, binary: bool + namespace: WebsocketEventNamespace, namespace_app: Mock, event: str, binary: bool ): """A client may not send a message under a reserved name, on either frame kind. @@ -1334,16 +1294,14 @@ async def test_channel_reserved_inbound_name_is_rejected( send the channel API refuses, so the name never reaches it. """ channel = RecordingChannel(accepts_binary=True) - mock_app._channels = {"probe": channel} - websocket = FakeWebSocket() - websocket.feed( + namespace_app._channels = {"probe": channel} + websocket = await serve( + namespace, [OPEN_MESSAGE, None, "probe"], encode_channel_frame(event, {"fig": "f1"}, "probe", [b"\x00"]) if binary else [event, {"fig": "f1"}, "probe"], ) - await namespace.handle_websocket(websocket) # pyright: ignore[reportArgumentType] - await _drain_tasks() assert channel.messages == [] assert channel_frames(websocket)[-1][1]["code"] == "reserved_event" @@ -1352,36 +1310,35 @@ async def test_channel_reserved_inbound_name_is_rejected( @pytest.mark.asyncio async def test_malformed_binary_frame_closes_connection( - namespace: WebsocketEventNamespace, mock_app: Mock + namespace: WebsocketEventNamespace, + namespace_app: Mock, + probe_channel: RecordingChannel, ): """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() + websocket = await serve(namespace, b"\xff\xff\xff\xff not a frame") assert websocket.close_code == 1002 @pytest.mark.asyncio async def test_oversize_binary_frame_closes_connection( - namespace: WebsocketEventNamespace, mock_app: Mock, monkeypatch: pytest.MonkeyPatch + namespace: WebsocketEventNamespace, + namespace_app: Mock, + monkeypatch: pytest.MonkeyPatch, + probe_channel: RecordingChannel, ): """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() + websocket = await serve( + namespace, encode_channel_frame("push", None, "probe", [b"\x00" * 64]) + ) assert websocket.close_code == 1009 @pytest.mark.asyncio async def test_channel_handler_error_keeps_connection( - namespace: WebsocketEventNamespace, mock_app: Mock, caplog + namespace: WebsocketEventNamespace, namespace_app: Mock, caplog ): """A channel handler raising is logged without dropping the connection.""" @@ -1392,7 +1349,7 @@ async def on_message(self, session, event, data, buffers) -> None: msg = "handler failed" raise RuntimeError(msg) - mock_app._channels = {"boom": BoomChannel()} + namespace_app._channels = {"boom": BoomChannel()} websocket = FakeWebSocket() with caplog.at_level(logging.ERROR): websocket.feed([OPEN_MESSAGE, None, "boom"], ["go", None, "boom"], ["ping"]) @@ -1409,10 +1366,7 @@ 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() + websocket = await serve(namespace, ["sub", None, 42]) assert websocket.close_code == 1002 @@ -1541,7 +1495,7 @@ def test_binary_frame_codec_matches_the_client(tmp_path: Path): @pytest.mark.asyncio async def test_repeated_channel_open_reuses_the_session( - namespace: WebsocketEventNamespace, mock_app: Mock + namespace: WebsocketEventNamespace, namespace_app: Mock ): """Opening an already-open channel answers again without a second session. @@ -1558,11 +1512,10 @@ async def on_open(self, session: ChannelSession) -> None: 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() + namespace_app._channels = {"probe": channel} + websocket = await serve( + namespace, [OPEN_MESSAGE, None, "probe"], [OPEN_MESSAGE, None, "probe"] + ) assert len(channel.opened) == 1 assert [frame[0] for frame in channel_frames(websocket)] == [ @@ -1576,7 +1529,7 @@ async def on_open(self, session: ChannelSession) -> None: @pytest.mark.asyncio async def test_interrupted_teardown_still_unlinks_the_token( - namespace: WebsocketEventNamespace, mock_app: Mock + namespace: WebsocketEventNamespace, namespace_app: Mock ): """Token cleanup starts before any teardown await can be interrupted. @@ -1592,7 +1545,7 @@ async def on_close(self, session: ChannelSession) -> None: """Fail the way a cancelled cleanup await would.""" raise asyncio.CancelledError - mock_app._channels = {"probe": StallingChannel()} + namespace_app._channels = {"probe": StallingChannel()} websocket = FakeWebSocket() websocket.feed([OPEN_MESSAGE, None, "probe"]) with pytest.raises(asyncio.CancelledError): @@ -1604,7 +1557,7 @@ async def on_close(self, session: ChannelSession) -> None: @pytest.mark.asyncio async def test_interrupted_open_leaves_no_session_behind( - namespace: WebsocketEventNamespace, mock_app: Mock + namespace: WebsocketEventNamespace, namespace_app: Mock ): """A session interrupted inside on_open is still cleaned up on disconnect. @@ -1621,7 +1574,7 @@ async def on_open(self, session: ChannelSession) -> None: raise asyncio.CancelledError channel = InterruptedChannel() - mock_app._channels = {"probe": channel} + namespace_app._channels = {"probe": channel} websocket = FakeWebSocket() websocket.feed([OPEN_MESSAGE, None, "probe"]) with pytest.raises(asyncio.CancelledError): @@ -1634,17 +1587,14 @@ async def on_open(self, session: ChannelSession) -> None: @pytest.mark.asyncio async def test_disconnect_marks_the_session_closed( - namespace: WebsocketEventNamespace, mock_app: Mock + namespace: WebsocketEventNamespace, + namespace_app: Mock, + probe_channel: RecordingChannel, ): """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() + await serve(namespace, [OPEN_MESSAGE, None, "probe"]) - assert [session.open for session in channel.opened] == [False] + assert [session.open for session in probe_channel.opened] == [False] @pytest.mark.asyncio @@ -1658,10 +1608,7 @@ async def test_deeply_nested_frame_closes_connection( 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() + websocket = await serve(namespace, "[" * depth + "]" * depth) assert websocket.close_code == 1002 @@ -1707,7 +1654,7 @@ async def test_a_room_broadcast_serializes_one_frame_for_every_member( ): """Fan-out costs one encode and one buffer, not one per recipient.""" channel = RecordingChannel(accepts_binary=True) - sent: list[tuple[str | None, Any]] = [] + sent: list[tuple[str, Any]] = [] encodes = 0 real_encode = event_namespace.encode_channel_frame @@ -1716,9 +1663,9 @@ def counting_encode(*args: Any) -> bytes: encodes += 1 return real_encode(*args) - def record(to: str | None, payload: Any, label: str) -> None: + def record(sids: Iterable[str], payload: Any, label: str) -> None: # Stands in for queueing the frame on each client's connection. - sent.append((to, payload)) + sent.extend((sid, payload) for sid in sids) monkeypatch.setattr(event_namespace, "encode_channel_frame", counting_encode) monkeypatch.setattr(namespace, "_deliver", record) @@ -1750,7 +1697,7 @@ def record(to: str | None, payload: Any, label: str) -> None: ) async def test_a_session_whose_token_moved_is_closed_instead_of_sent_to( namespace: WebsocketEventNamespace, - mock_app: Mock, + namespace_app: Mock, send: Callable[[Channel, ChannelSession], Awaitable[Any]], ): """Channel messages stop at a socket whose token another instance claimed. @@ -1771,10 +1718,9 @@ async def on_open(self, session: ChannelSession) -> None: opened.set() channel = RoomChannel() - mock_app._channels = {"probe": channel} + namespace_app._channels = {"probe": channel} websocket = FakeWebSocket() - _open_session(websocket) - websocket._incoming.put_nowait(json.dumps([OPEN_MESSAGE, None, "probe"])) + websocket.feed([OPEN_MESSAGE, None, "probe"], disconnect=False) serving = asyncio.create_task(namespace.handle_websocket(websocket)) # pyright: ignore[reportArgumentType] await asyncio.wait_for(opened.wait(), 1) # What RedisTokenManager does on adopting another instance's record. @@ -1818,7 +1764,7 @@ async def test_closing_a_connection_the_heartbeat_already_closed_is_not_an_error @pytest.mark.asyncio async def test_handler_error_counters_do_not_outlive_their_sessions( - namespace: WebsocketEventNamespace, mock_app: Mock + namespace: WebsocketEventNamespace, namespace_app: Mock ): """Per-session error counters are dropped when the session goes away. @@ -1847,7 +1793,7 @@ async def on_close(self, session: ChannelSession) -> None: message = "boom" raise RuntimeError(message) - mock_app._channels = {"probe": FailingChannel()} + namespace_app._channels = {"probe": FailingChannel()} for index in range(3): websocket = FakeWebSocket(query_string=f"token=tok{index}".encode()) websocket.feed([OPEN_MESSAGE, None, "probe"]) @@ -1873,191 +1819,9 @@ def test_client_limits_match_the_protocol(): ) -@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. - """ - 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; }} }}; - -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. - // 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; -}} -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)); -""", - ) - - 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 - - -@pytest.mark.skipif(not NODE, reason="Requires node to run the client") -def test_client_refuses_to_emit_reserved_message_names(tmp_path: Path): - """The handle's own lifecycle names cannot be put on the wire. - - Emitting one is a consumer confusing `channel.on("connect")` with a - message, and the backend answers it with an error; throwing reports the - mistake where it was made. - """ - report = _run_client_script( - tmp_path, - f""" -import {{ getChannel }} from {CLIENT_MODULE}; - -const result = {{ sent: 0, refused: [] }}; -const channel = getChannel("probe"); -channel._transport = {{ _maxMessageSize: 0, _send: () => {{ result.sent += 1; }} }}; -channel.connected = true; -for (const name of {json.dumps(sorted(RESERVED_EVENTS))}) {{ - try {{ - channel.emit(name, {{ fig: "f1" }}); - }} catch (error) {{ - result.refused.push(name); - }} -}} -channel.emit("push", {{ fig: "f1" }}); - -console.log(JSON.stringify(result)); -""", - ) - - assert report["refused"] == sorted(RESERVED_EVENTS) - # Only the message under a usable name reached the transport, and nothing - # was queued behind it either. - 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 + namespace: WebsocketEventNamespace, namespace_app: Mock ): """Metadata is limited by the frame size, not by a second hidden cap. @@ -2068,7 +1832,7 @@ async def test_large_metadata_is_bounded_only_by_the_message_limit( because that is where such a frame would be turned into a close. """ channel = RecordingChannel(accepts_binary=True) - mock_app._channels = {"probe": channel} + namespace_app._channels = {"probe": channel} # Far past any header-shaped cap, far under the 1 MB message limit. metadata = {"spec": "x" * (128 * 1024)} websocket = FakeWebSocket() @@ -2094,7 +1858,7 @@ async def test_large_metadata_is_bounded_only_by_the_message_limit( ], ) async def test_frames_from_a_session_whose_token_went_away_close_it( - namespace: WebsocketEventNamespace, mock_app: Mock, caplog, frame: Any + namespace: WebsocketEventNamespace, namespace_app: Mock, caplog, frame: Any ): """A session that loses its token mid-connection is closed, whatever it sends. @@ -2106,7 +1870,7 @@ async def test_frames_from_a_session_whose_token_went_away_close_it( frame, it would be a log flood and an injection vector both. """ channel = RecordingChannel(accepts_binary=True) - mock_app._channels = {"probe": channel} + namespace_app._channels = {"probe": channel} if frame is None: frame = encode_channel_frame( "push", {"x": "\n[fake] log line"}, "probe", [b"!"] @@ -2140,7 +1904,7 @@ async def receive(self) -> dict[str, Any]: # 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() + namespace_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 @@ -2150,7 +1914,7 @@ async def receive(self) -> dict[str, Any]: @pytest.mark.asyncio @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 + namespace: WebsocketEventNamespace, namespace_app: Mock, caplog, hook: str ): """Tracebacks from any handler a client keeps breaking are budgeted. @@ -2177,7 +1941,7 @@ async def on_close(self, session) -> None: if hook == "on_close": raise KeyError(session.sid) - mock_app._channels = {"boom": BoomChannel()} + namespace_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. @@ -2227,3 +1991,55 @@ def test_decode_asgi_headers(): (b"x-raw", b"\xfe"), ("x-café".encode(), "café".encode()), ]) == {"x-ÿ": "a", "x-raw": "þ", "x-café": "café"} + + +@pytest.mark.asyncio +async def test_link_token_to_sid_records_the_connecting_identity( + namespace: WebsocketEventNamespace, + linked_state: Mock, + monkeypatch: pytest.MonkeyPatch, +): + """The session var carries the token the state was loaded under. + + Duplicate-token handling hands back a fresh token, and the state is loaded + under it. Leaving `rx_router_session.client_token` empty until the first + event would let anything reading it in between -- a background task, a + shared-state link -- address the wrong state tree. + """ + state = linked_state + + # No duplicate: the connecting token is recorded. + await namespace.link_token_to_sid("sid1", "tok1") + assert state.router_data[constants.RouteVar.CLIENT_TOKEN] == "tok1" + assert state.rx_router_session.client_token == "tok1" + assert state.rx_router_session.session_id == "sid1" + + # Duplicate: the *new* token is recorded, not the one the client sent. + monkeypatch.setattr( + namespace._token_manager, + "link_token_to_sid", + AsyncMock(return_value="a-fresh-token"), + ) + await namespace.link_token_to_sid("sid2", "tok1") + assert state.router_data[constants.RouteVar.CLIENT_TOKEN] == "a-fresh-token" + assert state.rx_router_session.client_token == "a-fresh-token" + assert state.rx_router_session.session_id == "sid2" + + +def test_client_error_constants_match_frontend(): + """The socket event and error types are duplicated as literals in state.js. + + Nothing at runtime keeps the two definitions in sync, so pin them here. + """ + state_js = (WEB_TEMPLATE_ROOT / "utils/state.js").read_text() + assert ( + f'const CLIENT_ERROR_EVENT = "{constants.SocketEvent.CLIENT_ERROR}"' in state_js + ) + assert ( + f'const ERROR_TYPE_DISPATCH_MISSING = "{constants.ClientErrorType.DISPATCH_MISSING}"' + in state_js + ) + assert ( + f'const ERROR_TYPE_STATE_UPDATE = "{constants.ClientErrorType.STATE_UPDATE}"' + in state_js + ) diff --git a/tests/units/test_socketio_namespace.py b/tests/units/test_socketio_namespace.py index 3ecec917b4f..1cde018110d 100644 --- a/tests/units/test_socketio_namespace.py +++ b/tests/units/test_socketio_namespace.py @@ -8,37 +8,19 @@ import pytest from reflex_base import constants, otel -from reflex.socketio_namespace import EventNamespace +from reflex.socketio_namespace import EventNamespace, _sio_dumps, _sio_loads from .conftest import metric_points @pytest.fixture -def mock_app() -> Mock: - """A mock app for the event namespace. - - Returns: - The mock app. - """ - app = Mock() - app._state = None - app._channels = {} - app.router = Mock(return_value=None) - app.event_processor.enqueue = AsyncMock() - return app - - -@pytest.fixture -def namespace(mock_app: Mock, mocker) -> EventNamespace: - """A Socket.IO event namespace with a mock app and a local token manager. - - Redis is disabled so token linking cannot leak into a shared Redis. +def namespace(namespace_app: Mock) -> EventNamespace: + """A namespace serving the mock app. Returns: The namespace. """ - mocker.patch("reflex.utils.prerequisites.check_redis_used", return_value=False) - return EventNamespace("/_event", mock_app) + return EventNamespace("/_event", namespace_app) @pytest.mark.asyncio @@ -84,7 +66,10 @@ async def test_refused_connect_is_not_counted_as_a_connection( @pytest.mark.asyncio async def test_connect_processes_boot_event_from_auth( - namespace: EventNamespace, mock_app: Mock + namespace: EventNamespace, + namespace_app: Mock, + linked_state: Mock, + boot_event: dict[str, Any], ): """The hydrate event carried in the CONNECT packet is processed on connect. @@ -92,12 +77,7 @@ async def test_connect_processes_boot_event_from_auth( state, so the connect does not load and save the state tree for that; a connect without a boot event still does. """ - mock_app._state = Mock() - state = Mock(router_data={}) - modify_state = mock_app.state_manager.modify_state = Mock( - return_value=AsyncMock(__aenter__=AsyncMock(return_value=state)) - ) - boot_event = {"name": "state.hydrate_and_load", "payload": {}, "router_data": {}} + modify_state = namespace_app.state_manager.modify_state environ = { "QUERY_STRING": "token=tok1", "asgi.scope": {"type": "websocket", "headers": []}, @@ -105,29 +85,31 @@ async def test_connect_processes_boot_event_from_auth( await namespace.on_connect("sid1", environ, {"event": boot_event}) - token, event = mock_app.event_processor.enqueue.await_args.args + token, event = namespace_app.event_processor.enqueue.await_args.args assert (token, event.name) == ("tok1", "state.hydrate_and_load") modify_state.assert_not_called() # Without a boot event (or without auth at all) nothing is processed, and # the connect records the new sid and token on the state itself. - mock_app.event_processor.enqueue.reset_mock() + namespace_app.event_processor.enqueue.reset_mock() await namespace.on_connect("sid2", {"QUERY_STRING": "token=tok2"}, None) await namespace.on_connect("sid3", {"QUERY_STRING": "token=tok3"}) - mock_app.event_processor.enqueue.assert_not_awaited() + namespace_app.event_processor.enqueue.assert_not_awaited() assert modify_state.call_count == 2 - assert state.router_data[constants.RouteVar.SESSION_ID] == "sid3" - assert state.router_data[constants.RouteVar.CLIENT_TOKEN] == "tok3" + assert linked_state.router_data[constants.RouteVar.SESSION_ID] == "sid3" + assert linked_state.router_data[constants.RouteVar.CLIENT_TOKEN] == "tok3" @pytest.mark.asyncio async def test_connect_reports_state_schema_mismatch( - namespace: EventNamespace, mock_app: Mock, caplog: pytest.LogCaptureFixture + namespace: EventNamespace, + namespace_app: Mock, + caplog: pytest.LogCaptureFixture, + boot_event: dict[str, Any], ): """A frontend compiled for another state tree is told once, and still hydrates.""" - mock_app._state = Mock(_to_frontend_schema=Mock(return_value="backend-schema")) + namespace_app._state = Mock(_to_frontend_schema=Mock(return_value="backend-schema")) emit = namespace.emit = AsyncMock() - boot_event = {"name": "state.hydrate_and_load", "payload": {}, "router_data": {}} def warnings() -> list[str]: return [ @@ -148,7 +130,7 @@ def warnings() -> list[str]: {"backend": "backend-schema", "reload_cooldown_ms": 10_000}, to="sid1", ) - token, event = mock_app.event_processor.enqueue.await_args.args + token, event = namespace_app.event_processor.enqueue.await_args.args assert (token, event.name) == ("tok1", "state.hydrate_and_load") (message,) = warnings() assert "frontend-schema" in message @@ -190,13 +172,13 @@ def warnings() -> list[str]: ], ) async def test_connect_sends_no_mismatch_without_one( - namespace: EventNamespace, mock_app: Mock, auth: dict[str, Any] | None + namespace: EventNamespace, + namespace_app: Mock, + linked_state: Mock, + auth: dict[str, Any] | None, ): """A matching or missing frontend schema sends nothing to the client.""" - mock_app._state = Mock(_to_frontend_schema=Mock(return_value="same")) - mock_app.state_manager.modify_state = Mock( - return_value=AsyncMock(__aenter__=AsyncMock(return_value=Mock(router_data={}))) - ) + namespace_app._state._to_frontend_schema = Mock(return_value="same") emit = namespace.emit = AsyncMock() await namespace.on_connect( @@ -204,20 +186,23 @@ async def test_connect_sends_no_mismatch_without_one( ) emit.assert_not_awaited() - assert mock_app.event_processor.enqueue.await_count == (1 if auth else 0) + assert namespace_app.event_processor.enqueue.await_count == (1 if auth else 0) @pytest.mark.asyncio async def test_connect_whose_boot_event_fails_is_refused( - namespace: EventNamespace, mock_app: Mock, otel_metrics, caplog + namespace: EventNamespace, + namespace_app: Mock, + otel_metrics, + caplog, + boot_event: dict[str, Any], ): """A boot event that fails to process refuses the connect and undoes it. Raising instead would leave the session registered with Socket.IO yet unanswered, so the client would neither hydrate nor retry. """ - mock_app.event_processor.enqueue.side_effect = ValueError("bad boot event") - boot_event = {"name": "state.hydrate_and_load", "payload": {}, "router_data": {}} + namespace_app.event_processor.enqueue.side_effect = ValueError("bad boot event") environ = { "QUERY_STRING": "token=tok1", "asgi.scope": {"type": "websocket", "headers": []}, @@ -242,7 +227,7 @@ async def test_connect_whose_boot_event_fails_is_refused( "data", ['{"name": "state.on_click", "payload": {}, "router_data": {}}', 42] ) async def test_undeserializable_event_disconnects_the_session( - namespace: EventNamespace, mock_app: Mock, caplog, data: object + namespace: EventNamespace, namespace_app: Mock, caplog, data: object ): """A malformed event ends the session, as on the plain transport. @@ -258,27 +243,422 @@ async def test_undeserializable_event_disconnects_the_session( await namespace.on_event("sid1", data) namespace.disconnect.assert_awaited_once_with("sid1") - mock_app.event_processor.enqueue.assert_not_awaited() + namespace_app.event_processor.enqueue.assert_not_awaited() assert all(record.levelno <= logging.DEBUG for record in caplog.records) @pytest.mark.asyncio async def test_connect_with_a_malformed_boot_event_is_refused( - namespace: EventNamespace, mock_app: Mock + namespace: EventNamespace, namespace_app: Mock, caplog: pytest.LogCaptureFixture ): - """A boot event that does not deserialize refuses the connect. + """A boot event that does not deserialize refuses the connect, quietly. Disconnecting from inside the connect handler, as a later malformed event - does, would acknowledge a session that never hydrated. + does, would acknowledge a session that never hydrated. The input is + client-controlled, so it is logged at debug level, as on the plain + transport, rather than as a handler error with a traceback. """ namespace.disconnect = AsyncMock() environ = {"QUERY_STRING": "token=tok1", "asgi.scope": {"headers": []}} - refused = await namespace.on_connect("sid1", environ, {"event": "not an event"}) + with caplog.at_level(logging.DEBUG, logger="reflex.event_namespace"): + refused = await namespace.on_connect("sid1", environ, {"event": "not an event"}) # Let the token cleanup task run. await asyncio.sleep(0) assert refused is False namespace.disconnect.assert_not_awaited() - mock_app.event_processor.enqueue.assert_not_awaited() + namespace_app.event_processor.enqueue.assert_not_awaited() assert "tok1" not in namespace.token_to_sid + assert all(record.levelno <= logging.DEBUG for record in caplog.records) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("handler", ["event", "ping", "client_error"]) +async def test_a_failing_handler_is_logged_within_the_error_budget( + namespace: EventNamespace, + namespace_app: Mock, + caplog: pytest.LogCaptureFixture, + handler: str, +): + """A server-side handler failure is logged within budget and keeps the session. + + Escaping into python-socketio, it would be logged with a traceback per + frame, outside the budget that bounds client-triggered error logs. + """ + await namespace.on_connect( + "sid1", {"QUERY_STRING": "token=tok1", "asgi.scope": {"headers": []}} + ) + namespace.disconnect = AsyncMock() + failure = RuntimeError("server bug") + namespace_app.event_processor.enqueue.side_effect = failure + namespace_app.frontend_exception_handler = Mock(side_effect=failure) + namespace.emit = AsyncMock(side_effect=failure) + calls = { + "event": lambda: namespace.on_event( + "sid1", {"name": "state.on_click", "payload": {}, "router_data": {}} + ), + "ping": lambda: namespace.on_ping("sid1"), + "client_error": lambda: namespace.on_client_error( + "sid1", {"error_type": "x", "message": "y"} + ), + } + + with caplog.at_level(logging.ERROR, logger="reflex.event_namespace"): + for _ in range(namespace._MAX_CLIENT_ERRORS_PER_SID + 2): + await calls[handler]() + + assert len(caplog.records) == namespace._MAX_CLIENT_ERRORS_PER_SID + assert all("server bug" in repr(record.exc_info) for record in caplog.records) + namespace.disconnect.assert_not_awaited() + + +def test_packets_with_an_unpaired_surrogate_stay_valid_utf8(): + """A packet carrying an unpaired surrogate is escaped, as plain frames are. + + A non-UTF-8 file name decodes to one; written raw, the text frame cannot + be encoded for sending and the update is lost. + """ + data = _sio_dumps({"file": "bad\udcffname"}, separators=(",", ":")) + + data.encode() + assert _sio_loads(data) == {"file": "bad\udcffname"} + + +@pytest.fixture +def frontend_errors(namespace: EventNamespace, namespace_app: Mock) -> list[str]: + """Capture what a linked session's client_error reports route to the app. + + Returns: + The reported messages. + """ + namespace.sid_to_token["known_sid"] = "some_token" + errors: list[str] = [] + namespace_app.frontend_exception_handler = lambda exc: errors.append(str(exc)) + return errors + + +def _connect_environ(token: str) -> dict[str, Any]: + """The environ of a connecting client carrying a token and two headers. + + Args: + token: The client token. + + Returns: + The environ. + """ + return { + "QUERY_STRING": f"token={token}", + "asgi.scope": { + "headers": [ + (b"origin", b"http://localhost:3000"), + (b"user-agent", b"test-agent"), + ], + "client": ("127.0.0.1", 1234), + }, + } + + +def _client_event() -> dict[str, Any]: + """An event as the frontend sends it. + + Returns: + The event payload. + """ + return { + "name": "state.hydrate", + "router_data": {"pathname": "/", "query": {}, "asPath": "/"}, + "payload": {}, + } + + +def _enqueued(namespace_app: Mock) -> list[Any]: + """The events the namespace handed to the event processor. + + Args: + namespace_app: The mock app. + + Returns: + The enqueued events, in order. + """ + return [ + call.args[1] for call in namespace_app.event_processor.enqueue.await_args_list + ] + + +def test_sio_json_records_message_sizes(otel_metrics): + """Socket.IO packet serialization records UTF-8 sizes in both directions.""" + data = _sio_dumps({"a": "é"}, separators=(",", ":")) + assert data == '{"a":"é"}' + assert _sio_loads(data) == {"a": "é"} + assert _sio_loads(data.encode()) == {"a": "é"} + # ASCII payloads take the fast path that sizes without encoding a copy. + ascii_data = _sio_dumps({"b": "x"}, separators=(",", ":")) + assert _sio_loads(ascii_data) == {"b": "x"} + points = { + p.attributes[otel.ATTR_NETWORK_IO_DIRECTION]: p.sum + for p in metric_points(otel_metrics, otel.METRIC_WEBSOCKET_MESSAGE_SIZE) + } + size = len(data.encode()) + assert size == len(data) + 1 + assert points == { + "transmit": size + len(ascii_data), + "receive": 2 * size + len(ascii_data), + } + + +@pytest.mark.asyncio +async def test_client_error_dispatch_missing_reports_actionable_error( + namespace: EventNamespace, frontend_errors: list[str] +): + """A dispatch_function_missing error reports the substate and the remedy.""" + await namespace.on_client_error( + "known_sid", + { + "error_type": constants.ClientErrorType.DISPATCH_MISSING, + "message": "Cannot process state update", + "substate": "reflex___state____state.my___state____my_state", + }, + ) + (message,) = frontend_errors + assert "reflex___state____state.my___state____my_state" in message + assert "skipped" in message.lower() + assert "rebuild" in message.lower() + + +@pytest.mark.asyncio +async def test_client_error_generic_reports_type_and_message( + namespace: EventNamespace, frontend_errors: list[str] +): + """A generic client error reports the error type and message.""" + await namespace.on_client_error( + "known_sid", + {"error_type": constants.ClientErrorType.STATE_UPDATE, "message": "boom"}, + ) + (message,) = frontend_errors + assert constants.ClientErrorType.STATE_UPDATE in message + assert "boom" in message + + +@pytest.mark.asyncio +@pytest.mark.parametrize("payload", ["not a dict", None, ["list"], 42]) +async def test_client_error_malformed_payload_is_ignored( + namespace: EventNamespace, frontend_errors: list[str], payload: Any +): + """Non-dict payloads are dropped without raising or reporting errors.""" + await namespace.on_client_error("known_sid", payload) + assert not frontend_errors + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sid", ["known_sid", "unknown_sid"]) +async def test_client_error_no_argument_emit_is_ignored( + namespace: EventNamespace, + frontend_errors: list[str], + caplog: pytest.LogCaptureFixture, + sid: str, +): + """A payload-less client_error emit is dropped like any malformed payload. + + python-socketio dispatches ``emit("client_error")`` without a payload as + ``on_client_error(sid)``, so a missing ``data`` argument must not raise a + TypeError before the anti-abuse guards run. + """ + with caplog.at_level(logging.DEBUG, logger="reflex.event_namespace"): + await namespace.on_client_error(sid) + assert not frontend_errors + assert all(record.levelno <= logging.DEBUG for record in caplog.records) + + +@pytest.mark.asyncio +async def test_client_error_unknown_sid_does_not_report_error( + namespace: EventNamespace, + frontend_errors: list[str], + caplog: pytest.LogCaptureFixture, +): + """Errors from sockets without a linked token are not reported.""" + with caplog.at_level(logging.DEBUG, logger="reflex.event_namespace"): + await namespace.on_client_error( + "unknown_sid", + { + "error_type": constants.ClientErrorType.STATE_UPDATE, + "message": "spam from unauthenticated socket", + }, + ) + assert not frontend_errors + assert all(record.levelno < logging.ERROR for record in caplog.records) + + +@pytest.mark.asyncio +async def test_client_error_values_are_sanitized_and_truncated( + namespace: EventNamespace, frontend_errors: list[str] +): + """Control characters are stripped and long messages truncated before reporting.""" + evil = "\x1b[31mINJECT\x1b[0m\nFAKE LOG LINE\t" + "A" * 5000 + await namespace.on_client_error( + "known_sid", {"error_type": "custom_type", "message": evil} + ) + (message,) = frontend_errors + assert "\x1b" not in message + assert "\n" not in message + assert "\t" not in message + assert len(message) < 700 + + +@pytest.mark.asyncio +async def test_client_error_reporting_is_rate_limited_per_sid( + namespace: EventNamespace, frontend_errors: list[str] +): + """A single session cannot flood the backend logs with error reports.""" + for _ in range(20): + await namespace.on_client_error( + "known_sid", {"error_type": "custom_type", "message": "spam"} + ) + assert len(frontend_errors) == EventNamespace._MAX_CLIENT_ERRORS_PER_SID + # Disconnecting removes the counter so the mapping cannot grow unboundedly. + if (task := namespace.on_disconnect("known_sid")) is not None: + await task + assert "known_sid" not in namespace._client_error_budget.counts + + +@pytest.mark.asyncio +async def test_client_error_reporting_bounded_across_reconnects( + namespace: EventNamespace, + frontend_errors: list[str], + caplog: pytest.LogCaptureFixture, +): + """Reconnecting with fresh SIDs does not grant an unlimited report budget.""" + with caplog.at_level(logging.WARNING, logger="reflex.event_namespace"): + for reconnect in range(50): + sid = f"sid_{reconnect}" + namespace.sid_to_token[sid] = f"token_{reconnect}" + for _ in range(5): + await namespace.on_client_error( + sid, {"error_type": "custom_type", "message": "spam"} + ) + assert len(frontend_errors) == EventNamespace._MAX_CLIENT_ERRORS_PER_WINDOW + # Suppression is not silent: one warning is logged when the cap trips, so + # a flooding client cannot invisibly starve reports from other sessions. + assert ["suppressing" in r.getMessage() for r in caplog.records] == [True] + # Once the window elapses, errors are reported again (not silenced forever). + namespace._client_error_budget.window_start -= ( + EventNamespace._CLIENT_ERROR_WINDOW_SECONDS + 1 + ) + namespace.sid_to_token["sid_fresh"] = "token_fresh" + await namespace.on_client_error( + "sid_fresh", {"error_type": "custom_type", "message": "after window"} + ) + assert len(frontend_errors) == EventNamespace._MAX_CLIENT_ERRORS_PER_WINDOW + 1 + + +def test_client_error_event_name_matches_handler(): + """python-socketio dispatches events to on_ methods by name. + + This pins the handler to SocketEvent.CLIENT_ERROR. + """ + assert ( + f"on_{constants.SocketEvent.CLIENT_ERROR}" + == EventNamespace.on_client_error.__name__ + ) + + +@pytest.mark.asyncio +async def test_on_event_uses_connection_scoped_router_data( + token: str, namespace: EventNamespace, namespace_app: Mock +): + """on_event merges the connection-scoped router_data into every event. + + Headers, client IP, and session id cannot change for the lifetime of a + connection, so the decode is cached on the ASGI scope and never repeated + per event. + """ + environ = _connect_environ(token) + await namespace.on_connect("sid1", environ) + + await namespace.on_event("sid1", _client_event()) + + (event,) = _enqueued(namespace_app) + assert namespace_app.event_processor.enqueue.await_args.args[0] == token + assert event.router_data[constants.RouteVar.CLIENT_TOKEN] == token + assert event.router_data[constants.RouteVar.SESSION_ID] == "sid1" + assert event.router_data[constants.RouteVar.CLIENT_IP] == "127.0.0.1" + assert event.router_data[constants.RouteVar.HEADERS] == { + "origin": "http://localhost:3000", + "user-agent": "test-agent", + "asgi-scope-client": "127.0.0.1", + } + assert event.router_data[constants.RouteVar.PATH] == "/404" + assert event.router_data[constants.RouteVar.QUERY] == {} + + # A later event on the same connection reuses the decoded headers. + environ["asgi.scope"]["headers"] = [(b"origin", b"decoded-again")] + await namespace.on_event("sid1", _client_event()) + next_event = _enqueued(namespace_app)[-1] + assert ( + next_event.router_data[constants.RouteVar.HEADERS]["origin"] + == "http://localhost:3000" + ) + + +@pytest.mark.asyncio +async def test_on_event_does_not_share_the_cached_headers( + token: str, namespace: EventNamespace, namespace_app: Mock +): + """Each event gets its own headers mapping, not the cached one. + + The headers reach `state.router_data`, a plain mutable dict, so sharing + the cached mapping would let a handler mutating it corrupt the connection + cache for every later event on the socket. + """ + environ = _connect_environ(token) + await namespace.on_connect("sid1", environ) + + await namespace.on_event("sid1", _client_event()) + (event,) = _enqueued(namespace_app) + event_headers = event.router_data[constants.RouteVar.HEADERS] + cached_headers = environ["asgi.scope"]["_reflex_static_router_data"][ + constants.RouteVar.HEADERS + ] + + assert event_headers == cached_headers + assert event_headers is not cached_headers + # Mutating what the handler sees must not reach the connection cache. + event_headers["user-agent"] = "mutated" + assert cached_headers["user-agent"] == "test-agent" + + await namespace.on_event("sid1", _client_event()) + next_event = _enqueued(namespace_app)[-1] + assert ( + next_event.router_data[constants.RouteVar.HEADERS]["user-agent"] == "test-agent" + ) + + +@pytest.mark.asyncio +async def test_on_event_builds_router_data_without_connect( + token: str, namespace: EventNamespace, namespace_app: Mock +): + """on_event derives the connection data from the environ if connect was missed.""" + await namespace._token_manager.link_token_to_sid(token, "sid1") + environ = _connect_environ(token) + namespace_app.sio = Mock(get_environ=Mock(return_value=environ)) + + await namespace.on_event("sid1", _client_event()) + await namespace.on_event("sid1", _client_event()) + + # The connection-scoped entries are derived once and cached on the scope. + assert environ["asgi.scope"]["_reflex_static_router_data"] == { + constants.RouteVar.SESSION_ID: "sid1", + constants.RouteVar.CLIENT_IP: "127.0.0.1", + constants.RouteVar.HEADERS: { + "origin": "http://localhost:3000", + "user-agent": "test-agent", + "asgi-scope-client": "127.0.0.1", + }, + } + events = _enqueued(namespace_app) + assert len(events) == 2 + for event in events: + assert event.router_data[constants.RouteVar.SESSION_ID] == "sid1" + assert ( + event.router_data[constants.RouteVar.HEADERS]["user-agent"] == "test-agent" + ) From c6b7cd3abb213107ba1f044d4713f0f436424fd6 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 17 Oct 2026 17:14:13 +0200 Subject: [PATCH 60/62] Dedupe the client transport and channel code websocket.js settles a closed connection in one place, flushes the channel and transport queues through one helper, and checks and reports oversized frames through one size check and one message. The single-caller emitChannel, which bypassed the channel's guards, is gone, and the generic JSON helpers move to json.js. state.js shares its warm-socket, socket disposal and connect-error helpers and drops an unreachable check. The client-only tests that ran websocket.js through the Python harness move to websocket_js.test.mjs, and the channels integration test now queues its frame on the transport, as its name says. --- .../.templates/web/utils/helpers/json.js | 23 ++ .../.templates/web/utils/helpers/websocket.js | 244 +++++++----------- .../reflex_base/.templates/web/utils/state.js | 63 +++-- .../tests_playwright/test_channels.py | 9 +- tests/units/compiler/state_js.test.mjs | 4 +- tests/units/compiler/websocket_js.test.mjs | 171 ++++++++++-- .../client_event_queue_runtime.mjs | 6 +- .../reflex_base/client_state_updates.mjs | 2 - 8 files changed, 321 insertions(+), 201 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/json.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/json.js index cd52a484ab6..5e5082cdf57 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/json.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/json.js @@ -70,3 +70,26 @@ export const parseJson = (str) => { return parseNonFiniteFloats(str); } }; + +/** + * 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. + */ +export const parseJsonLenient = (text, fallback) => { + try { + return parseJson(text); + } catch { + return fallback; + } +}; + +/** + * 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. + */ +export const undefinedToNull = (_k, v) => (v === undefined ? null : v); 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 95c7e612b74..a323ab8c0a0 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 @@ -2,7 +2,7 @@ // 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(), auth, io.opts.query, and _callbacks. -import { parseJson } from "$/utils/helpers/json"; +import { parseJsonLenient, undefinedToNull } from "$/utils/helpers/json"; // Protocol-level message names (must match reflex/event_namespace.py). const CONNECT_MESSAGE = "_connect"; @@ -58,29 +58,6 @@ const isReservedEvent = (event) => */ const report = (error) => (globalThis.reportError ?? console.error)(error); -/** - * 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. - */ -export const undefinedToNull = (_k, v) => (v === undefined ? null : v); - -/** - * 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. - */ -export const parseJsonLenient = (text, fallback) => { - try { - return parseJson(text); - } catch { - return fallback; - } -}; - /** * Serialize one channel message: binary when it carries attachments. * @param event The message name. @@ -103,14 +80,24 @@ const isChannelMessage = (message) => Array.isArray(message) && message.length > 2; /** - * Whether a serialized frame carries a channel message. + * Parse a serialized frame back into its message. * @param frame The serialized text or binary frame. - * @returns True for a binary frame, or a text frame naming a channel. + * @returns The parsed message, or undefined if malformed. */ -const isChannelFrame = (frame) => - // Only channel messages carry attachments, so only they go out binary. - typeof frame !== "string" || - isChannelMessage(parseJsonLenient(frame, undefined)); +const decodeFrame = (frame) => + typeof frame === "string" + ? parseJsonLenient(frame, undefined) + : decodeChannelFrame(frame); + +/** + * The size a serialized frame occupies on the wire. + * @param frame The serialized text or binary frame. + * @returns The size in bytes. + */ +const frameByteLength = (frame) => + typeof frame === "string" + ? TEXT_ENCODER.encode(frame).byteLength + : frame.byteLength; /** * Whether a serialized frame is over the backend's inbound message limit. @@ -119,31 +106,34 @@ const isChannelFrame = (frame) => * 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. + * @param limit The limit in bytes; falsy while it is unknown. * @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) { + if (!limit) { return false; } - return TEXT_ENCODER.encode(frame).byteLength > limit; + if (typeof frame === "string") { + if (frame.length > limit) { + return true; + } + if (frame.length * 4 <= limit) { + return false; + } + } + return frameByteLength(frame) > 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. + * Describe a frame over the backend's inbound message limit. + * @param subject What the frame carries, starting the sentence. + * @param frame The serialized frame. + * @param limit The limit in bytes. + * @returns The description, for the caller to finish with the consequence. */ -const frameByteLength = (frame) => - typeof frame === "string" - ? TEXT_ENCODER.encode(frame).byteLength - : frame.byteLength; +const oversizedMessage = (subject, frame, limit) => + `${subject} is ${frameByteLength(frame)} bytes, over the ${limit} the ` + + "backend accepts (REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE)"; /** * Serialize an outgoing frame. @@ -378,11 +368,9 @@ class ReflexChannel extends LocalEmitter { // time the channel opens. const frame = channelFrame(event, data, this.name, buffers); const limit = this._transport?._maxMessageSize; - if (limit && exceedsMessageLimit(frame, limit)) { + if (exceedsMessageLimit(frame, limit)) { throw new Error( - `Channel message "${event}" is ${frameByteLength(frame)} bytes, over ` + - `the ${limit} the backend accepts ` + - "(REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE).", + oversizedMessage(`Channel message "${event}"`, frame, limit) + ".", ); } if (this.connected && this._transport) { @@ -403,7 +391,7 @@ class ReflexChannel extends LocalEmitter { */ _attach(transport) { this._transport = transport; - transport.emitChannel(this.name, OPEN_MESSAGE, null, []); + transport._send(stringifyFrame([OPEN_MESSAGE, null, this.name])); } /** @@ -427,8 +415,8 @@ class ReflexChannel extends LocalEmitter { 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.`, + oversizedMessage("A queued channel message", frame, limit) + + "; it was dropped.", }); } @@ -457,18 +445,7 @@ 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._dropOversized(frame, limit); - continue; - } - // The transport re-queues it if its socket closed mid-flush. - this._transport._send(frame); - } + flushFrames(this._transport, queued); this._emitLocal("connect"); return; } @@ -540,21 +517,17 @@ export const disableChannels = (reason) => { */ const oversizedEventError = (name, frame, limit) => new Error( - `Message "${name}" is ${frameByteLength(frame)} bytes, over the ${limit} ` + - "the backend accepts (REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE); it was not sent.", + oversizedMessage(`Message "${name}"`, frame, limit) + "; it was not sent.", ); /** - * Report a frame from the transport's queue dropped for being over the - * backend's limit: to its channel, or as an error for an event. + * Report a queued frame dropped for being over the backend's limit: to its + * channel, or as an error for an event. * @param frame The serialized text or binary frame. * @param limit The limit in bytes. */ const dropOversizedFrame = (frame, limit) => { - const [event, data, channel] = - typeof frame === "string" - ? parseJsonLenient(frame, []) - : (decodeChannelFrame(frame) ?? []); + const [event, data, channel] = decodeFrame(frame) ?? []; if (channel === undefined) { report(oversizedEventError(data?.name ?? event, frame, limit)); } else { @@ -562,6 +535,24 @@ const dropOversizedFrame = (frame, limit) => { } }; +/** + * Send frames queued before the backend's limit was known, dropping any over + * it, which would cost the app its websocket and every frame flushed after it. + * @param transport The connected transport. + * @param frames The serialized frames, oldest first. + */ +const flushFrames = (transport, frames) => { + const limit = transport._maxMessageSize; + for (const frame of frames) { + if (exceedsMessageLimit(frame, limit)) { + dropOversizedFrame(frame, limit); + } else { + // The transport re-queues it if its socket closed mid-flush. + transport._send(frame); + } + } +}; + /** * Open every channel on a transport that just finished its handshake. * @param transport The connected transport. @@ -627,7 +618,10 @@ export class ReflexWebSocket extends LocalEmitter { typeof addEventListener === "function" && this._url.hostname !== "localhost" ) { - this._offlineListener = () => this._onOffline(); + this._offlineListener = () => + this._dropConnection("transport close", { + description: "network connection lost", + }); addEventListener("offline", this._offlineListener, false); } } @@ -672,7 +666,7 @@ export class ReflexWebSocket extends LocalEmitter { // Dialed ahead by open(). this._sendConnect(); } - this._clearConnectTimer(); + clearTimeout(this._connectTimer); this._connectTimer = setTimeout(() => { if (this._ws === ws && !this.connected) { ws.close(); @@ -726,22 +720,13 @@ export class ReflexWebSocket extends LocalEmitter { // A newer connection or an explicit disconnect() superseded this one. return; } - this._clearConnectTimer(); - this._clearWatchdog(); - const wasConnected = this.connected; - this.connected = false; - detachChannels(this, "transport close"); - if (!wasConnected) { + const details = { code: event.code, reason: event.reason }; + if (!this._settleClose("transport close", details)) { // Never handshaked: this was a failed connection attempt. this._emitLocal( "connect_error", new Error("websocket connection failed"), ); - } else { - this._emitLocal("disconnect", "transport close", { - code: event.code, - reason: event.reason, - }); } }; } @@ -760,17 +745,6 @@ export class ReflexWebSocket extends LocalEmitter { this._teardown("io client disconnect", undefined); } - /** - * Report the disconnect immediately when the browser goes offline. - */ - _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). @@ -778,27 +752,38 @@ export class ReflexWebSocket extends LocalEmitter { * @param details The disconnect details to report. */ _teardown(reason, details) { - this._clearConnectTimer(); - this._clearWatchdog(); const ws = this._ws; if (!ws) { return; } // Detach so the onclose handler does not double-report. this._ws = null; - const wasConnected = this.connected; - this.connected = false; if (ws.readyState <= WebSocket.OPEN) { ws.onclose = null; ws.onmessage = null; ws.close(1000); } + this._settleClose(reason, details); + } + + /** + * Settle the transport's state once its socket is gone, then report it. + * @param reason The disconnect reason to report. + * @param details The disconnect details to report. + * @returns Whether a session was open, so a disconnect was reported. + */ + _settleClose(reason, details) { + clearTimeout(this._connectTimer); + clearTimeout(this._watchdogTimer); + const wasConnected = this.connected; + this.connected = false; // Report only once the transport's own state is settled: the handlers // run application code. detachChannels(this, reason); if (wasConnected) { this._emitLocal("disconnect", reason, details); } + return wasConnected; } /** @@ -809,7 +794,7 @@ export class ReflexWebSocket extends LocalEmitter { emit(event, data) { const frame = stringifyFrame([event, data]); const limit = this._maxMessageSize; - if (limit && exceedsMessageLimit(frame, limit)) { + if (exceedsMessageLimit(frame, limit)) { // The backend closes the connection over an oversized frame, losing // every update in flight with it; drop this one message instead. report(oversizedEventError(data?.name ?? event, frame, limit)); @@ -818,17 +803,6 @@ export class ReflexWebSocket extends LocalEmitter { this._send(frame); } - /** - * 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(channelFrame(event, data, channel, buffers)); - } - /** * Write one serialized frame, buffering while disconnected. * @param frame The serialized text or binary frame. @@ -874,7 +848,7 @@ export class ReflexWebSocket extends LocalEmitter { // Application-level liveness confirmed; adopt the server's heartbeat // settings (sent in seconds, converted to ms) for the connection // watchdog. - this._clearConnectTimer(); + clearTimeout(this._connectTimer); this._watchdogMs = (payload.ping_interval + payload.ping_timeout) * 1000; this._resetWatchdog(); this.connected = true; @@ -894,21 +868,12 @@ export class ReflexWebSocket extends LocalEmitter { // backend that closes the socket over it, costing the app its state // updates too. this._sendQueue = this._sendQueue.filter( - (frame) => !isChannelFrame(frame), + (frame) => !isChannelMessage(decodeFrame(frame)), ); } - const queue = this._sendQueue; + const queued = this._sendQueue; this._sendQueue = []; - const limit = this._maxMessageSize; - for (const frame of queue) { - // Queued before this backend's limit was known: an oversized frame - // would cost the connection and every frame flushed after it. - if (limit && exceedsMessageLimit(frame, limit)) { - dropOversizedFrame(frame, limit); - continue; - } - this._ws.send(frame); - } + flushFrames(this, queued); this._emitLocal("connect"); return; } @@ -923,13 +888,14 @@ export class ReflexWebSocket extends LocalEmitter { * 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. + * @param details The disconnect details to report. */ - _dropConnection(reason) { + _dropConnection(reason, details) { if (this.connected) { // Reported now rather than from onclose: on a dead link the browser // fires that only when its closing handshake times out, up to a minute // later, and the transport would claim to be connected until then. - this._teardown(reason, undefined); + this._teardown(reason, details); } } @@ -938,30 +904,10 @@ export class ReflexWebSocket extends LocalEmitter { * included) arrives within the server's ping interval + timeout. */ _resetWatchdog() { - this._clearWatchdog(); + clearTimeout(this._watchdogTimer); this._watchdogTimer = setTimeout( () => this._dropConnection("ping timeout"), 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 f149e86cfb3..c6d8162ae7a 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 @@ -12,14 +12,13 @@ import { } from "react-router"; import { app, eventLoop } from "$/utils/context-registry"; import debounce from "$/utils/helpers/debounce"; +import { parseJsonLenient, undefinedToNull } from "$/utils/helpers/json"; 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 @@ -45,6 +44,9 @@ const NEW_VERSION_BANNER_ID = "reflex-new-version-banner"; const ERROR_TYPE_DISPATCH_MISSING = "dispatch_function_missing"; const ERROR_TYPE_STATE_UPDATE = "state_update_processing_error"; +// Connection errors kept for display, oldest dropped first. +const MAX_CONNECT_ERRORS = 10; + // These hostnames indicate that the backend and frontend are reachable via the same domain. const SAME_DOMAIN_HOSTNAMES = ["localhost", "0.0.0.0", "::", "0:0:0:0:0:0:0:0"]; @@ -199,18 +201,37 @@ const createSocket = (endpoint, token) => protocols: [reflexEnvironment.version], }); +/** + * Close a transport for good and remove its handlers. + * @param socket The transport. + */ +const disposeSocket = (socket) => { + socket.disconnect(); + // Also releases the plain WebSocket transport's offline listener. + socket.off(); +}; + let warmSocket = null; let cancelWarmup = () => {}; let socketStarted = false; -/** Close an unclaimed transport and remove its cleanup handlers. */ -const discardWarmSocket = () => { +/** + * Claim the warmed-up transport, cancelling its cleanup. + * @returns The warm transport, or null if there is none. + */ +const takeWarmSocket = () => { const socket = warmSocket; warmSocket = null; cancelWarmup(); - socket?.disconnect(); - // Also releases the transport's offline listener. - socket?.off(); + return socket; +}; + +/** Close an unclaimed transport and remove its cleanup handlers. */ +const discardWarmSocket = () => { + const socket = takeWarmSocket(); + if (socket !== null) { + disposeSocket(socket); + } }; // Dial the plain WebSocket transport while React is still preparing to mount. @@ -874,6 +895,17 @@ const handleStateMismatch = (info) => { showNewVersionBanner(); }; +/** + * Append a connection error, keeping only the most recent ones. + * @param connectErrors The errors so far. + * @param error The new error. + * @returns The updated errors. + */ +const addConnectError = (connectErrors, error) => [ + ...connectErrors.slice(1 - MAX_CONNECT_ERRORS), + error, +]; + /** * Connect to a websocket and set the handlers. * @param socket The socket object to connect. @@ -932,17 +964,13 @@ export const connect = async ( socketStarted = true; try { const session_token = getToken(); - if ( - warmSocket && - (env.SOCKETIO || warmSocket.io.opts.query.token !== session_token) - ) { + if (warmSocket && warmSocket.io.opts.query.token !== session_token) { discardWarmSocket(); } if (!env.SOCKETIO) { // Default transport: plain WebSocket speaking the Reflex event protocol. - socket.current = warmSocket ?? createSocket(endpoint, session_token); - warmSocket = null; - cancelWarmup(); + socket.current = + takeWarmSocket() ?? createSocket(endpoint, session_token); } else { // Socket.IO, over the engine.io transports given; the client library // is only loaded when this transport is configured. @@ -975,7 +1003,7 @@ export const connect = async ( // event, which reconnects, which fails again without end -- as a client // chunk the browser failed to load does on every retry. console.error("Failed to create the event socket:", error); - setConnectErrors((connectErrors) => [...connectErrors.slice(-9), error]); + setConnectErrors((connectErrors) => addConnectError(connectErrors, error)); return; } finally { socket.connecting = false; @@ -1058,7 +1086,7 @@ export const connect = async ( socket.current.wait_connect = false; let n_connect_errors = 0; setConnectErrors((connectErrors) => { - const new_errors = [...connectErrors.slice(-9), error]; + const new_errors = addConnectError(connectErrors, error); n_connect_errors = new_errors.length; return new_errors; }); @@ -1550,8 +1578,7 @@ export const useEventLoop = ( socket.visibilityHandler = null; } if (socket.current) { - socket.current.disconnect(); - socket.current.off(); + disposeSocket(socket.current); socket.current = null; } }; diff --git a/tests/integration/tests_playwright/test_channels.py b/tests/integration/tests_playwright/test_channels.py index 3bf980f0c0f..eb9f1e49236 100644 --- a/tests/integration/tests_playwright/test_channels.py +++ b/tests/integration/tests_playwright/test_channels.py @@ -38,11 +38,12 @@ 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. + // Close the socket and emit in the same tick: it is CLOSING, but its + // close is not reported yet, so the channel still reports connected + // and the frame queues on the transport rather than on the channel. + // (A drop detaches the channel at once, queueing on the channel.) window.__probe.dropThenEmit = (n) => { - probeChannel._transport._dropConnection("test"); + probeChannel._transport._ws.close(); probeChannel.emit("push", { n }, [new Uint8Array([1, 2, 3, 4])]); }; // Emit while down, then mutate what was passed: the queued message must diff --git a/tests/units/compiler/state_js.test.mjs b/tests/units/compiler/state_js.test.mjs index 9d31052b7df..8f1a57355a3 100644 --- a/tests/units/compiler/state_js.test.mjs +++ b/tests/units/compiler/state_js.test.mjs @@ -118,8 +118,6 @@ async function setup({ ReflexWebSocket, disableChannels() {}, getChannel() {}, - parseJsonLenient() {}, - undefinedToNull() {}, }, mergician: { mergician() {} }, "$/env.json": { @@ -160,7 +158,7 @@ async function setup({ }, eventLoop: { addEvents() {}, connectErrors: [] }, }, - "$/utils/helpers/json": { parseJson: JSON.parse }, + "$/utils/helpers/json": { parseJsonLenient() {}, undefinedToNull() {} }, "$/utils/helpers/debounce": { default() {} }, "$/utils/helpers/throttle": { default() {} }, "$/utils/helpers/upload": { uploadFiles() {} }, diff --git a/tests/units/compiler/websocket_js.test.mjs b/tests/units/compiler/websocket_js.test.mjs index 989252920b0..25e38db9e15 100644 --- a/tests/units/compiler/websocket_js.test.mjs +++ b/tests/units/compiler/websocket_js.test.mjs @@ -3,19 +3,23 @@ import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; -const source = await readFile( - new URL( - "../../../packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/websocket.js", - import.meta.url, - ), - "utf8", +const helpers = new URL( + "../../../packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/", + import.meta.url, ); +const source = await readFile(new URL("websocket.js", helpers), "utf8"); +const jsonSource = await readFile(new URL("json.js", helpers), "utf8"); /** Evaluate the actual transport module against a scripted browser WebSocket. */ -async function setup({ throwOnConstruct = false, closeLater = false } = {}) { +async function setup({ + throwOnConstruct = false, + closeLater = false, + url = "http://localhost:8000/_event", +} = {}) { const sockets = []; const timers = new Map(); const reported = []; + const listeners = new Map(); class WebSocket { static CONNECTING = 0; @@ -80,23 +84,20 @@ async function setup({ throwOnConstruct = false, closeLater = false } = {}) { return fn; }, clearTimeout: (fn) => timers.delete(fn), + addEventListener: (name, fn) => listeners.set(name, fn), + removeEventListener: (name, fn) => + listeners.get(name) === fn && listeners.delete(name), }); const module = new vm.SourceTextModule(source, { context }); - await module.link( - () => - new vm.SyntheticModule( - ["parseJson"], - function () { - this.setExport("parseJson", JSON.parse); - }, - { context }, - ), - ); + await module.link((specifier) => { + assert.equal(specifier, "$/utils/helpers/json"); + return new vm.SourceTextModule(jsonSource, { context }); + }); await module.evaluate(); - const transport = new module.namespace.ReflexWebSocket( - "http://localhost:8000/_event", - { query: { token: "tok" }, protocols: ["version"] }, - ); + const transport = new module.namespace.ReflexWebSocket(url, { + query: { token: "tok" }, + protocols: ["version"], + }); const events = []; for (const name of ["connect", "connect_error", "disconnect", "event"]) { transport.on(name, (...args) => events.push([name, ...args])); @@ -107,6 +108,7 @@ async function setup({ throwOnConstruct = false, closeLater = false } = {}) { timers, events, reported, + listeners, getChannel: module.namespace.getChannel, decodeChannelFrame: module.namespace.decodeChannelFrame, }; @@ -261,6 +263,23 @@ test("the ping watchdog reports a dead link at once", async () => { assert.equal(channel.connected, false); }); +test("going offline reports the disconnect at once", async () => { + // Localhost keeps working offline, so only a remote backend is watched. + const app = await setup({ + url: "https://example.com/_event", + closeLater: true, + }); + connectTransport(app); + app.listeners.get("offline")(); + assert.equal(app.transport.connected, false); + const [name, reason, details] = app.events.at(-1); + assert.deepEqual([name, reason], ["disconnect", "transport close"]); + assert.equal(details.description, "network connection lost"); + // Disposing of the transport releases the listener. + app.transport.off(); + assert.equal(app.listeners.size, 0); +}); + test("a channel opened twice reports connect once", async () => { const app = await setup(); const socket = connectTransport(app); @@ -289,9 +308,20 @@ test("a socket constructor that throws becomes a connect error", async () => { test("a channel refuses names and attachments the backend cannot take", async () => { const app = await setup(); - connectTransport(app); + const socket = connectTransport(app); const channel = app.getChannel("strict"); - for (const name of [undefined, 42, "", "_open", "_custom", "connect"]) { + socket.receive(["_opened", null, "strict"]); + const before = socket.sent.length; + for (const name of [ + undefined, + 42, + "", + "_open", + "_custom", + "connect", + "disconnect", + "error", + ]) { assert.throws(() => channel.emit(name, null)); } for (const buffers of [ @@ -307,6 +337,9 @@ test("a channel refuses names and attachments the backend cannot take", async () for (const name of [undefined, "has space", "x".repeat(65)]) { assert.throws(() => app.getChannel(name), { name: "TypeError" }); } + // Nothing refused was sent or queued behind the message that is valid. + channel.emit("push", null); + assert.deepEqual(socket.sent.slice(before), [["push", null, "strict"]]); }); test("shared buffers are sent whole", async () => { @@ -372,3 +405,95 @@ test("a channel message queued on a closing socket is checked against the next b ["message_too_large"], ); }); + +test("a channel message over the backend's limit is refused, measured in UTF-8 bytes", async () => { + const app = await setup(); + const socket = connectTransport(app, { max_message_size: 1024 }); + const channel = app.getChannel("sized"); + socket.receive(["_opened", null, "sized"]); + const before = socket.sent.length; + assert.throws( + () => channel.emit("push", { blob: "x".repeat(5000) }), + /"push" is \d+ bytes, over the 1024 the backend accepts \(REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE\)\.$/, + ); + // 400 characters, 1200 UTF-8 bytes: only a byte-accurate check catches it. + assert.throws( + () => channel.emit("push", "\u20ac".repeat(400)), + (error) => { + const bytes = Number(/is (\d+) bytes/.exec(error.message)[1]); + return bytes > 1024 && bytes < 4 * 400; + }, + ); + channel.emit("push", { small: true }); + assert.deepEqual(socket.sent.slice(before), [ + ["push", { small: true }, "sized"], + ]); +}); + +test("a channel message emitted before any limit was known is checked when the channel opens", async () => { + const app = await setup(); + const channel = app.getChannel("early"); + const errors = []; + channel.on("error", (error) => errors.push(error)); + // No transport yet, so no limit to check against. + channel.emit("push", { blob: "y".repeat(5000) }); + channel.emit("push", { small: true }); + const socket = connectTransport(app, { max_message_size: 1024 }); + socket.receive(["_opened", null, "early"]); + assert.deepEqual(socket.sent.slice(1), [ + ["_open", null, "early"], + ["push", { small: true }, "early"], + ]); + assert.equal(errors.length, 1); + assert.equal(errors[0].code, "message_too_large"); + assert.match( + errors[0].message, + /^A queued channel message is \d+ bytes, over the 1024 the backend accepts \(REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE\); it was dropped\.$/, + ); +}); + +test("channel frames still queued are dropped on a backend without channels", async () => { + const app = await setup(); + let socket = connectTransport(app); + const channel = app.getChannel("probe"); + const errors = []; + channel.on("error", (error) => errors.push(error.code)); + socket.receive(["_opened", null, "probe"]); + // The socket is closing, but the browser has not reported the close yet, so + // these frames queue on the transport. + socket.readyState = socket.constructor.CLOSING; + app.transport.emit("event", { name: "state.event" }); + channel.emit("push", { n: 1 }); + channel.emit("frame", { n: 2 }, [new Uint8Array([1, 2, 3])]); + socket.readyState = socket.constructor.CLOSED; + socket.onclose({ code: 1006, reason: "" }); + + // The reconnect lands on a backend that closes the socket over either. + socket = connectTransport(app, { protocol: 1 }); + assert.deepEqual(socket.sent.slice(1), [["event", { name: "state.event" }]]); + assert.equal(app.transport._sendQueue.length, 0); + assert.deepEqual(errors, ["channels_unsupported"]); +}); + +test("an _opened from a transport the channel has left changes nothing", async () => { + const app = await setup(); + const old = connectTransport(app); + const channel = app.getChannel("moved"); + let connects = 0; + channel.on("connect", () => connects++); + old.close(); + channel.emit("push", { n: 1 }); + // A straggler answering the open the closed socket carried. + old.receive(["_opened", null, "moved"]); + assert.equal(channel.connected, false); + assert.equal(connects, 0); + + // The queued message is owed to the transport the channel attaches to next. + const socket = connectTransport(app); + socket.receive(["_opened", null, "moved"]); + assert.equal(connects, 1); + assert.deepEqual(socket.sent.slice(1), [ + ["_open", null, "moved"], + ["push", { n: 1 }, "moved"], + ]); +}); diff --git a/tests/units/reflex_base/client_event_queue_runtime.mjs b/tests/units/reflex_base/client_event_queue_runtime.mjs index 29ec706c6a0..70c50617cbf 100644 --- a/tests/units/reflex_base/client_event_queue_runtime.mjs +++ b/tests/units/reflex_base/client_event_queue_runtime.mjs @@ -50,6 +50,10 @@ export async function createQueueRuntime( eventLoop: { addEvents: unused }, }, "$/utils/helpers/debounce": { default: unused }, + "$/utils/helpers/json": { + parseJsonLenient: unused, + undefinedToNull: unused, + }, "$/utils/helpers/throttle": { default: throttle ?? unused }, "$/utils/helpers/upload": { uploadFiles: uploadFiles ?? unused, @@ -58,8 +62,6 @@ export async function createQueueRuntime( ReflexWebSocket: unused, disableChannels: unused, getChannel: unused, - parseJsonLenient: unused, - undefinedToNull: unused, }, ...dependencyOverrides, }; diff --git a/tests/units/reflex_base/client_state_updates.mjs b/tests/units/reflex_base/client_state_updates.mjs index 3310f4c5fbf..a9e42318c55 100644 --- a/tests/units/reflex_base/client_state_updates.mjs +++ b/tests/units/reflex_base/client_state_updates.mjs @@ -95,8 +95,6 @@ async function createConnection( }, disableChannels() {}, getChannel() {}, - parseJsonLenient() {}, - undefinedToNull() {}, }, "socket.io-client": { default: () => { From b4e65d1ae0043c8caab9372e8913ab01f51da42e Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 17 Oct 2026 17:14:15 +0200 Subject: [PATCH 61/62] Dedupe the benchmark tooling The benchmark socket client detects a backend's protocol once instead of per connection, so Socket.IO reconnect timings no longer include a failed WebSocket upgrade per client, and codecs carry their bare connect and ping frames instead of callers branching on the protocol. reflex-bench shares its process spawn, stop and report handling, its worker wrapper and small session helpers, and drift tests pin the protocol constants it copies from Reflex. The bench tests share their fixtures through factories.py. --- .../src/reflex_bench/drivers/echo_server.py | 55 ++- .../src/reflex_bench/drivers/events.py | 330 ++++++++++++------ .../src/reflex_bench/suites/wire.py | 29 +- tests/benchmarks/support/idle_clients.py | 7 +- tests/benchmarks/support/socket_client.py | 100 +++--- .../reflex_bench/drivers/test_echo_server.py | 97 ++++- .../units/reflex_bench/drivers/test_events.py | 102 +++--- tests/units/reflex_bench/factories.py | 57 ++- .../units/reflex_bench/suites/test_events.py | 10 +- .../units/reflex_bench/suites/test_memory.py | 2 +- tests/units/reflex_bench/suites/test_wire.py | 84 +++-- 11 files changed, 529 insertions(+), 344 deletions(-) diff --git a/packages/reflex-bench/src/reflex_bench/drivers/echo_server.py b/packages/reflex-bench/src/reflex_bench/drivers/echo_server.py index d0ef159b44b..522e7691608 100644 --- a/packages/reflex-bench/src/reflex_bench/drivers/echo_server.py +++ b/packages/reflex-bench/src/reflex_bench/drivers/echo_server.py @@ -15,7 +15,6 @@ import asyncio import contextlib import json -import multiprocessing import os import urllib.parse import uuid @@ -39,16 +38,15 @@ HYDRATE_AND_LOAD_EVENT, HYDRATE_EVENT, HYDRATED_VAR, - KILL_GRACE_S, MAX_FRAME_BYTES, ON_LOAD_EVENT, - PING, ROOT_STATE, - SIO_CONNECT_FRAME, SIO_OPEN_PREFIX, - SIO_PING, WireProtocol, event_loop, + spawn_process, + stop_processes, + wait_readable, ) # The plain protocol's version, announced in its handshake. @@ -171,7 +169,7 @@ async def _ping(self, ws: ServerConnection) -> None: Args: ws: The connection. """ - ping = SIO_PING if self.codec.protocol == "socketio" else self.codec.emit(PING) + ping = self.codec.ping with contextlib.suppress(ConnectionClosed): while True: await asyncio.sleep(self.ping_interval_s) @@ -180,15 +178,19 @@ async def _ping(self, ws: ServerConnection) -> None: async def on_connect(self, ws: ServerConnection, sid: str) -> None: """Acknowledge the connect. + A protocol that greets announced the heartbeat in its greeting, so its + ack carries the session id only, as python-socketio's does. + Args: ws: The connection. sid: The session id. """ - if self.codec.protocol == "socketio": - await ws.send(SIO_CONNECT_FRAME + json.dumps({"sid": sid})) + codec = self.codec + if codec.greets: + await ws.send(codec.join + json.dumps({"sid": sid})) return await ws.send( - self.codec.emit( + codec.emit( HANDSHAKE, { "ping_interval": self.ping_interval_s, @@ -229,15 +231,15 @@ def reply(self, event: dict[str, Any]) -> str: def _serve_process( - conn: Connection, delta_key: str, seq_var: str, cpus: Sequence[int] | None + delta_key: str, seq_var: str, cpus: Sequence[int] | None, conn: Connection ) -> None: """Run an echo server in this process until the parent closes the pipe or terminates it. Args: - conn: Receives the bound port; closing its other end stops the server. delta_key: The state whose delta echoes the sequence number. seq_var: The var that echoes it. cpus: The CPUs to run on (Linux), or ``None``. + conn: Receives the bound port; closing its other end stops the server. """ if cpus is not None and hasattr(os, "sched_setaffinity"): os.sched_setaffinity(0, cpus) @@ -248,9 +250,7 @@ async def main() -> None: conn.send(port) # The parent holds the pipe open while it needs the server, and # its end closes however the parent exits. - parent_gone = asyncio.Event() - asyncio.get_running_loop().add_reader(conn.fileno(), parent_gone.set) - await parent_gone.wait() + await wait_readable(conn) event_loop().run_until_complete(main()) @@ -284,17 +284,10 @@ def start(self, timeout: float = 30.0) -> str: Raises: RuntimeError: When it does not listen in time. """ - context = multiprocessing.get_context("spawn") - conn, child = context.Pipe() - proc = self._proc = context.Process( - target=_serve_process, - args=(child, *self._args), - name="reflex-bench-echo", - daemon=True, + proc, conn = spawn_process( + _serve_process, *self._args, name="reflex-bench-echo" ) - proc.start() - child.close() - self._conn = conn + self._proc, self._conn = proc, conn try: port = conn.recv() if conn.poll(timeout) else None except (EOFError, OSError): @@ -322,13 +315,7 @@ def pid(self) -> int: def stop(self) -> None: """Stop the server; safe to call again.""" - proc, conn = self._proc, self._conn - if conn is not None: - conn.close() - if proc is None: - return - proc.terminate() - proc.join(KILL_GRACE_S) - if proc.is_alive(): - proc.kill() - proc.join(KILL_GRACE_S) + if self._conn is not None: + self._conn.close() + if self._proc is not None: + stop_processes([self._proc]) diff --git a/packages/reflex-bench/src/reflex_bench/drivers/events.py b/packages/reflex-bench/src/reflex_bench/drivers/events.py index 400ea6c13c4..58017bd3522 100644 --- a/packages/reflex-bench/src/reflex_bench/drivers/events.py +++ b/packages/reflex-bench/src/reflex_bench/drivers/events.py @@ -256,6 +256,10 @@ class Codec(ABC): greets: ClassVar[bool] # The hydration events to send once the server acknowledged the connect. load_events: ClassVar[tuple[str, ...]] + # The frame that opens a session without hydrating it. + join: ClassVar[str] + # The server's keepalive. + ping: ClassVar[str] # The answer to a ping. pong: ClassVar[str] # The frame that leaves the session before the websocket closes, if any. @@ -344,6 +348,8 @@ class WebsocketCodec(Codec): protocol = "websocket" greets = False load_events = () + join = json.dumps([CONNECT, {}], separators=_COMPACT) + ping = json.dumps([PING]) pong = json.dumps([PONG]) leave = None @@ -407,6 +413,8 @@ class SocketIOCodec(Codec): protocol = "socketio" greets = True load_events = (HYDRATE_EVENT, ON_LOAD_EVENT) + join = SIO_CONNECT_FRAME + ping = SIO_PING pong = SIO_PONG leave = SIO_DISCONNECT_FRAME @@ -475,7 +483,7 @@ def connect_frame( Returns: ``40/_event,``. """ - return SIO_CONNECT_FRAME + return self.join CODECS: dict[WireProtocol, Codec] = { @@ -483,6 +491,49 @@ def connect_frame( } +def end_reason(args: list[Any], frame: str | bytes) -> str | None: + """Tell why a message ends the session, if it does. + + Args: + args: The message. + frame: Its raw frame. + + Returns: + Why the server refused or disconnected the session, or ``None``. + """ + name = args[0] + if name == REFUSED: + return f"the server refused {NAMESPACE}: {args[1]}" + if name == DISCONNECTED: + return f"the server disconnected the session ({frame!r})" + return None + + +async def leave(ws: ClientConnection, codec: Codec) -> None: + """Leave a session before its websocket closes, on a protocol that has a leave frame. + + Args: + ws: The websocket. + codec: Its protocol. + """ + if codec.leave is not None: + with contextlib.suppress(ConnectionClosed): + await ws.send(codec.leave) + + +def is_hydrated(delta: Mapping[str, Any]) -> bool: + """Tell whether a delta completes a hydration. + + Args: + delta: The delta. + + Returns: + Whether it sets the root state's ``is_hydrated``. + """ + root = delta.get(ROOT_STATE) + return root is not None and root.get(HYDRATED_VAR) is True + + async def detect_protocol(backend_url: str) -> WireProtocol: """Find the protocol of a backend's event websocket. @@ -874,7 +925,7 @@ def event(self, seq: int) -> str: The frame. """ payload = json.dumps( - {**self._payload(seq), **self._client}, separators=(",", ":") + {**self._payload(seq), **self._client}, separators=_COMPACT ) return f"{self._head}{payload}{self._tail}" @@ -1007,11 +1058,10 @@ async def _open(self, codec: Codec) -> None: break if name == PING: await ws.send(codec.pong) - elif name == REFUSED: - msg = f"the server refused {NAMESPACE}: {args[1]}" - raise ProtocolError(msg) elif name in CONTROL: - msg = f"unexpected frame in the handshake: {message[:40]!r}" + msg = end_reason(args, message) or ( + f"unexpected frame in the handshake: {message[:40]!r}" + ) raise ProtocolError(msg) else: # Events of the connect come first, e.g. a new token. @@ -1047,14 +1097,17 @@ async def _read(self, ws: ClientConnection, codec: Codec) -> None: async for message in ws: now = time.perf_counter_ns() args = parse(message) - name = None if args is None else args[0] + if args is None: + self._fail(f"unexpected frame: {message[:40]!r}") + return + name = args[0] if name == PING: await ws.send(codec.pong) - elif name == DISCONNECTED: - self._fail(f"the server disconnected the session ({message!r})") - return - elif args is None or name in CONTROL: - self._fail(f"unexpected frame: {message[:40]!r}") + elif name in CONTROL: + self._fail( + end_reason(args, message) + or f"unexpected frame: {message[:40]!r}" + ) return else: self._on_emit(args, message, now) @@ -1103,11 +1156,7 @@ def _on_emit(self, args: list[Any], frame: str | bytes, now: int) -> None: and self.link.delta_key in delta ): self._linked.set() - if ( - not self._hydrated.is_set() - and (root := delta.get(ROOT_STATE)) - and root.get(HYDRATED_VAR) is True - ): + if not self._hydrated.is_set() and is_hydrated(delta): self._hydrated.set() elif name == "new_token": # The token was in use by another session; reflex hands out a new one. @@ -1265,9 +1314,8 @@ async def close(self) -> None: ws, codec = self.ws, self.codec if ws is not None: assert codec is not None - if self.error is None and codec.leave is not None: - with contextlib.suppress(ConnectionClosed): - await ws.send(codec.leave) + if self.error is None: + await leave(ws, codec) await ws.close() if self._reader is not None: self._reader.cancel() @@ -1696,6 +1744,120 @@ async def wait_readable(conn: Connection) -> None: loop.remove_reader(fd) +def spawn_process( + target: Callable[..., None], *args: Any, name: str +) -> tuple[BaseProcess, Connection]: + """Start a daemon process with a pipe to it. + + It is spawned, not forked: the harness runs hooks on threads. + + Args: + target: The process's function; the child's end of the pipe is its + last argument. + *args: Its other arguments. + name: The process's name. + + Returns: + The started process and the parent's end of the pipe. + """ + context = multiprocessing.get_context("spawn") + conn, child = context.Pipe() + proc = context.Process(target=target, args=(*args, child), name=name, daemon=True) + proc.start() + # Only the process holds the child's end now, so its exit closes the pipe. + child.close() + return proc, conn + + +def stop_processes(procs: Sequence[BaseProcess]) -> None: + """Terminate started processes together, killing each one that does not exit in time. + + Args: + procs: The processes. + """ + for proc in procs: + if proc.is_alive(): + proc.terminate() + for proc in procs: + proc.join(KILL_GRACE_S) + if proc.is_alive(): + proc.kill() + proc.join(KILL_GRACE_S) + + +def _ready_timeout(sessions: int) -> float: + """Bound the wait for a process to prime its sessions. + + Args: + sessions: The process's sessions. + + Returns: + Seconds: a margin plus the prime timeout per batch of + ``PRIME_CONCURRENCY`` sessions. + """ + return READY_MARGIN_S + -(-sessions // PRIME_CONCURRENCY) * PRIME_TIMEOUT_S + + +def _check_started(errors: Sequence[str], sessions: int) -> None: + """Fail when sessions could not start. + + Args: + errors: Why sessions failed to prime. + sessions: How many were to start. + + Raises: + LoadError: With how many failed and the first reason. + """ + if errors: + msg = f"{len(errors)} of {sessions} sessions could not start; {errors[0]}" + raise LoadError(msg) + + +def _report(conn: Connection, proc: BaseProcess, who: str) -> Any: + """Receive a process's next report. + + Args: + conn: The pipe to it, readable or closed. + proc: The process. + who: The process as messages name it, e.g. ``generator process 0``. + + Returns: + The report's value. + + Raises: + LoadError: When the process exited or reports a failure. + """ + try: + kind, value = conn.recv() + except (EOFError, OSError): + msg = f"{who} exited with code {proc.exitcode}" + raise LoadError(msg) from None + if kind == "error": + msg = f"{who} failed:\n{value}" + raise LoadError(msg) + return value + + +def _serve( + conn: Connection, sessions: int, main: Callable[[], Awaitable[None]] +) -> None: + """Run a spawned process's work on a fresh loop, reporting a failure to the parent. + + Args: + conn: The pipe to the parent. + sessions: The sessions the work opens. + main: Starts the work, which reports its results itself. + """ + # The parent decides when to stop, also on Ctrl-C. + signal.signal(signal.SIGINT, signal.SIG_IGN) + try: + raise_fd_limit(sessions) + event_loop().run_until_complete(main()) + except BaseException: + with contextlib.suppress(OSError): + conn.send(("error", traceback.format_exc())) + + async def _handshake(conn: Connection, errors: list[str]) -> int | None: """Report the primed sessions to the parent and wait for the start. @@ -1728,28 +1890,29 @@ def abort() -> None: return value +async def _load(plan: LoadPlan, indices: Sequence[int], conn: Connection) -> None: + """Run a generator process's sessions on its CPUs and send the parent what they measured. + + Args: + plan: The load plan. + indices: The sessions of this process. + conn: The pipe to the parent. + """ + if plan.cpus is not None and hasattr(os, "sched_setaffinity"): + os.sched_setaffinity(0, plan.cpus) + shard = await _run_sessions(plan, indices, lambda errors: _handshake(conn, errors)) + conn.send(("result", shard)) + + def _worker(plan: LoadPlan, indices: Sequence[int], conn: Connection) -> None: - """Run one generator process: its sessions, on its CPUs, reporting over a pipe. + """Run one generator process, reporting over a pipe. Args: plan: The load plan. indices: The sessions of this process. conn: The pipe to the parent. """ - # The parent decides when to stop, also on Ctrl-C. - signal.signal(signal.SIGINT, signal.SIG_IGN) - try: - if plan.cpus is not None and hasattr(os, "sched_setaffinity"): - os.sched_setaffinity(0, plan.cpus) - raise_fd_limit(len(indices)) - loop = event_loop() - shard = loop.run_until_complete( - _run_sessions(plan, indices, lambda errors: _handshake(conn, errors)) - ) - conn.send(("result", shard)) - except BaseException: - with contextlib.suppress(OSError): - conn.send(("error", traceback.format_exc())) + _serve(conn, len(indices), lambda: _load(plan, indices, conn)) class LoadRunner: @@ -1775,8 +1938,6 @@ def __init__(self, plan: LoadPlan) -> None: def run(self, on_window: Callable[[str], None] | None = None) -> LoadResult: """Start the processes, prime every session, run the schedule and merge the results. - Processes are spawned, not forked: the harness runs hooks on threads. - Args: on_window: Called with ``"start"`` and ``"end"`` on this thread when the measured window starts and ends, e.g. to read the server's @@ -1790,30 +1951,25 @@ def run(self, on_window: Callable[[str], None] | None = None) -> LoadResult: out, or the runner was stopped. """ plan = self.plan - context = multiprocessing.get_context("spawn") try: with self._lock: if self._stopped: msg = "the load was stopped before it started" raise LoadError(msg) for index in range(plan.processes): - conn, child = context.Pipe() - self._conns.append(conn) - proc = context.Process( - target=_worker, - args=(plan, range(index, plan.sessions, plan.processes), child), + proc, conn = spawn_process( + _worker, + plan, + range(index, plan.sessions, plan.processes), name=f"reflex-bench-load-{index}", - daemon=True, ) - proc.start() - child.close() self._procs.append(proc) + self._conns.append(conn) per_process = -(-plan.sessions // plan.processes) - batches = -(-per_process // PRIME_CONCURRENCY) - ready = self._collect("ready", READY_MARGIN_S + batches * PRIME_TIMEOUT_S) - if errors := [error for errors in ready for error in errors]: - msg = f"{len(errors)} of {plan.sessions} sessions could not start; {errors[0]}" - raise LoadError(msg) + ready = self._collect("ready", _ready_timeout(per_process)) + _check_started( + [error for errors in ready for error in errors], plan.sessions + ) t0 = time.perf_counter_ns() + int(START_DELAY_S * 1e9) for conn in self._conns: conn.send(("go", t0)) @@ -1885,18 +2041,9 @@ def _collect(self, kind: str, timeout: float) -> list[Any]: msg = f"generator processes sent no {kind} within {timeout:g} s" raise LoadError(msg) for index, conn in pending: - if conn not in ready and self._procs[index].sentinel not in ready: - continue - try: - got, value = conn.recv() - except (EOFError, OSError): - code = self._procs[index].exitcode - msg = f"generator process {index} exited with code {code}" - raise LoadError(msg) from None - if got == "error": - msg = f"generator process {index} failed:\n{value}" - raise LoadError(msg) - values[index] = value + proc = self._procs[index] + if conn in ready or proc.sentinel in ready: + values[index] = _report(conn, proc, f"generator process {index}") return [values[index] for index in range(len(self._conns))] def stop(self) -> None: @@ -1906,14 +2053,7 @@ def stop(self) -> None: """ with self._lock: self._stopped = True - for proc in self._procs: - if proc.is_alive(): - proc.terminate() - for proc in self._procs: - proc.join(KILL_GRACE_S) - if proc.is_alive(): - proc.kill() - proc.join(KILL_GRACE_S) + stop_processes(self._procs) def run_load( @@ -1955,14 +2095,7 @@ def _hold_worker(endpoint: Endpoint, count: int, conn: Connection) -> None: count: How many. conn: The pipe to the parent. """ - # The parent decides when to stop, also on Ctrl-C. - signal.signal(signal.SIGINT, signal.SIG_IGN) - try: - raise_fd_limit(count) - event_loop().run_until_complete(_hold(endpoint, count, conn)) - except BaseException: - with contextlib.suppress(OSError): - conn.send(("error", traceback.format_exc())) + _serve(conn, count, lambda: _hold(endpoint, count, conn)) class SessionHold: @@ -1998,21 +2131,13 @@ def start(self) -> SessionHold: Raises: LoadError: When the hold was killed before it started. """ - context = multiprocessing.get_context("spawn") with self._lock: if self._stopped: msg = "the hold was stopped before it started" raise LoadError(msg) - conn, child = context.Pipe() - self._conn = conn - proc = self._proc = context.Process( - target=_hold_worker, - args=(self.endpoint, self.count, child), - name="reflex-bench-hold", - daemon=True, + self._proc, self._conn = spawn_process( + _hold_worker, self.endpoint, self.count, name="reflex-bench-hold" ) - proc.start() - child.close() return self def ready(self) -> None: @@ -2022,11 +2147,8 @@ def ready(self) -> None: LoadError: When sessions could not start, with how many, or the process failed, timed out or was killed. """ - batches = -(-self.count // PRIME_CONCURRENCY) - errors = self._receive("ready", READY_MARGIN_S + batches * PRIME_TIMEOUT_S) - if errors: - msg = f"{len(errors)} of {self.count} sessions could not start; {errors[0]}" - raise LoadError(msg) + errors = self._receive("ready", _ready_timeout(self.count)) + _check_started(errors, self.count) def close(self) -> list[str]: """Leave every session and close its websocket, then end the process. @@ -2074,15 +2196,7 @@ def _receive(self, kind: str, timeout: float) -> list[str]: if not ready: msg = f"the hold sent no {kind} report within {timeout:g} s" raise LoadError(msg) - try: - got, value = conn.recv() - except (EOFError, OSError): - msg = f"the hold's process exited with code {proc.exitcode}" - raise LoadError(msg) from None - if got == "error": - msg = f"the hold's process failed:\n{value}" - raise LoadError(msg) - return value + return _report(conn, proc, "the hold's process") def kill(self) -> None: """Terminate the process, killing it if it does not exit. @@ -2092,14 +2206,8 @@ def kill(self) -> None: with self._lock: self._stopped = True proc = self._proc - if proc is None: - return - if proc.is_alive(): - proc.terminate() - proc.join(KILL_GRACE_S) - if proc.is_alive(): - proc.kill() - proc.join(KILL_GRACE_S) + if proc is not None: + stop_processes([proc]) def hold_sessions(endpoint: Endpoint, count: int) -> SessionHold: diff --git a/packages/reflex-bench/src/reflex_bench/suites/wire.py b/packages/reflex-bench/src/reflex_bench/suites/wire.py index b90b408c7ed..077edb0fe74 100644 --- a/packages/reflex-bench/src/reflex_bench/suites/wire.py +++ b/packages/reflex-bench/src/reflex_bench/suites/wire.py @@ -41,7 +41,6 @@ from __future__ import annotations import asyncio -import contextlib import dataclasses import hashlib import json @@ -53,27 +52,25 @@ from typing import Any, Protocol, TypeVar from websockets.asyncio.client import connect -from websockets.exceptions import ConnectionClosed from reflex_bench.context import Context from reflex_bench.drivers.app_process import AppProcess, run_cli from reflex_bench.drivers.events import ( CODECS, - DISCONNECTED, EVENT, HANDSHAKE, - HYDRATED_VAR, MAX_FRAME_BYTES, ON_LOAD_EVENT, OPENED, PING, - REFUSED, - ROOT_STATE, Codec, Endpoint, EventShape, ProtocolError, + end_reason, event_loop, + is_hydrated, + leave, resolve_codec, seq_payload, ) @@ -268,12 +265,8 @@ async def _receive(self) -> tuple[str | bytes, int, list[Any]]: elif name == "reload": msg = "the server asked the page to reload" raise ProtocolError(msg) - elif name == DISCONNECTED: - msg = f"the server disconnected the session ({message!r})" - raise ProtocolError(msg) - elif name == REFUSED: - msg = f"the server refused the namespace: {args[1]}" - raise ProtocolError(msg) + elif (reason := end_reason(args, message)) is not None: + raise ProtocolError(reason) return message, size, args @staticmethod @@ -329,8 +322,7 @@ async def hydrate(self) -> Hydration: for name in codec.load_events: await self._send(self._event(name, {})) continue - delta = self._delta(args, sizes) - if delta.get(ROOT_STATE, {}).get(HYDRATED_VAR) is True: + if is_hydrated(self._delta(args, sizes)): break return Hydration( sent_bytes=self.sent_bytes - sent0, @@ -391,10 +383,7 @@ async def navigate(self, pathname: str, query: dict[str, str]) -> Exchange: self.pathname = pathname self.query = query request = await self._send(self._event(ON_LOAD_EVENT, {})) - return await self._reply( - request, - lambda delta: delta.get(ROOT_STATE, {}).get(HYDRATED_VAR) is True, - ) + return await self._reply(request, is_hydrated) async def _reply( self, request: int, done: Callable[[dict[str, Any]], bool] @@ -463,9 +452,7 @@ async def run() -> _T: msg = f"the session did not finish within {WIRE_TIMEOUT_S:g} s" raise TimeoutError(msg) from None finally: - if codec.leave is not None: - with contextlib.suppress(ConnectionClosed): - await ws.send(codec.leave) + await leave(ws, codec) loop = event_loop() try: diff --git a/tests/benchmarks/support/idle_clients.py b/tests/benchmarks/support/idle_clients.py index de97c6ab3fb..8d1cf755ef6 100644 --- a/tests/benchmarks/support/idle_clients.py +++ b/tests/benchmarks/support/idle_clients.py @@ -17,7 +17,7 @@ from reflex_base.environment import environment from websockets.exceptions import ConnectionClosed -from tests.benchmarks.support.socket_client import EventClient, _connect +from tests.benchmarks.support.socket_client import EventClient, _codec, _connect def _keepalive_interval() -> float: @@ -44,7 +44,7 @@ def _keep_alive(clients: list[EventClient], stop: threading.Event) -> None: for client in clients: # A session the server ended stays ended; keep the others alive. with contextlib.suppress(ConnectionClosed, ConnectionError): - client.poll() + client.drain(0) def main() -> int: @@ -57,8 +57,9 @@ def main() -> int: payload = json.loads(payload_json) clients: list[EventClient] = [] try: + codec = _codec(url) for index in range(int(count)): - client = _connect(url, f"{token_prefix}-{index}", timeout=10) + client = _connect(url, f"{token_prefix}-{index}", codec, timeout=10) clients.append(client) client.emit("event", payload) client.receive(10, "event") diff --git a/tests/benchmarks/support/socket_client.py b/tests/benchmarks/support/socket_client.py index 66fb9b8e94e..458c180c342 100644 --- a/tests/benchmarks/support/socket_client.py +++ b/tests/benchmarks/support/socket_client.py @@ -3,7 +3,7 @@ It speaks the plain WebSocket protocol of Reflex's default transport, and the Socket.IO packets of a backend that refuses that endpoint (``transport="socketio"`` apps and the python-socketio baseline server), so both sides of a comparison -pay the same client cost. +pay the same client cost. The protocol is detected once per backend. """ from __future__ import annotations @@ -14,27 +14,29 @@ import dataclasses import functools import json +import threading import time from collections.abc import Callable, Mapping from typing import Any from reflex_bench.drivers.events import ( CODECS, - CONNECT, - DISCONNECTED, HANDSHAKE, PING, - REFUSED, - SIO_CONNECT_FRAME, Codec, + detect_protocol, + end_reason, ) -from websockets.exceptions import ConnectionClosed, InvalidStatus +from websockets.exceptions import ConnectionClosed from websockets.sync.client import ClientConnection, connect # Silence window that marks the connection quiescent while draining the extra # deltas the first event on a fresh token emits (hydrate + on_load_internal). _PRIME_DRAIN_TIMEOUT = 0.5 +_codecs: dict[str, Codec] = {} +_codecs_lock = threading.Lock() + @dataclasses.dataclass(frozen=True) class ClientLoadResult: @@ -101,17 +103,20 @@ def receive(self, timeout: float, name: str | None = None) -> list[Any]: continue if args[0] == PING: self._ws.send(codec.pong) - elif args[0] in {REFUSED, DISCONNECTED}: - msg = f"the server ended the session: {message!r}" - raise ConnectionError(msg) + elif (reason := end_reason(args, message)) is not None: + raise ConnectionError(reason) elif name is None or args[0] == name: return args - def poll(self) -> None: - """Handle the frames that already arrived, answering pings.""" + def drain(self, quiet_s: float) -> None: + """Consume frames until the socket is quiet, answering pings. + + Args: + quiet_s: Seconds of silence that end the drain. + """ with contextlib.suppress(TimeoutError): while True: - self.receive(0) + self.receive(quiet_s) def close(self) -> None: """Leave the session and close the websocket.""" @@ -121,56 +126,47 @@ def close(self) -> None: self._ws.close() -def _open(url: str, token: str, codec: Codec, timeout: float) -> ClientConnection: - """Open the websocket of a session. +def _codec(url: str) -> Codec: + """Find the protocol of a backend's event websocket, detecting it once per backend. Args: url: Backend URL. - token: Reflex client token. - codec: The protocol. - timeout: Maximum connection wait. Returns: - The websocket. + Its codec. """ - return connect( - codec.url(url, token), - additional_headers={"Origin": url}, - compression=None, - proxy=None, - open_timeout=timeout, - ping_interval=None, - close_timeout=1, - max_size=None, - ) + # Concurrent clients wait for one detection instead of each running one. + with _codecs_lock: + if (codec := _codecs.get(url)) is None: + codec = _codecs[url] = CODECS[asyncio.run(detect_protocol(url))] + return codec -def _connect(url: str, token: str, timeout: float) -> EventClient: +def _connect(url: str, token: str, codec: Codec, timeout: float) -> EventClient: """Connect a client the way the Reflex frontend does, without a boot event. - A backend that refuses the plain protocol's endpoint gets Socket.IO. - Args: url: Backend URL. token: Reflex client token. + codec: The backend's protocol. timeout: Maximum wait for the connection and its handshake. Returns: The connected client. """ - codec = CODECS["websocket"] - try: - ws = _open(url, token, codec, timeout) - except InvalidStatus: - codec = CODECS["socketio"] - ws = _open(url, token, codec, timeout) + ws = connect( + codec.url(url, token), + additional_headers={"Origin": url}, + compression=None, + proxy=None, + open_timeout=timeout, + ping_interval=None, + close_timeout=1, + max_size=None, + ) client = EventClient(ws, codec) try: - ws.send( - SIO_CONNECT_FRAME - if codec.protocol == "socketio" - else codec.emit(CONNECT, {}) - ) + ws.send(codec.join) client.receive(timeout, HANDSHAKE) except BaseException: ws.close() @@ -191,18 +187,6 @@ def _payload_size(response: Any) -> int: return len(json.dumps(payload, separators=(",", ":"), default=str)) -def _drain(client: EventClient, timeout: float) -> None: - """Consume buffered responses until the socket is quiet for ``timeout``. - - Args: - client: Event websocket client. - timeout: Silence window that marks the connection quiescent. - """ - with contextlib.suppress(TimeoutError): - while True: - client.receive(timeout) - - def _prime( client: EventClient, event_name: str, @@ -224,7 +208,7 @@ def _prime( timeout: Maximum wait bounding the drain silence window. """ client.emit(event_name, dict(payload)) - _drain(client, min(timeout, _PRIME_DRAIN_TIMEOUT)) + client.drain(min(timeout, _PRIME_DRAIN_TIMEOUT)) async def run_socket_client( @@ -298,7 +282,7 @@ def _run_socket_client_sync( errors: list[str] = [] try: - client = _connect(url, token, timeout) + client = _connect(url, token, _codec(url), timeout) _prime(client, event_name, payload, timeout) for _ in range(events): started = time.perf_counter_ns() @@ -387,8 +371,10 @@ def _run_reconnect_client_sync( errors: list[str] = [] try: + # Outside the clock: the first client of a backend runs the detection. + codec = _codec(url) started = time.perf_counter_ns() - client = _connect(url, token, timeout) + client = _connect(url, token, codec, timeout) connect_ms = (time.perf_counter_ns() - started) / 1_000_000 started = time.perf_counter_ns() client.emit(event_name, dict(payload)) diff --git a/tests/units/reflex_bench/drivers/test_echo_server.py b/tests/units/reflex_bench/drivers/test_echo_server.py index 239e35780cd..8e1f92b4b46 100644 --- a/tests/units/reflex_bench/drivers/test_echo_server.py +++ b/tests/units/reflex_bench/drivers/test_echo_server.py @@ -1,19 +1,108 @@ """Tests for reflex_bench.drivers.echo_server. The echo server's answers are exercised by the generator's tests; these check -the process that the calibration runs it in. +its handshake against reflex's and the process that the calibration runs it in. """ from __future__ import annotations +import asyncio +import json +from typing import Any, cast +from unittest.mock import AsyncMock, Mock + import psutil import pytest -from reflex_bench.drivers import events -from reflex_bench.drivers.echo_server import EchoProcess +from reflex_bench.drivers import echo_server, events +from reflex_bench.drivers.echo_server import EchoProcess, EchoServer from reflex_bench.drivers.events import Endpoint, EventShape, LoadPlan, run_load +from reflex_bench.suites.events import SEQ_VAR +from starlette.websockets import WebSocket +from websockets.asyncio.server import ServerConnection + +from reflex import event_namespace +from reflex.utils import prerequisites +from tests.units.reflex_bench.factories import WEBSOCKET STATE = "reflex___state____state.playground___state____bench_state" -SEQ_VAR = "last_seq_rx_state_" + + +class _Socket: + """Just enough of a starlette websocket for reflex to open a plain session.""" + + def __init__(self) -> None: + """Queue a connect frame without a boot event, then the disconnect.""" + self.scope = {"type": "websocket", "query_string": b"token=tok", "headers": []} + self.headers: dict[str, str] = {} + self.sent: list[Any] = [] + self._received = [ + {"type": "websocket.receive", "text": WEBSOCKET.join}, + {"type": "websocket.disconnect", "code": 1000}, + ] + + async def accept(self, subprotocol: str | None = None) -> None: + """Accept the connection. + + Args: + subprotocol: The chosen subprotocol. + """ + + async def send_text(self, text: str) -> None: + """Record a frame. + + Args: + text: The frame. + """ + self.sent.append(json.loads(text)) + + async def receive(self) -> dict[str, Any]: + """Take the next queued message once the writer ran, as the network lets it. + + Returns: + The ASGI message. + """ + await asyncio.sleep(0) + return self._received.pop(0) + + async def close(self, code: int = 1000) -> None: + """Close the connection. + + Args: + code: The close code. + """ + + +class _Sent(list[str]): + """A server connection that records what is sent on it.""" + + async def send(self, frame: str) -> None: + """Record a frame. + + Args: + frame: The frame. + """ + self.append(frame) + + +def test_the_handshake_is_copied_from_reflex(monkeypatch: pytest.MonkeyPatch): + # reflex_bench drives releases without reflex.event_namespace, so it keeps a copy. + monkeypatch.setattr(prerequisites, "check_redis_used", lambda: False) + app = Mock(_state=None, _channels={}, router=Mock(return_value=None)) + app.event_processor.enqueue = AsyncMock() + namespace = event_namespace.WebsocketEventNamespace(events.NAMESPACE, app) + socket = _Socket() + asyncio.run(namespace.handle_websocket(cast(WebSocket, socket))) + reflex_name, reflex_settings = socket.sent[0] + sent = _Sent() + server = EchoServer(delta_key=STATE, seq_var=SEQ_VAR) + asyncio.run(server.on_connect(cast(ServerConnection, sent), "sid")) + (frame,) = sent + message = WEBSOCKET.parse(frame) + assert message is not None + name, settings = message + assert name == reflex_name == events.HANDSHAKE + assert set(settings) == set(reflex_settings) + assert echo_server.PROTOCOL_VERSION == event_namespace.PROTOCOL_VERSION def test_echo_process_answers_a_load_and_stops(): diff --git a/tests/units/reflex_bench/drivers/test_events.py b/tests/units/reflex_bench/drivers/test_events.py index 41fc86a2b69..af0de404226 100644 --- a/tests/units/reflex_bench/drivers/test_events.py +++ b/tests/units/reflex_bench/drivers/test_events.py @@ -11,16 +11,16 @@ from __future__ import annotations import asyncio -import contextlib import dataclasses import json import multiprocessing import threading import time -from collections.abc import AsyncIterator, Awaitable, Callable, Iterator +from collections.abc import AsyncIterator, Awaitable, Callable from typing import Any, cast import pytest +from reflex_base.constants import CompileVars, SocketEvent from reflex_bench.drivers import events from reflex_bench.drivers.app_process import free_ports from reflex_bench.drivers.echo_server import EchoServer @@ -38,21 +38,25 @@ open_sessions, run_load, ) +from reflex_bench.suites.events import CLIENT_VAR, SEQ_VAR from websockets.asyncio.server import ServerConnection -from tests.units.reflex_bench.factories import make_load_result +from reflex import event_namespace +from tests.units.reflex_bench.factories import ( + PROTOCOLS, + SOCKETIO, + WEBSOCKET, + make_load_result, + serving, +) STATE = "reflex___state____state.playground___state____bench_state" -SEQ_VAR = "last_seq_rx_state_" SHAPE = EventShape( name=f"{STATE}.set_seq", payload=events.seq_payload, delta_key=STATE, seq_var=SEQ_VAR, ) -WEBSOCKET = CODECS["websocket"] -SOCKETIO = CODECS["socketio"] -PROTOCOLS = pytest.mark.parametrize("protocol", ["websocket", "socketio"]) # The hydration events of a session, by protocol. HYDRATION = { "websocket": [events.HYDRATE_AND_LOAD_EVENT], @@ -69,39 +73,6 @@ ) -@contextlib.contextmanager -def serving(server: EchoServer) -> Iterator[str]: - """Run an echo server on a thread with its own event loop. - - Args: - server: The server. - - Yields: - Its base URL, like the backend URL of a started app. - """ - loop = asyncio.new_event_loop() - ready = threading.Event() - port: list[int] = [] - done: list[asyncio.Future[None]] = [] - - async def main() -> None: - done.append(loop.create_future()) - async with server.serve("127.0.0.1", 0) as bound: - port.append(bound) - ready.set() - await done[0] - - thread = threading.Thread(target=loop.run_until_complete, args=(main(),)) - thread.start() - assert ready.wait(10) - try: - yield f"http://127.0.0.1:{port[0]}" - finally: - loop.call_soon_threadsafe(done[0].set_result, None) - thread.join(10) - loop.close() - - def plan(url: str, **overrides: Any) -> LoadPlan: values: dict[str, Any] = { "endpoint": Endpoint(url), @@ -165,8 +136,8 @@ async def end(self, ws: ServerConnection) -> None: Args: ws: The connection. """ - if self.codec.protocol == "socketio": - await ws.send(events.SIO_DISCONNECT_FRAME) + if (leave := self.codec.leave) is not None: + await ws.send(leave) else: await ws.close(1008) @@ -201,6 +172,18 @@ async def seq_event( return True +def test_the_plain_protocol_is_copied_from_reflex(): + # reflex_bench drives releases without reflex.event_namespace, so it keeps a copy. + assert (events.CONNECT, events.HANDSHAKE, events.PING, events.PONG) == ( + event_namespace.CONNECT_MESSAGE, + event_namespace.HANDSHAKE_MESSAGE, + event_namespace.PING_MESSAGE, + event_namespace.PONG_MESSAGE, + ) + assert events.EVENT == SocketEvent.EVENT + assert events.BOOT_KEY == CompileVars.CONNECT_AUTH_EVENT + + def test_event_urls(): assert ( WEBSOCKET.url("http://localhost:8000", "tok") @@ -282,10 +265,25 @@ def test_both_codecs_decode_to_the_plain_protocols_messages(): assert WEBSOCKET.parse(frame) is None assert SOCKETIO.parse("6") is None assert SOCKETIO.parse("42/_event,nope") is None + assert (WEBSOCKET.join, SOCKETIO.join) == ('["_connect",{}]', "40/_event,") + assert (WEBSOCKET.ping, SOCKETIO.ping) == ('["_ping"]', "2") assert (WEBSOCKET.pong, SOCKETIO.pong) == ('["_pong"]', "3") assert (WEBSOCKET.leave, SOCKETIO.leave) == (None, "41/_event,") +def test_only_a_refusal_or_a_disconnect_ends_a_session(): + refused = SOCKETIO.parse('44/_event,{"message":"no"}') + assert refused is not None + assert ( + events.end_reason(refused, "") == 'the server refused /_event: {"message":"no"}' + ) + assert events.end_reason([events.DISCONNECTED], "1") == ( + "the server disconnected the session ('1')" + ) + for message in ([events.PING], ["event", {}], ["new_token", "t"]): + assert events.end_reason(message, "") is None + + @pytest.mark.parametrize( ("reply", "codec"), [(HEAD_REPLY, WEBSOCKET), (SOCKETIO_REPLY, SOCKETIO), (OLD_REPLY, SOCKETIO)], @@ -694,7 +692,6 @@ def test_closed_loop_reports_service_time_only(): BOARD = "reflex___state____state.playground___state____board_state" -CLIENT_VAR = "last_client_rx_state_" LINK = LinkEvent(name=f"{BOARD}.join", payload={"token": "board-1"}, delta_key=BOARD) BOARD_SHAPE = EventShape( name=f"{BOARD}.set_seq_shared", @@ -1004,7 +1001,7 @@ def spy(method: str | None = None) -> Any: result = LoadRunner(load).run( on_window=lambda edge: windows.append((edge, time.perf_counter_ns())) ) - assert contexts == ["spawn"] + assert contexts == ["spawn", "spawn"] assert result.processes == 2 assert result.sessions == 4 # Without warmup, the server saw exactly the measured events of both processes. @@ -1051,14 +1048,10 @@ def run() -> None: def test_a_generator_process_ends_when_its_parent_goes_away(): # However the harness exits, the closed pipe ends the generator's run. with serving(Scripted()) as url: - context = multiprocessing.get_context("spawn") - conn, child = context.Pipe() load = plan(url, sessions=1, rate=20.0, duration_s=60.0) - proc = context.Process( - target=events._worker, args=(load, range(1), child), daemon=True + proc, conn = events.spawn_process( + events._worker, load, range(1), name="reflex-bench-test" ) - proc.start() - child.close() assert conn.poll(30) assert conn.recv() == ("ready", []) conn.send(("go", time.perf_counter_ns())) @@ -1287,13 +1280,8 @@ async def on_event(self, ws, event): def test_kill_ends_a_hold_that_is_still_priming(): - class NeverPrimed(Holding): - """Ignores the events that complete the hydration, so no session finishes priming.""" - - async def on_event(self, ws, event): - """Answer everything but those.""" - if event["name"] not in HYDRATING: - await super().on_event(ws, event) + class NeverPrimed(NeverHydrated, Holding): + """A holding server where no session finishes priming.""" with serving(NeverPrimed()) as url: hold = events.hold_sessions(Endpoint(url), 2) diff --git a/tests/units/reflex_bench/factories.py b/tests/units/reflex_bench/factories.py index 3a3e4c029cf..f92d97035c2 100644 --- a/tests/units/reflex_bench/factories.py +++ b/tests/units/reflex_bench/factories.py @@ -1,23 +1,74 @@ -"""Builders for reflex-bench result documents used across the tests.""" +"""Builders and helpers shared by the reflex-bench tests.""" from __future__ import annotations +import asyncio +import contextlib import logging import random import sys -from collections.abc import Mapping, Sequence +import threading +from collections.abc import Iterator, Mapping, Sequence from pathlib import Path from typing import Any +import pytest from reflex_bench.context import Context, Subject -from reflex_bench.drivers.events import LoadResult +from reflex_bench.drivers.echo_server import EchoServer +from reflex_bench.drivers.events import CODECS, LoadResult from reflex_bench.registry import Metric from reflex_bench.scheduler import Policy, finalize from reflex_bench.schema import SCHEMA_ID, BenchmarkDoc, MachineDoc, ResultDoc, RunKind +from reflex_bench.suites.events import PlaygroundStates + +import reflex as rx +from reflex.istate.shared import SharedStateBaseInternal VERSION = "sha256:" + "0" * 64 WALL = Metric(unit="s", direction="lower") BYTES = Metric(unit="B", direction="lower", assume="exact") +WEBSOCKET = CODECS["websocket"] +SOCKETIO = CODECS["socketio"] +PROTOCOLS = pytest.mark.parametrize("protocol", ["websocket", "socketio"]) +# The playground's states as the workspace's reflex names them: the shared +# state sits under reflex's internal parent of shared states. +STATES = PlaygroundStates( + bench=f"{rx.State.get_full_name()}.playground___state____bench_state", + board=f"{SharedStateBaseInternal.get_full_name()}.playground___state____board_state", +) + + +@contextlib.contextmanager +def serving(server: EchoServer) -> Iterator[str]: + """Run an echo server on a thread with its own event loop. + + Args: + server: The server. + + Yields: + Its base URL, like the backend URL of a started app. + """ + loop = asyncio.new_event_loop() + ready = threading.Event() + port: list[int] = [] + done: list[asyncio.Future[None]] = [] + + async def main() -> None: + done.append(loop.create_future()) + async with server.serve("127.0.0.1", 0) as bound: + port.append(bound) + ready.set() + await done[0] + + thread = threading.Thread(target=loop.run_until_complete, args=(main(),)) + thread.start() + assert ready.wait(10) + try: + yield f"http://127.0.0.1:{port[0]}" + finally: + loop.call_soon_threadsafe(done[0].set_result, None) + thread.join(10) + loop.close() def make_subject(reflex_version: str | None = "0.9.12") -> Subject: diff --git a/tests/units/reflex_bench/suites/test_events.py b/tests/units/reflex_bench/suites/test_events.py index 9d387487872..7f29967f162 100644 --- a/tests/units/reflex_bench/suites/test_events.py +++ b/tests/units/reflex_bench/suites/test_events.py @@ -21,17 +21,9 @@ from reflex_bench.scheduler import Planned, Policy, Scheduler, plan from reflex_bench.suites import events as suite -import reflex as rx -from reflex.istate.shared import SharedStateBaseInternal -from tests.units.reflex_bench.factories import make_load_result, make_subject +from tests.units.reflex_bench.factories import STATES, make_load_result, make_subject SHAPES = ("background", "complex", "cross", "simple") -# The playground's states as the workspace's reflex names them: the shared -# state sits under reflex's internal parent of shared states. -STATES = suite.PlaygroundStates( - bench=f"{rx.State.get_full_name()}.playground___state____bench_state", - board=f"{SharedStateBaseInternal.get_full_name()}.playground___state____board_state", -) CHEAP = [ "events.simple.capacity[manager=disk,sessions=10]", "events.simple.latency[manager=disk,sessions=10,rate=500]", diff --git a/tests/units/reflex_bench/suites/test_memory.py b/tests/units/reflex_bench/suites/test_memory.py index 28e701584b5..c31c4badde2 100644 --- a/tests/units/reflex_bench/suites/test_memory.py +++ b/tests/units/reflex_bench/suites/test_memory.py @@ -29,13 +29,13 @@ from reflex_bench.suites import memory from tests.units.reflex_bench.factories import ( + STATES, fake_proc, fake_rollup, make_context, make_load_result, make_subject, ) -from tests.units.reflex_bench.suites.test_events import STATES MIB = 1024**2 APP_PID = 1001 diff --git a/tests/units/reflex_bench/suites/test_wire.py b/tests/units/reflex_bench/suites/test_wire.py index 4cff2809ed0..e333856b6f8 100644 --- a/tests/units/reflex_bench/suites/test_wire.py +++ b/tests/units/reflex_bench/suites/test_wire.py @@ -49,15 +49,17 @@ from websockets.exceptions import ConnectionClosedError from websockets.frames import Close -from tests.units.reflex_bench.drivers.test_events import serving -from tests.units.reflex_bench.factories import make_subject -from tests.units.reflex_bench.suites.test_events import STATES +from tests.units.reflex_bench.factories import ( + SOCKETIO, + STATES, + WEBSOCKET, + make_subject, + serving, +) BENCH_STATE = STATES.bench SHAPES = {name: shape_of(STATES) for name, shape_of in events_suite.SHAPES.items()} PLAYGROUND_STATE = "reflex___state____state.playground___state____playground_state" -WEBSOCKET = CODECS["websocket"] -SOCKETIO = CODECS["socketio"] HANDSHAKE_FRAME = ( '["_handshake",{"ping_interval":25.0,"ping_timeout":120.0,"protocol":2,' '"max_message_size":10000000}]' @@ -93,18 +95,21 @@ class FakeTransport: def __init__( self, script: Callable[[str], Sequence[Reply]], + codec: Codec = WEBSOCKET, greeting: Sequence[Reply] = (), ) -> None: """Queue the server's greeting. Args: script: Maps a sent frame to the frames it triggers. - greeting: The frames waiting before anything is sent, e.g. the - engine.io open of Socket.IO; none by default. + codec: The protocol; one that greets opens with an engine.io open. + greeting: More frames waiting before anything is sent. """ self.script = script self.sent: list[str] = [] - self.inbox: collections.deque[Reply] = collections.deque(greeting) + self.inbox: collections.deque[Reply] = collections.deque( + [OPEN, *greeting] if codec.greets else greeting + ) async def send(self, message: str) -> None: """Record a frame and queue what the script answers. @@ -262,7 +267,7 @@ def test_hydration_counts_every_frame_until_hydrated(): def test_a_socketio_hydration_joins_then_sends_the_hydration_events(): script = playground_script(new_token="tok-2", codec=SOCKETIO) - ws = FakeTransport(script, greeting=[OPEN]) + ws = FakeTransport(script, SOCKETIO) session = wire.WireSession(ws, "tok-1", codec=SOCKETIO) hydration = run(session.hydrate()) hydrate = SOCKETIO.event_frame(HYDRATE_EVENT, {}, token="tok-2", pathname="/") @@ -291,8 +296,7 @@ def test_a_ping_during_hydration_is_answered_and_not_counted( protocol: WireProtocol, ping: str, pong: str, sent_frames: int, received_frames: int ): codec = CODECS[protocol] - greeting = [OPEN, ping] if codec.greets else [ping] - ws = FakeTransport(playground_script(codec=codec), greeting=greeting) + ws = FakeTransport(playground_script(codec=codec), codec, greeting=[ping]) hydration = run(wire.WireSession(ws, "tok", codec=codec).hydrate()) assert ws.sent[1] == pong # Keepalives are timing, not payload: neither the ping nor the pong counts. @@ -324,7 +328,7 @@ def script(message: str) -> list[Reply]: args = codec.parse(message) return frames if args is not None and args[0] in {CONNECT, HANDSHAKE} else [] - ws = FakeTransport(script, greeting=[OPEN] if codec.greets else ()) + ws = FakeTransport(script, codec) with pytest.raises((ProtocolError, ConnectionClosedError), match=match): run(wire.WireSession(ws, "tok", codec=codec).hydrate()) @@ -467,10 +471,7 @@ def test_a_disconnect_while_waiting_for_the_reply_fails( ): codec = CODECS[protocol] simple = SHAPES["simple"].name - ws = FakeTransport( - playground_script(replies={simple: [end]}, codec=codec), - greeting=[OPEN] if codec.greets else (), - ) + ws = FakeTransport(playground_script(replies={simple: [end]}, codec=codec), codec) async def exchange() -> wire.Exchange: session = wire.WireSession(ws, "tok", codec=codec) @@ -502,47 +503,42 @@ def connecting( async def connect(url: str, **kwargs: Any) -> AsyncIterator[FakeTransport]: urls.append(url) headers.append(kwargs["additional_headers"]) - greeting = [OPEN] if codec.greets else [] - transports.append( - ws := FakeTransport(playground_script(codec=codec), greeting=greeting) - ) + transports.append(ws := FakeTransport(playground_script(codec=codec), codec)) yield ws monkeypatch.setattr(wire, "connect", connect) yield transports, urls, headers -def test_measure_connects_hydrates_and_closes(monkeypatch: pytest.MonkeyPatch): - with connecting(monkeypatch, WEBSOCKET) as (transports, urls, headers): - hydration = wire.measure( - Endpoint("http://localhost:8000", "/page", protocol="websocket"), - wire.WireSession.hydrate, - ) - (ws,) = transports - assert urls[0].startswith("ws://localhost:8000/_event?token=") - # A browser sends the page's origin; the router's page host comes from it. - assert headers == [{"Origin": "http://localhost:8000"}] - assert hydration.received_frames == 3 - # The plain protocol leaves by closing the websocket: the connect is all it sent. - (connect,) = ws.sent - assert '"pathname":"/page"' in connect - - -def test_measure_joins_and_leaves_the_socketio_namespace( +@pytest.mark.parametrize( + ("protocol", "url", "received_frames"), + [ + ("websocket", "ws://localhost:8000/_event?token=", 3), + ("socketio", "ws://localhost:8000/_event/?EIO=4&transport=websocket&token=", 4), + ], +) +def test_measure_connects_hydrates_and_leaves( monkeypatch: pytest.MonkeyPatch, + protocol: WireProtocol, + url: str, + received_frames: int, ): - with connecting(monkeypatch, SOCKETIO) as (transports, urls, _headers): + codec = CODECS[protocol] + with connecting(monkeypatch, codec) as (transports, urls, headers): hydration = wire.measure( - Endpoint("http://localhost:8000", "/page", protocol="socketio"), + Endpoint("http://localhost:8000", "/page", protocol=protocol), wire.WireSession.hydrate, ) (ws,) = transports - assert urls[0].startswith( - "ws://localhost:8000/_event/?EIO=4&transport=websocket&token=" + assert urls[0].startswith(url) + # A browser sends the page's origin; the router's page host comes from it. + assert headers == [{"Origin": "http://localhost:8000"}] + assert hydration.received_frames == received_frames + assert '"pathname":"/page"' in ws.sent[hydration.sent_frames - 1] + # Only the leave follows the hydration; the plain protocol leaves by closing. + assert ws.sent[hydration.sent_frames :] == ( + [] if codec.leave is None else [codec.leave] ) - assert hydration.received_frames == 4 - assert ws.sent[-1] == SIO_DISCONNECT_FRAME - assert '"pathname":"/page"' in ws.sent[1] @pytest.mark.parametrize( From df90814a33babcb8319e90d8d5b48891465aabf8 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 17 Oct 2026 17:59:06 +0200 Subject: [PATCH 62/62] Match the echo server's handshake to Reflex and bound protocol detection The echo server announced its generator's 16 MiB frame limit as its message size, while a default Reflex app (and its own Socket.IO greeting) announces 1 MB; the drift test only compared keys. It now announces Reflex's default on both protocols, and the test compares the whole handshake against a default-configured app. detect_protocol always waited up to 30 seconds to open its websocket, so the benchmark socket client's first connection to a hung backend ignored the caller's timeout. Detection now takes the timeout, and the client passes its own through. --- .../src/reflex_bench/drivers/echo_server.py | 11 ++++++----- .../src/reflex_bench/drivers/events.py | 7 +++++-- tests/benchmarks/support/idle_clients.py | 2 +- tests/benchmarks/support/socket_client.py | 9 +++++---- tests/units/benchmarks/test_support.py | 17 +++++++++++++++++ .../reflex_bench/drivers/test_echo_server.py | 15 +++++++++++---- tests/units/reflex_bench/drivers/test_events.py | 11 +++++++++++ 7 files changed, 56 insertions(+), 16 deletions(-) diff --git a/packages/reflex-bench/src/reflex_bench/drivers/echo_server.py b/packages/reflex-bench/src/reflex_bench/drivers/echo_server.py index 522e7691608..f9e5b1d839c 100644 --- a/packages/reflex-bench/src/reflex_bench/drivers/echo_server.py +++ b/packages/reflex-bench/src/reflex_bench/drivers/echo_server.py @@ -38,7 +38,6 @@ HYDRATE_AND_LOAD_EVENT, HYDRATE_EVENT, HYDRATED_VAR, - MAX_FRAME_BYTES, ON_LOAD_EVENT, ROOT_STATE, SIO_OPEN_PREFIX, @@ -51,6 +50,8 @@ # The plain protocol's version, announced in its handshake. PROTOCOL_VERSION = 2 +# Reflex's default message size limit, announced by either protocol. +MAX_MESSAGE_BYTES = 1_000_000 class EchoServer: @@ -71,8 +72,8 @@ def __init__( delta_key: The state whose delta echoes the sequence number. seq_var: The var that echoes it. protocol: The protocol to speak. - ping_interval_s: Seconds between pings. - ping_timeout_s: The ping timeout announced in the handshake. + ping_interval_s: Seconds between pings, by default reflex's. + ping_timeout_s: The ping timeout announced, by default reflex's. """ self.delta_key = delta_key self.seq_var = seq_var @@ -137,7 +138,7 @@ async def handle(self, ws: ServerConnection) -> None: "upgrades": [], "pingInterval": int(self.ping_interval_s * 1000), "pingTimeout": int(self.ping_timeout_s * 1000), - "maxPayload": 1_000_000, + "maxPayload": MAX_MESSAGE_BYTES, }) ) pinger = asyncio.create_task(self._ping(ws)) @@ -196,7 +197,7 @@ async def on_connect(self, ws: ServerConnection, sid: str) -> None: "ping_interval": self.ping_interval_s, "ping_timeout": self.ping_timeout_s, "protocol": PROTOCOL_VERSION, - "max_message_size": MAX_FRAME_BYTES, + "max_message_size": MAX_MESSAGE_BYTES, }, ) ) diff --git a/packages/reflex-bench/src/reflex_bench/drivers/events.py b/packages/reflex-bench/src/reflex_bench/drivers/events.py index 58017bd3522..b28365bae52 100644 --- a/packages/reflex-bench/src/reflex_bench/drivers/events.py +++ b/packages/reflex-bench/src/reflex_bench/drivers/events.py @@ -534,7 +534,9 @@ def is_hydrated(delta: Mapping[str, Any]) -> bool: return root is not None and root.get(HYDRATED_VAR) is True -async def detect_protocol(backend_url: str) -> WireProtocol: +async def detect_protocol( + backend_url: str, timeout: float = PRIME_TIMEOUT_S +) -> WireProtocol: """Find the protocol of a backend's event websocket. The plain protocol's endpoint accepts the websocket before the client @@ -543,6 +545,7 @@ async def detect_protocol(backend_url: str) -> WireProtocol: Args: backend_url: The backend's URL, e.g. ``http://localhost:8000``. + timeout: Seconds to wait for the websocket to open. Returns: ``websocket`` or ``socketio``. @@ -551,7 +554,7 @@ async def detect_protocol(backend_url: str) -> WireProtocol: async with connect( CODECS["websocket"].url(backend_url, str(uuid.uuid4())), proxy=None, - open_timeout=PRIME_TIMEOUT_S, + open_timeout=timeout, ping_interval=None, close_timeout=1, ): diff --git a/tests/benchmarks/support/idle_clients.py b/tests/benchmarks/support/idle_clients.py index 8d1cf755ef6..0adff94e8ed 100644 --- a/tests/benchmarks/support/idle_clients.py +++ b/tests/benchmarks/support/idle_clients.py @@ -57,7 +57,7 @@ def main() -> int: payload = json.loads(payload_json) clients: list[EventClient] = [] try: - codec = _codec(url) + codec = _codec(url, timeout=10) for index in range(int(count)): client = _connect(url, f"{token_prefix}-{index}", codec, timeout=10) clients.append(client) diff --git a/tests/benchmarks/support/socket_client.py b/tests/benchmarks/support/socket_client.py index 458c180c342..4c40fe036e1 100644 --- a/tests/benchmarks/support/socket_client.py +++ b/tests/benchmarks/support/socket_client.py @@ -126,11 +126,12 @@ def close(self) -> None: self._ws.close() -def _codec(url: str) -> Codec: +def _codec(url: str, timeout: float) -> Codec: """Find the protocol of a backend's event websocket, detecting it once per backend. Args: url: Backend URL. + timeout: Maximum wait for the detection's connection. Returns: Its codec. @@ -138,7 +139,7 @@ def _codec(url: str) -> Codec: # Concurrent clients wait for one detection instead of each running one. with _codecs_lock: if (codec := _codecs.get(url)) is None: - codec = _codecs[url] = CODECS[asyncio.run(detect_protocol(url))] + codec = _codecs[url] = CODECS[asyncio.run(detect_protocol(url, timeout))] return codec @@ -282,7 +283,7 @@ def _run_socket_client_sync( errors: list[str] = [] try: - client = _connect(url, token, _codec(url), timeout) + client = _connect(url, token, _codec(url, timeout), timeout) _prime(client, event_name, payload, timeout) for _ in range(events): started = time.perf_counter_ns() @@ -372,7 +373,7 @@ def _run_reconnect_client_sync( try: # Outside the clock: the first client of a backend runs the detection. - codec = _codec(url) + codec = _codec(url, timeout) started = time.perf_counter_ns() client = _connect(url, token, codec, timeout) connect_ms = (time.perf_counter_ns() - started) / 1_000_000 diff --git a/tests/units/benchmarks/test_support.py b/tests/units/benchmarks/test_support.py index 18b27bb63ad..6a4cb909124 100644 --- a/tests/units/benchmarks/test_support.py +++ b/tests/units/benchmarks/test_support.py @@ -2,7 +2,9 @@ import asyncio import json +import socket import sys +import time from types import SimpleNamespace import pytest @@ -164,6 +166,21 @@ async def test_run_clients_leaves_default_executor_usable(): assert await asyncio.to_thread(lambda: "still-usable") == "still-usable" +@linux_only +@pytest.mark.asyncio +async def test_reconnect_client_detects_the_protocol_within_its_timeout(): + """Detecting a hung backend's protocol is bounded by the client's timeout.""" + from tests.benchmarks.support.socket_client import run_reconnect_client + + # The listener never answers the upgrade. + with socket.create_server(("127.0.0.1", 0)) as listener: + url = f"http://127.0.0.1:{listener.getsockname()[1]}" + started = time.monotonic() + result = await run_reconnect_client(url, "token", {}, timeout=0.1) + assert result.errors == ("response_timeout",) + assert time.monotonic() - started < 5 + + class _FakeRedis: """Minimal async Redis stand-in recording lifecycle calls.""" diff --git a/tests/units/reflex_bench/drivers/test_echo_server.py b/tests/units/reflex_bench/drivers/test_echo_server.py index 8e1f92b4b46..a5533771935 100644 --- a/tests/units/reflex_bench/drivers/test_echo_server.py +++ b/tests/units/reflex_bench/drivers/test_echo_server.py @@ -13,7 +13,8 @@ import psutil import pytest -from reflex_bench.drivers import echo_server, events +from reflex_base.environment import environment +from reflex_bench.drivers import events from reflex_bench.drivers.echo_server import EchoProcess, EchoServer from reflex_bench.drivers.events import Endpoint, EventShape, LoadPlan, run_load from reflex_bench.suites.events import SEQ_VAR @@ -85,7 +86,14 @@ async def send(self, frame: str) -> None: def test_the_handshake_is_copied_from_reflex(monkeypatch: pytest.MonkeyPatch): - # reflex_bench drives releases without reflex.event_namespace, so it keeps a copy. + # reflex_bench drives releases without reflex.event_namespace, so it keeps a + # copy of the handshake a default-configured app sends. + for var in ( + environment.REFLEX_SOCKET_INTERVAL, + environment.REFLEX_SOCKET_TIMEOUT, + environment.REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE, + ): + monkeypatch.delenv(var.name, raising=False) monkeypatch.setattr(prerequisites, "check_redis_used", lambda: False) app = Mock(_state=None, _channels={}, router=Mock(return_value=None)) app.event_processor.enqueue = AsyncMock() @@ -101,8 +109,7 @@ def test_the_handshake_is_copied_from_reflex(monkeypatch: pytest.MonkeyPatch): assert message is not None name, settings = message assert name == reflex_name == events.HANDSHAKE - assert set(settings) == set(reflex_settings) - assert echo_server.PROTOCOL_VERSION == event_namespace.PROTOCOL_VERSION + assert settings == reflex_settings def test_echo_process_answers_a_load_and_stops(): diff --git a/tests/units/reflex_bench/drivers/test_events.py b/tests/units/reflex_bench/drivers/test_events.py index af0de404226..958e320273b 100644 --- a/tests/units/reflex_bench/drivers/test_events.py +++ b/tests/units/reflex_bench/drivers/test_events.py @@ -14,6 +14,7 @@ import dataclasses import json import multiprocessing +import socket import threading import time from collections.abc import AsyncIterator, Awaitable, Callable @@ -313,6 +314,16 @@ def test_a_named_protocol_is_not_detected(): asyncio.run(events.detect_protocol(endpoint.backend_url)) +def test_detection_gives_up_at_its_timeout(): + # The listener never answers the upgrade, as a hung backend does. + with socket.create_server(("127.0.0.1", 0)) as listener: + url = f"http://127.0.0.1:{listener.getsockname()[1]}" + started = time.monotonic() + with pytest.raises(TimeoutError): + asyncio.run(events.detect_protocol(url, 0.1)) + assert time.monotonic() - started < 5 + + def test_a_refused_namespace_fails_the_session(): class Refusing(Scripted): """Refuses the namespace, as python-socketio does when on_connect rejects."""