Control Socket Protocol — Version 1.6¶
This document specifies the wire protocol spoken between a Ryll session
with no host window — --headless or --web — and any external driver
that connects to its Unix-domain control socket. It is the load-bearing
contract for the automated SPICE-test-harness work: external drivers
such as latency loadtests and Sextant scenario tests implement against
this document. Read the whole document before writing a client or
implementing a verb.
Protocol version: 1.6
Version history:
- 1.0 — initial.
hello,status,send_key,paste,screenshot,subscribe,unsubscribeverbs;latency,agent_connected,paste_completed,paste_failed,droppedevents. - 1.1 — added the
surface_drawnevent (one wire event per display draw command) so a loadtest can compute keypress-to-screen latency rather than thelatencyevent's PING-interval sample. Added thedigest_updatedevent behind thedigest-decodeCargo feature. Backwards-compatible at the major-version level: v1.0 clients can still hello and operate; they just do not subscribe to v1.1 events. - 1.2 — the socket became available in
--webmode as well as--headless; no wire change, but the flag combination a client can expect to find a socket behind is wider. Correctedsend_key's encoding of 0xE0-prefixed extended scancodes, which every version before this transmitted with the prefix byte second and the break bit on the prefix rather than the scancode — see the note undersend_key. Plain scancodes are unaffected, so a client that only sends those sees no difference. - 1.3 — added the
clipboard_set,clipboard_getandresizeverbs, theclipboard_changedevent and thequeue_fullerror code, so a session with no host window can carry clipboard text to and from the guest and ask the guest for a display size. Additive; a client that does not use them sees no difference. - 1.4 — added the
statsverb, which returns the display channel's byte, message andDISPLAY_MARKcounts and its video stream counters, so a test can measure a server's frame rate and check its video streams. Additive. - 1.5 — added the
input_rttevent, a round trip measured on the inputs channel from a pointer message to the server's acknowledgement of it. Thelatencyevent is unchanged: it was always the interval between the server's PINGs, which says little about the path when a server pings every 30 s. Additive. - 1.6 — added a
playbackobject to thestatsresult: the playback channel's start, stop, packet, decode and byte counts and the current audio stream's codec, rate and channels, so a test can check that audio arrives and decodes. It is absent when no playback channel has linked, including in every build without theaudiofeature. Additive.
Contents¶
- Scope and non-goals
- Transport
- Framing
- Message envelopes
- Hello handshake
- Concurrency
- Verb reference
- Event reference
- Subscription semantics
- Backpressure
- Error model
- Versioning
- End-to-end worked example
- Implementation
Scope and non-goals¶
What this protocol covers¶
- Driving a Ryll session that has no host window —
--headlessor--web— from an external process over a Unix-domain socket. The GUI cannot host the socket; see the Transport section. - Querying session state (SPICE connection status, surfaces, agent availability) and the display channel's counters (bytes, messages, frame marks, video streams).
- Sending keyboard input as individual scancodes or as paste-as- keystrokes text.
- Capturing a screenshot of any live surface.
- Setting and reading the session's clipboard, which ryll syncs with the guest's through the SPICE vdagent, and asking the guest for a display size.
- Subscribing to an asynchronous stream of events: the server's PING
interval and inputs-channel round trips, agent connect/disconnect
transitions, paste completion notifications, text the guest copied,
per-draw surface notifications, decoded visual-digest updates (on
digest-decodebuilds, in both--headlessand--web), and queue-overflow notifications. - Negotiating the protocol version at connection time so clients and servers can evolve independently within a major version.
What this protocol does NOT cover (v1 non-goals)¶
- Mouse and USB-redirection verbs. No current test consumer needs them. They will be added as new minor-version verbs when a test that requires them arrives.
- Authentication or encryption on the socket. Unix-socket file permissions are the security boundary for v1. Cross-host control is not a goal; if it ever becomes one, that is a separate design.
- Multi-client concurrency. Version 1 accepts exactly one client at
a time. A second connection attempt while a client is connected
receives a
busyerror and is closed immediately. - Control socket in GUI mode. The socket is valid with
--headlessor--web, both of which run a session with no host window. Combining--control-socketwith the GUI is a CLI error: the window owns input and the surface, so a second driver injecting events behind its back has no defined meaning. - Replacing the
--cadence,--paste-text, or--latency-fileflags. Those flags keep working unchanged. The control socket is a new, orthogonal interface.
Transport¶
The control socket is a Unix-domain stream socket (type
SOCK_STREAM). Its path is supplied by the caller via Ryll's
--control-socket <path> flag. This flag needs --headless or
--web; Ryll rejects it in GUI mode at launch.
On startup, before it connects to the SPICE server, Ryll:
- Unlinks any existing socket at the path (so a stale socket from a previous run does not block startup). Any other kind of file at the path is left alone, and is an error.
- Creates a new socket file and calls
bind(). - Sets the file mode to 0600 (owner read/write only). A socket that is bound but not yet listening refuses every connection, so there is no moment at which anyone but the owner can connect, whatever the process umask. File permissions are the sole access-control mechanism; protect the path accordingly.
- Calls
listen()and begins accepting connections.
If any of these steps fails, Ryll exits non-zero with an error naming
the path, without connecting. Common causes are a path longer than
sockaddr_un allows (108 bytes on Linux, 104 on macOS), a directory
that does not exist or is not writable, and a regular file at the
path. A client waiting for the socket to appear can therefore also
watch for Ryll exiting, rather than waiting for a socket that will
never appear.
When the SPICE session ends (normally or on error), Ryll closes the listening socket and unlinks the socket file.
The socket is a streaming transport, not a datagram transport. Because NDJSON framing (see below) is self-delimiting, the client and server never need to know how many bytes to read in advance; they read until they see a newline.
Framing¶
All messages are framed as line-delimited JSON (NDJSON). Every
message is exactly one JSON value encoded as a single line terminated
by a \n (ASCII 0x0A) byte. No length prefix, no binary envelope, no
trailing null byte. Each line is a self-contained, parseable JSON
object.
- Encoding: UTF-8.
- Line terminator:
\n. The sender appends\nafter every serialised object. The receiver splits the byte stream on\nboundaries and feeds each non-empty line to a JSON parser. - A single connection is full-duplex: the client writes request lines; the server writes response lines and event lines. Writes from each side are independent; neither side buffers until it hears from the other.
- The server never sends a partial line. If the server has nothing to
say, it is silent. A reader that reads a complete
\n-terminated line always has a complete JSON object. - JSON values that span multiple lines are not supported. Do not pretty- print messages on the wire.
- A request line may be at most 629,145,600 bytes (600 MiB) plus
1,048,576 (1 MiB), not counting its
\n: six times the largestclipboard_settext, which is the most JSON escaping can grow it (a control character becomes\u00XX), plus room for the envelope. Any request within the verbs' own limits therefore fits, however the client's encoder escapes. The server stops reading a longer line once it passes the limit, writes abad_paramserror with noid(the line was never parsed), and closes the connection, since the rest of the line is still unread and there is no reliable way back to a line boundary. Added in v1.3; earlier servers had no limit. - A request line that is not valid UTF-8 gets a
bad_paramserror with noid, and the connection stays open. Earlier servers closed the connection.
Message envelopes¶
There are three envelope shapes. Every message on the wire is one of these three.
Request (client to server)¶
Fields:
| Field | Type | Required | Description |
|---|---|---|---|
id |
integer or string | yes | Caller-chosen correlation token. The server echoes it in the matching response. Must be unique among in-flight requests on this connection. |
method |
string | yes | The verb name. See the verb reference section. |
params |
object | yes | Verb-specific parameters. May be {} for verbs that take no arguments. Never omit the key. |
The id field may be any JSON integer or JSON string. Integers are
recommended for simplicity. The server treats it as an opaque token;
it never performs arithmetic on it.
Response (server to client)¶
Success:
Failure:
{"id": 1, "ok": false, "error": {"code": "bad_state", "message": "unrecognised state value \"sideways\""}}
Fields:
| Field | Type | Present when | Description |
|---|---|---|---|
id |
integer or string | always | The id copied from the matching request. |
ok |
boolean | always | true on success, false on error. |
result |
object | ok is true |
Verb-specific result payload. May be {}. |
error |
object | ok is false |
Error descriptor. Contains code (stable string) and message (human-readable string). |
Event (server to client, unsolicited)¶
Fields:
| Field | Type | Description |
|---|---|---|
event |
string | The event name. See the event reference section. |
data |
object | Event-specific payload. |
Events have no id field and never correspond to a request. They are
delivered whenever the subscribed condition occurs. A client that has
not called subscribe receives no events; see the subscription
semantics section.
Hello handshake¶
Every fresh connection must begin with a hello request before any
other request. This rule exists so both sides can negotiate the
protocol version before doing anything that depends on a particular
verb signature.
Ordering rule¶
The server tracks whether the current client has completed a successful
hello exchange. If the server receives any request other than
hello before a successful hello response has been sent, it
responds immediately with:
{"id": <the_request_id>, "ok": false, "error": {"code": "no_hello_yet", "message": "first request must be hello"}}
Importantly, the connection stays open after this error. The client
may then send a hello request and continue normally. This is
intentional: a buggy client that accidentally sends one request out of
order should be able to recover without reconnecting.
Hello request¶
Parameters:
| Field | Type | Description |
|---|---|---|
client_name |
string | A human-readable identifier for the client. Used in server logs; not validated. |
protocol_version |
string | The major.minor protocol version the client is requesting. Must be a dotted two-part string, e.g. "1.0". |
Hello response — success¶
{"id": 1, "ok": true, "result": {"server_name": "ryll", "protocol_version": "1.6", "supported_methods": ["hello", "status", "send_key", "paste", "screenshot", "subscribe", "unsubscribe", "clipboard_set", "clipboard_get", "resize", "stats"], "supported_events": ["latency", "agent_connected", "paste_completed", "paste_failed", "dropped", "surface_drawn", "clipboard_changed", "input_rtt"]}}
Result fields:
| Field | Type | Description |
|---|---|---|
server_name |
string | Always "ryll" in this implementation. |
protocol_version |
string | The version the server will speak. In v1.x this is "1.6"; older servers reported "1.0", "1.1", "1.2", "1.3", "1.4" or "1.5". Clients should not compare for exact equality — see the compat note below. |
supported_methods |
array of string | The complete set of verb names the server recognises. Clients should use this list rather than hard-coding expectations, especially when connecting to a newer server. |
supported_events |
array of string | The complete set of event names the server can emit. |
Hello response — version mismatch¶
If the client's protocol_version has a different major component
from the server's supported major version, the server responds with an
error and then closes the connection:
{"id": 1, "ok": false, "error": {"code": "protocol_version_mismatch", "message": "server speaks major version 1; client requested major version 2"}}
The server writes the error line and then closes the socket. The client will see EOF immediately after reading that line.
Minor version mismatches are accepted in both directions. A v1.6
client connecting to a v1.0 server, or vice versa, is fine: the server
responds with whatever minor version it speaks (e.g. "1.0" or
"1.1"), and both sides behave according to the features they each
know about. The client should check supported_methods and
supported_events at runtime rather than assuming every verb in this
document is available on every server.
Concurrency¶
Version 1 supports one client at a time.
The server maintains a flag that tracks whether a client is currently
connected. A second accept() while a client is connected results in
the server writing a single error line on the new connection and then
closing it:
Note that this synthetic response does not have an id field, because
no request was received. A client reading this line can detect the
busy condition by checking ok and error.code. After writing
this line the server closes the new connection. The existing client is
unaffected.
Clients that need to retry should implement a simple back-off loop. A reasonable strategy is to poll every 250 ms with a short timeout.
Verb reference¶
Every verb subsection describes the params object, the result object on
success, the error codes specific to that verb, and a worked NDJSON
example. The common error codes (no_hello_yet, unknown_method,
bad_params, internal_error) are not repeated per-verb; see the
error model section for their definitions.
hello¶
Documented in full in the hello handshake section. Repeated here for completeness as a verb-reference entry.
Params:
| Field | Type | Description |
|---|---|---|
client_name |
string | Human-readable client identifier. |
protocol_version |
string | Dotted major.minor string the client is requesting. |
Result on success:
| Field | Type | Description |
|---|---|---|
server_name |
string | "ryll". |
protocol_version |
string | The version the server will speak. |
supported_methods |
array of string | Verb names this server supports. |
supported_events |
array of string | Event names this server can emit. |
Verb-specific error codes:
| Code | When |
|---|---|
protocol_version_mismatch |
Major version in protocol_version does not match the server's major version. Connection is closed after the error. |
Worked example:
→ {"id": 1, "method": "hello", "params": {"client_name": "demo", "protocol_version": "1.1"}}
← {"id": 1, "ok": true, "result": {"server_name": "ryll", "protocol_version": "1.6", "supported_methods": ["hello", "status", "send_key", "paste", "screenshot", "subscribe", "unsubscribe", "clipboard_set", "clipboard_get", "resize", "stats"], "supported_events": ["latency", "agent_connected", "paste_completed", "paste_failed", "dropped", "surface_drawn", "clipboard_changed", "input_rtt"]}}
status¶
Query the current state of the headless SPICE session.
Params: {} (none required)
Result on success:
| Field | Type | Description |
|---|---|---|
spice_connected |
boolean | Whether the SPICE main channel is currently established. |
agent_connected |
boolean | Whether a SPICE vdagent is currently running in the guest. Some operations (paste) require the agent. |
surfaces |
array of surface objects | The set of display surfaces currently known to Ryll. May be empty while the session is still initialising. |
Each surface object:
| Field | Type | Description |
|---|---|---|
channel_id |
u8 | The display channel number this surface belongs to. |
surface_id |
u32 | The surface identifier within that channel. Surface 0 is the primary surface. |
width |
u32 | Width in pixels. |
height |
u32 | Height in pixels. |
Verb-specific error codes: none beyond the common set.
Worked example:
→ {"id": 2, "method": "status", "params": {}}
← {"id": 2, "ok": true, "result": {"spice_connected": true, "agent_connected": true, "surfaces": [{"channel_id": 1, "surface_id": 0, "width": 1024, "height": 768}]}}
send_key¶
Send a single keyboard scancode event to the guest.
Params:
| Field | Type | Description |
|---|---|---|
scancode |
u16 | The AT-set 1 make code to send, in logical form — the way scancode tables are conventionally written. Extended scancodes (0xE0 prefix) are supplied as the full 16-bit value with the prefix byte in the high byte, e.g. 0xE04B for left arrow. This is not the byte order SPICE uses on the wire; the server converts. Do not send a pre-swapped wire value. |
state |
string | One of "down", "up", or "press". "press" sends a down event immediately followed by an up event in a single operation. |
The server owns both halves of the AT-set 1 encoding, and a client should send the logical make code for every state:
- Byte order. SPICE carries the scancode as a little-endian
32-bit value, and the wire wants the
0xE0prefix first. The server swaps the two bytes, so the logical0xE04Bis transmitted asE0 4B. - Release bit. For
"up"(and the up half of"press") the server sets the break bit, which belongs on the scancode byte rather than on the prefix.
A client that passes a logical code with the release bit already set
for "up" — 0x9E rather than 0x1E, or 0xE0CB rather than
0xE04B — still produces the identical wire value, because the
server ORs the bit in and OR-ing a set bit is a no-op. That
affordance is guaranteed and covered by a test.
Protocol 1.1 and earlier got extended keys wrong. Servers before 1.2 injected the supplied value verbatim, so
0xE04Breached the guest as4B E0— prefix second — and| 0x80put the break bit on the prefix byte. Every 0xE0-prefixed key was wrong in both directions, and a client could only reach the guest correctly by sending a pre-swapped wire value, which 1.2 rejects as the malformed input it always was. Plain (non-extended) scancodes are unaffected and behave identically across both versions. A client that needs extended keys should requireprotocol_version≥ 1.2 fromhello.
Result on success: {}
Verb-specific error codes:
| Code | When |
|---|---|
bad_state |
The state field contains a value other than "down", "up", or "press". |
Worked example (Enter key press):
→ {"id": 3, "method": "send_key", "params": {"scancode": 28, "state": "press"}}
← {"id": 3, "ok": true, "result": {}}
Worked example (extended key, left arrow, explicit down then up):
→ {"id": 4, "method": "send_key", "params": {"scancode": 57419, "state": "down"}}
← {"id": 4, "ok": true, "result": {}}
→ {"id": 5, "method": "send_key", "params": {"scancode": 57419, "state": "up"}}
← {"id": 5, "ok": true, "result": {}}
Note: 57419 decimal is 0xE04B, the scancode for the left arrow key.
paste¶
Paste a string of text into the guest by translating it into US-QWERTY key events and queuing them for delivery.
This verb is asynchronous. The response is returned as soon as the
paste task has been queued, not when it has finished typing all the
characters. Completion (or failure) is communicated as a
paste_completed or paste_failed event delivered to any client that
has subscribed to those events. The request_id field in those events
matches the id of the paste request, so the caller can correlate
the outcome.
This design mirrors how --paste-text works internally: the paste
produces a series of synthetic KeyDown/KeyUp events spaced by
char_delay_ms, and those events are generated by a background task
that runs concurrently with the SPICE session loop. Blocking the
paste response until the last character is typed would stall the
control socket for potentially several seconds, which is worse than
async reporting.
If the client disconnects while a paste is in progress, Ryll cancels the outstanding paste task and stops generating synthetic key events. This is tracked per request ID via a cancellation token held in the session's in-flight action registry.
Params:
| Field | Type | Description |
|---|---|---|
text |
string | The text to type. Must be representable in US-QWERTY layout (ASCII printable characters). Characters that cannot be represented will cause a paste_failed event. |
char_delay_ms |
u32 or null | Milliseconds to wait between each character. Defaults to 10 ms if omitted or null — a control-socket default, independent of the --paste-char-delay-ms CLI flag (whose default is 16 ms). Useful for guests with slow keystroke handling. |
Result on success: {} (returned immediately on queue, not on completion)
Verb-specific error codes:
| Code | When |
|---|---|
agent_not_connected |
The SPICE vdagent is not currently connected. The paste infrastructure depends on the agent for clipboard operations. Wait for an agent_connected event with connected: true before retrying. |
Worked example:
→ {"id": 6, "method": "paste", "params": {"text": "hunter2", "char_delay_ms": 20}}
← {"id": 6, "ok": true, "result": {}}
After some time, assuming the client is subscribed to paste_completed:
Or on failure (e.g. an unrepresentable character mid-string):
← {"event": "paste_failed", "data": {"request_id": 6, "reason": "character U+2022 is not representable in US-QWERTY"}}
screenshot¶
Capture a PNG or raw RGBA image of a display surface and return it inline in the response, base64-encoded.
This is a synchronous verb: the server locks the surface mirror, snapshots the pixel buffer, encodes the image, and responds before accepting another request. The response therefore may be large and slow to arrive.
Size and latency warning. At 1024 x 768:
- A raw RGBA snapshot is 1024 x 768 x 4 = 3,145,728 bytes (~3 MB).
- A PNG encode of typical desktop content takes approximately 5-10 ms and produces roughly 300-900 KB.
- Base64 inflates the payload by 33 %. A 600 KB PNG becomes roughly 800 KB as a JSON string.
Callers that need low-latency screenshots (e.g. a digest assertion
loop) should use "format": "rgba" to skip the PNG encode. Callers
that need human-readable images (e.g. test-failure artefacts) should
use "format": "png" (the default).
Headless mode does not instantiate a SurfaceMirror by default. The
mirror is created on first use of screenshot and kept alive until the
session ends. The first screenshot call may therefore take slightly
longer than subsequent ones as the mirror catches up on buffered
surface events.
Params:
| Field | Type | Description |
|---|---|---|
surface_id |
u32 or null | The surface to capture. Defaults to surface 0 (the primary surface) if omitted or null. |
format |
string or null | "png" (default) or "rgba". |
Result on success:
| Field | Type | Description |
|---|---|---|
width |
u32 | Width of the captured surface in pixels. |
height |
u32 | Height of the captured surface in pixels. |
format |
string | The format actually used: "png" or "rgba". Echoed back so the client does not need to remember its own request. |
data_base64 |
string | The image data, base64-encoded (standard alphabet, no line breaks). For "png", this is a complete PNG file. For "rgba", this is a raw byte array in row-major order, 4 bytes per pixel (R, G, B, A), top-left origin. |
Verb-specific error codes:
| Code | When |
|---|---|
no_such_surface |
No surface with the requested surface_id exists in the current session. Check status for the available surface list. |
unsupported_format |
The format field contains a value other than "png" or "rgba". |
Worked example:
→ {"id": 7, "method": "screenshot", "params": {"surface_id": 0, "format": "png"}}
← {"id": 7, "ok": true, "result": {"width": 1024, "height": 768, "format": "png", "data_base64": "iVBORw0KGgo..."}}
The data_base64 value above is truncated; a real PNG will be
hundreds of kilobytes when base64-encoded.
clipboard_set¶
Added in v1.3. Set the session's clipboard, as if the user had copied the text on the host.
Ryll keeps the clipboard in memory and syncs it with the guest's
through the SPICE vdagent, the same way the GUI syncs the host's.
It checks the clipboard every 500 ms, and when the text has changed
it tells the guest agent that the clipboard has new content (a
VD_AGENT_CLIPBOARD_GRAB offering UTF-8 text). The guest fetches
the text when something in it pastes. So the response does not mean
the guest has the text yet: allow up to about half a second, more
while the agent is starting.
The text is held whether or not the agent is connected; the grab
follows once the agent has connected and both sides have announced
their capabilities. This verb therefore never fails with
agent_not_connected. Only the CLIPBOARD selection is synced, not
PRIMARY, and only UTF-8 text.
Setting text equal to the last text that crossed -- copied in the
guest, or set here and offered to it -- does not grab: the guest
already has it. The comparison ignores differences in line endings
(CRLF, CR and LF) and trailing whitespace, so that text which
made a round trip through a host clipboard is not offered back to the
guest. A client that needs the guest to receive an exact byte
sequence differing from its last copy only in those ways should vary
the text some other way. Empty text is stored, and clipboard_get
returns it, but is never offered to the guest, which keeps its own
clipboard. Setting the clipboard does not raise clipboard_changed,
which reports only text from the guest.
Params:
| Field | Type | Description |
|---|---|---|
text |
string | The text to put on the clipboard, at most 104,857,600 bytes (100 MiB) of UTF-8. |
Result on success: {}
Verb-specific error codes:
| Code | When |
|---|---|
bad_params |
text is missing, not a string, or longer than 100 MiB. The limit is spice-gtk's default max-clipboard, the largest clipboard ryll accepts from a guest; the clipboard is left unchanged. |
Worked example:
→ {"id": 10, "method": "clipboard_set", "params": {"text": "hello from the host"}}
← {"id": 10, "ok": true, "result": {}}
clipboard_get¶
Added in v1.3. Read the session's clipboard: the text most recently
set by clipboard_set or copied in the guest, whichever came last.
Params: {} (none required)
Result on success:
| Field | Type | Description |
|---|---|---|
text |
string or null | The clipboard's text, or null if nothing has been set or copied yet. |
Verb-specific error codes: none beyond the common set.
Worked example:
→ {"id": 11, "method": "clipboard_get", "params": {}}
← {"id": 11, "ok": true, "result": {"text": "hello from the guest"}}
resize¶
Added in v1.3. Ask the guest for a display size, as the GUI does
when its window is resized: ryll sends a VD_AGENT_MONITORS_CONFIG
with one monitor of that size (or --monitors identical monitors
side by side).
Each side must be between 1 and 16384, the largest surface ryll will display. Within that range each side is rounded down to a multiple of 8, with 8 as the floor, as the GUI rounds its window size; the result reports the size actually asked for.
This verb is asynchronous in the way clipboard_set is. Ryll
debounces display sizes for 200 ms, so a burst of requests reaches
the guest as the last of them, and sends the size once the agent is
connected and ryll has announced its capabilities. A size asked for
before then is held and sent when they are, so this verb never fails
with agent_not_connected. A size equal to the last one sent is not
sent again. Whether the guest applies the size is up to the guest;
watch status (or screenshot) for the new surface dimensions.
In --web mode the browser reports its viewport size on the same
queue, so a later viewport change -- the browser window resized, or a
viewer connecting -- replaces a size a control client asked for, and
the reverse. A scenario that needs a fixed size should run with no
browser connected, or resize after the browser has settled.
Params:
| Field | Type | Description |
|---|---|---|
width |
u32 | Requested width in pixels, 1 to 16384. |
height |
u32 | Requested height in pixels, 1 to 16384. |
Result on success:
| Field | Type | Description |
|---|---|---|
width |
u32 | The width asked of the guest, after rounding. |
height |
u32 | The height asked of the guest, after rounding. |
Verb-specific error codes:
| Code | When |
|---|---|
bad_params |
A side is missing, not a non-negative integer, zero, or larger than 16384. |
queue_full |
Ryll's display-size queue is full, which takes dozens of requests faster than the session reads them. Nothing was queued; retry after a short delay. |
internal_error |
The display-size queue is closed: the session has ended. |
Worked example:
→ {"id": 12, "method": "resize", "params": {"width": 1366, "height": 768}}
← {"id": 12, "ok": true, "result": {"width": 1360, "height": 768}}
stats¶
Added in v1.4. Read the display channel's counters: what it has
received, and the video streams the server has sent it. A test polls
this to measure a server, for example its frame rate as the rate of
marks_received, or to check that a video stream was created with the
expected codec, decoded without failures, and was destroyed. From v1.6
it also reads the playback channel's counters, so a test can check
that audio arrives and decodes.
The counters are those the display channel last published, which it
does after each batch of messages it reads, so they trail the wire by
at most one batch. They count from the session's connection. With more
than one display channel (some guests open one per monitor) they are
the counters of whichever channel published last, which channel_id
names. A client taking differences between polls should check that
channel_id did not change, or the counters can appear to go
backwards.
Params: {} (none required)
Result on success: an object with a display field and, from v1.6, an
optional playback field. display holds:
| Field | Type | Description |
|---|---|---|
channel_id |
u8 | The display channel these counters are from. |
bytes_in |
u64 | Bytes received on the display channel. |
messages_received |
u64 | Messages received on the display channel. |
marks_received |
u64 | DISPLAY_MARK messages received. The GUI's frame rate is the rate of these, so the difference between two polls divided by the time between them is the same measure. |
streams_active |
u64 | Video streams open now: the length of streams. |
streams_created_total |
u64 | Video streams the server has created. |
streams_destroyed_total |
u64 | Video streams the server has destroyed. |
stream_data_orphan_count |
u64 | Stream frames naming a stream that is not open. |
stream_reports_sent_total |
u64 | STREAM_REPORT messages ryll has sent the server. |
decodes |
object | Stream frame decodes by codec name, for every stream, open or destroyed. Each value has decodes_total, the frames handed to the decoder (an H.264 packet that completed no frame included), and decode_failures_total, those it failed on. Only mjpeg and h264 can appear, and each appears once a frame of it has been decoded. |
streams |
array of stream objects | The open video streams. |
streams_recently_destroyed |
array of stream objects | The most recently destroyed streams, oldest first, at most 16. |
Each stream object:
| Field | Type | Description |
|---|---|---|
stream_id |
u32 | The server's id for the stream. Ids are reused. |
surface_id |
u32 | The surface the stream paints. |
codec |
string | "mjpeg", "vp8", "h264", "vp9", "h265" or "unknown". Ryll decodes MJPEG and H.264, and opens no stream for other codecs. |
codec_type |
u8 | The codec's SPICE video_codec_type number. |
width |
u32 | The stream's width, from STREAM_CREATE. |
height |
u32 | The stream's height, from STREAM_CREATE. |
frames_received |
u64 | Frames received, decodable or not. |
frames_decoded_ok |
u64 | Frames decoded and painted. |
frames_decode_failed |
u64 | Frames the decoder failed on. |
created_at_secs |
f64 | When the stream was created, in seconds since the session started. |
destroyed_at_secs |
f64 or null | When the stream was destroyed, on the same clock; null while it is open. |
A stream destroyed between two polls is in streams_recently_destroyed
at the second, with its codec and final counts, unless more than 16
streams were destroyed in between. streams_destroyed_total says
whether that happened.
playback (v1.6) is present once a playback channel has linked and
published its counters, which it does after each batch of messages
and every 100 ms while the server is silent. It is absent, not zeroed,
when the server offered no playback channel, before the channel links,
after it closes, and always in a --headless session of a ryll built
without the audio Cargo feature (the slim --no-default-features
build), which does not link the playback channel, so a client can
tell "no audio here" from "audio, but nothing played". A --web
session links it in every build, since it sends the audio to the
browser rather than a local device. The counters count from the channel
linking and survive STOP and the next START. It holds:
| Field | Type | Description |
|---|---|---|
starts |
u64 | PLAYBACK_START messages received. |
stops |
u64 | PLAYBACK_STOP messages received. |
packets_received |
u64 | PLAYBACK_DATA messages received, decodable or not. |
packets_decoded |
u64 | DATA packets decoded (Opus) or passed through (raw PCM). |
decode_failures |
u64 | DATA packets the Opus decoder failed on. A packet in a mode ryll cannot play, or with no audio data, is in neither this nor packets_decoded. |
bytes_received |
u64 | Bytes of audio data received in DATA packets, before decoding. |
codec |
string or null | The current stream's codec: "opus", "raw", or "mode_N" for any other SPICE audio_data_mode number N. null when no stream has started or the last one stopped. |
sample_rate |
u32 or null | The current stream's sample rate from START, in Hz; null as for codec. |
channels |
u8 or null | The current stream's channel count from START, saturating at 255; null as for codec. |
The codec is the data mode the server last sent in PLAYBACK_MODE.
Decoding does not need a sound device, and runs whether or not the
audio is played locally: a --headless ryll with audio and no device
decodes into its ring and retries the device in the background, and a
--web session decodes without opening a device at all, so these
counters move on a test host with no sound hardware too.
Verb-specific error codes: none beyond the common set.
Worked example, polled while an H.264 stream plays:
→ {"id": 13, "method": "stats", "params": {}}
← {"id": 13, "ok": true, "result": {"display": {"channel_id": 0, "bytes_in": 48211953, "messages_received": 5120, "marks_received": 1474, "streams_active": 1, "streams_created_total": 1, "streams_destroyed_total": 0, "stream_data_orphan_count": 0, "stream_reports_sent_total": 92, "decodes": {"h264": {"decodes_total": 460, "decode_failures_total": 0}}, "streams": [{"stream_id": 0, "surface_id": 0, "codec": "h264", "codec_type": 3, "width": 1280, "height": 720, "frames_received": 460, "frames_decoded_ok": 460, "frames_decode_failed": 0, "created_at_secs": 31.42, "destroyed_at_secs": null}], "streams_recently_destroyed": []}}}
The same poll from a ryll with the audio feature, while Opus audio
plays, also carries:
"playback": {"starts": 1, "stops": 0, "packets_received": 3120, "packets_decoded": 3120, "decode_failures": 0, "bytes_received": 411840, "codec": "opus", "sample_rate": 48000, "channels": 2}
subscribe¶
Register interest in one or more named events. After a successful
subscribe call, the server will begin delivering matching events to
this client as they occur.
Event delivery is asynchronous. Events may arrive at any time after the
subscribe response, interleaved with responses to other requests.
There is no guarantee of ordering between events and responses on the
wire, except that the server writes each line atomically.
Unknown event names are silently ignored and will not appear in the
subscribed result. This is intentional forward-compatibility
behaviour: a client compiled against a newer version of this document
may ask for digest_updated (a v1.1 event, feature-gated) while
talking to a v1.0 server, or to a v1.1 server built without the
digest-decode feature, and neither knows the name. Rather than
failing the call, the server silently drops unrecognised names from
the result. The client
can check subscribed to discover which names were actually accepted,
and fall back gracefully if a name it needs is not present.
Params:
| Field | Type | Description |
|---|---|---|
events |
array of string | The event names to subscribe to. Unknown names are silently ignored. |
Result on success:
| Field | Type | Description |
|---|---|---|
subscribed |
array of string | The subset of requested event names that the server actually agreed to deliver. Names in events that the server does not recognise will not appear here. |
Verb-specific error codes: none beyond the common set.
Worked example:
→ {"id": 8, "method": "subscribe", "params": {"events": ["latency", "digest_updated"]}}
← {"id": 8, "ok": true, "result": {"subscribed": ["latency"]}}
In this example digest_updated was silently dropped — the server
is either a v1.0 build or a v1.1 build without the digest-decode
feature.
unsubscribe¶
Cancel delivery of one or more named events. After a successful
unsubscribe, matching events will no longer be delivered to this
client.
Unsubscribing from an event name that the client is not currently
subscribed to is a no-op and is not an error. Similarly,
unsubscribing from an unknown event name is a no-op. The unsubscribed
result will reflect only the names that were actually removed from the
active subscription set.
Params:
| Field | Type | Description |
|---|---|---|
events |
array of string | The event names to unsubscribe from. |
Result on success:
| Field | Type | Description |
|---|---|---|
unsubscribed |
array of string | The subset of requested event names that were removed from the active subscription. Names not currently subscribed are absent from this list. |
Verb-specific error codes: none beyond the common set.
Worked example:
→ {"id": 9, "method": "unsubscribe", "params": {"events": ["latency"]}}
← {"id": 9, "ok": true, "result": {"unsubscribed": ["latency"]}}
Event reference¶
Events are unsolicited lines written by the server. They have no id
and do not correspond to a request. The client receives only events it
has subscribed to. All events share the same envelope shape:
{"event": "<name>", "data": {...}}.
latency¶
Emitted once per server PING on the SPICE main channel.
The sample is not a round-trip time. SPICE has no client-originated
probe — SPICE_MSG_PING is server→client only — so Ryll cannot measure
network RTT. What it measures instead is the client-observed interval
between two consecutive server PINGs, which includes the server's own
send cadence as well as the network path and Ryll's receive turnaround.
Spikes indicate a network or server stall; the absolute value largely
reflects how often the server chose to ping. See
diagnostics.md for the same explanation from the UI
side. (The --latency-file flag was intended to write this metric to
disk in headless mode, but is currently declared and unused — this
event stream is the working way to collect samples.)
The field names are unchanged, so this is a correction to the
description rather than a change to the wire contract. For a round trip
that measures the path, subscribe to input_rtt.
High-frequency callers (e.g. a latency loadtest) should subscribe to
this event and accumulate samples client-side rather than polling with
status calls.
Data fields:
| Field | Type | Description |
|---|---|---|
sample_ms |
f64 | Interval between consecutive server PINGs, in milliseconds. Always non-negative. |
wallclock_us |
u64 | Unix timestamp in microseconds at the moment Ryll emitted the sample, which is just after it responded to the PING. Useful for aligning samples with external wall-clock logs. |
Worked example:
input_rtt¶
Added in v1.5. Emitted for each round trip measured on the SPICE inputs channel.
The server acknowledges every fourth pointer message (MOUSE_POSITION
or MOUSE_MOTION) with a MOUSE_MOTION_ACK. Ryll times each bunch from
sending its fourth message to receiving the ack for it, so the sample is
the network path in both directions plus the server's time to read and
answer. It is only sampled while the pointer moves: a session with no
pointer input, such as a headless one driven only by send_key, emits
none.
Data fields:
| Field | Type | Description |
|---|---|---|
rtt_ms |
f64 | Round trip, in milliseconds. Always non-negative. |
wallclock_us |
u64 | Unix timestamp in microseconds at the moment Ryll emitted the sample, just after the ack arrived. |
Worked example:
agent_connected¶
Emitted when the SPICE vdagent connection state transitions. This event
fires on transitions only — once when the agent connects and once
when it disconnects. It does not fire periodically as a heartbeat. The
initial state after hello can be queried via status.
Ryll reports the agent's latest state rather than every change, so a
disconnect and reconnect close enough together that this client's
connection never observed the intermediate state produce no event. The
state is read from the session directly rather than from the event bus,
so falling behind the bus cannot skip a transition; only a full
per-client outbound queue can, and that is reported by dropped like
any other loss.
Callers that depend on agent-required verbs (paste) should subscribe
to this event and watch for "connected": true before issuing the
first paste request.
Data fields:
| Field | Type | Description |
|---|---|---|
connected |
boolean | true when the agent has just connected; false when it has just disconnected. |
Worked example (agent connects):
Worked example (agent disconnects, e.g. guest shutdown):
paste_completed¶
Emitted when a paste operation finishes successfully. The request_id
field matches the id of the paste request that initiated the
operation, allowing the caller to correlate the completion with its
original request.
Data fields:
| Field | Type | Description |
|---|---|---|
request_id |
integer or string | The id from the originating paste request. Type matches what the client sent. |
chars_sent |
u32 | The number of characters successfully typed into the guest. |
Worked example:
paste_failed¶
Emitted when a paste operation fails before completing. This includes cases such as an unrepresentable character in the text, an agent disconnect mid-paste, or a client disconnect that caused the paste to be cancelled.
Data fields:
| Field | Type | Description |
|---|---|---|
request_id |
integer or string | The id from the originating paste request. |
reason |
string | A human-readable description of why the paste failed. Not a stable code; do not parse this string programmatically. |
Worked example:
dropped¶
Emitted once per drop episode to inform the client that events were
lost due to backpressure. See the backpressure section for the full
semantics. The cumulative count covers all events dropped since the
previous dropped event (or since the start of the session if this is
the first one).
Data fields:
| Field | Type | Description |
|---|---|---|
count |
u32 | Number of events that were discarded since the last dropped event. |
Worked example:
clipboard_changed¶
Added in v1.3. Emitted each time text copied in the guest arrives
and is stored as the session's clipboard: the guest agent grabbed the
clipboard offering UTF-8 text, ryll fetched it at once, and the text
came back. Only the CLIPBOARD selection is synced, so selecting text
in an X11 guest (PRIMARY) does not raise this event. Text set with
clipboard_set does not raise it either.
The event does not carry the text, only its length: a copy can be as
large as 100 MiB, and events wait in a per-client queue bounded by
count rather than size. Read the text with clipboard_get. That
returns the clipboard as it is when the request is handled, which is
the copy this event reported unless the guest has copied again, or
clipboard_set has been called, since.
Every copy is reported, including a copy of the same text as the
last. The copy is noticed from the session directly rather than from
the event bus, like agent_connected, so falling behind the bus does
not skip it, and a client slow to read several copies made in quick
succession may hear only of the newest. Like any event, though, it is
lost if this client's outbound queue is full when it is produced; that
loss is reported by a dropped event, after which a client waiting on
a copy should call clipboard_get rather than wait. Copies made before
the client connected are not replayed; use clipboard_get.
Data fields:
| Field | Type | Description |
|---|---|---|
bytes |
u64 | The length of the copied text in bytes, as UTF-8. |
Worked example:
← {"event": "clipboard_changed", "data": {"bytes": 20}}
→ {"id": 13, "method": "clipboard_get", "params": {}}
← {"id": 13, "ok": true, "result": {"text": "hello from the guest"}}
surface_drawn¶
Added in v1.1. Emitted once per draw command that modifies a display
surface. Fires unconditionally on every ImageReady, ImageReadyChroma,
ImageReadyAlpha, FillRect, CopyBits, and Invert event produced
by the renderer's display channel — DisplayMark (frame boundary) does
not fire surface_drawn.
The intended use case is computing keypress-to-screen latency: a
subscriber sends a send_key down and records its keypress wallclock,
then takes the wallclock of the first surface_drawn it receives
afterwards as the time-of-first-visible-pixel. Subsequent
surface_drawn events from the same logical frame are deduplicated by
the consumer, not by the server.
Data fields:
| Field | Type | Description |
|---|---|---|
display_channel_id |
u8 | The display channel that produced the draw. |
surface_id |
u32 | The SPICE surface id painted (0 is the primary surface). |
produced_at_secs |
f64 | Renderer-internal session-relative seconds at the moment the channel handler called event_tx.send. Monotonic. |
wallclock_us |
u64 | Server wallclock at translation time, microseconds since the Unix epoch. Cross-process consumers should compute deltas in this clock rather than mixing it with produced_at_secs. |
Worked example:
← {"event": "surface_drawn", "data": {"display_channel_id": 0, "surface_id": 0, "produced_at_secs": 1.234, "wallclock_us": 1717000000123456}}
Backpressure note: surface_drawn shares the same 256-slot per-client
event buffer as every other event (see Backpressure).
A guest that paints aggressively can fire hundreds of surface_drawn
events per second; slow consumers will see dropped events. The
recommended pattern for the loadtest is to drain the queue as fast as
the consumer's CSV writer can flush, and to treat dropped as a soft
quality signal rather than a fatal error.
digest_updated¶
Added in v1.1. Available only when ryll is built with
--features digest-decode; on a default-features build (or any build
without digest-decode) the event is not advertised in the hello
response and subscribe for it returns an empty subscribed list.
Emitted when a new QR-encoded visual digest is detected on the primary surface and decoded successfully. The payload carries the parsed digest record rather than the raw QR bytes; clients do not need to know the visual-digest wire format.
Data fields:
| Field | Type | Description |
|---|---|---|
frame_counter |
u32 | Monotonic frame counter from the digest header. The polling task deduplicates by this value: an unchanged counter does not fire an event. |
framebuffer_hash |
u32 | CRC32C of the framebuffer at the moment the digest was encoded. |
events |
array | Decoded raw-record events from the digest. Each element is the shakenfist-visual-digest crate's Event enum in its serde shape: an externally-tagged, single-key object whose key is the snake_case variant name and whose value is an object of the variant's fields (see below). |
wallclock_us |
u64 | Server wallclock at translation time, microseconds since the Unix epoch. |
The events array is a serde pass-through of the digest crate's
Event enum, not a normalised {"kind": ..., "payload": ...}
envelope. Variant names and field names are snake_case; nested
enums such as Phase and BootloaderChoice serialise as
snake_case strings; booleans and numbers are native JSON values.
Observed on the wire:
{"event": "digest_updated", "data": {"events": [
{"keypress": {"scancode": 0, "timestamp_ms": 138550, "unicode": "\r"}},
{"scene_transition": {"from": "awaiting", "timestamp_ms": 138550, "to": "booting"}},
{"line_rendered": {"row": 0, "timestamp_ms": 138550}}
], "frame_counter": 281, "framebuffer_hash": 4022250974, "wallclock_us": 1765432100000000}}
Clients matching on these records should treat unknown variant keys as forward-compatible additions and skip them.
Future-work notes:
- The polling task runs on a fixed 100 ms interval. A rate-limit knob
(
--digest-min-interval-ms) may be added if a future consumer needs it. - Because the wire
eventsschema tracksshakenfist-visual-digest::Eventdirectly, a schema-breaking v2 of the digest crate changes this event's payload and the protocol will need a re-cut.
Subscription semantics¶
The default state for a new connection is no subscriptions. The
server never pushes events to a client that has not called subscribe.
Rules:
- A
subscribecall adds the named events to the client's active subscription set. Subscribing to an event that is already subscribed is a no-op. - An
unsubscribecall removes the named events from the active subscription set. Unsubscribing from an event that is not currently subscribed is a no-op. - Unknown event names in either call are silently ignored. This enables forward compatibility: a client compiled against a future version of this document can ask for events that the current server does not know without breaking the call.
- When the client disconnects, its subscription state is discarded. There is no persistent subscription across reconnections.
The subscription state is per-client and per-connection. Because v1 supports only one concurrent client, there is only ever one active subscription set.
Backpressure¶
The server maintains a bounded per-client event queue with a capacity of 256 items. Events are placed in this queue by the SPICE event producers (channel handlers running on the tokio runtime) and drained by the socket writer task.
If the client is reading slowly and the queue fills up, the server
drops the oldest queued events to make room for newer ones. Dropped
events are counted. When the queue next drains to empty, or after a
short bounded delay if the queue remains under pressure but the count
is non-zero, the server emits a single dropped event with the
cumulative drop count since the last dropped event.
The server never blocks the SPICE channel producers waiting for the client to read. The consequence is explicit: a slow client gets dropped events, not backpressure that degrades the SPICE session. This is the correct trade-off for a test-harness control socket where the SPICE session must remain responsive to the guest at all times.
Clients that want to receive all events reliably should drain the socket promptly. If the client's consumer loop is slower than the event rate, it should either:
- Unsubscribe from high-rate events (e.g.
latency) that it does not need. - Increase the rate at which it drains the socket (e.g. use a background thread or async task for reading).
- Accept that drops will occur and handle
droppedevents as a diagnostic signal rather than an error.
The dropped event itself is never itself dropped — if it cannot fit
in the queue, the existing dropped count is incremented in-place
rather than adding a second dropped entry.
Error model¶
All error responses share the same structure:
The code field is a stable, machine-readable string. Do not parse
message programmatically; it may change between minor versions.
The following error codes are defined in v1.x (all were present in
v1.0 except queue_full, added in v1.3):
| Code | Semantics |
|---|---|
no_hello_yet |
A request other than hello was received before the hello handshake completed. The connection stays open. |
protocol_version_mismatch |
The major version in the hello params does not match the server's major version. The server closes the connection after writing this error. |
busy |
A second client attempted to connect while one is already connected. Written as the first and only line on the new connection, which is then closed. Note: no id field on this response. |
unknown_method |
The method field names a verb the server does not know. |
bad_params |
The params object is missing a required field, has a field of the wrong type, or is not a JSON object at all. |
bad_state |
The state field in a send_key request is not "down", "up", or "press". |
agent_not_connected |
A paste was requested but the SPICE vdagent is not currently connected. Not returned by clipboard_set or resize, which hold their request until the agent connects. |
no_such_surface |
A screenshot was requested for a surface_id that does not exist in the current session. |
unsupported_format |
A screenshot was requested with a format value other than "png" or "rgba". |
queue_full |
A queue into the session was full, so nothing was queued. Retryable after a short delay. Returned by resize. |
not_implemented |
The method is recognised but not yet implemented. Should not appear in a complete implementation; retained in the code so partial builds have a stable code to return during incremental rollout of a new verb. |
internal_error |
An unexpected condition occurred server-side. The message field will contain details. Report these as bugs. |
Additional error codes may be added in future minor version bumps.
Clients should treat any unrecognised code value as a generic error
(print the message and fail the operation) rather than treating it as
internal_error. This ensures forward compatibility when a client
compiled against v1.0 connects to a v1.6 server that has added new
domain-specific codes.
Versioning¶
The protocol version is a dotted major.minor string. The current
version is 1.6.
Rules:
- A major version bump (e.g. 1.x → 2.0) indicates a breaking
change: a message envelope shape has changed, a verb has been
removed, or a field has been renamed or re-typed. Clients and servers
with different major versions are not compatible. The server rejects a
hellowith a mismatched major version withprotocol_version_mismatchand closes the connection. - A minor version bump (e.g. 1.0 → 1.1) indicates an additive,
backward-compatible change: a new verb was added, a new event was
added, a new optional parameter was added to an existing verb, or a
new error code was introduced. Clients and servers with the same major
but different minor versions SHOULD interoperate. The server accepts
any
hellowhoseprotocol_versionhas a matching major, regardless of the minor component. - A minor bump is also used for a correction to behaviour that was never usable as specified — 1.2's extended-scancode fix is the only instance so far. It is not a major bump because nothing about the envelope, verb set or field types changes, and a client cannot have been depending on the broken behaviour while also reaching the guest correctly. It gets a version number rather than passing silently so that a client needing the fixed behaviour has something to test for.
Forward-compatibility obligations:
- Clients must gracefully ignore unknown event names they receive
(they should never have arrived, but defensively discarding them is
safer than panicking). Clients must also ignore unknown fields in
response
resultobjects; future minor versions may add fields. - Servers must route unknown
methodvalues to theunknown_methoderror rather than panicking. Servers must also silently ignore unknown event names insubscribe/unsubscribeparams.
Version 1.0 was the first published version. Version 1.1 added the
surface_drawn and digest_updated events. Version 1.2 extended the
socket to --web mode and corrected send_key's extended-scancode
encoding. Version 1.3 added the clipboard and resize verbs and the
clipboard_changed event. Version 1.4 added the stats verb. Version
1.5 added the input_rtt event. Version 1.6 (the current version)
added the stats result's playback object; see the version history
at the top of this document.
End-to-end worked example¶
The following transcript shows a complete client session: connect,
hello, status, subscribe, a latency event arriving, a key press, a
paste with async completion, and then disconnect. Arrow direction
indicates the sender: → is client-to-server, ← is server-to-client.
→ {"id": 1, "method": "hello", "params": {"client_name": "demo-client", "protocol_version": "1.0"}}
← {"id": 1, "ok": true, "result": {"server_name": "ryll", "protocol_version": "1.6", "supported_methods": ["hello", "status", "send_key", "paste", "screenshot", "subscribe", "unsubscribe", "clipboard_set", "clipboard_get", "resize", "stats"], "supported_events": ["latency", "agent_connected", "paste_completed", "paste_failed", "dropped", "surface_drawn", "clipboard_changed", "input_rtt"]}}
→ {"id": 2, "method": "status", "params": {}}
← {"id": 2, "ok": true, "result": {"spice_connected": true, "agent_connected": true, "surfaces": [{"channel_id": 1, "surface_id": 0, "width": 1024, "height": 768}]}}
→ {"id": 3, "method": "subscribe", "params": {"events": ["latency", "agent_connected", "paste_completed", "paste_failed"]}}
← {"id": 3, "ok": true, "result": {"subscribed": ["latency", "agent_connected", "paste_completed", "paste_failed"]}}
← {"event": "latency", "data": {"sample_ms": 1.2, "wallclock_us": 1717000000000100}}
→ {"id": 4, "method": "send_key", "params": {"scancode": 28, "state": "press"}}
← {"id": 4, "ok": true, "result": {}}
← {"event": "latency", "data": {"sample_ms": 1.1, "wallclock_us": 1717000002000200}}
→ {"id": 5, "method": "paste", "params": {"text": "hello", "char_delay_ms": 10}}
← {"id": 5, "ok": true, "result": {}}
← {"event": "latency", "data": {"sample_ms": 1.3, "wallclock_us": 1717000002100300}}
← {"event": "paste_completed", "data": {"request_id": 5, "chars_sent": 5}}
After the paste_completed event the client closes the connection
(TCP FIN). Ryll detects the EOF on the socket, cleans up the per-client
state (cancellation tokens, subscription set, queue), and resumes
listening for the next client.
Key observations from this transcript:
- The
hellohandshake always comes first and its response lists the full verb and event catalogue. Note the minor-version interop at work: the client requested"1.0"and the v1.6 server accepted it, responding with the version it speaks and advertising verbs and events this v1.0 client simply never uses. statusgives a snapshot of the session state at that instant. The surfaces list shows one surface, which is all that most single-monitor guests present.- After
subscribe, events arrive interleaved with responses. The twolatencyevents arrived between request 4 and request 5, and again between request 5's response and thepaste_completedevent. The client must be prepared to receive events at any time while subscribed. - The
pasteresponse (id: 5) arrived before thepaste_completedevent. This is guaranteed: the response is written when the task is queued; the event is written when the task finishes. Therequest_idinpaste_completedties the outcome back to the originalpasterequest. - The connection can be torn down by either side simply closing the socket. No explicit disconnect verb is required.
Implementation¶
Everything above this point is the contract. This section describes how ryll implements it, and is not binding on other implementations.
The control socket is exposed by headless and web mode via the
--control-socket <path> CLI flag; the GUI rejects it at launch.
Both modes bind the socket with control::Server::bind before they
connect, so a bind failure stops the launch, and then spawn the bound
server through shakenfist_spice_renderer::spawn_control_socket, so
the two cannot drift apart.
Web mode's socket carries disproportionate weight: web mode is the one mode with its own browser-side scancode table, so the scenario tests that drive a session through this protocol and assert on the QR visual digest are the only automated check on that table. The socket was headless-only for a long time, and four input bugs shipped behind that gap -- which is why both modes now spawn the same server rather than growing separate implementations.
Module layout. The control surface lives entirely under
shakenfist-spice-renderer/src/control/:
| File | Role |
|---|---|
mod.rs |
Public re-exports: Server, SessionHandles, StatusProvider |
protocol.rs |
Wire-level types: Request, Response, Event, verb params and result structs, serialisation helpers |
server.rs |
Server::bind, which binds the socket at startup, and Server::run, the tokio task that accepts one client at a time and dispatches verbs |
Runtime shape. Server::bind binds a Unix socket at the
supplied path, sets its file mode to 0600 (owner read/write only),
and only then listens. It uses socket2 for this because
std::os::unix::net::UnixListener::bind binds and listens in one
call, which would leave the socket connectable with the umask's mode
until a later chmod. It needs no tokio runtime, so web mode binds
before its runtime starts. Server::run hands the listener to tokio.
The socket file is unlinked when the Server is dropped, so a server
that is bound but never run, or whose task is aborted, does not leave
it behind. File permissions are the sole access-control mechanism; no
authentication is performed on the wire. Exactly one client is
accepted at a time; a second connection attempt while a client is
active receives a {"ok": false, "error": {"code": "busy", ...}}
line and is immediately closed.
Architectural integration.
session.rsfans the existing per-channel event mpsc into atokio::sync::broadcast::Sender<ChannelEvent>. Subscribers on this broadcast bus includeHeadlessStats, the headless-modeSurfaceMirror-apply task, and the control server's per-client event-translator task.- The control server holds
Arc<tokio::sync::Mutex<SurfaceMirror>>for two purposes: answeringscreenshotrequests synchronously (locks, snapshots pixel buffer, encodes) and enumerating live surfaces in thestatusverb response. - The control server is given an
Arc<dyn StatusProvider>sostatuscan query SPICE-connection state and agent presence without couplingserver.rstosession.rsinternals. - What the verbs act on arrives as one
SessionHandlesvalue: the inputs channel's queue, the surface mirror, the session's clipboard, the main channel's display-size queue, and the display and playback channels' publishedDisplaySnapshotandPlaybackSnapshot, whichstatsreads. The playback snapshot'schannel_linkedflag, set by a running playback channel and cleared when it closes, decides whetherstatsincludesplayback. Each client gets a clone. - The clipboard is a
MemoryClipboard(clipboard.rs), an implementation of the sameClipboardBackendtrait the GUI's host clipboard implements. The mode creates it only when a control socket is configured, and gives the same one to the main channel and to the server. The main channel polls it and stores guest copies in it exactly as it does the GUI's clipboard;clipboard_setwrites it through a separate method so that only guest copies are published on its watch, which is whatclipboard_changedis translated from. The watch carries each copy's length, not its text, so neither it nor a slow client's event queue holds a second copy of a large clipboard. resizesends on the same display-size queue the GUI's window and the browser's viewport use (in web mode it shares the browser's), so the main channel's debounce and its hold-until-the-agent-connects apply to all of them alike. Its rounding is the GUI's, from the sharedalign_monitor_dimension.- Per-client outbound events use a two-task pipeline: an
event-translator task (subscribes to the broadcast bus, filters by
the client's subscription set, converts
ChannelEventto JSON) feeds a 256-slot mpsc into a writer task (drains the mpsc, writes newline-terminated lines to the socket). When the mpsc is full, the translator drops the oldest queued events and increments a drop counter; when the queue next drains, a singledroppedevent is emitted with the cumulative count. The SPICE session is never back-pressured by a slow control-socket client. This is the mechanism behind the Backpressure contract above.
How the v1.1 events are produced. Both sit on top of the v1.0
verb/event surface, implemented by extending translate_event (the
per-client broadcast filter) — no new ChannelEvent variants were
required on the SPICE side, and no new module on the control-server
side:
surface_drawnfires once per display draw command (ImageReady,ImageReadyChroma,ImageReadyAlpha,FillRect,CopyBits,Invert). Renderer-internalproduced_at_secsflows through unchanged; the event carries a freshwallclock_uscaptured at translation time so cross- process consumers (the kerbside loadtest orchestrator) can compute keypress-to-screen latency against wallclock-recorded keypress times.digest_updatedlives behind thedigest-decodeCargo feature. Off in production builds. When enabled,crate::digest::run_digest_pollerticks every 100 ms, snapshots the primary surface RGBA, runsshakenfist-visual-digest::decode_qr_rgbafollowed bydecode, deduplicates byframe_counter, and broadcasts aChannelEvent::DigestUpdated. The translator converts that to adigest_updatedwire event. The hello / subscribe paths both gate onprotocol::supported_events(), which only advertisesdigest_updatedwhen the feature is on.
Both events are pushed to subscribers via the same broadcast bus / per-client mpsc / drop-oldest backpressure path the v1.0 events use.
Future work. The following items are explicitly out of scope for v1 and will be addressed by follow-up plans:
- Mouse-click and pointer-move verbs (requires Sextant pointer
collector, which is marked deferred in its own
ARCHITECTURE.md). - USB-redirection and WebDAV verbs.
- Authentication or encryption on the socket beyond Unix file permissions.
- Multi-client concurrency (the v1 single-client model is intentional and adequate for the harness use-case).
- Out-of-band (non-JSON) screenshot transport for low-latency digest-assertion loops.
- Synchronous paste (the async model was chosen deliberately; a synchronous convenience wrapper belongs in the client, not the server).
- Rate-limit knobs for
digest_updated(currently a fixed 100 ms tick; add a--digest-min-interval-msif the Sextant scenarios produce too many events for slow consumers). - Cross-platform CI matrix for the new feature combinations; only Linux is verified today.
The design rationale for the control socket and the v1.1
additions lives in the kerbside test-harness master plan,
shakenfist/kerbside/docs/plans/PLAN-test-harness.md.