Repository navigation
bare websockets - #6932
bare websockets#6932benedikt-bartscher wants to merge 84 commits into
Conversation
Merging this PR will regress 1 benchmark
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ❌ | test_proxy_list_mutation |
9.5 ms | 10 ms | -4.05% |
| ⚡ | test_decode_event_headers[64] |
186.2 µs | 162.6 µs | +14.5% |
| ⚡ | test_decode_event_headers[16] |
71.6 µs | 64.5 µs | +11.06% |
| ⚡ | test_decode_event_headers[4] |
42.5 µs | 39.9 µs | +6.46% |
| 🆕 | test_transport_inbound_socketio |
N/A | 17.6 ms | N/A |
| 🆕 | test_transport_inbound_websocket |
N/A | 10.2 ms | N/A |
| 🆕 | test_transport_outbound_socketio |
N/A | 34.4 ms | N/A |
| 🆕 | test_transport_outbound_websocket |
N/A | 17 ms | N/A |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing benedikt-bartscher:make-sio-optional (df90814) with main (5ae5dcd)
[Critical impact] The changes since the previous review appear safe to merge. SummaryThis PR replaces the default Socket.IO connection with a plain WebSocket, adds channels with binary attachments, and moves
Reviews (72) · Last reviewed commit: "Match the echo server's handshake to Ref..." · 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
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.
There was a problem hiding this comment.
All reported issues were addressed across 39 files
Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
Re-trigger cubic
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.
`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.
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.
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.
There was a problem hiding this comment.
All reported issues were addressed across 40 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
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.
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.
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