Web mode internals¶
How ryll --web turns a live SPICE session into something a browser can
consume: the H.264 encoder pipeline, the WebRTC bridge, the four real-data
relays that connect them to the SPICE channels, and the lifecycle and
packaging concerns around them.
This is the developer-facing companion to the
--web operator guide, which covers running the thing.
See Architecture for where these crates sit overall.
Encoder module¶
shakenfist-spice-renderer/src/encoder/ is the live H.264 encoder
pipeline.
H264Encoder¶
A stateful wrapper around openh264. Takes an RGBA pixel buffer,
converts to YUV 4:2:0, encodes to Annex-B framed NAL units
(each NAL prefixed with the 4-byte start code 00 00 00 01).
Every IDR frame is accompanied by SPS (NAL type 7) and PPS
(NAL type 8) NALs so keyframes are self-contained. Even-dimension
constraint: width and height are rounded down to even numbers
before encoding. The force_keyframe: bool parameter to
encode() calls openh264's force_intra_frame() for the
next encode.
EncoderTask¶
Async driver that lives on tokio's blocking pool (openh264 is a
synchronous C library; spawn_blocking keeps it off the async
executor). The task loop:
- Ticks at a configurable FPS cap (default 30; period ≈ 33 333 µs).
- On each tick, calls
source.next_frame(). IfSome, encodes and sendsEncodedFrameon the output channel. IfNone, skips the tick — no idle frames are produced. - Handles
EncoderControl::RequestKeyframeby setting akeyframe_pendingflag consumed on the next encode. - Handles
EncoderControl::Stopby breaking the loop.
FrameSource and FrameRef¶
FrameSource is a trait that decouples the encoder from how
pixels arrive. The implementing type handles dirty tracking and
synchronisation with concurrent display-channel writers. It
returns Option<FrameRef<'_>> where FrameRef carries width,
height, RGBA bytes, and a timestamp_us used to derive RTP
timestamps. SyntheticFrameSource is a test/CI source that
generates animated gradient frames.
WebRTC bridge (shakenfist-spice-webrtc)¶
shakenfist-spice-webrtc is a separate crate (not part of the
renderer) because the webrtc-rs dependency tree (DTLS, SRTP,
ICE, SCTP, STUN) is heavy and not all SPICE-client consumers
need it. The renderer stays a pure SPICE substrate; the bridge
is one specific delivery mechanism.
WebrtcBridge¶
Wraps an RTCPeerConnection and owns:
- A video
TrackLocalStaticRTP(H.264, 90 kHz clock rate). - An audio
TrackLocalStaticRTP(Opus, 48 kHz clock rate). - A "control"
RTCDataChannel(ordered + reliable). - An
mpsc::Sender<EncoderControl>to request keyframes.
Construction via WebrtcBridge::new(WebrtcBridgeConfig):
builds the PC via webrtc-rs's APIBuilder + MediaEngine
pattern, registers H.264 and Opus codecs, creates both tracks,
adds them to the PC, creates the control DC, and registers three
callbacks — connection state change, ICE gathering state change,
and control-DC message — all delegating to one BridgeEvents
struct. BridgeEvents is deliberately the shape of webrtc-rs
0.20's PeerConnectionEventHandler trait, so the 0.20 port adds
an impl and deletes the closures without the bodies moving.
The state-change handler shadows the latest
RTCPeerConnectionState (the inherent accessor does not survive
the 0.20 port) and raises the sticky dead signal on the first
terminal transition. The gathering handler raises the sticky
gathered signal on Complete. Both signals are
StickySignals (sticky.rs): a Notify + sticky AtomicBool
pair giving level-triggered, raise-exactly-once semantics — see
the WebRTC conventions section of AGENTS.md for why a bare
Notify is not safe here.
WebrtcBridgeConfig carries the ICE server list (empty for
LAN-only use) and the EncoderControl sender.
SDP flow¶
accept_offer(sdp: String) -> Result<String> is the single SDP
entry point for the HTTP /offer handler. It:
- Sets the remote description (browser's offer).
- Creates an answer.
- Sets the local description.
- Waits for ICE gathering to complete, by awaiting the sticky
gatheredsignal raised byBridgeEvents(webrtc-rs 0.17'sgathering_complete_promise()does not exist in 0.20). - Returns the fully-resolved answer SDP.
ICE gathering completion is awaited so the answer already
contains all host candidates — trickle ICE is not needed for
LAN-only use. Because the gathering signal is sticky and
never resets, a WebrtcBridge handles exactly one offer/answer
exchange; renegotiation requires a new bridge, and the web
frontend constructs one per POST /offer.
Video pump¶
spawn_video_pump(rx: mpsc::Receiver<EncodedFrame>) drives the
video track:
- Consumes
EncodedFrames from the encoder output channel. - Strips Annex-B start codes from each NAL.
- Payloads raw NALs via
H264Payloader(fromrtp::codecs::h264). - Sets the
markerbit on the last RTP packet of each access unit (per RFC 6184 §5.1 — decoder pacing depends on this). - Derives RTP timestamps from
EncodedFrame::timestamp_usat 90 kHz:rtp_ts = (timestamp_us × 90_000) / 1_000_000. - Sequence numbers increment via
wrapping_add.
SPS/PPS NALs produce empty payload sets (the H264Payloader
caches them and bundles them as STAP-A with the next IDR slice);
the pump skips empty sets cleanly.
Control datachannel¶
Ordered + reliable, labelled "control". It carries ping/pong for
smoke testing, input events (scancodes, pointer coordinates), and
cursor overlay updates. send_control(&[u8]) and control_rx()
are the public API.
spawn_synthetic_audio_pump() remains available for testing
without a SPICE server: it emits a 440 Hz sine wave encoded as
Opus at 50 fps (20 ms per frame, 960 samples at 48 kHz), through
the same TrackLocalStaticRTP consumer interface the real audio
path uses.
Keyframe-on-attach¶
The bridge sends EncoderControl::RequestKeyframe when the
RTCPeerConnection transitions to the Connected state, so the
first frame the browser sees is always a full IDR. A PLI
(Picture Loss Indication) RTCP handler is also registered for
the same purpose when a viewer requests a refresh.
webrtc-rs convention: on_track must spawn a task¶
See the "WebRTC conventions" section in AGENTS.md for the normative rule regarding on_track and read_rtp.
SPICE wire-up¶
Four connections carry real data between the SPICE session and the bridge: display frames, audio, keyboard/mouse input, and cursor overlay.
SurfaceMirror¶
shakenfist-spice-renderer/src/surface_mirror.rs — subscribes
to the renderer's broadcast ChannelEvent stream and maintains
a HashMap<(u8, u32), DisplaySurface> keyed by
(channel_id, surface_id). The mirror is the authoritative
surface state for the web encoder path; it is separate from
the RyllApp surface map so the --web mode can run without
any egui dependency.
RealFrameSource¶
shakenfist-spice-renderer/src/encoder/frame_source.rs — a
FrameSource implementation that reads from a SurfaceMirror
under try_lock. Returns None on lock contention (the encoder
skips the tick rather than blocking) and also returns None when
the primary surface is not dirty, achieving genuine
encode-on-dirty behaviour within the 30 fps cap.
OpusPacketSink trait¶
shakenfist-spice-renderer/src/audio_sink.rs — a pre-decode
tap on the SPICE playback channel. When a type implementing this
trait is injected into the playback channel constructor, raw Opus
packets from the SPICE server are delivered to push_opus_packet
before being decoded to PCM for the cpal path. The --web mode
uses this to route Opus packets to the WebRTC audio track without
re-encoding. When the SPICE server negotiates raw PCM (not Opus),
the sink receives no packets; the web audio track is silent (a
warning is logged).
Input relay¶
ryll/src/web/inputs.rs — drains the bridge's control
datachannel, parses the JSON input events that the browser shell
posts, and emits InputEvent variants (key down/up with AT
scancodes, mouse position/button) into the renderer's existing
inputs channel handler. Viewport-resize messages from the browser
are forwarded to maybe_send_monitors_resize so the SPICE guest
can track the browser viewport size at connect time.
Cursor relay¶
ryll/src/web/cursor.rs — subscribes to the renderer's
broadcast ChannelEvent stream and watches for CursorImage
and CursorPos events (the same events the egui frontend
consumes). Cursor shapes are encoded as PNG (base64 = "0.22"
for the data-URL wrapper) and sent as JSON over the control
datachannel. The browser shell decodes the data-URL, updates
an <img> overlay element, and repositions it to follow cursor
motion events — keeping cursor latency on the datachannel path
rather than the video encoder path.
Audio adapter¶
ryll/src/web/audio.rs — WebOpusSink implements
OpusPacketSink. When the RTCPeerConnection reaches
Connected, the bridge activates the audio pump; WebOpusSink
routes each incoming Opus packet to the bridge's audio track
via the WebRTC audio pump. PCM-only SPICE servers do not
trigger any push_opus_packet calls, so the audio track emits
silence (and a one-time warning is logged).
End-to-end data flow¶
SPICE server
│
▼
shakenfist-spice-renderer::run_connection
│
├─► DisplayChannel ──► broadcast ChannelEvent ──► SurfaceMirror
│ └─► CursorRelay (cursor.rs)
│
├─► PlaybackChannel ──► OpusPacketSink (audio.rs) ──► WebRTC audio track
│
└─► InputsChannel ◄── web inputs relay (inputs.rs) ◄── control DC ◄── browser
│
shakenfist-spice-webrtc::WebrtcBridge │
├─► video track ◄── EncoderTask ◄── RealFrameSource ◄── SurfaceMirror │
├─► audio track ◄── WebOpusSink ◄── PlaybackChannel (Opus path) │
└─► control DC ◄──────────────────────────────────── cursor/input relay ──┘
│
▼
Browser (RTCPeerConnection)
├─ <video> H.264 display
├─ <audio> Opus audio
└─ datachannel: cursor overlay + input events
The on_track-must-spawn-a-task webrtc-rs idiom (documented
above) and the rustls CryptoProvider init (required once at
process start, before any TLS handshake) both apply to the
--web mode and are handled in ryll/src/main.rs before
run_web() is called.
Bridge lifecycle¶
Bridge dead signal¶
WebrtcBridge carries a dead: Arc<StickySignal> field —
raised exactly once when the RTCPeerConnection reaches
Failed, Disconnected, or Closed. StickySignal
(sticky.rs) pairs a Notify with a sticky AtomicBool, so
late callers do not wait on an already-dead bridge and a raise
landing mid-subscribe is not lost.
Public API:
wait_for_dead(&self) -> impl Future— resolves when the PC reaches a terminal state. Returns immediately if the signal is already raised (late-subscriber safety).dead_signal(&self) -> Arc<StickySignal>— exposes the signal for consumers that hold their own clone without keeping a reference to the bridge;handle.wait().awaitis equivalent towait_for_dead().
Server-side reaper (ryll/src/web/lifecycle.rs)¶
run_bridge_reaper(state: Arc<WebState>) is a long-lived
task spawned from run_web. Its loop:
- Clones the active bridge's
dead_signal()without holding the slot lock for long. - If no bridge is active, sleeps 500 ms and retries.
- Awaits the dead signal.
- Takes the bridge out of
bridge_slot, callsbridge.close().await. - Calls
EncoderInfra::stop()— sendsEncoderControl::Stopand awaits the encoder task handle (2-second ceiling). - Clears
opus_active_tx.
The SPICE session (run_connection) is left completely
untouched. A subsequent /offer from the browser rebuilds
a fresh bridge and encoder from the same live SPICE state.
Race condition: a new /offer and the reaper both race to
take the bridge via bridge_slot.lock(). Both serialise on
the mutex; whichever arrives first takes the slot, the other
observes None and no-ops.
EncoderInfra::stop¶
A helper alongside restart(): sends EncoderControl::Stop
to the running encoder task and joins the handle with a
2-second timeout. Used by both the reaper (bridge died) and
the shutdown path (process exiting). Any orphaned task exits
naturally on its next send error.
Graceful shutdown sequence¶
run_web's shutdown sequence is:
- Ctrl-C →
SHUTDOWN_REQUESTED.store(true). - The bridge between
SHUTDOWN_REQUESTEDand the SPICEcancelflag flips the cancel. axum::serve(…).with_graceful_shutdown(…).awaitdrains.- Take the active bridge from the slot and close it (2-second ceiling) so DTLS/SRTP tears down cleanly.
- Call
EncoderInfra::stop()to release the encoder task. run_webreturns; the tokio runtime drops.
Browser-side auto-reconnect¶
app.js keeps the RTCPeerConnection setup and SDP offer flow
inside a callable connect() function rather than a one-shot
IIFE. On ICE-failed or connection-state-failed events,
scheduleReconnect() is called with backoff delays of 1 s, 2 s,
4 s, 8 s, 16 s (max 5 attempts). A hidden "Click to reconnect"
button is revealed when all attempts are exhausted. Each attempt
constructs a brand-new RTCPeerConnection; the backoff counter
resets on a successful Connected transition.
CI and packaging¶
shakenfist-spice-rendererandshakenfist-spice-webrtcare in thepublish-cratesstep inrelease.ymlin dependency order (renderer after compression; webrtc after usbredir; ryll last).libopus-devis present in the devcontainer (and installed on the aarch64 Linux CI runner) so theopuscrate dynamic-links against a system libopus. The deb/rpm packaging metadata recordslibopus0as a runtime dependency. On macOS and Windows theaudiopus_syssource-build fallback applies (pkg-config absent → compile from source → no runtime dep).tools/web-smoke.shis a CI step on the Linux x86_64 build job only (make web-smoke/make web-smoke-tls, run inside the devcontainer). It launchesryll --webwith a stub.vv, asserts the process stays alive for 3 seconds, sends SIGTERM, and verifies clean exit within 5 seconds. macOS and Windows CI verifies the--webdependencies link but does not run the smoke test.
Native TLS¶
axum-server (with the tls-rustls feature) provides HTTPS.
Two CLI flags — --web-tls-cert <PATH> and --web-tls-key
<PATH> — activate it; clap's requires = enforces that both
are supplied together or neither. When TLS is active the startup
URL line prints https:// and the server uses
axum_server::bind_rustls(addr, RustlsConfig) with a
Handle::graceful_shutdown shim driven by SHUTDOWN_REQUESTED,
keeping the graceful-shutdown semantics above intact. A
reference systemd unit at examples/ryll-web.service shows the
TLS-enabled invocation with an EnvironmentFile pattern.
Operator-facing TLS setup, cert recipes and the reverse-proxy
fallback are in the --web operator guide.