WebSocket Transport Proposal¶
Status: proposal
This document captures the plan for adding a WebSocket-based transport alongside the existing PipeTransport. It is not a settled decision yet. When the implementation stabilizes and the durable lesson is clear, the final doctrine should move into Design Decisions.
For the current high-level priority, see Roadmap. For the deferred summary entry, see Backlog.
Problem¶
PipeTransport uses multiprocessing.Pipe or a thread queue, both of which require the session and the frontend to live in the same OS environment. That makes it impossible to run the backend on WSL (Linux, no vispy, no display) and the frontend on Windows, even though WSL2 and the Windows host share a loopback-style network interface.
The immediate concrete need: NEURON simulations and other Linux-native backends need to run in WSL. VisPy renders on the Windows host. Pipes cannot bridge that boundary.
A secondary goal is to establish the wire protocol foundation that a future non-Python frontend (e.g. Unity) would need. That frontend replaces VisPy entirely and implements its own WebSocket client in C#; the backend server is the same in both cases.
Goals¶
- Let the backend session run in WSL with no display, no Qt, no vispy.
- Let the vispy frontend on Windows connect to that backend via WebSocket over the WSL2 loopback.
- Add a
WebSocketTransportto the vispy frontend that presents the same interface asPipeTransportand integrates with the Qt event loop. - Add a standalone
run_backend_server()entry point that runs a session loop with no Qt dependency. - Design the wire protocol to be language-agnostic from the start so a Unity C# client can implement it later without forcing a serialization redesign.
- Keep every existing
Sessionsubclass unchanged.
Non-Goals For This Phase¶
- Building the Unity frontend.
- Authentication or TLS (loopback/trusted-LAN use case only for now).
- Multi-client broadcasting (one backend, one frontend).
- Automatic reconnection or fault recovery beyond basic error surfacing.
- Changing the session protocol types or the
Session/BufferedSessioninterface.
The Two Extensibility Axes¶
Transport and frontend are independent axes and must stay that way.
Axis 1 — transport: how backend and frontend communicate (pipes, WebSocket, ...).
AppSpec.transport and WebSocketTransport live here. This axis is about the
vispy frontend's connection to wherever the backend runs.
Axis 2 — frontend: what renders the scene (vispy, Unity, browser, ...).
A Unity frontend replaces VispyFrontendWindow entirely. It does not use
AppSpec at all; it implements its own WebSocket client and speaks the same
wire protocol directly.
The connection between the two axes is the wire protocol. If the protocol is language-agnostic, axis 2 can be exercised by any client language. If it is Python-specific, axis 2 is blocked.
Design¶
1. AppSpec.transport field¶
Add a transport field to AppSpec. The frontend uses it when present instead of constructing PipeTransport from session:
@dataclass(slots=True)
class AppSpec:
scene: Scene | None = None
session: Any = None
transport: Any = None # new — pass one of session or transport, not both
interaction_target: Any = None
title: str | None = None
diagnostics: DiagnosticsSpec | None = None
Frontend construction logic in VispyFrontendWindow.__init__:
transportgiven → use it directly, skipPipeTransportconstructionsessiongiven, notransport→ constructPipeTransport(session)as today- neither → static scene, no polling
WebSocketTransport.__init__ must not create the QWebSocket yet — it defers
that to start(), called inside the window constructor after the Qt application
exists. This matches the pattern PipeTransport already uses for subprocess
spawning.
Authoring shape:
# Windows frontend
run_app(AppSpec(transport=WebSocketTransport("localhost", 8765)))
# WSL backend (no Qt, no vispy)
run_backend_server(MySession, host="0.0.0.0", port=8765)
2. Transport protocol¶
Define a structural Transport protocol so both transports are explicitly typed
without requiring inheritance:
class Transport(Protocol):
def start(self) -> None: ...
def poll_updates(self) -> list[SessionUpdate]: ...
def send_command(self, command: SessionCommand) -> None: ...
def stop(self) -> None: ...
PipeTransport already satisfies this. WebSocketTransport implements it. The
AppSpec.transport field is typed Transport | None.
3. WebSocketTransport (frontend side)¶
A QObject that wraps QWebSocket from PyQt6.QtWebSockets. Signal-based,
integrates with the Qt event loop, no additional thread required.
Responsibilities:
- start() — create QWebSocket, connect signals, call open(url)
- On binaryMessageReceived signal → decode payload, append to internal queue
- poll_updates() — drain the queue and return updates (same call site as today)
- send_command(cmd) → encode and call QWebSocket.sendBinaryMessage()
- stop() → send StopSession, close the socket
The existing 60 Hz QTimer in the frontend drives _poll_transport() unchanged.
WebSocketTransport.poll_updates() just drains from an internal deque instead
of a pipe.
4. run_backend_server() (WSL side)¶
A standalone entry point with no Qt, no vispy dependency:
def run_backend_server(
session_source: SessionSource,
host: str = "0.0.0.0",
port: int = 8765,
) -> None:
Internally: asyncio + websockets library. Accepts one connection, runs the
same session loop as _session_process in pipe.py, sends serialized updates,
receives and dispatches serialized commands. When the connection closes or
StopSession arrives, shuts down cleanly.
The session loop logic is almost identical to _session_process. The main
difference is that sends and receives go through the WebSocket rather than
Pipe.send / Pipe.recv.
5. Serialization¶
Pickle-first. For the WSL-to-Windows vispy use case, use pickle as the
initial codec. The network hop (WSL2 loopback) is the bottleneck, not
serialization speed. Pickle of a numpy array and a proper binary codec are
comparable in payload size; the marginal difference is not measurable at
simulation frequencies. Pickle also requires zero codec development to ship the
first working implementation.
The WSL-to-Windows use case is trusted and same-environment (same Python version, same numpy version), so the normal pickle safety concerns do not apply.
Swappable codec seam. Isolate encode and decode behind two functions from day one so swapping to msgpack later is a one-file change:
# in websocket transport module
def _encode(obj) -> bytes:
return pickle.dumps(obj)
def _decode(data: bytes):
return pickle.loads(data)
Language-agnostic path (for Unity). When a non-Python frontend is needed,
replace the codec with msgpack + numpy extension. MessagePack-CSharp is a
mature Unity library. msgpack with a numpy extension stores dtype + shape + raw
bytes — comparable size to pickle for array data, and natively decodable in C#.
JSON + base64 is viable for control/metadata-only messages but unsuitable for
dense array fields.
The decision to switch codecs is entirely isolated to the two functions above. No changes to protocol types, session logic, or the frontend dispatch path.
6. WSL2 networking¶
WSL2 runs behind a virtual NAT adapter. The recommended pattern:
- backend binds
0.0.0.0:PORTin WSL - frontend connects to
localhost:PORTon Windows
Windows 11 with recent WSL2 auto-proxies localhost to the WSL2 virtual
interface. If that fails, the WSL2 IP is available via cat /etc/resolv.conf
(nameserver line) and can be passed as a config argument to WebSocketTransport.
No special networking setup is required for the standard case.
7. Optional: Socket.IO variant¶
Socket.IO is WebSocket with a thin envelope layer on top: named events,
automatic reconnection with exponential backoff, a built-in heartbeat, and
optional room/namespace routing. It has been used successfully in prior work for
similar backend-to-frontend bridging and is worth documenting as an explicit
alternative to the raw websockets implementation.
What it adds over raw WebSocket:
- Auto-reconnection — directly resolves the reconnection open question without any custom retry logic.
- Named events (
socket.emit("update", data)vs raw binary frames) — cleaner message routing if the protocol gains multiple message kinds at the socket level, though the current design handles multiplexing through payload type rather than socket events. - Built-in heartbeat / ping-pong — no manual keepalive implementation needed.
- Room support — not needed now, but relevant if multi-client broadcasting is ever added.
Server side (WSL): python-socketio with aiohttp or uvicorn/starlette.
The session loop is the same; only the send/receive calls change.
Client side (Windows/Qt) tradeoff: This is the complication. QWebSocket
speaks raw WebSocket; it does not implement the Socket.IO handshake and envelope
protocol. Two options:
- Run
python-socketio's asyncio client in a daemon thread, feeding received updates into aqueue.Queuethatpoll_updates()drains on the Qt thread. This works but adds a thread and an asyncio event loop alongside the Qt event loop. - Implement the Socket.IO handshake on top of
QWebSocketmanually. Not recommended — it duplicates whatpython-socketioalready provides.
The thread-based client option is straightforward and has no correctness risk.
The cost is a slightly more complex WebSocketTransport compared to the pure
QWebSocket path.
For Unity: Several maintained Socket.IO client libraries exist for Unity
(SocketIOUnity, socket.io-unity). They handle reconnection, namespaces, and
typed event callbacks natively in C#. This is a meaningful advantage if Unity is
the target frontend — it removes the need to implement reconnection and keepalive
in C# from scratch.
Recommendation: Start with raw websockets + QWebSocket for the initial
vispy/WSL implementation. It is the simpler path, and the only open question it
leaves unanswered is reconnection. If reconnection turns out to be important in
practice, or if a Unity client is the next frontend, switching the server to
python-socketio and the client to the thread-backed asyncio variant is a
contained change that does not touch anything above the transport layer.
Effort Estimate¶
| Piece | Effort |
|---|---|
| Swappable codec (pickle, two functions) | half day |
WebSocketTransport (QWebSocket-based) |
1 day |
run_backend_server() (asyncio + websockets) |
1 day |
AppSpec.transport field + Transport protocol |
half day |
| WSL connection config + example | half day |
| Total | ~4 days |
Tradeoffs and Risks¶
- Pickle as initial codec is fast to ship and safe for the trusted loopback case, but it creates a hidden dependency on both sides using the same Python and numpy versions. This is acceptable for the WSL use case, but must be replaced before any untrusted or cross-version deployment.
QWebSocketis synchronous-friendly (signal-based) but addsPyQt6.QtWebSocketsas a frontend dependency. This module ships with PyQt6 on Windows; no extra install is required.websockets(asyncio) adds a backend dependency. It is a small, stable library with no C extensions, appropriate for a WSL server with no display.- A single-connection server is the simplest correct model for now. Multi-client support is a separate problem and should not be designed in prematurely.
AppSpecnow has two mutually exclusive session-source fields (sessionandtransport). The mutual exclusivity should be enforced at runtime with a clear error message rather than silently preferring one over the other.
Open Questions¶
- Should
WebSocketTransportattempt reconnection when the connection drops, or surface a fatal error and stop the poll timer asPipeTransportdoes on worker death? Switching to Socket.IO (see section 7) would resolve this without any custom retry logic. - Should
run_backend_server()accept astartup_scenehook so the Windows frontend can display a loading scene before the firstSceneReadyarrives, as the currentstartup_sceneclassmethod does forPipeTransport? - What port should be the default? 8765 is conventional for Python WebSocket examples and unassigned by IANA for this range.