Skip to content

WebSocket state machine, consumers, channels, groups, history

Module: sillo.websockets Source files:

  • /Users/admin/sillo.build/core/sillo/websockets/base.py (231 lines)
  • /Users/admin/sillo.build/core/sillo/websockets/consumers.py (213 lines)
  • /Users/admin/sillo.build/core/sillo/websockets/channels.py (277 lines)
  • /Users/admin/sillo.build/core/sillo/websockets/history.py (124 lines)
  • /Users/admin/sillo.build/core/sillo/websockets/errors.py (40 lines)
  • /Users/admin/sillo.build/core/sillo/websockets/status.py (66 lines)
  • /Users/admin/sillo.build/core/sillo/websockets/utils.py (48 lines)
  • /Users/admin/sillo.build/core/sillo/core/routing/websocket.py (308 lines)

Version: 2026-08-11 Audience: Core maintainers, framework architects Purpose: Deep documentation of the WebSocket state machine, consumers, channels, groups, history management, and error handling


The WebSocket subsystem provides a full-duplex communication layer built on the ASGI protocol. In the core it is the connection and nothing above it:

  • A strict state machine for connection lifecycle
  • Error middleware for exception handling
  • Close-code constants and routing

Rooms, broadcast, presence and message history live in sillo-wire.

graph TD
    A[Client] <-->|"WebSocket"| B[WebsocketRoute]
    B -->|"creates"| C[WebSocketContext]
    C -->|"passed to"| D[handler]
    D -->|"send / receive"| A

    subgraph "sillo-wire, installed separately"
        E[Peer] -->|"joins"| F[Hub]
        F -->|"broadcast()"| G[every Peer in the room]
    end

    C -.->|"wrapped by"| E

File: /Users/admin/sillo.build/core/sillo/websockets/base.py, line 15

class WebSocketState(enum.Enum):
CONNECTING = 0
CONNECTED = 1
DISCONNECTED = 2
RESPONSE = 3

The RESPONSE state is used for HTTP upgrade denial. When a WebSocket connection is rejected, the response is sent as an HTTP response.


File: /Users/admin/sillo.build/core/sillo/websockets/base.py (231 lines)

from sillo import WebSocketContext, BaseContext
class WebSocketContext(BaseContext):
def __init__(self, scope: Scope, receive: Receive, send: Send) -> None:
super().__init__(scope, receive)
assert scope["type"] == "websocket"
self._receive = receive
self._send = send
self.client_state = WebSocketState.CONNECTING
self.application_state = WebSocketState.CONNECTING

The WebSocket maintains two independent state tracks:

TrackVariablePurpose
Clientclient_stateTracks messages received from the client
Applicationapplication_stateTracks messages sent by the application
stateDiagram-v2
    state "Client State" as CS {
        [*] --> CONNECTING
        CONNECTING --> CONNECTED : websocket.connect received
        CONNECTED --> DISCONNECTED : websocket.disconnect received
    }

    state "Application State" as AS {
        [*] --> CONNECTING_2 : CONNECTING
        CONNECTING_2 --> CONNECTED_2 : websocket.accept sent
        CONNECTING_2 --> DISCONNECTED_2 : websocket.close sent
        CONNECTING_2 --> RESPONSE : websocket.http.response.start sent
        CONNECTED_2 --> DISCONNECTED_2 : websocket.close sent
        RESPONSE --> DISCONNECTED_2 : websocket.http.response.body (no more_body)
    }
async def receive(self) -> Message:
if self.client_state == WebSocketState.CONNECTING:
message = await self._receive()
message_type = message["type"]
if message_type != "websocket.connect":
raise RuntimeError(f'Expected "websocket.connect", got {message_type!r}')
self.client_state = WebSocketState.CONNECTED
return message
elif self.client_state == WebSocketState.CONNECTED:
message = await self._receive()
message_type = message["type"]
if message_type not in {"websocket.receive", "websocket.disconnect"}:
raise RuntimeError(f'Expected "websocket.receive" or "websocket.disconnect", got {message_type!r}')
if message_type == "websocket.disconnect":
self.client_state = WebSocketState.DISCONNECTED
return message
else:
raise RuntimeError('Cannot call "receive" once a disconnect message has been received.')
async def send(self, message: Message) -> None:
if self.application_state == WebSocketState.CONNECTING:
message_type = message["type"]
if message_type not in {"websocket.accept", "websocket.close", "websocket.http.response.start"}:
raise RuntimeError(...)
if message_type == "websocket.close":
self.application_state = WebSocketState.DISCONNECTED
elif message_type == "websocket.http.response.start":
self.application_state = WebSocketState.RESPONSE
else:
self.application_state = WebSocketState.CONNECTED
await self._send(message)
elif self.application_state == WebSocketState.CONNECTED:
message_type = message["type"]
if message_type not in {"websocket.send", "websocket.close"}:
raise RuntimeError(...)
if message_type == "websocket.close":
self.application_state = WebSocketState.DISCONNECTED
try:
await self._send(message)
except OSError:
self.application_state = WebSocketState.DISCONNECTED
raise WebSocketDisconnect(code=1006)
elif self.application_state == WebSocketState.RESPONSE:
message_type = message["type"]
if message_type != "websocket.http.response.body":
raise RuntimeError(...)
if not message.get("more_body", False):
self.application_state = WebSocketState.DISCONNECTED
await self._send(message)
else:
raise RuntimeError('Cannot call "send" once a close message has been sent.')
async def accept(
self,
subprotocol: str | None = None,
headers: Iterable[tuple[bytes, bytes]] | None = None,
) -> None:
headers = headers or []
if self.client_state == WebSocketState.CONNECTING:
await self.receive() # Wait for websocket.connect
await self.send({
"type": "websocket.accept",
"subprotocol": subprotocol,
"headers": headers,
})
from sillo import WebSocketContext
async def receive_text(self) -> str:
if self.application_state != WebSocketState.CONNECTED:
raise RuntimeError('WebSocketContext is not connected. Need to call "accept" first.')
message = await self.receive()
self._raise_on_disconnect(message)
return message["text"]
async def receive_bytes(self) -> bytes:
# Similar pattern
async def receive_json(self, mode: str = "text") -> Any:
message = await self.receive()
self._raise_on_disconnect(message)
if mode == "text":
text = message["text"]
else:
text = message["bytes"].decode("utf-8")
return json.loads(text)
async def iter_text(self) -> AsyncIterator[str]:
try:
while True:
yield await self.receive_text()
except WebSocketDisconnect:
pass
async def iter_bytes(self) -> AsyncIterator[bytes]:
try:
while True:
yield await self.receive_bytes()
except WebSocketDisconnect:
pass
async def iter_json(self) -> AsyncIterator[Any]:
try:
while True:
yield await self.receive_json()
except WebSocketDisconnect:
pass

All iterators silently catch WebSocketDisconnect to end the iteration cleanly.

async def send_text(self, data: str) -> None:
await self.send({"type": "websocket.send", "text": data})
async def send_bytes(self, data: bytes) -> None:
await self.send({"type": "websocket.send", "bytes": data})
async def send_json(self, data: Any, mode: str = "text") -> None:
text = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
if mode == "text":
await self.send({"type": "websocket.send", "text": text})
else:
await self.send({"type": "websocket.send", "bytes": text.encode("utf-8")})
async def close(self, code: int = 1000, reason: str | None = None) -> None:
await self.send({"type": "websocket.close", "code": code, "reason": reason or ""})
def is_connected(self) -> bool:
return (
self.client_state == WebSocketState.CONNECTED
and self.application_state == WebSocketState.CONNECTED
)
class WebSocketDisconnect(Exception):
def __init__(self, code: int = 1000, reason: str | None = None) -> None:
self.code = code
self.reason = reason or ""

These moved to sillo-wire in v1 and are documented there. The core keeps the connection; the package adds everything about addressing more than one of them at once.

Was, in this moduleIs, in sillo.wire
WebSocketConsumerRoomConsumer
ChannelPeer
ChannelBoxHub — an object, not process-global class state
BaseHistoryManagerBacklog protocol, with MemoryBacklog and NullBacklog

The split is a dependency direction: the room layer needs a socket, a socket needs nothing from the room layer, and fan-out is the part that grows a backend once groups have to outlive a single worker process.

File: /Users/admin/sillo.build/core/sillo/websockets/errors.py (40 lines)

from sillo import WebSocketContext
class WebSocketErrorMiddleware:
def __init__(self, app: ASGIApp):
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send):
if scope["type"] == "websocket":
websocket = WebSocketContext(scope, receive, send)
try:
await self.app(scope, receive, send)
except WebSocketException as exc:
await websocket_exception_handler(websocket, exc)
else:
await self.app(scope, receive, send)
from sillo import WebSocketContext
async def websocket_exception_handler(websocket: WebSocketContext, exc: WebSocketException):
await websocket.close(code=exc.code, reason=str(exc))

