Repository navigation
bare websockets - #6932
bare websockets#6932benedikt-bartscher wants to merge 93 commits into
Conversation
Merging this PR will not alter performance1 warning
Performance Changes
Comparing |
[High impact] The latest changes appear safe to merge and fix the outstanding hydration-hook issue. SummaryThe PR replaces the default Socket.IO transport with plain WebSockets, adds channels with binary messages, and moves
Reviews (80) · Last reviewed commit: "Run the Socket.IO boot event through on_..." · Reviewed by Greptile |
There was a problem hiding this comment.
All reported issues were addressed across 19 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…ace, awaited disconnect cleanup; revert server-wide ws_max_size
There was a problem hiding this comment.
All reported issues were addressed across 17 files
Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
Re-trigger cubic
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
… message shape, log protocol closes; add transport benchmarks
…cut client watchdog churn
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
There was a problem hiding this comment.
All reported issues were addressed across 6 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
…icorn] extra with gunicorn
There was a problem hiding this comment.
All reported issues were addressed across 6 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
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.
There was a problem hiding this comment.
All reported issues were addressed across 62 files
Reply with feedback, questions, or to request a fix.
View guided diff | Re-trigger cubic
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.
Port upstream's EventNamespace changes to the moved namespace modules: the connect-time state schema check now runs in BaseEventNamespace.handle_connect for both transports (connect_boot_event becomes connect_auth, which also returns the frontend schema), and the unknown-substate client_error report uses upstream's wording. Run client_state_updates.mjs over both the plain WebSocket and Socket.IO transports; the queue test runtime now serves dynamic imports.
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.
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.
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.
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.
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.
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.
There was a problem hiding this comment.
All reported issues were addressed across 31 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
View guided diff | Re-trigger cubic
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.
Port upstream's repeated-header combining (reflex-dev#7588) from the removed reflex/app.py helper to _decode_asgi_headers in reflex/event_namespace.py, which both transports share, keeping the branch's Latin-1 fallback. Its tests move from tests/units/test_app.py to tests/units/test_event_namespace.py.
Granian sets TCP_NODELAY on listeners it binds itself, but the harness binds its own socket, so with the Redis state manager each update stalled ~40 ms on delayed ACKs and test_client_side_state raced it. Also poll the client storage reads that follow an event or navigation.
Port upstream's reconnect back-off (reflex-dev#7605) to the transport abstraction: retryConnect keeps the branch's addConnectError helper and caps the attempt count at MAX_CONNECT_ERRORS. Socket.IO reports a transport closed before the namespace connected as a disconnect, which now retries with back-off; the plain WebSocket reports it as connect_error, so it gets the same back-off. The state.js back-off tests run over both transports, with the fake socket reporting a close before connecting the way each transport does, and read connect errors through upstream's lazily applied updaters.
Port upstream's EventNamespace changes to the moved namespace modules: the trusted proxy hops for the client IP (reflex-dev#7614) apply in build_static_router_data, and the cancellation of a disconnected socket's page-load chains (reflex-dev#7415) runs in BaseEventNamespace.handle_disconnect, so both transports get them. The branch already decoded non-UTF-8 header values (reflex-dev#7602). Their tests move from tests/units/test_app.py to tests/units/test_event_namespace.py, and the transport benchmark's app double gains the event processor attribute the disconnect reads. Port the oversized hydrate check of reflex-dev#7551 to the Socket.IO transport only: state.js wraps the socket.io encoder where it creates the Socket.IO socket, since the plain WebSocket has no engine.io encoder. Its state.js tests run over Socket.IO, whose fake socket now encodes CONNECT packets, and the reconnect back-off of reflex-dev#7605 stays in place for both transports. uv.lock is upstream's, relocked for the branch's dependencies.
The plain WebSocket sends its hydrate with the connect frame, before any handshake could announce the server's message limit. A frame over the limit closed the socket with 1009, which the client reported as a generic connection failure and retried with back-off forever. The server now names the limit in that close's reason, and the transport reports such a close as the hydration_payload_too_large error reflex-dev#7551 added for Socket.IO, with the frame's size and the limit when the reason gives it. A reconnect over a limit an earlier handshake announced is refused before it is sent. retryConnect treats the error as final for both transports: it records it once, schedules no retry and keeps wait_connect set, so reconnect() does not rebuild the hydrate, and the toast and banner keyed on the error code show it.
When a token moved to another socket, the transport dropped the stale recipient but send_to_token still returned True; the sender now reports whether it accepted anyone and the fan-out returns that.
There was a problem hiding this comment.
1 issue found across 4 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="reflex/event_namespace.py">
<violation number="1" location="reflex/event_namespace.py:1136">
P2: `bool(recipients)` reports success for linked SIDs even when `_deliver` skips the connection or `_Connection.send` rejects it after close. Return the number of recipients actually accepted by `_deliver` (or otherwise check the live connection state) so `send_to_token` does not report a delivery that never reached a socket.</violation>
</file>
Reply with feedback, questions, or to request a fix.
View guided diff | Re-trigger cubic
| # teardown forgets its channel sessions. | ||
| connection.close(1008) | ||
| self._deliver(recipients, payload, event) | ||
| return bool(recipients) |
There was a problem hiding this comment.
P2: bool(recipients) reports success for linked SIDs even when _deliver skips the connection or _Connection.send rejects it after close. Return the number of recipients actually accepted by _deliver (or otherwise check the live connection state) so send_to_token does not report a delivery that never reached a socket.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At reflex/event_namespace.py, line 1136:
<comment>`bool(recipients)` reports success for linked SIDs even when `_deliver` skips the connection or `_Connection.send` rejects it after close. Return the number of recipients actually accepted by `_deliver` (or otherwise check the live connection state) so `send_to_token` does not report a delivery that never reached a socket.</comment>
<file context>
@@ -1130,6 +1133,7 @@ async def _send_channel_message(
# teardown forgets its channel sessions.
connection.close(1008)
self._deliver(recipients, payload, event)
+ return bool(recipients)
async def _send_channel_error(
</file context>
A linked session whose connection was closing, overflowed its send backlog, or was already gone still counted as a recipient; the connection now reports whether it queued the frame.
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="reflex/event_namespace.py">
<violation number="1" location="reflex/event_namespace.py:437">
P2: `send` reports a failed overflow send after already appending the frame, so the independent writer can still deliver it before teardown cancels the writer. Remove the just-appended frame and decrement `_backlog` before returning `False` to prevent false misses and duplicate retries.</violation>
</file>
Reply with feedback, questions, or to request a fix.
View guided diff | Re-trigger cubic
| Whether the frame was queued on a connection that is not closing. | ||
| """ | ||
| if self.close_code is not None: | ||
| return False |
There was a problem hiding this comment.
P2: send reports a failed overflow send after already appending the frame, so the independent writer can still deliver it before teardown cancels the writer. Remove the just-appended frame and decrement _backlog before returning False to prevent false misses and duplicate retries.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At reflex/event_namespace.py, line 437:
<comment>`send` reports a failed overflow send after already appending the frame, so the independent writer can still deliver it before teardown cancels the writer. Remove the just-appended frame and decrement `_backlog` before returning `False` to prevent false misses and duplicate retries.</comment>
<file context>
@@ -424,23 +424,27 @@ def __init__(self, websocket: WebSocket):
"""
if self.close_code is not None:
- return
+ return False
frames = self._frames
frames.append(frame)
</file context>
| return False | |
| frames.pop() | |
| self._backlog -= len(frame) | |
| return False |
A frame that overflowed the send backlog was queued before the connection closed, so the writer could still deliver a send reported as a miss.
…g it An app that replaced EventNamespace and overrode on_event saw every event but the hydrate on connect. The boot event goes through on_event again, re-raised while booting so a failure still refuses the connect, and overriding on_event now warns in favor of handle_event, which runs for every event on both transports.
saves ~11.6 KB gz / ~41 KB raw frontend size (a small js chunk replaces whole socketio)
dropped python deps: wsproto, simple-websocket, python-socketio, python-engineio, bidict
new benchmarks in test_event_transport.py show about 2x performance increase compared to socketio
also migrates AppHarness to granian for consistency. otherwise downstream tests using AppHarness still need uvicorn and wsproto/websockets