20 KiB
04 — Transport Layer (Python aiohttp + KCP, browser side clean-room)
This chapter covers the transport layer for the MIT approach: how Wayland wire traffic flows between the Python proxy and the browser compositor.
Recommended Python transport stack
| Layer | Choice | Why |
|---|---|---|
| WebSocket framework | aiohttp.web (v3.10+) | Best binary perf (300-600 Mbps on CPython, 2× on PyPy), native backpressure via await ws.drain(), single dependency, asyncio-native — eliminates the ASGI indirection tax of Starlette/uvicorn and the framework overhead of FastAPI |
| KCP library | kcp PyPI package (RealistikDash/kcp.py, v0.1.6, MIT, Cython wrapper over upstream ikcp.c) |
API (enqueue/receive/get_all_received/update/outbound_handler) maps cleanly to Greenfield's Kcp class. ~1500 LoC port of Greenfield's TS KCP would buy nothing — the C-backed package is faster and battle-tested |
| libwayland binding | pywayland 0.4.19 (Apache-2.0, CFFI) + cffi escape hatch for wl_event_loop_get_fd |
pywayland's EventLoop wrapper omits wl_event_loop_get_fd(). Fix: call pywayland.lib.wl_event_loop_get_fd(loop._ptr) directly via cffi, then asyncio.get_running_loop().add_reader(wl_fd, dispatch_wl). Replaces Greenfield's entire proxy-poll-addon.node C addon with ~10 lines of Python |
| Multi-process model | multiprocessing.Process (fork start method) per Odoo session |
Matches Greenfield's per-compositorSessionId forking. FD passing of WS upgrade socket via multiprocessing.reduction.send_handle/recv_handle (uses SCM_RIGHTS on Linux). Preserves the SIGHUP/SIGKILL escalation semantics |
| Encoder threading | threading.Thread + GMainLoop + loop.call_soon_threadsafe |
Mirrors Greenfield's napi_threadsafe_function pattern. GIL released during C encoding calls (GStreamer). Reuse Greenfield's MIT C files for dmabuf/egl via cffi — preserves all EGL/GBM/GStreamer pipelines |
| Binary message format | Keep Greenfield's verbatim | Browser-side TS unchanged; no need for a version field until v2. KCP gives message boundaries |
| ReconnectingWebSocket | Reuse Greenfield's vendored copy (Pedro Ladaria, MIT, 514 lines TS) | Browser-side; copy with attribution into Odoo OWL shell assets |
Channel types (same as Greenfield)
| Type | Class | KCP? | Purpose |
|---|---|---|---|
| PROTOCOL | ARQChannel | Yes | Wayland wire protocol, per wl_client |
| FRAME | ARQChannel | Yes | H.264/PNG encoded surface content, per wl_surface |
| XWM | ARQChannel | Yes | XWayland window manager protocol |
| FEEDBACK | SimpleChannel | No | 4-byte telemetry (refreshInterval + decode avg), 1 Hz |
WebSocket endpoints (9 paths, same as Greenfield)
/signal # JSON control plane (per-app)
/channel # binary data channels (multiplexed)
/mkfifo # create a POSIX pipe, return 2 ProxyFDs
/mkstemp-mmap # create a memfd-backed shm, return 1 ProxyFD
/read-fd # read bytes from a ProxyFD (one-shot)
/read-fd-as-stream # read bytes from a ProxyFD (streaming)
/write-fd-as-stream # write bytes to a ProxyFD (streaming)
/close-fd # close a ProxyFD
/request-keyframe # ask the encoder for a keyframe
All endpoints require JWT auth (HS256, validated by pyjwt).
asyncio + libwayland integration (the riskiest piece)
Verified working in the sandbox:
import asyncio, cffi
from pywayland.server import Display
extras_ffi = cffi.FFI()
extras_ffi.cdef("""
int wl_event_loop_get_fd(void *loop);
void *wl_display_get_event_loop(void *display);
int wl_event_loop_dispatch(void *loop, int timeout);
void wl_display_flush_clients(void *display);
""")
_lib = extras_ffi.dlopen("libwayland-server.so.0") # or pywayland's bundled
display = Display()
display.add_socket()
display.init_shm()
_ptr_int = int(pywayland.ffi.cast('uintptr_t', display._ptr))
_display_voidp = extras_ffi.cast('void *', _ptr_int)
_loop_voidp = _lib.wl_display_get_event_loop(_display_voidp)
_wl_fd = _lib.wl_event_loop_get_fd(_loop_voidp)
async def main():
loop = asyncio.get_running_loop()
def on_ready():
_lib.wl_event_loop_dispatch(_loop_voidp, 0)
display.flush_clients()
loop.add_reader(_wl_fd, on_ready)
await asyncio.Future() # run forever
asyncio.run(main())
This pattern is used in production by qtile's Wayland backend
(libqtile/backend/wayland/core.py). qtile is GPL-2.0 so don't copy code,
but the architectural pattern is documented and reusable.
Process model — three layers (same as Greenfield)
Layer 1: Main process (HTTP + WS hub)
Single Python process listens on :8081. Per Odoo session, it forks
one SessionProcess child — one child per compositor_session_id, not
per app. Multiple apps in the same browser tab share one child.
WebSocket upgrades are handed off to the matching child via
multiprocessing.reduction.send_handle (uses SCM_RIGHTS on Linux).
Layer 2: Session process
The forked child. Receives IPC messages:
start— createsSession(Display, initShm, initDrm) + WS dispatcherlaunch_app— spawns the actual app binary viasubprocess.Popen+ bubblewrapws_upgrade— hands WS to the dispatcher
On session.close() the process exits.
Layer 3: Per-client session
For each new wl_client (one per Wayland connection), creates a
PROTOCOL ARQ channel + FRAME ARQ channel. Marshalls FDs as ProxyFD
JSON. Routes requests via per-resource dispatchers.
FD virtualization (same approach as Greenfield)
ProxyFD = { handle: number, type: 'pipe-read'|'pipe-write'|'shm'|'unknown', host: string }
In Python:
os.pipe()for pipesos.memfd_create(name, flags=0)for shared memory (Python 3.8+ builtin, replaces Greenfield'screateMemoryMappedFilenative addon)asyncio.StreamReader/StreamWriterfor streaming reads/writes over separate WebSocket endpoints
Reconnection & lifecycle (same as Greenfield)
Browser side
Vendored ReconnectingWebSocket (MIT):
minDelay=1-5s,factor=1.3,maxDelay=10sminUptime=5s,connTimeout=4s,retries=∞- Close codes 4000-4999 tear down KCP; lower codes auto-reconnect
Python side
No auto-reconnect (it's the server). Channel object lives on, waiting for
new WS upgrade with matching id + key. Outbound messages queue and
flush on reconnect.
App kill timers
When browser disconnects from /signal WebSocket:
- 10-minute SIGHUP grace period
- After 10 min: SIGTERM, then SIGKILL after another 10s
- Close code 1001 → immediate SIGHUP (intentional tab close)
- Close code 4001 → immediate SIGTERM (force quit)
JWT auth (NEW vs Greenfield)
Every WebSocket connection must include a valid JWT (HS256, 15-min expiry):
Authorization: Bearer <jwt>
Or as a query param: ?token=<jwt>
JWT payload:
{
"iss": "odoo.example.com",
"aud": "compositor-proxy",
"sub": 42, // Odoo user ID
"db": "production", // Odoo database name
"company_ids": [1, 3],
"scope": "compositor",
"compositor_session_id": "uuid-here",
"iat": 1736000000,
"exp": 1736000900
}
Validated by the proxy using compositor.jwt_secret from Odoo's
ir.config_parameter (shared via env var at proxy startup).
ASCII diagram
PYTHON HOST (Linux) BROWSER (Odoo OWL shell)
========================== ========================
┌─────────────────────────┐ ┌──────────────────────────────┐
│ Native app (gtk4-demo) │ │ Odoo web client │
│ (sandboxed in bwrap) │ │ (Odoo 20 OWL shell addon) │
└───────────┬─────────────┘ └──────────────┬───────────────┘
│ WAYLAND_DISPLAY=wayland-0 │
│ Unix socket + SCM_RIGHTS FD passing │ DOM events → input
▼ │ WebSocket API
┌──────────────────────────────────────────┐ │
│ SessionProcess (fork'd Python child) │ │
│ ┌────────────────────────────────────┐ │ │
│ │ pywayland (Apache-2.0) │ │ │
│ │ + 5-line cffi shim │ │ │
│ │ - wl_display + wl_client │ │ │
│ │ - asyncio.add_reader for wl_fd │ │ │
│ └─────────────┬──────────────────────┘ │ │
│ │ wire msg + FDs │ │
│ ▼ │ │
│ ┌─────────────────────────────────────┐ │ │
│ │ Per-resource dispatchers │ │ │
│ │ (auto-generated by pywayland) │ │ │
│ │ - route to {native, browser} │ │ │
│ │ - serialize FDs as ProxyFD JSON │ │ │
│ │ - fast-sync optimization │ │ │
│ └─────────────┬───────────────────────┘ │ │
│ │ outbound buffer │ │
│ ▼ │ │
│ ┌─────────────────────────────────────┐ │ │
│ │ ARQChannel (KCP-wrapped) │ │ │
│ │ - kcp PyPI package (MIT) │ │ │
│ │ - MTU=64000, snd_wnd=128, rcv=1024 │ │ │
│ │ - kcp.update() every 100ms │ │ │
│ │ - PROTOCOL (per wl_client) │ │ │
│ │ - FRAME (per wl_surface) │ │ │
│ │ - XWM (per XWayland) │ │ │
│ └─────────────┬───────────────────────┘ │ │
│ ┌─────────────┴───────────────────────┐ │ │
│ │ SimpleChannel (FEEDBACK, no KCP) │ │ │
│ │ 4 bytes: refreshIntervalMs + │ │ │
│ │ avgDecodeDurationMs │ │ │
│ └─────────────┬───────────────────────┘ │ │
│ ┌─────────────▼───────────────────────┐ │ │
│ │ NativeAppContext │ │ │
│ │ - signaling WebSocket (JSON) │ │ │
│ │ - kill timers: SIGHUP@10min, │ │ │
│ │ SIGKILL@+10s │ │ │
│ │ - bubblewrap + seccomp sandbox │ │ │
│ └─────────────┬───────────────────────┘ │ │
│ ┌─────────────▼───────────────────────┐ │ │
│ │ SessionController │ │ │
│ │ - aiohttp.web.Application │ │ │
│ │ - 9 endpoints │ │ │
│ │ - JWT validation (pyjwt) │ │ │
│ │ - perMessageDeflate: false │ │ │
│ └─────────────┬───────────────────────┘ │ │
│ ┌─────────────▼───────────────────────┐ │ │
│ │ Encoder thread (GStreamer + GLib │ │ │
│ │ MainLoop in separate thread) │ │ │
│ │ - vaapih264enc / nvh264enc / │ │ │
│ │ openh264enc (NOT x264enc — GPL) │ │ │
│ │ - asyncio.run_coroutine_threadsafe │ │ │
│ │ for cross-thread marshalling │ │ │
│ └─────────────┬───────────────────────┘ │ │
└────────────────┼─────────────────────────────┘ │
│ │
│ ┌──────────────────────────┐ │
│ │ main.py (HTTP+WS hub) │ │
│ │ - socket.pause() + IPC │ │
│ │ send to session proc │ │
│ │ - multiprocessing.fork │ │
│ │ - JWT issuance delegated │ │
│ │ to Odoo backend │ │
│ └────────────┬─────────────┘ │
│ │ │
│ ◄──── 1 WebSocket per channel ────► │
│ ◄── 1 signaling WS per app ──► │
│ ◄── ad-hoc WebFD streams ─► │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────┐ │
│ │ ReconnectingWebSocket (vendored MIT) │ │
│ │ min 1-5s, factor 1.3, max 10s │ │
│ │ minUptime 5s, connTimeout 4s │ │
│ │ + Authorization: Bearer <jwt> │ │
│ └────────────┬───────────────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────────────────┐ │
│ │ ARQChannel / SimpleChannel (clean-room TS) │ │
│ │ snd_wnd=1024, rcv_wnd=128 │ │
│ │ kcp.input → peekSize/recv → onMessage │ │
│ └────────────┬───────────────────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────────────────┐ │
│ │ Custom Browser Compositor (clean-room TS) │ │
│ │ - WebCodecs VideoDecoder (HW-accelerated)│ │
│ │ - WebGL2 + OffscreenCanvas in Worker │ │
│ │ - Surface/View/Output/Seat/Keyboard/... │ │
│ └────────────┬───────────────────────────────┘ │
│ │ input events flow back via the │
│ │ PROTOCOL channel as ordinary │
│ │ Wayland requests (same framing) │
└──────────────────┘ │
Browser DOM ◄─┘
Effort estimate (Python transport)
| Component | LoC | Dev-days | Risk |
|---|---|---|---|
| aiohttp WS server (9 endpoints + HTTP launcher) | 600 | 3 | Low |
Channel/ARQChannel/SimpleChannel port |
250 | 1.5 | Low |
kcp.py integration adapter |
100 | 0.5 | Low |
NativeAppContext port (signaling, escalation) |
350 | 2 | Low |
ProxyInputOutput port (FD workaround) |
300 | 2 | Medium |
| libwayland event-loop integration | 80 | 1 | Medium-High (under-documented) |
| Multiprocessing scaffolding + FD passing | 200 | 1.5 | Medium |
| GStreamer encoder thread + marshalling | 250 | 2 | Medium |
| Reconnection & lifecycle | 150 | 1 | Low |
| Tests (pytest + pytest-asyncio) | 600 | 3 | Low |
| Total | 2880 | ~17 |
Top risks & mitigations
- Highest risk: libwayland ↔ asyncio event-loop integration (under-documented). Mitigation: Spend 1 day on a standalone prototype (the 10-line script above) before committing.
- Medium risk: FD passing via
multiprocessing.reduction. Fallback: single-process with asyncio concurrency. - Medium risk: GStreamer threading. Mitigation: reuse Greenfield's MIT C files via cffi — only the marshalling needs Python.
Dependency manifest
# requirements.txt
aiohttp>=3.10,<4 # WebSocket server + HTTP
kcp>=0.1.6 # Cython KCP bindings (RealistikDash)
pywayland>=0.4.19 # libwayland CFFI wrapper (Apache-2.0)
cffi>=1.16 # for wl_event_loop_get_fd escape hatch
pygobject>=3.48 # GStreamer Python bindings
pyjwt>=2.8 # JWT validation
System packages (Debian):
libwayland-dev libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
libgstreamer-plugins-bad1.0-dev libgbm-dev libdrm-dev libegl-dev
gstreamer1.0-vaapi # Intel/AMD hardware encoding
libnvidia-encode1 # NVIDIA hardware encoding (if applicable)
bubblewrap libseccomp-dev
python3-dev build-essential pkg-config
DO NOT INSTALL: gstreamer1.0-plugins-ugly (provides x264enc, GPL) or
gstreamer1.0-libav (LGPL source but GPL binary on Debian).