Skip to content

feat(websocket): native RSGI WebSocket support - #91

Merged
ZhuchkaTriplesix merged 5 commits into
issue-88-drop-asgifrom
issue-90-native-websocket
Apr 27, 2026
Merged

ZhuchkaTriplesix merged 5 commits into
issue-88-drop-asgifrom
issue-90-native-websocket

Conversation

@ZhuchkaTriplesix

Copy link
Copy Markdown
Member

Closes #90.

⚠️ Depends on #89 (issue-88-drop-asgi). Base is set to that branch on
purpose; merge order should be #89 → main → #90 rebased onto dev.

Summary

OxyRoute v0.3.0 dropped the ASGI bridge alongside the legacy ASGI WebSocket
spike. This PR re-introduces WebSocket support natively over RSGI, with no
Python helper class and no second event loop.

  • New Rust WebSocket pyclass (oxyroute._oxyroute.WebSocket) wraps Granian's
    RSGIWebsocketProtocol / transport. accept, receive /
    receive_text / receive_bytes, send_text / send_bytes / send_json
    and close(code=None) all return real awaitables that delegate to the
    underlying Granian coroutines via pyo3_async_runtimes::tokio::into_future.
  • App.add_websocket_route(path, handler) registers in a dedicated
    matchit::Router next to the HTTP routers (also covered by freeze()'s
    compiled snapshot for lock-free lookup).
  • run_rsgi branches on scope.proto == "websocket" to a new
    run_rsgi_websocket dispatcher. Unknown paths → polite close(1000);
    handler errors → close(1011).
  • Python-side @app.websocket(path) decorator + oxyroute.WebSocket
    re-exported from the package.

Test plan

  • tests/test_websocket_native.py — 9 cases covering: echo round-trip
    with path params (text + bytes), send_json, no-route close, handler
    raise → 1011, peer-side close (kind=0) → no double close, frame kind
    mismatch, send-before-accept, frozen app rejects new ws routes.
  • Full pytest (102 / 102) green.
  • cargo fmt / cargo clippy --all-targets -- -D warnings clean.
  • ruff check / ruff format --check clean on Python sources.

Out of scope / follow-ups

  • End-to-end Granian + real WS client subprocess test (deferred — covered by
    the existing pattern in tests/test_granian_e2e.py).
  • Subprotocol negotiation hooks (Granian negotiates via headers; the helper
    intentionally does not expose a subprotocol parameter on accept).

Adds first-class WebSocket support over Granian RSGI without going through
the (now-removed) ASGI bridge:

- `AppState` gains a dedicated `websocket: Mutex<Router<usize>>` plus a
  `websocket_routes: Vec<WebsocketRoute>` table, and `CompiledRouters`
  snapshots it together with the HTTP routers so WS lookup is also
  lock-free after `freeze()`.
- `App.add_websocket_route(path, handler)` registers a coroutine handler
  in the Rust state and clears the cached compiled snapshot so the next
  request rebuilds it.
- `run_rsgi` now branches on `scope.proto == "websocket"` to a new
  `run_rsgi_websocket` dispatcher: matches the WS router, builds a
  `WebSocket` helper, invokes the handler (sync or async) and closes
  with 1011 if the handler raises before/after await.
- New `WebSocket` pyclass (exported as `oxyroute._oxyroute.WebSocket`)
  wraps the Granian `RSGIWebsocketProtocol` / transport directly. All
  async methods delegate to the underlying coroutine via
  `pyo3_async_runtimes::tokio::into_future` to keep the Tokio bridge
  cost off the hot path of HTTP dispatch.

The Python `@app.websocket(path)` decorator wiring lands in the
follow-up commit.
WebSocket.close() previously returned plain Python None, which Python
refuses to await ('object NoneType can't be used in await expression').
Handlers naturally write 'await ws.close()', so promote the method to
return an immediately-resolving awaitable backed by future_into_py.

Also avoid calling protocol.close() a second time after the peer has
already disconnected (kind=0) — receive_text/receive_bytes mark the
WebSocket as closed before raising RuntimeError, so the user's followup
'await ws.close()' becomes a no-op.
- Adds App.websocket(path) registration decorator that delegates to the
  native add_websocket_route binding. Path syntax matches HTTP routes
  (matchit '/ws/:room'); the handler receives a single oxyroute.WebSocket
  argument.
- Re-exports WebSocket from the package so users can write
  'from oxyroute import App, WebSocket'.
Drives App.handle_rsgi directly with mocked Granian-style WebSocket
scope/protocol/transport objects (no ASGI shim). Covers:
- echo round-trip with path params (text + bytes)
- send_json convenience helper
- no matching route → polite close 1000
- handler raising → close 1011
- peer-side close (kind=0) → ws.is_closed True, no double close
- text/binary frame kind mismatch → ValueError
- send before accept → RuntimeError
- @app.websocket() rejected after freeze()
- New docs/websocket.md walks through @app.websocket(path), the
  oxyroute.WebSocket API surface (accept / receive* / send* / close),
  routing semantics, error handling and the in-process testing pattern.
- Linked from docs/index.md table of contents.
- CHANGELOG bumped: Added section now covers the native WebSocket
  support that supersedes the removed ASGI spike.

@ZhuchkaTriplesix ZhuchkaTriplesix left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

123

@ZhuchkaTriplesix
ZhuchkaTriplesix merged commit b7db743 into issue-88-drop-asgi Apr 27, 2026
ZhuchkaTriplesix added a commit that referenced this pull request Jul 20, 2026
feat(websocket): native RSGI WebSocket support
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant