Key design decisions¶
Why ryll is shaped the way it is. Each entry records a decision and the reasoning behind it, so a change that contradicts one is a deliberate choice rather than an accident. See Architecture for the structure these decisions produced.
-
Immediate mode rendering - egui was chosen because SPICE sends bitmap tiles to blit onto surfaces. Retained-mode GUIs (like tkinter) accumulate objects, causing memory issues. egui just redraws the current surface state each frame.
-
Async over threads - The Python version used threads with queues. Rust uses tokio async tasks with mpsc channels, which is more idiomatic and efficient.
-
Headless mode - Essential for automated testing. Runs the full protocol stack without GUI overhead. Headless is also the first evidence of the project's broader multi-modal client stance: the SPICE stack is frontend-agnostic, and additional frontends (
--webbrowser mode shipped end-to-end viadocs/plans/PLAN-web-frontend.md) are first-class peers of the GUI rather than retrofits. When you add or modify a feature, ask which modes it should be reachable from; if a mode physically cannot host the feature, say so in the docs rather than leaving the gap unstated. -
Cadence mode - Sends automatic keystrokes every 2 seconds to generate predictable input→display latency measurements.
-
Graceful Ctrl+C shutdown - A SIGINT handler in
main.rssets a globalSHUTDOWN_REQUESTEDAtomicBool. The eframe update loop (app.rs) and the headless tokio select loop both poll this flag and shut down cleanly, ensuring capture sessions are finalized. -
Unbuffered capture I/O on dedicated tasks - Pcap and MP4 writers in
capture.rswrite directly toFile(noBufWriter), so written bytes are always on disk and survive SIGINT without explicit flush. Both writers run on dedicated tokio tasks (pcap_writer_task,video_writer_task); the channel handlers and the egui frame loop enqueue via non-blockingtry_sendso slow disk cannot back-pressure the SPICE socket or stall the GUI. Queue capsPCAP_QUEUE_CAPACITY = 1024andVIDEO_QUEUE_CAPACITY = 8; drops are counted in per-channelwriter_dropped_count(channels) andAppSnapshot::video_drop_count(video). MP4 finalisation runs on the encoder task after the sender drops, so a bug report assembled within milliseconds ofCaptureSession::close()may see an unfinalised MP4 — see the phase-3 plan for the trade-off. -
Display channel capabilities - Ryll advertises COMPOSITE, MONITORS_CONFIG, SIZED_STREAM, A8_SURFACE, plus seven more added by the stream-caps-and-flap plan: STREAM_REPORT (4), LZ4_COMPRESSION (5), PREF_COMPRESSION (6), MULTI_CODEC (8), CODEC_MJPEG (9), CODEC_H264 (11), and PREF_VIDEO_CODEC_TYPE (12). Without COMPOSITE, the guest QXL driver falls back to a slow software rendering path that sends only raw Pixmap data via
draw_copy, making keyboard input appear to have no effect because the client is overwhelmed with uncompressed frames. The newer caps cover stream-report feedback to the server's encoder, LZ4-compressed images, multi-codec video (H.264 plus the legacy MJPEG fallback), and per-codec / per-compression preference messages sent at link-up. See the "Display Channel Capabilities" table in spice-protocol.md for the full bit list. -
GLZ win_head_dist eviction - The GLZ dictionary evicts cached images based on the
win_head_distfield from each GLZ header, rather than using a fixed cache size. This matches the server's reference window and prevents both premature eviction (corrupting cross-frame references) and unbounded memory growth. -
Pcap TCP segmentation - Large SPICE messages are split into multiple TCP segments in the pcap writer to avoid exceeding the IPv4 maximum packet length (65535 bytes), which would panic in the header construction code.
-
USB panel uses identity-based commands - The GUI sends device identity (bus/address for physical, path/read-only for virtual) rather than pre-opened device handles via
UsbCommand. The channel handler does async device lookup and open in its tokio context. This avoids async operations in the synchronous egui render loop and keeps device lifecycle management co-located in the channel handler. Physical USB device support (RealDevice,DeviceSource::Physical,UsbCommand::ConnectPhysical) is gated with#[cfg(target_os = "linux")]— on macOS/Windows only virtual disk devices are available. The file picker for adding virtual disks also runs on a background thread with results polled viatry_recv(). -
WebDAV shares local directory via embedded HTTP server - Each mux client gets a
tokio::io::DuplexStream; hyper parses HTTP/1.1 and dav-server handles WebDAV operations against the local filesystem. Response data flows back to the main loop viampsc::Sender<MuxResponse>, the same pattern used by usbredir's interrupt polling tasks. The Folders UI panel mirrors the USB panel structure. -
QUIC decoder is a bespoke pure-Rust port - SPICE QUIC is a proprietary image codec (not the IETF QUIC network protocol). No pre-existing Rust crate provides SPICE QUIC decoding, so the decoder was ported from the canonical C source in
spice-common/common/quic.c. Constant tables (TABRAND_CHAOS, BESTTRIGTAB, J) have been verified against the C reference. Golomb coding parameters are clamped to safe bounds before use to prevent out-of-bounds panics on malformed data. -
Multi-monitor via agent infrastructure - Multiple display channels are opened (one per
--monitors N) and the main channel sendsVDAgentMonitorsConfigto the guest via the VDI port agent protocol. The GLZ dictionary is shared across display channels via aGlzDictionarystruct (with notify-based cross-frame reference resolution). Surfaces are keyed by(display_channel_id, surface_id)to prevent cross-channel collisions. -
Dedicated audio thread with lock-free ring buffer - The cpal audio output stream runs on a dedicated
std::thread, not in the tokio runtime. This avoids theunsafe impl Sendthat was previously needed (cpal streams are!Sendon macOS/Windows). The tokio network task pushes decoded PCM samples into anrtrbsingle-producer single-consumer ring buffer; the audio thread drains it into a localVecDequefor the resampler. This eliminates mutex contention in the real-time cpal callback. -
Paste-as-keystrokes: cooperative state machine in the select! loop - The paste feature translates text to US-QWERTY scancodes and types them as synthetic key events. A
PasteStatestruct tracks the current character index and sub-step (Press/Release). A conditional third arm in the inputs channel'sselect!loop usestokio::time::sleep_untilto fire at the right moment; between firings the other two arms (server reads and UI events) run normally. Theadvance_pastemethod sends one sub-step per invocation and updates the next-fire time. Modifier keys (Ctrl, Shift, Alt) are tracked viaKeyDown/KeyUpobservations and saved/restored around the paste. Thesend_key_down/send_key_uphelpers bypass event recording and modifier tracking for synthetic paste events. Public API:translate_paste(text: &str) -> Result<Vec<PasteKey>, PasteError>,PasteKey(struct with press, release, shift fields),PasteError(enum with Unrepresentable variant). -
Mouse mode negotiation - On session init, ryll requests client mouse mode (absolute positioning) via
MOUSE_MODE_REQUESTif the server supports it. If the server remains in server mode (e.g. no SPICE agent), ryll sends relativeMOUSE_MOTIONmessages instead of absoluteMOUSE_POSITION. The mode is checked on every pointer move in app.rs. -
Event-driven egui repaints via
repaint_notify- egui only repaints when something asks it to. Channel handlers run on the tokio runtime and have no direct access toegui::Context. Every channel handler therefore holds anArc<tokio::sync::Notify>(repaint_notify) alongside itsevent_tx: mpsc::Sender<ChannelEvent>, and a small "repaint bridge" tokio task (spawned fromRyllApp::new) waits onnotify.notified().awaitand callsctx.request_repaint()whenever a notification arrives. Convention: everyevent_tx.send(...)call in a channel handler must be immediately followed byrepaint_notify.notify_one(). A 1 Hz fallback inupdate()covers time-based UI like the bandwidth and latency sparklines. New channel handlers must acceptArc<tokio::sync::Notify>in their constructor and follow this pairing convention or idle CPU will silently regress. -
Draw-op coverage: one
decode_*per opcode, warn-once everything skipped - Every implementedDRAW_*opcode on the display channel follows the same shape: a purefn decode_<op>(payload) -> io::Result<<Op>Outcome>classifier that parses the wire struct and returns an Outcome enum describing what to do (Paint,SkipNonOpPut { rop }, etc.), then anasync fn handle_<op>shim that destructures the outcome, fireswarn_once!on each skip variant, and emits a typedChannelEvent. Any feature the handler deliberately ignores (non-OP_PUTROP descriptors, non-solid brushes, non-nullSpiceQMask, non-zeroalpha_flags, etc.) must firewarn_once!with a stable colon-delimited static key so the gap enters the process-global warn_once registry. Unknown opcodes uselog_unknown_oncewhich registers the same way but includes a first-occurrence hex dump. See STYLEGUIDE.md §"warn_once for protocol gaps" for the full convention (key format, test discipline, append-only contract). -
Colour conversion in the channel, not the surface - SPICE colour fields (brush colours, chroma keys, BGRX image pixels) are BGRX on the wire;
DisplaySurfacestores pixels as RGBA. The conversion lives exclusively in the channel handler (before event emission) so surface helpers trust their inputs are already RGBA. Concretely:FillRect.colour,ImageReadyChroma.chroma_rgba, and everyImageReady*.pixelsbuffer reachapp.rspre-converted. The idiom at the channel site is[(c>>16)&0xff, (c>>8)&0xff, c&0xff, 0xff]for a wireu32colour. Do NOT add BGRX handling insideDisplaySurface— surfaces are RGBA-only. -
--pedanticmode: registry observer pattern - The warn_once registry is a process-globalHashSet<&'static str>with aregister_gap_observer(Fn(&'static str))hook. The observer fires once per newly-inserted key (with replay-on-late-registration so observers don't miss keys fired before they registered). Two layers sit on top today: an always-visibleGaps: Nstatus-bar widget that pollswarn_once_count()each frame (no observer needed), and--pedanticmode which registers an observer that spawns a tokio task per new gap to write a bug-report zip viaBugReport::write_pedantic. The observer is registered insideRyllApp::new/run_headlessso it captures liveTrafficBuffersandChannelSnapshotsrather than stubs — this matters because the traffic pcap is what makes a pedantic report actionable for debugging. -
Auto-disconnect snapshots and the bug-report directory chain - Every
ChannelEvent::Error/ChannelEvent::DisconnectedcallsRyllApp::maybe_write_disconnect_snapshot, which builds abugreport::DisconnectCause(channel name, error message, keepalive-timeout flag fromMainSnapshot, session uptime, per-channel diagnostics map) and invokesBugReport::write_disconnect. The fire-on-every-channel scope is deliberate: under ticket-based deployments (oVirt, Kerbside) every channel disconnect is permanent, so the data must be captured at the moment of failure. A 60 s cooldown is enforced viaRyllApp::last_disconnect_report_atand is updated even on write failure to avoid retry storms. Output directory resolution (shared with the manual F8 button viamanual_bug_report_dir):--bug-report-dir→<--capture>/bug-reports/→ CWD. The--pedantic-dirflag falls back through the same chain when unspecified:--pedantic-dir→--bug-report-dir→./ryll-pedantic-reports/. Runtime metrics are deliberatelyRuntimeMetrics::unavailable(...)here — sampling on the GUI thread blocks the render loop for ~1 s. -
Notifications go through the unified store, not direct UI calls - The notification store at
ryll/src/notifications.rsis the single producer boundary. Channel handlers, the bug-report writer, the screenshot dialog, and the gap observer all pushNotificationEntryvalues viaArc<Mutex<NotificationStore>>; the GUI side panel and the status-bar bell read from the same store. Adding a new notification producer means: build aNotificationEntry::new(severity, source, message)(optionally.with_visibility(v)), thennotifications.lock().push(entry). NewNotificationSourcevariants are added to the enum innotifications.rs; the side panel'sNotificationSource::label()impl dictates how the new variant renders. Bug-report zips automatically include any new entries vianotifications.json. Current source inventory:Gap,BugReport,Spice {channel, what},Internal,Connection(every connection-state transition, pushed via theRyllApp::push_connection_eventhelper).Prefer
RyllApp::push_notificationover a barenotifications.lock().push(entry)from insideRyllApp: the wrapper also captures aTrafficBufferssnapshot keyed by the new entry's id. That snapshot is what the "File…" button on each notification row consumes to produce an at-fire bug report. Producers outsideRyllApp(channel handlers, pedantic observer) still go through the raw store — they don't have access to the snapshot store, and the button falls back gracefully to post-event-only when no snapshot exists. -
Auto-reconnect: pure state-machine transition, side effects at the call site - The
ReconnectStateenum onRyllApp(ryll/src/app.rs) replaces the oldshow_disconnect_dialogboolean.Idle/Pending { attempt, next_at, latest_error }/Modal(ModalVariant). The transition functionReconnectState::on_disconnect()is pure — it takes the current state, anawaiting_outcomebool, the cluster-reset timestamp, the wall clock, aReconnectPolicy, and the latest error string, and returns the next state (orNonefor a duplicate storm event to ignore). Side effects — pushing notifications, bumpingauto_reconnect_count, writing the disconnect snapshot, logging clock-skew warnings — live at the call site inRyllApp::handle_critical_disconnect, never inside the transition function. This keeps the state machine unit-testable (seeapp.rs::tests::reconnect_*andticket_*tests) without building a fullRyllApp. When extending: pure transitions add branches toon_disconnect; side effects go in the handler. Theawaiting_reconnect_outcomeflag onRyllAppis the gate that distinguishes "the in-flight retry just failed" from "another channel in the same storm just dropped" — set when the GUI-tick poll callsreconnect(), cleared on the next event. Three modal variants exist (Generic { latest_error },OneShotConsumed,TicketExpired { expired_at }) driven byReconnectPolicyderived from the.vvfile'sdelete-this-fileandticket-valid-untilkeys; the policy short-circuits the state machine straight to the matching Modal when retry would be doomed.