Websocket — decisions¶
Version: 0.3 · Last Updated: 2026-09-08 · Status: 🔴 DA REVISIONARE
The websocket, with the work finished. Read this as a report from the day everything described here is running: it says what the transport is, and never what it lacks. What the code holds today is status.md's subject.
Every voice carries its source. A voice sourced to the owner and a date was
decided in conversation before it reached this file. Two working documents hold
those conversations: the decision register of the investigation opened on
2026-09-05, which carries the W-1…W-13 questions and the namings from N14
on, and the investigation's diary, which carries the namings N1…N13 decided
on 2026-09-05 turn by turn. The tags are their own numbering, kept here so a
later reader can find the conversation that produced a line.
1. The server holds the connection¶
Source: owner, 2026-09-05 (W-1), «ok. primario quello e websocket alta
frequenza solo un domani». BaseServer.on_websocket is the one place a
websocket is accepted, read, written and closed. The server is the only party
that sees every application and every connection, so the questions with one
machine-wide answer are answered there once: the Origin gate, the identity, the
registry of live connections, the refusal while the server is not RUNNING.
That last one is judged HIGHER than the others (owner, 2026-09-07): above the demux, before anybody knows which application would have served the socket, and for the raw mode too. The state is the machine's business and not the protocol's, as it is on the http branch. The price is the shape of the refusal: the handshake is turned away before the accept, so the browser sees it fail with no readable code — 1013 exists only after an accept, and in the raw mode the accept belongs to the application.
Each message is then handed to the application its path names, through the
same demux an HTTP request goes through. So one websocket per browser serves
every mounted application, and an application that lives in the server process
is reachable on the same socket as the SPA.
The alternative — every application holding its own socket, the old
serve_websocket shape — makes a page that talks to two applications open two
websockets, and rebuilds identity, Origin, registry and the refusal per
application, which is this decision again one level down.
Deliberately not built: a direct browser → worker channel for high-frequency traffic. It is reopened only on a measurement (owner, 2026-09-05): how many messages per second the server sustains with the lane in between.
2. Every message is a request, and the SPA's road to the worker is a CALL¶
Source: owner, 2026-09-05 (W-2), the third road. A message for the SPA
becomes an ordinary CALL on the worker's lane, in the http form the worker
already serves. The wire keeps its two lanes, CALL and REPLY: no third envelope
type, no port in the worker, no client websocket opened from the server.
What this buys is everything the core already built for a request: the message
is served on a task of its own with a request slot, the worker's events ride
its REPLY, the photo and the fold see it like any other call. It is also what
the old repo's WsxHandler did with every WSX message — one message, one
request.
What it costs is one round trip on the lane per message, and a map from
connection to send held at the server.
Deliberately not built: the worker holding its own websocket behind a port of its own — the shape that would give a Django Channels application a native websocket. It stays a future extension, reached by naming a different worker class in the recipe, never a switch between two transports both built (owner, 2026-09-05: «potremmo immaginare di decidere a livello di configurazione?» → yes, as the choice of the class).
3. What the websocket carries, and what stays pull¶
Source: owner, 2026-09-05 (W-3), «a», widened; owner, 2026-09-06 (W-11). On the websocket travel the page's rpc — every call the client chooses to send this way, its own data synchronisation included — the commands addressed to one page, and the shared object when it exists.
What does NOT travel here is the unsolicited delivery of what happens
elsewhere: another user's writes, a table's dbevents. That delivery stays
pull, as ratified on 2026-08-29 — a queue per page, collected on the page's
next call. The owner's words, 2026-09-06: «sul websocket viaggia ciò che una
pagina chiede (rpc, anche di sincronizzazione dei suoi dati) e ciò che il
server manda a UNA pagina con un comando indirizzato via send_message; non
viaggia la consegna non sollecitata di ciò che accade altrove». A page-level
synchronisation protocol — revisions, a save barrier — belongs to whoever
writes the pages.
So a dbevent waits for the next call, seconds, exactly as it does today. Pushing it the moment it happens would put the ordering of the push against the ordering of the reply's queue, and would make the freeze decide what becomes of what was in flight. Neither was asked for.
4. The protocol is WSX, and TYTX carries the values¶
Source: owner, 2026-09-05 (W-4), «a». A message is the text prefix
WSX:// followed by JSON: id (optional), method, path, data,
page_id (optional), reply_path (optional). An answer is id, status,
data. The prefix is what tells a WSX message from any other text on the
socket, and the four fields are the ones the lane's own Frame already
carries, so a message copies into a CALL one field at a time.
data is the TYTX string — what to_tytx(value, "json") produces, placed in
the envelope as a JSON string; the receiver calls from_tytx(envelope.data,
"json"). Dates, Decimals and Bags survive that round trip in both languages,
which was proven end to end before this was written. Bytes travel as the RAW
type of genro-tytx.
The form must be producible by any client with no library of ours: the owner asked for it with Django in view (2026-09-05).
4a. An application does not learn a new method¶
Source: owner, 2026-09-05 (W-4c), «abbiamo delle request che generiamo per
GET, POST, PUT, DELETE e ora per WSK. Se una app usa websocket in questo modo
(emulazione rpc) accetta la convenzione». The server builds a synthetic HTTP
request from the message and calls the application the ordinary way. The signal
is the METHOD: WSK. There is no translation to POST — the hosted site sees
WSK — and no new method on any application class.
4b. A message with no id is an event¶
Source: owner, 2026-09-05 (W-4d), «se non c'è id NON rispondiamo». It is
executed and nothing is answered; a failure goes to the log. A page that wants
to be called back later carries reply_path, a common field of the envelope
rather than a per-application convention — chosen so «che venga gestita in modo
anarchico» could not happen — and whoever served the message calls
SpaWorker.send_message(page_id, reply_path, data) when the work is done.
An event is also NOT registered in the server's RequestRegistry (owner,
2026-09-05, W-4f): the shutdown does not wait for it, and a handler that opens
a database closes what it opens.
4c. page_id and reply_path reach the hosted code as environ keys¶
Source: owner, 2026-09-05 (W-4e), «Le chiavi si chiamano genro.page_id e
genro.reply_path, documentate in spa/environ.py accanto a
genro.identity. Su una request http vera sono assenti, non None».
Synthetic headers were refused: they are forgeable from outside. page_id
travels in the CALL's payload too, because the per-page queue is read before
the stitching.
5. Identity is judged once; every message is placed again¶
Source: owner, 2026-09-05 (W-5), «a+». The handshake is the only HTTP request of the connection, so it is where the avatar is resolved — header first, then session, the way the chain does for HTTP — and a refusal closes the socket there. For the SPA the identity is the connection id in the cookie, and a login changes the owner of that id exactly as it does over HTTP.
Every message is then placed like a request: the barrier, the index, the worker
of the moment. The worker does not own the physical websocket and receives
no physical-disconnect notification. The later §6 and §14a rules qualify this:
openchannel declares the page's channel on its worker row, and that wsx
field travels in frozen parcels while its process-local queue does not. After
a transfer the next message goes to the new worker on that declared channel.
What is given up is presence: the worker cannot tell whether the browser is still there. Whoever wants presence builds it with a message of its own — the server's registry already knows who is connected.
6. Order: parallel with a ceiling, and the queue belongs to the page¶
Source: owner, 2026-09-05 (W-8), «sì, mi pare bello», revised the same day.
At the server every message is a task, with a per-connection ceiling
(max_concurrent) and the control ping outside it; the client correlates on
the id. The ceiling is configurable, default 16 (owner, 2026-09-06:
«configurabile default 16»). It exists because a client that floods must not
sink the server, and it is a setpoint because how many calls a page fires at
once is an installation's own business. This is the semantics the HTTP calls of the same page already have —
a page fires dozens of calls at once — and a slow message blocks neither the
others nor the ping.
Whether a page is served one message at a time is the PAGE's own declaration.
The owner's words: «ragioniamo su un page_id specifico: una pagina di ordini,
lì mi va bene che tutte le chiamate websocket vadano in parallelo; poi ho una
pagina che fa monitoraggio eventi su un dispositivo e lì dico che tutta la
pagina deve essere serializzata come eventi». The declaration lives in the
wsx field of the page's row — absent, True, or a dict whose sequential
key is the flag — written by the page's first WSX message, the mandatory
openchannel command, and enforced by the WORKER through the row's own lock. A
message for a page that never opened its channel is refused with a clear error.
A route-level metadatum was considered and WITHDRAWN: the grain is the page.
7. The handshake cookie gate¶
Source: owner, 2026-09-06 (W-13), «a». The path of the handshake names the
socket's home application through the server's demux, and that application's
handshake_cookie property says which cookie the handshake must carry —
None for an application that requires none. The owner's words: «assente →
accept e 1008 "connection cookie required"». A handshake on a path no
application serves is accepted and closed 1008, «no application at this path».
With a home application that declares no cookie, there is no cookie gate.
Per message the rule holds independently: a message addressed to the SPA from a socket carrying no connection id is answered with status 403.
8. The server speaks first in the shape of a request¶
Source: owner, 2026-09-05 (W-4b), «a»; refined 2026-09-06 (W-12). A
message the server sends by itself has the shape of a request — method,
path, data, page_id — and the client routes it on the path, the way the
server routes the client's own. One codec serves both directions, and the path
names the sending application.
send_message is fire-and-forget (owner, 2026-09-06): its reply says «written
on the socket» or «no websocket for this page», and nothing more. «Delivered»
means the ASGI send returned — never «executed by the page». Server messages
carry no id; an answer from the browser is a client rpc on reply_path or on
a path of its own. Waiting on a future with a timeout, and the space the
server's own ids would live in, are a registered extension and are not built.
9. One websocket per index page¶
Source: owner, 2026-09-05 (W-9), «la connessione è della index page e tutte
le sottopagine lo devono comunque mettere. Potenzialmente potrei avere 3 index
page e ognuna 6 sub pages e avere 3 websocket e non 18». The envelope carries
an OPTIONAL page_id, message by message, in both directions — absent for a
client that has no pages. The socket belongs to the root page; a subpage sends
through it and puts its own page_id in every message. How the client
channels that is the page framework's business.
10. The worker serves ASGI applications, through one seam¶
Source: owner, 2026-09-06. The first target of this transport is a worker
hosting an ASGI application, with the same user, connection and page rows and
the same pool as today. SpaWorker.asgi_app is the seam a consumer assigns;
AsgiSeam builds the ASGI scope from the http dict a CALL carries, hands the
body whole in one http.request, and collects the answer. The identity in the
scope is the SPA's rule — the user resolved for THAT message — never the avatar
fixed at a handshake: an ASGI application inside the worker is served by the
SPA, it is not an application of the server.
The seam is served INSIDE the same _serve_request: same pendings, same row
put in order, same request slot, same events riding the REPLY. Synchronous work
— a legacy database built by the group's engine — runs on the traffic pool
through SpaWorker.run_sync(work), which copies the CALL's context so the
thread finds the same slot.
There is no streaming, deliberately, exactly as for WSGI: an application that never finishes never finishes its CALL.
11. Mixed applications: one seam in the core, the routing in the consumer¶
Source: owner, 2026-09-06, form B. A worker that hosts both a legacy WSGI
site and new ASGI pages has ONE seam in the core, asgi_app. The legacy enters
through WsgiSeam, which is an ASGI application around the WSGI callable and
runs inside the already-accepted request, on the traffic pool, with that
request's slot — no second CALL, no duplicated prologue — and keeps the
legacy's own view of the URLs: SCRIPT_NAME from root_path, PATH_INFO from
what is left of the path, the query, the body, Set-Cookie and the redirects.
wsgi_app remains as the shortcut for whoever hosts WSGI only, and the core
serves it through that same adapter: one road, not two. Assigning both is an
explicit error — the shortcut is an alternative, not an addition — and
assigning neither was originally specified as an error as well. That last
requirement was explicitly reversed by the owner on 2026-09-07 in §14 below:
a worker without either callable may boot; requesting its absent hosted seam
fails at use. Declaring both remains a boot error. SpaWorker.hosted_app_seam
resolves the callable when it is needed.
The mixed routing lives in the CONSUMER's ASGI router, which calls the adapter
for the legacy paths and serves the new ones itself. The core knows no path
prefixes: a rule on the path inside the core was the alternative, and it was
refused. Which family a page belongs to is a metadatum of the consumer on its
own row; wsx stays the channel and never doubles as that discriminator.
12. Nothing is carried over as code¶
Source: owner, 2026-09-05 (W-7), «pensavo che, capito il problema,
riscrivessi ex novo con nomi giusti e convenzioni attuali». The facade, the
envelope, the per-connection object and the registry are written new here, with
the names the owner baptised and today's conventions: classes rather than
module-level functions, no demo block, genro-tytx as a hard dependency, the
core's own Request on a synthetic scope instead of the old MsgRequest.
The old repo stays a REFERENCE: the cases its 71 websocket tests covered are
the list of behaviours the new tests must cover, written before the code, and
one of its points is cited with file:line when a comparison is needed. The
pipe that copied frames towards a worker is not rewritten in any form.
13. The async process of genropy falls¶
Source: owner, 2026-09-05 (W-6), «diciamo che sotto genro-asgi gnrasync è tutto nuovo» and «ovviamente gnrasync è compito del bridge rifarlo e fare in modo che venga importato lui, magari con una modesta modifica a genropy». Under genro-asgi there is no separate async process. Its functions — rpc over websocket, commands to a page, the shared object — are rebuilt by the genropy-asgi bridge, which gets itself imported in its place. The deployment loses a process to govern.
14. The names¶
Every name below was baptised by the owner, one per turn, between 2026-09-05 and 2026-09-07 — the ones the code needed as it was written included, because a name that was not foreseen is still a name.
| What it is | Name |
|---|---|
| The per-connection object that speaks WSX | WsxConnection |
| The neutral facade over the ASGI websocket, used by both modes | WebSocket |
The registry of the server's live connections, with the page_id → socket association |
WebSocketRegistry |
The WSX envelope as a class (the lane's Frame untouched) |
WsxEnvelope |
| The method admitted for a raw websocket handed to an application | serve_websocket |
| The page row's channel declaration, and the command that writes it | field wsx + openchannel |
The flag inside the wsx dict that puts a page's messages in single file |
sequential |
The config element, with its origins and its per-connection ceiling |
server/websocket, max_concurrent, default 16 |
| The reserved first segment of a control message | _wsx |
| The commander branch the worker's push arrives on | /commander/websocket/send |
| The worker verb that addresses one page | SpaWorker.send_message(page_id, path, data) |
| The field a page names to be called back on | reply_path |
| The worker branch that opens a page's channel | /wsx/openchannel |
The environ and scope keys, beside genro.identity |
genro.page_id, genro.reply_path |
| The worker's seam for a hosted ASGI application | asgi_app |
The sister of WsgiSeam that builds the ASGI scope |
AsgiSeam, in spa/environ.py |
| The worker's way of running synchronous work with the request's slot | SpaWorker.run_sync(work) |
| The application property that says which cookie the handshake must carry | handshake_cookie |
| The property that yields the seam onto the hosted application | SpaWorker.hosted_app_seam |
| The WSGI → ASGI adapter | WsgiSeam itself, with __call__(scope, receive, send) |
| How the ASGI application reaches the worker | the consumer binds it, in its own worker class |
| How the server reaches one layer of its middleware chain | get_middleware(middleware_class) |
| The pure reading of the session of a scope the chain never saw | SessionMiddleware.get_session(scope) |
| The whole life of one connection | WsxConnection.serve() |
| What the registry does | register / unregister, bind_page / get_page_socket |
| How a handshake is turned away with no accept | WebSocket.refuse(code, reason) |
| Where the sending to a page lives | BaseServer.send_message(page_id, path, data) |
| The commander's resolution every form of request shares | resolve_worker(cid, *, hold_timeout) |
The worker's method for the wsx payload form |
serve_wsx(frame, payload) |
The front's routing class under _wsx |
WsxControl |
The commander's door for the wsx form |
serve_wsx_request(cid, payload, *, hold_timeout) |
| The worker's dispatcher branch for what a page asks of its channel | WsxCommands |
| The queue a page's calls wait in, on its own row | call_lock |
| How a handler asks for the request itself | it declares _request |
Two of them carry a reason worth keeping:
hosted_app_seam(owner, 2026-09-06): the word «seam» stays, becauseWsgiSeamalready carries it and the family is not rebaptised. Its docstring says «the one seam a consumer assigned», not «whichever of the two is assigned», because it raises when both are.WsgiSeamas the adapter (owner, 2026-09-06): the same class, with the ASGI entrance replacing the dict entrance, which goes away with its two readers. No new name,__all__unchanged. Its constructor takes the worker and holds it asself.worker, forrun_sync.
And one of them is deliberately NOT a convention of the core: how the
application reaches the worker (owner, 2026-09-06). The consumer binds them
in its own worker class, at construction or as an attribute afterwards — the
road the genropy bridge already takes when it writes site.spa_worker = self.
The core adds nothing and writes NO live object into the scope, which stays the
JSON-safe facts the front packed.
Three more carry a reason, and all three were decided while the code was being written rather than before it:
- What the boot checks (owner, 2026-09-07). A worker that declared BOTH hosted seams is a contradiction somebody wrote down, and it dies before its wire exists. A worker with NEITHER is not an error at all: it is the base worker, which the spawn entry declares legitimate — it serves its orders and hosts nothing — and it learns it hosts nothing when an http CALL finally asks it to serve a request. The plan said «neither → error»; the plan was wrong, and it said so in a line nobody had ratified.
- Where the page names itself in
openchannel(owner, 2026-09-07). In the envelope's ownpage_idfield, never in the payload — so the CONNECTION, which must bind that page to its socket, reads what is its to read and leaves the payload to the application. The command names no page in its parameters at all: it takes them from the scope, where the envelope's field landed. - Who binds the page to the socket (owner, 2026-09-07). The application decides — it validates the page against the vertex's own map and writes the channel on its row — and the connection binds, because it is the one holding the socket, and only because the answer was 200. Nothing is ever bound that will have to be unbound.
14a. The channel travels; the queue does not¶
Source: owner, 2026-09-07, confirming a reading of two decisions already
taken. The wsx field travels in the parcel like any other datum of a page,
because W-8 says the dict lives on the page's row and travels with it, and W-5
says a freeze does not touch the websocket — the browser of a user parked for
being idle notices nothing. A row that came back from the deposit without its
channel would refuse the very next message of a page that is still connected,
and the binding on the server would outlive the row on the worker. What stays
behind is call_lock alone: a lock is an object, and the page that comes back
is served by a loop that never saw the old one.
15. What is deliberately absent¶
- No pipe and no port in the worker (§2).
- No push of datachanges or dbevents (§3).
- No shared object yet: where it lives was postponed by the owner on
2026-09-05, «non è nelle priorità attuali. Però dobbiamo tenerlo presente
come problema di fondo per evitare decisioni che poi alzino il livello di
difficoltà». The decisions above are the ones that keep it possible: a socket
that can serve an application living in the server process, a
pathin every message, an identity resolved at the handshake, and a way for the server to address a connection outside any request. - No NATS, no pub/sub, no streaming, no binary frames.
- No direct browser → worker channel (§1).
- The monitor is NOT the first consumer: it is under review, and the owner postponed it on 2026-09-05. The first live proof is a test application.