Skip to content

WebSocket data plane

openengine-cluster-server carries the backend-neutral Dispatcher over one WebSocket connection. Its transport-neutral connection core also carries the generic subscription framing defined in watch.md. This document specifies the WebSocket framing rules and the boundary between the binding and its host. Apart from $/cancelRequest, the binding adds no protocol method or event semantics; methods, events, and subscription notifications match the in-process and NDJSON bindings.

Framing

One WebSocket text message carries exactly one JSON-RPC object (request, response, or notification); there is no line delimiter, and batching is not allowed. A binary frame is not a supported encoding: it closes the connection immediately with code 1003 (unsupported data). A text message whose UTF-8 byte length exceeds 1,048,576 after frame reassembly closes the connection with code 1009 (message too big), matching the NDJSON stdio binding's line-length bound. A text message that fails to parse as JSON-RPC receives a normal PARSE_ERROR JSON-RPC error response on the same connection; unlike the size and encoding violations above, a parse failure does not close the connection. Ping/pong control frames are answered automatically and otherwise ignored.

Subscription delivery uses the generic event/subscription/cancel/subscription/closed notification framing described in watch.md. The WebSocket binding adds no WebSocket-specific subscription notification names, and results, events, and errors are byte-equivalent between the in-process, NDJSON, and WebSocket bindings for the same request sequence.

$/cancelRequest

$/cancelRequest{id} is a client notification that makes a best-effort attempt to cancel an in-flight unary request by its RequestId on the same connection. It carries no response. An unknown or already-completed ID is ignored; the connection remains usable, and the server sends no error. Cancelling a request that has committed backend state does not roll that state back or compensate for it. Cancellation only suppresses delivery of the response, if it is still in flight. $/cancelRequest targets unary/passthrough requests only; an established watch/logs/agent/attach subscription is cancelled with subscription/cancel instead, exactly as over NDJSON.

Connection isolation over a shared backend

One backend instance can be shared by multiple independently accepted WebSocket connections. At acceptance, the binding invokes its host-owned identity resolver exactly once, before decoding the first frame, and constructs an immutable ConnectionContext containing the resulting typed ConnectionIdentity: opaque principal and tenant identifiers, optional issuance time, required hard expiry, and binding-specific opaque attributes. Identity has no serde/wire representation. principal, tenant, and expiresAt keys in protocol params are ordinary unknown fields and fail typed parameter decoding with INVALID_PARAMS; they never change the connection identity.

The binding checks expiry at every inbound request decode boundary. A frame observed at or after expires_at_ms reaches neither admission nor the backend: WebSocket closes with application code 4401, while NDJSON writes one terminal diagnostic and closes. The dispatcher never partitions state. Connections with the same or distinct tenants observe exactly the sharing or isolation chosen by the backend that receives their read-only contexts.

Capsule data-plane placement

A capsule exposes this data-plane surface from the runtime process or container that accepts WebSocket connections and serves one backend's Dispatcher. The capsule host, not this crate, owns everything upstream of an accepted connection:

  • provisioning or scheduling the capsule itself;
  • TLS termination (this binding speaks plain WebSocket text frames; TLS, if any, terminates in front of it);
  • resolving the principal, tenant, expiry, and opaque binding attributes supplied to the connection's identity resolver;
  • billing and usage metering;
  • workspace or secret storage/services;
  • artifact bytes (this protocol carries status, events, and control, never artifact payloads);
  • issuing or validating the token/credential that authorized the connection.

A hosting process accepts the raw connection and supplies ConnectionBinding with its shared backend, identity resolver, time source, and connection cancellation signal. serve_websocket preserves that cancellation handle while resolving identity and constructing the context before reading frames; this document defines only what happens from that handoff onward.

Client dialing and TLS

openengine-cluster-client::dial_websocket is the outbound network boundary, separate from the plaintext server binding above. It accepts only ws:// and wss:// endpoints with a host and rejects userinfo, query strings, and fragments before opening a socket. It connects only to the validated endpoint supplied by its caller: WebSocket redirect handshake responses are errors, their Location targets are never opened, and wss:// is never downgraded to ws://.

The workspace TLS implementation is rustls 0.23 with tokio-rustls 0.26, selected through tokio-tungstenite's rustls native-root connector. A wss:// connection loads platform/system roots by default and fails closed if they cannot be loaded. Callers may explicitly add private CA roots for one connection. The optional bundled-roots client feature is disabled by default and augments the successfully loaded system store; bundled roots are never an automatic fallback for an unavailable system store. A ws:// endpoint, including loopback, is refused unless that individual connection uses WebSocketDialOptions::allow_plaintext(true).

Native TLS is intentionally excluded: on Linux it resolves to openssl-sys, imposing a system OpenSSL build dependency that breaks cross-compilation and static-musl builds. TLS features are enabled only by the dialing client; openengine-cluster-server::serve_websocket remains a plaintext, accepted-stream binding whose production TLS termination stays in its front proxy.

Cloud HTTP boundary

The crate has no cloud HTTP listener, certificate provisioning, or HTTP server. A later binding can use the same rustls/tokio-rustls stack inside openengine-cluster-server: accept TCP, perform the TLS handshake with a server rustls configuration, and hand the resulting asynchronous stream to an HTTP server implementation. reqwest is an HTTP client and cannot serve this binding; selecting an HTTP server remains a separate decision.

Fixture and test boundary

crates/openengine-cluster-server/tests/websocket.rs covers this binding's own framing and admission behavior directly against raw tungstenite frames. crates/openengine-cluster-client/tests/websocket.rs covers the typed WebSocketTransport client against the same binding. crates/openengine-cluster-testkit/tests/protocol_websocket.rs proves byte-equivalence against the in-process and NDJSON bindings and exercises two independently authorized connections sharing one backend. Those established framing suites continue to drive serve_websocket over an in-memory duplex pipe. Loopback TCP, TLS trust, plaintext opt-in, preflight, and redirect behavior belong to crates/openengine-cluster-client/tests/tls_dialer.rs.