# 04 — Transport Layer (WebSocket / KCP / Channel Layer) This chapter covers Greenfield's transport: how Wayland wire traffic flows between the Linux-side proxy and the browser-side compositor over WebSocket, the binary framing, message ordering, latency/backpressure, KCP usage, multiplexing, and connection lifecycle. ## Architecture at a glance Greenfield uses a **multi-WebSocket, KCP-wrapped, asymmetric flow-control** design: - **Two-tier process model**: A `main.ts` HTTP hub forks one `SessionProcess` per browser session (per `compositorSessionId`). Raw `net.Socket`s are handed to children via Node IPC `sendHandle`. Each session child owns its own `wl_display` (via `wayland-server-addon.node`). - **9 WebSocket endpoints** per session: `/signal` (JSON control), `/channel` (binary data channels), `/mkfifo`, `/mkstemp-mmap`, `/read-fd`, `/read-fd-as-stream`, `/write-fd-as-stream`, `/close-fd`, `/request-keyframe`. All with `perMessageDeflate: false`, TCP `noDelay: true`. ## Channel types There are **4 logical channel types** per app client, each implemented as a separate WebSocket: | 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 | **There is no multiplexing** of multiple Wayland clients onto one WS. Each logical channel is its own WS, eliminating head-of-line blocking between video, input, and control at the cost of many connections per app (10+ for complex apps). ## KCP-over-WebSocket `kcp.ts` is a ~1100-line TypeScript port of the canonical C `ikcp.c`. Greenfield runs it **on top of WebSocket** (not real UDP) for: - 30 ms min RTO vs TCP's 200 ms. - 100 ms update tick (`setInterval(kcp.update, 100)`). - Per-channel congestion domains independent of TCP. - `setNoDelay(1, 100, 0, 1)` — nodelay enabled, congestion control disabled (`nc=1`). - 24-byte segment header: `conv|cmd|frg|wnd|ts|sn|una|len`. - MTU=64000 (commented "webrtc datachannel MTU" — legacy of a prior WebRTC design). ### Asymmetric windows (deliberate) - Proxy side: `snd_wnd=128, rcv_wnd=1024` - Browser side: `snd_wnd=1024, rcv_wnd=128` So proxy→browser (heavy video) has a smaller in-flight cap to bound latency; browser→proxy (bursty input) has a large window to avoid stalls. ### Why KCP-over-WebSocket? Because real-time video over plain TCP suffers from head-of-line blocking and slow retransmit. KCP gives Greenfield a UDP-like congestion behavior in user-space, while still riding on WebSocket (the only bidirectional binary transport universally available in browsers today). The long-term plan, documented in `docs/pages/design/index.md`, is to switch to **WebTransport** (real UDP) when browser support is widespread, and pair it with KCP for reliability. The KCP code is already in place; only the WebSocket transport layer needs to be swapped out. ## Binary message format All multi-byte fields are **little-endian**. ### PROTOCOL channel ``` u32 outOfBandOpcode (0 = in-band events, else 1=ResourceDestroyed, 2=BufferCreation, 6=RecycledResourceIds, 7=CommitSerial) u32 eventSerial u32 fdsCount for each fd: u32 jsonLen + u8[] JSON{handle,type,host} (4-byte aligned) concatenated Wayland wire messages (object_id + opcode/size + args) ``` ### FRAME channel (per H.264 frame) ``` u32 bufferId u32 bufferCreationSerial u32 bufferContentSerial encodedFrame: u32 contentSerial, u32 encodingType (0=h264/1=png), u32 width, u32 height, u32 encodedWidth, u32 encodedHeight, u32 opaqueLength, u8[] opaqueData, u32 alphaLength, u8[] alphaData ← alpha encoded as a SEPARATE H.264 stream ``` ### FEEDBACK channel Just 4 bytes: `u16 refreshIntervalMs + u16 avgDecodeDurationMs`. Sent at 1 Hz from browser to proxy. Used by the proxy to pace `wl_frame.done` callbacks so the native app sees a virtual monitor refresh rate matching the browser's actual RAF cadence. ## File descriptor handling Can't send FDs over WebSocket — Greenfield virtualizes them: - **ProxyFD** = `{handle:number, type:'pipe-read'|'pipe-write'|'shm'|'unknown', host:string}`. - Serialized as JSON inline in the message stream with a u32 length prefix. - Browser opens **separate ad-hoc WebSocket** to `/read-fd`, `/write-fd-as-stream`, etc., only when it actually needs the bytes. - Most `wl_buffer` FDs never need to be read because the proxy intercepts `wl_surface.commit`, reads the buffer locally via DRM/SHM, encodes to H.264, and ships only the encoded bytes. - **Foreign ProxyFD** (`proxyFD.host !== local baseURL`) triggers proxy-to-proxy relay: local proxy creates a native pipe, opens a WS to the foreign proxy's `/write-fd-as-stream`, and pipes the data through. This is how copy-paste between two proxy instances avoids a browser round-trip. ## Reconnection & lifecycle ### Browser side Vendored `ReconnectingWebSocket` (pladaria) with: - `minDelay=1-5s`, `factor=1.3`, `maxDelay=10s` - `minUptime=5s`, `connTimeout=4s` - `retries=∞` Channel state preservation: only close codes 4000–4999 (application-level) tear down KCP. Transient codes (1006, 1011) are absorbed — KCP keeps ticking and the WS reconnects underneath. ### Proxy side No auto-reconnect (it's the server). The Channel object lives on, waiting for a new WS upgrade with matching `id` + `key`. Outbound messages queue in `outboundMessages` and flush on `onOpen`. ### App kill timers When the browser disconnects from the `/signal` WebSocket: - **10-minute SIGHUP grace period** — gives the user time to reconnect (e.g. if they refresh the browser tab). - After 10 minutes of no reconnect, escalating to **SIGKILL after another 10s**. - Close code 1001 → immediate SIGHUP (user closed tab intentionally). - Close code 4001 → immediate SIGTERM (user clicked "force quit" in the shell). ### Session close When all `wl_clients` disconnect, 1-second grace then `session.close()` → `process.exit()` in the session child. Main process deletes the entry from its `sessionProcesses` map. ## Process model — three layers ### Layer 1: CLI Front Door (`compositor-proxy-cli/src/main.ts`) A single Node.js process listens on `:8081`. Per browser session, it `fork()`s one `SessionProcess` child — one child per `compositorSessionId`, **not** per app. Multiple apps in the same browser tab share one child. WebSocket upgrades are handed off to the matching child via `childProcess.send(wsUpgrade, socket)`. ### Layer 2: Session Process (`SessionProcess.ts`) The forked child. Receives `start` (creates `Session` + `SessionController`), `launchApp` (spawns the actual app binary), and `wsUpgrade` (hands WS to the controller) IPC messages. On `launchApp` it calls `launchApplication()` from `@gfld/compositor-proxy`, which `spawn()`s the executable, captures `pid`, replies to main with `{pid, key}`. On `session.close()` the process exits. ### Layer 3: Proxy Core (`compositor-proxy/src/`) - **`NativeWaylandCompositorSession`** — creates a real `wl_display` via `createDisplay()`, calls `addSocketAuto()` which creates the UNIX socket at `$XDG_RUNTIME_DIR/wayland-N` and returns the name `wayland-N` (this becomes `WAYLAND_DISPLAY`). Calls `initShm()` (adds YUV420/NV12/YUV444/ NV21 formats) and `initDrm()` (opens `/dev/dri/renderD128` via EGL/GBM, registers `zwp_linux_dmabuf_v1` + `wl_drm` globals). Integrates the wl_display FD into Node's libuv loop via `startPoll()`. - **`NativeWaylandClientSession`** — per-client wire-message interceptor. Uses `MessageInterceptor` + auto-generated per-resource interceptor modules (`src/protocol/wl_*_interceptor.js`). Each request returns `{native, browser, neverReplies?}` routing decision. Handles `wl_display.sync` fast-path (skips browser round-trip when no reply expected). Marshalls FDs as `ProxyFD` JSON. - **`NativeAppContext`** — per-app state: `pid`, `key` (16-hex random signaling auth), signaling WS, client sessions, data channels. Lifecycle: 10-min SIGHUP grace on browser disconnect, SIGTERM→SIGKILL escalation (10 s) on force-quit, auto-destroy on app exit. - **`SessionController`** — WS demultiplexer by pathname: `/signal` (per-app control), `/channel` (multiplexed data channels), `/mkfifo`, `/mkstemp-mmap`, `/read-fd`, `/write-fd-as-stream`, `/request-keyframe`. - **`XWaylandSession`** — lazy Xwayland fork (only on first X client connect). Xwayland itself becomes a Wayland client of the proxy. The `wm_fd` is bridged via xtsb to the browser, which implements the actual XWM logic. - **`Channel`** — `ARQChannel` (KCP-over-WebSocket, reliable, low-latency) for PROTOCOL/FRAME/XWM; `SimpleChannel` for FEEDBACK. ## ASCII diagram of the full transport stack ``` LINUX SIDE | BROWSER SIDE | ┌─────────────────────────┐ | ┌──────────────────────────────┐ │ gtk4-demo (process) │ | │ Browser tab │ │ libwayland-client.so │ | │ (Greenfield compositor) │ └───────────┬─────────────┘ | └──────────────┬───────────────┘ │ WAYLAND_DISPLAY=wayland-0 │ │ Unix socket + SCM_RIGHTS FD passing │ DOM events → input ▼ │ WebSocket API ┌──────────────────────────────────────────┐ │ │ SessionProcess (fork'd Node child) │ │ │ ┌────────────────────────────────────┐ │ │ │ │ wayland-server-addon.node │ │ │ │ │ (libwayland-server N-API) │ │ │ │ │ - wl_display + wl_client │ │ │ │ │ - epoll via proxy-poll-addon │ │ │ │ └─────────────┬──────────────────────┘ │ │ │ │ wire msg + FDs │ │ │ ▼ │ │ │ ┌─────────────────────────────────────┐ │ │ │ │ NativeWaylandClientSession │ │ │ │ │ - interceptRequest / interceptEvent│ │ │ │ │ - onWireMessageEnd: serialize │ │ │ │ │ FDs as JSON ProxyFDs inline │ │ │ │ │ - fastSync optimization │ │ │ │ └─────────────┬───────────────────────┘ │ │ │ │ outbound buffer │ │ │ ▼ │ │ │ ┌─────────────────────────────────────┐ │ │ │ │ ARQChannel (KCP-wrapped) │ │ │ │ │ MTU=64000, snd_wnd=128, rcv_wnd=1024│ │ │ │ │ KCP seg: conv|cmd|frg|wnd|ts|sn|una|len (24B) + data │ │ │ kcp.update() every 100ms │ │ │ │ │ - PROTOCOL (per wl_client) │ │ │ │ │ - FRAME (per wl_surface) │ │ │ │ │ - XWM (per XWayland) │ │ │ │ └─────────────┬───────────────────────┘ │ │ │ ┌─────────────┴───────────────────────┐ │ │ │ │ SimpleChannel (FEEDBACK, no KCP) │ │ │ │ │ 4 bytes: refreshIntervalMs + │ │ │ │ │ avgDecodeDurationMs │ │ │ │ └─────────────┬───────────────────────┘ │ │ │ ┌─────────────▼───────────────────────┐ │ │ │ │ NativeAppContext │ │ │ │ │ - signalingWebSocket (JSON msgs) │ │ │ │ │ - kill timers: SIGHUP@10min, │ │ │ │ │ SIGKILL@+10s │ │ │ │ └─────────────┬───────────────────────┘ │ │ │ ┌─────────────▼───────────────────────┐ │ │ │ │ SessionController │ │ │ │ │ ws.WebSocketServer (noServer) │ │ │ │ │ perMessageDeflate: false │ │ │ │ │ Endpoints: /signal /channel /mkfifo│ │ │ │ │ /mkstemp-mmap /read-fd /write-fd-as-stream│ │ │ │ /read-fd-as-stream /close-fd /request-keyframe│ │ │ └─────────────┬───────────────────────┘ │ │ └────────────────┼─────────────────────────────┘ │ │ │ │ ┌──────────────────────────┐ │ │ │ main.ts (HTTP+WS hub) │ │ │ │ - socket.pause() + IPC │ │ │ │ send to session proc │ │ │ │ - noDelay:true (TCP) │ │ │ └────────────┬────────────┘ │ │ │ │ │ ◄──── 1 WebSocket per channel ────► │ │ ◄── 1 signaling WS per app ──► │ │ ◄── ad-hoc WebFD streams ─► │ │ │ │ │ ▼ │ │ ┌────────────────────────────────────────┐ │ │ │ ReconnectingWebSocket │ │ │ │ min 1-5s, factor 1.3, max 10s │ │ │ │ minUptime 5s, connTimeout 4s │ │ │ └────────────┬───────────────────────────┘ │ │ ▼ │ │ ┌────────────────────────────────────────────┐ │ │ │ ARQChannel / SimpleChannel (mirror image) │ │ │ │ snd_wnd=1024, rcv_wnd=128 │ │ │ │ kcp.input → peekSize/recv → onMessage │ │ │ └────────────┬───────────────────────────────┘ │ │ ▼ │ │ ┌────────────────────────────────────────────┐ │ │ │ RemoteAppLauncher.onChannel │ │ │ │ PROTOCOL → session.display.createClient │ │ │ │ FRAME → deliverContentToBufferStream │ │ │ │ XWM → XWindowManagerConnection │ │ │ │ FEEDBACK → ClientEncodersFeedback │ │ │ └────────────┬───────────────────────────────┘ │ │ ▼ │ │ ┌────────────────────────────────────────────┐ │ │ │ @gfld/compositor-protocol Client │ │ │ │ - parses Wayland wire format │ │ │ │ - dispatches to wl_*_resource impls │ │ │ │ - H264NALDecoder.worker.ts decodes frames │ │ │ └────────────┬───────────────────────────────┘ │ │ ▼ │ │ ┌────────────────────────────────────────────┐ │ │ │ Surface + Scene rendering │ │ │ │ - ImageBitmap / VideoFrame / Canvas │ │ │ │ - DOM input → wl_keyboard/pointer reqs │ │ │ └────────────┬───────────────────────────────┘ │ │ │ input events flow back via the │ │ │ PROTOCOL channel as ordinary │ │ │ Wayland requests (same framing) │ └──────────────────┘ │ Browser DOM ◄─┘ ``` ## Key takeaways for building your own transport 1. **Separate control plane from data plane** — Greenfield uses `/signal` (JSON) vs `/channel` (binary). The control plane handles app launch, termination, kill timers; the data plane handles Wayland wire + encoded frames. 2. **One WS per logical stream** — simpler, no HoL blocking, but many TCP connections. 3. **KCP-over-WS** trades double-reliability overhead for fast (30 ms) retransmits; reasonable for real-time video. 4. **Virtualize FDs as JSON tokens** with lazy separate-WS endpoints for actual byte transfer. 5. **`bufferedAmount` check ≠ backpressure** — Greenfield's check is a drop gate, not real flow control (acknowledged TODO). 6. **Distinguish transient vs permanent close** — codes 4000-4999 tear down; lower codes auto-reconnect. 7. **Piggyback out-of-band opcodes** on the data channel via a leading u32 to avoid extra control WS. 8. **Asymmetric KCP windows** match each direction's bandwidth profile. 9. **Decode feedback drives frame pacing** — the proxy synthesizes `wl_frame.done` callbacks at the browser's reported cadence. 10. **One Node child per browser session** for fault isolation and per-session `wl_display` ownership. ## File reference ### Native side | File | Role | |---|---| | `packages/compositor-proxy/src/Channel.ts` | `ARQChannel` + `SimpleChannel` | | `packages/compositor-proxy/src/kcp.ts` | ~1100-line KCP implementation | | `packages/compositor-proxy/src/Session.ts` | Top-level proxy session | | `packages/compositor-proxy/src/SessionController.ts` | WS server with 9 endpoint paths | | `packages/compositor-proxy/src/NativeAppContext.ts` | Per-app state, signaling WS, kill timers | | `packages/compositor-proxy/src/NativeWaylandClientSession.ts` | Per-client message interceptor | | `packages/compositor-proxy/src/NativeWaylandCompositorSession.ts` | wl_display creation, initShm, initDrm | | `packages/compositor-proxy/src/FrameFeedback.ts` | Receives 4-byte decode feedback, paces wl_frame.done | | `packages/compositor-proxy/src/ProxyBuffer.ts` | Proxy-side buffer representation | | `packages/compositor-proxy/src/SurfaceBufferEncoding.ts` | Encoding pipeline orchestration | | `packages/compositor-proxy/src/XWaylandSession.ts` | XWayland spawn + bridge | | `packages/compositor-proxy/src/wayland-server.ts` | TS wrapper around wayland-server-addon.node | | `packages/compositor-proxy/src/io/` | WebSocket server glue | | `packages/compositor-proxy/src/encoding/` | High-level encoding orchestration | | `packages/compositor-proxy/src/protocol/` | Generated interceptor modules | | `packages/compositor-proxy/src/addons/` | .node file shims (Linux-x64) | ### Browser side | File | Role | |---|---| | `packages/compositor/src/remote/Channel.ts` | `ARQChannel` + `SimpleChannel` (mirror) | | `packages/compositor/src/remote/kcp.ts` | KCP mirror | | `packages/compositor/src/remote/RemoteAppLauncher.ts` | Channel setup per app, frame delivery | | `packages/compositor/src/remote/RemoteOutOfBandChannel.ts` | OOB opcode dispatch | | `packages/compositor/src/remote/RemoteInputOutput.ts` | FD-over-WebSocket for native apps | | `packages/compositor/src/remote/EncoderFeedback.ts` | Sends 4-byte decode feedback at 1 Hz | | `packages/compositor/src/remote/StreamingBuffer.ts` | Per-surface encoded-frame buffer | | `packages/compositor/src/remote/BufferStream.ts` | Manages serial → frame promise resolution | | `packages/compositor/src/remote/EncodedFrame.ts` | Wire format parser for encoded frames | | `packages/compositor/src/remote/webcodec-buffer-decoder.ts` | WebCodecs `VideoDecoder` path | | `packages/compositor/src/remote/wasm-buffer-decoder.ts` | FFmpeg Wasm fallback path | | `packages/compositor/src/remote/H264NALDecoder.worker.ts` | Worker entry, runs FFmpeg Wasm decoder | | `packages/compositor/src/remote/api.ts` | Public exports | ### CLI | File | Role | |---|---| | `packages/compositor-proxy-cli/src/main.ts` | HTTP + WS hub, forks per session | | `packages/compositor-proxy-cli/src/main-controller.ts` | CLI arg parsing | | `packages/compositor-proxy-cli/src/main-args.ts` | CLI arg definitions | | `packages/compositor-proxy-cli/src/SessionProcess.ts` | Per-session child, IPC handler | | `packages/compositor-proxy-cli/example-applications.json` | Pre-configured apps | | `packages/compositor-proxy-cli/wait-until-ready.sh` | Startup readiness check |