Architecture
🏗️ Architecture
Section titled “🏗️ Architecture”This document explains how oxicast works internally.
Overview
Section titled “Overview”oxicast uses a split-stream reactor — three background tokio tasks handle the Cast protocol concurrently:
CastClient (your code) | |-- send() -----> [write_tx mpsc] --> Writer Task --> TLS WriteHalf --> Device | | | TLS ReadHalf <----------------------+ | | |<-- next_event() <-- [event_tx mpsc] <-- Router <-- Reader Task |<-- watch_*() <-- [watch channels] <--+ | +-- Heartbeat Task (PING every 5s, timeout detection)This design means:
- Commands are instant.
pause(),seek(),set_volume()go through the Writer and never wait for the Reader. - Heartbeats are invisible. The Heartbeat Task sends PINGs and the Reader auto-replies with PONGs. Your code never sees them.
- Status is always fresh. The Router updates
watchchannels on every inbound status message, regardless of whether anyone callsnext_event().
Reader Task
Section titled “Reader Task”Owns the TLS read half. Loops forever, reading length-prefixed protobuf messages (4-byte big-endian u32 + payload, max 64KB). Each message is decoded and passed to the Router.
On I/O error or EOF:
- Sets
alive = false - Sends
CastEvent::Disconnectedviatry_send(non-blocking) - Sends
ConnectionState::Disconnectedto the watch channel - Clears all pending request trackers
- Exits — this triggers auto-reconnect
On cancellation (during disconnect() or reconnect()):
- Does not emit Disconnected — the caller manages the state transition.
Writer Task
Section titled “Writer Task”Owns the TLS write half. Receives outbound messages from a 64-deep mpsc channel. Each message is protobuf-encoded, length-prefixed, and written as a single TLS record.
On write error:
- Sets
alive = false - Sends
ConnectionState::Disconnectedto the watch channel - Exits — triggers auto-reconnect
Heartbeat Task
Section titled “Heartbeat Task”Sends PING messages on a configurable interval (default 5s). After each PING, checks the last_activity timestamp (monotonic Instant, updated by the Reader on every inbound message). If the elapsed time exceeds the timeout (default 15s):
- Sets
alive = false - Sends
ConnectionState::Disconnectedto the watch channel - Sends
CastEvent::HeartbeatTimeoutviatry_send - Exits — triggers auto-reconnect
Request-Response Correlation
Section titled “Request-Response Correlation”Cast protocol commands use a requestId field in JSON payloads. oxicast tracks these with RequestTracker:
- Register: Allocate an atomic u32 ID, insert a
oneshot::Senderinto aHashMap<u32, Sender> - Send: Include the ID in the outbound JSON
- Route: When the Reader receives a response with a matching
requestId, the Router removes the entry and sends the JSON through the oneshot - Wait: The client awaits the oneshot with a configurable timeout
If the timeout expires or the connection drops, the pending entry is cleaned up to prevent leaks.
Event Delivery
Section titled “Event Delivery”Events are delivered through two independent mechanisms:
Event channel (next_event())
Section titled “Event channel (next_event())”A bounded mpsc channel (default 64). The Router calls try_send() — if the buffer is full, the event is dropped. This guarantees the Reader is never blocked by a slow consumer.
After disconnect(), the sender is replaced with a dead channel, so next_event() returns None after draining buffered events.
After reconnect(), a fresh channel pair is created and swapped in atomically.
Watch channels (watch_media_status(), watch_receiver_status())
Section titled “Watch channels (watch_media_status(), watch_receiver_status())”tokio::sync::watch channels always hold the latest value. Updated on every inbound MEDIA_STATUS and RECEIVER_STATUS message. These are never lossy — the latest state is always available regardless of consumer speed.
Auto-Reconnect
Section titled “Auto-Reconnect”When ConnectionState::Disconnected is observed on the watch channel (from any of the three task exit paths):
- Wait with exponential backoff + jitter (wall-clock nanos for entropy)
- Establish a new TLS connection
- Create fresh write/event channels and swap them into the client
- Spawn new Reader, Writer, and Heartbeat tasks
- Re-send
CONNECTtoreceiver-0and the previous app transport (if any) - Emit
CastEvent::Reconnected
Reconnect serialization: A dedicated mutex ensures that manual reconnect() and auto-reconnect don’t race. If one reconnect succeeds while another is waiting for the lock, the second one detects alive == true and returns immediately.
Exhaustion: After max_reconnect_attempts failures, the task emits Disconnected("reconnect failed"), sets ConnectionState::Disconnected, and exits permanently.
Weak reference: The auto-reconnect task holds a Weak<ClientInner>, not a strong Arc. When all user-held CastClient handles are dropped, the Weak upgrade fails and the task exits cleanly. No Arc cycle, no leaked connection.
Connection Lifecycle
Section titled “Connection Lifecycle”connect() -----> Connected | (I/O error | heartbeat timeout | write failure) | v Disconnected | (auto-reconnect or manual reconnect()) | v Reconnecting { attempt: 1 } | (TLS connect + CONNECT message) | success ──────> Connected | failure ──────> Reconnecting { attempt: 2 } | ... (max attempts) | v Disconnected (permanent)Cast devices use self-signed certificates. Certificate verification is disabled by default — the connection is encrypted but not authenticated. This is the same trade-off made by every Cast client library across languages.
Set verify_tls(true) on the builder if your device has a CA-signed certificate (uncommon).
Protocol Subset
Section titled “Protocol Subset”oxicast implements the Cast v2 JSON control protocol:
| Namespace | Supported |
|---|---|
urn:x-cast:com.google.cast.tp.connection | CONNECT, CLOSE |
urn:x-cast:com.google.cast.tp.heartbeat | PING, PONG |
urn:x-cast:com.google.cast.receiver | LAUNCH, STOP, GET_STATUS, SET_VOLUME |
urn:x-cast:com.google.cast.media | LOAD, PLAY, PAUSE, SEEK, STOP, QUEUE_LOAD, QUEUE_INSERT |
| Custom namespaces | Via send_raw() / CastEvent::RawMessage |
Not implemented: Binary payloads (payload_binary), message chunking (continued/remaining_length), device authentication (urn:x-cast:com.google.cast.tp.deviceauth). These are not used by standard Cast media control.