File: /Users/admin/sillo.build/core/sillo/websockets/status.py (66 lines)

ConstantCodeDescription
WS_1000_NORMAL_CLOSURE1000Normal closure
WS_1001_GOING_AWAY1001Endpoint going away
WS_1002_PROTOCOL_ERROR1002Protocol error
WS_1003_UNSUPPORTED_DATA1003Unsupported data type
WS_1005_NO_STATUS_RCVD1005No status received
WS_1006_ABNORMAL_CLOSURE1006Abnormal closure
WS_1007_INVALID_FRAME_PAYLOAD_DATA1007Invalid frame payload
WS_1008_POLICY_VIOLATION1008Policy violation
WS_1009_MESSAGE_TOO_BIG1009Message too big
WS_1010_MANDATORY_EXT1010Mandatory extension
WS_1011_INTERNAL_ERROR1011Internal server error
WS_1012_SERVICE_RESTART1012Service restart
WS_1013_TRY_AGAIN_LATER1013Try again later
WS_1014_BAD_GATEWAY1014Bad gateway
WS_1015_TLS_HANDSHAKE1015TLS handshake failure

Deprecated aliases:

  • WS_1004_NO_STATUS_RCVD → 1004 (reserved, should not be used)
  • WS_1005_ABNORMAL_CLOSURE → 1005 (duplicate of WS_1005_NO_STATUS_RCVD)

File: /Users/admin/sillo.build/core/sillo/core/routing/websocket.py (308 lines)

from sillo import WebSocketContext
class WebsocketRoute(BaseRoute):
def __init__(self, path: str, handler: WsHandlerType):
# Compile path pattern
# Store handler
def match(self, scope: Scope) -> tuple[Any, Any]:
# Match path against compiled pattern
# Return (path_params, remaining_scope) or raise/no-match
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
# Create WebSocketContext from scope
# Apply error middleware
# Call handler(websocket, **path_params)
def url_path_for(self, name: str, **path_params) -> URLPath:
# Generate URL for this route

from sillo import WebSocketContext
async def websocket_handler(websocket: WebSocketContext):
await websocket.accept()
try:
while True:
data = await websocket.receive_json()
await websocket.send_json({"echo": data})
except WebSocketDisconnect:
pass
app.websocket("/ws/echo")(websocket_handler)

Both live in sillo-wire:

from sillo.wire import Hub, Peer
hub = Hub()
async def chat(socket: WebSocketContext, room: str):
await socket.accept()
peer = Peer(socket)
await hub.join(peer, room)
try:
async for message in socket.iter_json():
await hub.broadcast(room, message)
finally:
await hub.disconnect(peer)

File: /Users/admin/sillo.build/core/sillo/websockets/utils.py

class ChannelAddStatusEnum(Enum):
CHANNEL_ADDED = "CHANNEL_ADDED"
CHANNEL_EXIST = "CHANNEL_EXIST"
class ChannelRemoveStatusEnum(Enum):
CHANNEL_REMOVED = "CHANNEL_REMOVED"
CHANNEL_DOES_NOT_EXIST = "CHANNEL_DOES_NOT_EXIST"
GROUP_REMOVED = "GROUP_REMOVED"
GROUP_DOES_NOT_EXIST = "GROUP_DOES_NOT_EXIST"
class GroupSendStatusEnum(Enum):
GROUP_SEND = "GROUP_SEND"
NO_SUCH_GROUP = "NO_SUCH_GROUP"
class PayloadTypeEnum(Enum):
JSON = "json"
TEXT = "text"
BYTES = "bytes"

The client/application state split mirrors the ASGI spec’s design. The client state tracks what the client has sent; the application state tracks what the server has sent. Both must be CONNECTED for is_connected() to return True.

The group registry was class-level state on a ChannelBox, which made it a process-wide singleton every consumer shared — untestable in isolation and impossible to scope per tenant. It is now a Hub object in sillo-wire, which also gives the fan-out somewhere to grow a cross-process backend without the core acquiring a Redis dependency.


ComponentFileLines
WebSocketState enumcore/sillo/websockets/base.py15-21
WebSocketDisconnectcore/sillo/websockets/base.py24-30
WebSocketContext classcore/sillo/websockets/base.py33-231
WebSocketErrorMiddlewarecore/sillo/websockets/errors.py20-40
Status codescore/sillo/websockets/status.py1-66
Hub, Peer, RoomConsumer, Backlogsillo-wireseparate package
WebsocketRoutecore/sillo/core/routing/websocket.py21-308