Websocket — current state¶
Version: 0.3 · Last Updated: 2026-09-08 · Status: 🔴 DA REVISIONARE
Verified against 2465fcc. Historical phase order and test totals are archived
in technical notes; they are not a current coverage measurement.
Opened at phase 0 of
#68 on develop = a434a23;
all six phases of code have landed since — 1, 2, 3, 4a, 4 and 5. The socket is
no longer empty: a handshake reaches the motor, every message it carries is
served as a request of its user on his own row, a page opens its channel and is
bound to its socket, the site can write back to it, and an application that
wants the socket itself is handed it.
Raw WebSocket application seam¶
The admitted mode — BaseApplication defines no serve_websocket, and an
application that defines one is handed the raw scope, receive and send by
BaseServer.on_websocket (server.py),
with its mount already off the path. Nothing else of the motor runs for it. 7
contract tests in tests/core/test_websocket_raw_seam.py, including the two that
draw the line: the raw application is never even named while the server is not
RUNNING, because the state is judged above the demux, and both modes are turned
away by that one refusal.
Claim anchors: BaseServer, on_websocket.
Page channel binding, worker dispatch and sequential calls¶
The channel of a page — WsxControl under the front's _wsx root
(spa_app.py),
SpaCommander.serve_wsx_request
(spa_commander.py),
SpaWorker.serve_wsx and the WsxCommands branch
(spa_worker.py),
14 contract tests in
tests/spa/orchestration/test_orchestration_websocket_e2e.py that enter where a
real message enters — the socket — over a real pool. openchannel is
validated by the front against page_connection_map, written on the page's row
by the worker through the same prologue a request goes through, and bound to
the socket by the CONNECTION, only on a 200.
One resolution for every form — SpaCommander.resolve_worker is what
serve_request and serve_wsx_request both call: the barrier, the
reception-first rule, the placement.
The queue of a page — call_lock on PageRow
(register_row.py), an
asyncio.Lock among the fields the parcel leaves behind, taken around the
whole call when the page opened its channel with sequential. The CHANNEL
itself travels: a user parked for being idle and woken by his next request
never lost his websocket, so a row that came back without wsx would refuse
the very next message of a page that is still connected.
The way back — SpaWorker.send_message, the websocket branch the front
attaches under CommanderOperations, and BaseServer.send_message at the end
of it. A page the vertex no longer knows is reachable by nobody, because the
branch validates before it writes.
The channel is the price of being addressed — a call that names a page is
refused unless that page opened its channel: openchannel is what makes a page
addressable, and a message that skips it is a client out of step with its own
row. A request that names no page — the ordinary HTTP of the site — is
untouched.
The request itself, for a handler that needs it — the _request injection
moved from the _server app's own bind_kwargs into
RoutedApplication.bind_kwargs: the seam is nobody's private business, and
openchannel is its second reader.
Claim anchors: WsxControl, SpaCommander, serve_wsx_request, serve_wsx, SpaWorker, WsxCommands, websocket, openchannel, openchannel, resolve_worker.
Hosted ASGI and WSGI worker seams¶
One seam on the worker — SpaWorker.asgi_app, and the property
hosted_app_seam
(spa_worker.py),
which is the one road out of _serve_request: AsgiSeam on the assigned
application, or AsgiSeam(WsgiSeam(wsgi_app, worker)) when the consumer took
the shortcut. Both assigned is a contradiction, and WorkerEntry kills the
process at boot; NEITHER is the base worker, which
worker_entry.py
declares legitimate — it serves its orders, and an http CALL is refused with
the property's message (owner, 2026-09-07, N29).
The two seams — environ.py, verified by
tests/spa/orchestration/test_orchestration_asgi_seam.py. AsgiSeam turns the
http dict into an ASGI scope and calls the application as a server would;
WsgiSeam is an ASGI application around a WSGI callable, its dict entrance
gone with its two readers. SCRIPT_NAME is root_path and PATH_INFO what is
left of path; Set-Cookie and Location travel like any other header; the
callable runs through SpaWorker.run_sync, which is the traffic pool with this
CALL's slot following onto the thread.
What did not change: the whole existing rig passes untouched. The bridge
assigns wsgi_app and knows nothing of the adapter it now goes through.
Claim anchors: SpaWorker, hosted_app_seam, _serve_request, AsgiSeam, WsgiSeam, WorkerEntry, run_sync, run_sync.
Server-initiated page messages¶
The server speaks first — BaseServer.send_message(page_id, path, data)
(server.py), 8 contract tests in
tests/core/test_websocket_server_send.py. It finds the socket that page speaks on
and writes one message with the shape of a request and NO id: not an answer,
and nobody answers it. True says it was written to the socket, False that
the page speaks on none or that its socket already closed — delivered means
written, never executed by the page. The name and the signature are
SpaWorker.send_message's, which calls it from a worker through the commander.
Reduced from the plan by the owner (2026-09-07, N28): the sending lives on the server, which knows the protocol, and the registry stays a map. There is no sending by identity or by connection, and the registry does not learn a socket's identity, because nothing reads either yet.
Claim anchors: BaseServer, send_message, send_message, SpaWorker.
WSX handshake, registry and concurrency configuration¶
The connection — WsxConnection in
wsx.py, covered in the historical phase run by
tests/core/test_wsx_connection.py. serve() is one socket's whole life: the gate,
the accept, the read loop, the bounded drain. The gate closes 1008 on a path no
application serves and on a missing home cookie, and REFUSES a hostile Origin
before the accept. It never sees a connection the machine had already refused:
the server's own state is judged above the demux, in on_websocket, for every
websocket alike. Identity is judged once — server.authenticate for the avatar,
SessionMiddleware.get_session for the session — and travels with every
message. Each message becomes a synthetic http scope (method: "WSK", the
handshake's headers, auth, session, and genro.page_id /
genro.reply_path when the envelope carried them), routed by server.demux
straight to the application: never through server(), whose chain would run
once per message. An HTTPException becomes the answer's status, anything
else a 500, and the socket survives both.
The reach into the chain — MiddlewareMixin.get_middleware
(middleware/__init__.py)
walks the assembled chain from its head and hands back the layer of a class,
or None when that middleware is off; BaseServer.get_middleware is the base
answer, None, like authenticate and session beside it.
SessionMiddleware.get_session(scope)
(middleware/session.py) is the
pure reading the handshake needs — the session the store holds for a scope's
cookie, creating nothing — and the middleware's own __call__ now uses it.
The registry — WebSocketRegistry in
websocket.py, reached as
server.websockets, 12 contract tests. register / unregister for the live
sockets, bind_page / get_page_socket for the association openchannel
writes. A rebind follows a reconnected page; unregister drops only the
pages still bound to THAT socket.
The refusal — WebSocket.refuse(code, reason): consumes the connect and
closes with no accept. Both the Origin gate and the server state gate use refusal before acceptance.
The config — server/websocket with origins (comma-separated in a
recipe, a list on the server) and max_concurrent
(config/elements.py,
config/handler.py). The ceiling
defaults to 16 (WEBSOCKET_MAX_CONCURRENT in
server.py).
Claim anchors: WsxConnection, refuse, demux, on_websocket, websocket, authenticate, SessionMiddleware, get_session, HTTPException, MiddlewareMixin.
WebSocket facade and WSX envelope¶
The facade — websocket.py, 100%
covered by tests/core/test_websocket_facade.py (27 contract tests). WebSocket
wraps scope, receive and send: accept() consumes the connect and answers
it (with a subprotocol and with response headers, the one place a websocket can
carry a Set-Cookie), close() writes once and refuses to run before an
accept, the reads refuse the wrong payload kind and raise
WebSocketDisconnect when the client is gone, the writes refuse a socket
nobody accepted, and iterating yields the incoming texts until that disconnect.
connected is the whole state, one boolean, and the handshake facts — path,
headers, cookies, offered subprotocols — are read off the scope in the
constructor, the way Request reads an HTTP request.
The envelope — wsx.py, covered in the historical phase run by
tests/core/test_wsx_envelope.py (29 contract tests). WsxEnvelope(text) reads a
message, WsxEnvelope(id=…, method=…, path=…, data=…) builds one, and
encode() gives the wire text. A field nobody set does not reach the wire, so
an event has no id and a request has no status. data is a Python value
here and the TYTX string inside the JSON body there: the round-trip tests are
executable examples over Decimal, date, datetime, null, bytes, a Bag with
attributes, a nested Bag, and a string full of characters JSON must escape. A
text without the prefix, a body that is not JSON and a body that is not an
object all raise ValueError — one answer for the read loop: this is not a
message of ours.
The disconnect — WebSocketDisconnect in
exceptions.py, with code and
reason. It sits beside the HTTP exceptions and is not one: a disconnect is
not a value a read can return, so it arrives as an exception.
The dependency. genro-tytx is pinned >=0.14.0
(pyproject.toml:34, 50), the release that carries
the RAW type: bytes in a message travel base64 under ::RAW on JSON and
native on msgpack, and the page encodes nothing by hand.
Claim anchors: WebSocket, accept, WebSocketDisconnect, connected, WsxEnvelope, encode.
HTTP middleware and transport boundaries¶
The empty socket, until phase 2. BaseServer.on_websocket consumed the
connect and closed with code 1000 — the D7 socket, whose docstring said the
motor of Q1 would override this hook. It does now. The state the http branch
renders as a 503 with Retry-After is read here too, at the top of
on_websocket: each transport judges the same fact and renders its own
refusal, and this one turns the handshake away before the accept.
The test that drove it (tests/core/test_demux.py, TestEmptyWebsocket) became
TestTheWebsocketBranch, asserting only that __call__ hands the scope to the
motor; what the motor does is tests/core/test_wsx_connection.py's subject.
The middleware chain does not see it. MiddlewareMixin.__call__
(middleware/__init__.py:107-112)
routes only http scopes through the chain; every other scope passes straight
through. So at the handshake scope["auth"] and scope["session"] are NOT
already there — which is why the handshake resolves the identity itself
(decisions.md §5).
The front receives synthetic HTTP scopes. SpaApplication.__call__
(spa_app.py:837-844)
demultiplexes between its own router and the hosted site on the PATH, and never
reads the scope's type. It receives WSX messages as synthetic HTTP scopes from the connection motor.
The raw handshake scope stays with the server's WebSocket entry point.
The pieces used by the motor. The demux
(server.py:247-273), the request registry (server.py:95, registered in the
HTTP cycle at :225-240), the identity (auth/core.py:160-179,
auth/mixin.py:151-163), the session from the cookie
(middleware/session.py:76-78, 118-127), the routing tree with its filtered
walk (routed_application.py:173-218), and the lane's own envelope, which
already speaks WSX with the same four fields (channel/frame.py:15-23,
94-100).
Claim anchors: middleware, BaseServer, on_websocket, MiddlewareMixin, SpaApplication.
SPA cookie gate, worker refusals and buffered protocol¶
The SPA requires its connection cookie. BaseApplication.handshake_cookie
returns None; SpaApplication.handshake_cookie overrides it with
spa_connection_id (#70 / PR #71). A missing cookie is accepted then closed
1008. SpaApplication.gateway_response also preserves explicit worker refusal
status and text, including the 409 for a page that skipped openchannel.
Evidence: tests/spa/test_spa_application.py and
tests/spa/orchestration/test_orchestration_websocket_e2e.py.
The worker protocol is still the buffered baseline. pack_http uses a
JSON-safe dict with base64 body, and AsgiSeam collects the response. Issue #72
has not changed that protocol in this revision.
SPECIFICATION.md §6 Q1 is marked RESOLVED as of this phase: the design it
asked for is decisions.md and design.md, and the
code follows in phases 1 to 5.
Claim anchors: handshake_cookie, SpaApplication, gateway_response, openchannel, openchannel, pack_http, AsgiSeam.