feat(hyperhdr): add the protocol codec and the network thread
proto encodes registration, image and clear requests with the [u32 big-endian length][flatbuffer] framing and decodes the replies. client drives them from a thread: it connects while frames flow, applies the pacer, sends keepalives and reconnects with backoff.
This commit is contained in:
@@ -0,0 +1,508 @@
|
|||||||
|
//! Network thread: connects to HyperHDR while frames are available, paces them, keeps the
|
||||||
|
//! connection alive and reconnects with backoff. Blocking `std::net` I/O with timeouts.
|
||||||
|
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::net::{Shutdown, SocketAddr, TcpStream, ToSocketAddrs};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use tracing::{debug, info, info_span, warn};
|
||||||
|
|
||||||
|
use super::proto::{self, Encoder};
|
||||||
|
use crate::config::{Config, HYPERHDR_MAX_MESSAGE};
|
||||||
|
use crate::pacing::{Action, Backoff, Pacer, PacingConfig, RateLimit};
|
||||||
|
use crate::slot::{Frame, FrameSlot};
|
||||||
|
use crate::stats::{self, Stats};
|
||||||
|
|
||||||
|
/// A connection closed this quickly, before any reply, was most likely rejected by HyperHDR.
|
||||||
|
const REJECT_WINDOW: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ClientConfig {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub priority: i32,
|
||||||
|
pub origin: String,
|
||||||
|
pub pacing: PacingConfig,
|
||||||
|
pub connect_timeout: Duration,
|
||||||
|
pub write_timeout: Duration,
|
||||||
|
pub backoff_initial: Duration,
|
||||||
|
pub backoff_max: Duration,
|
||||||
|
pub backoff_reset_after: Duration,
|
||||||
|
/// Minimum interval between logged error replies and rejection hints.
|
||||||
|
pub error_log_interval: Duration,
|
||||||
|
pub stats_interval: Option<Duration>,
|
||||||
|
/// Bound for sending the final `Clear` and for waiting until the server closes.
|
||||||
|
pub shutdown_timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientConfig {
|
||||||
|
/// Daemon defaults for the given server.
|
||||||
|
pub fn new(host: impl Into<String>, port: u16) -> Self {
|
||||||
|
Self {
|
||||||
|
host: host.into(),
|
||||||
|
port,
|
||||||
|
priority: 150,
|
||||||
|
origin: "gamescope".to_owned(),
|
||||||
|
pacing: PacingConfig {
|
||||||
|
min_interval: Duration::from_secs(1) / 30,
|
||||||
|
keepalive: Duration::from_secs(1),
|
||||||
|
idle_clear: None,
|
||||||
|
},
|
||||||
|
connect_timeout: Duration::from_secs(2),
|
||||||
|
write_timeout: Duration::from_secs(2),
|
||||||
|
backoff_initial: Duration::from_millis(500),
|
||||||
|
backoff_max: Duration::from_secs(10),
|
||||||
|
backoff_reset_after: Duration::from_secs(30),
|
||||||
|
error_log_interval: Duration::from_secs(10),
|
||||||
|
stats_interval: Some(Duration::from_secs(60)),
|
||||||
|
shutdown_timeout: Duration::from_millis(400),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_config(cfg: &Config) -> Self {
|
||||||
|
Self {
|
||||||
|
priority: cfg.priority,
|
||||||
|
origin: cfg.origin.clone(),
|
||||||
|
pacing: PacingConfig {
|
||||||
|
min_interval: cfg.min_send_interval(),
|
||||||
|
keepalive: cfg.keepalive(),
|
||||||
|
idle_clear: cfg.idle_clear(),
|
||||||
|
},
|
||||||
|
connect_timeout: cfg.connect_timeout(),
|
||||||
|
write_timeout: cfg.write_timeout(),
|
||||||
|
..Self::new(cfg.host.clone(), cfg.port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the network thread. It runs until [`FrameSlot::shutdown`].
|
||||||
|
pub fn spawn(
|
||||||
|
cfg: ClientConfig,
|
||||||
|
slot: Arc<FrameSlot>,
|
||||||
|
stats: Arc<Stats>,
|
||||||
|
) -> io::Result<JoinHandle<()>> {
|
||||||
|
thread::Builder::new()
|
||||||
|
.name("hyperhdr".into())
|
||||||
|
.spawn(move || Client::new(cfg, slot, stats).run())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct ConnShared {
|
||||||
|
closed: AtomicBool,
|
||||||
|
got_reply: AtomicBool,
|
||||||
|
reason: Mutex<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Connection {
|
||||||
|
stream: TcpStream,
|
||||||
|
peer: SocketAddr,
|
||||||
|
connected_at: Instant,
|
||||||
|
shared: Arc<ConnShared>,
|
||||||
|
reader: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Connection {
|
||||||
|
fn open(cfg: &ClientConfig, slot: &Arc<FrameSlot>, stats: &Arc<Stats>) -> io::Result<Self> {
|
||||||
|
let mut last_err = None;
|
||||||
|
for addr in (cfg.host.as_str(), cfg.port).to_socket_addrs()? {
|
||||||
|
match TcpStream::connect_timeout(&addr, cfg.connect_timeout) {
|
||||||
|
Ok(stream) => return Self::start(stream, addr, cfg, slot, stats),
|
||||||
|
Err(e) => last_err = Some(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(last_err.unwrap_or_else(|| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::NotFound,
|
||||||
|
format!("{} resolved to no addresses", cfg.host),
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(
|
||||||
|
stream: TcpStream,
|
||||||
|
peer: SocketAddr,
|
||||||
|
cfg: &ClientConfig,
|
||||||
|
slot: &Arc<FrameSlot>,
|
||||||
|
stats: &Arc<Stats>,
|
||||||
|
) -> io::Result<Self> {
|
||||||
|
stream.set_nodelay(true)?;
|
||||||
|
stream.set_write_timeout(Some(cfg.write_timeout))?;
|
||||||
|
let read_half = stream.try_clone()?;
|
||||||
|
let shared = Arc::new(ConnShared::default());
|
||||||
|
let reader = {
|
||||||
|
let (shared, slot, stats) = (shared.clone(), slot.clone(), stats.clone());
|
||||||
|
let (priority, interval) = (cfg.priority, cfg.error_log_interval);
|
||||||
|
let span = tracing::Span::current();
|
||||||
|
thread::Builder::new()
|
||||||
|
.name("hyperhdr-reader".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let _enter = span.enter();
|
||||||
|
read_replies(read_half, &shared, &stats, priority, interval);
|
||||||
|
slot.wake();
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
stream,
|
||||||
|
peer,
|
||||||
|
connected_at: Instant::now(),
|
||||||
|
shared,
|
||||||
|
reader: Some(reader),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_closed(&self) -> bool {
|
||||||
|
self.shared.closed.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_reason(&self) -> String {
|
||||||
|
let reason = self.shared.reason.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
reason
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "connection closed".to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes both directions immediately and reaps the reader.
|
||||||
|
fn abort(mut self) {
|
||||||
|
let _ = self.stream.shutdown(Shutdown::Both);
|
||||||
|
if let Some(reader) = self.reader.take() {
|
||||||
|
let _ = reader.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends FIN and lets the reader drain replies until the server closes or `timeout`.
|
||||||
|
/// Closing with unread replies would send RST, which may discard our last message.
|
||||||
|
fn finish(self, timeout: Duration) {
|
||||||
|
let _ = self.stream.shutdown(Shutdown::Write);
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
while self.reader.as_ref().is_some_and(|r| !r.is_finished()) && Instant::now() < deadline {
|
||||||
|
thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
self.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_replies(
|
||||||
|
mut stream: TcpStream,
|
||||||
|
shared: &ConnShared,
|
||||||
|
stats: &Stats,
|
||||||
|
priority: i32,
|
||||||
|
error_log_interval: Duration,
|
||||||
|
) {
|
||||||
|
let mut buf = Vec::with_capacity(64);
|
||||||
|
let mut error_log = RateLimit::new(error_log_interval);
|
||||||
|
let mut registered = false;
|
||||||
|
let reason = loop {
|
||||||
|
match proto::read_message(&mut stream, &mut buf, HYPERHDR_MAX_MESSAGE) {
|
||||||
|
Ok(true) => {}
|
||||||
|
Ok(false) => break "connection closed by HyperHDR".to_owned(),
|
||||||
|
Err(e) => break format!("read failed: {e}"),
|
||||||
|
}
|
||||||
|
shared.got_reply.store(true, Ordering::Relaxed);
|
||||||
|
match proto::decode_reply(&buf) {
|
||||||
|
Ok(reply) => {
|
||||||
|
if let Some(error) = reply.error {
|
||||||
|
stats::inc(&stats.error_replies);
|
||||||
|
if let Some(suppressed) = error_log.check(Instant::now()) {
|
||||||
|
warn!(suppressed, "HyperHDR replied with an error: {error}");
|
||||||
|
}
|
||||||
|
} else if reply.registered != -1 && !registered {
|
||||||
|
registered = true;
|
||||||
|
if reply.registered == priority {
|
||||||
|
info!("registered with priority {priority}");
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
"HyperHDR reports priority {} instead of {priority}",
|
||||||
|
reply.registered
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if let Some(suppressed) = error_log.check(Instant::now()) {
|
||||||
|
warn!(suppressed, "undecodable reply from HyperHDR: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
*shared.reason.lock().unwrap_or_else(|e| e.into_inner()) = Some(reason);
|
||||||
|
shared.closed.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Client {
|
||||||
|
cfg: ClientConfig,
|
||||||
|
slot: Arc<FrameSlot>,
|
||||||
|
stats: Arc<Stats>,
|
||||||
|
pacer: Pacer,
|
||||||
|
backoff: Backoff,
|
||||||
|
encoder: Encoder,
|
||||||
|
frame: Frame,
|
||||||
|
conn: Option<Connection>,
|
||||||
|
retry_at: Option<Instant>,
|
||||||
|
/// The next successful connection is a reconnect (after a failure).
|
||||||
|
reconnecting: bool,
|
||||||
|
failed_attempts: u32,
|
||||||
|
last_connect_error: Option<String>,
|
||||||
|
reject_log: RateLimit,
|
||||||
|
next_stats: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
fn new(cfg: ClientConfig, slot: Arc<FrameSlot>, stats: Arc<Stats>) -> Self {
|
||||||
|
Self {
|
||||||
|
pacer: Pacer::new(cfg.pacing),
|
||||||
|
backoff: Backoff::new(
|
||||||
|
cfg.backoff_initial,
|
||||||
|
cfg.backoff_max,
|
||||||
|
cfg.backoff_reset_after,
|
||||||
|
),
|
||||||
|
encoder: Encoder::new(),
|
||||||
|
frame: Frame::default(),
|
||||||
|
conn: None,
|
||||||
|
retry_at: None,
|
||||||
|
reconnecting: false,
|
||||||
|
failed_attempts: 0,
|
||||||
|
last_connect_error: None,
|
||||||
|
reject_log: RateLimit::new(cfg.error_log_interval.max(Duration::from_secs(60))),
|
||||||
|
next_stats: cfg.stats_interval.map(|i| Instant::now() + i),
|
||||||
|
cfg,
|
||||||
|
slot,
|
||||||
|
stats,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(mut self) {
|
||||||
|
let server = format!("{}:{}", self.cfg.host, self.cfg.port);
|
||||||
|
let span = info_span!("hyperhdr", server = %server);
|
||||||
|
let _enter = span.enter();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let deadline = self.next_deadline(Instant::now());
|
||||||
|
let update = self.slot.wait(&mut self.frame, deadline);
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
if update.shutdown {
|
||||||
|
if let Some(conn) = self.conn.take() {
|
||||||
|
self.close_gracefully(conn, "shutting down");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if update.source_gone {
|
||||||
|
self.on_source_gone();
|
||||||
|
}
|
||||||
|
if update.new_frame {
|
||||||
|
let was_cleared = self.pacer.is_cleared();
|
||||||
|
if self.pacer.on_new_frame(now) {
|
||||||
|
stats::inc(&self.stats.superseded);
|
||||||
|
}
|
||||||
|
if was_cleared {
|
||||||
|
info!("new frames after idle, resuming");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(conn) = self.conn.as_ref().filter(|c| c.is_closed()) {
|
||||||
|
let reason = conn.close_reason();
|
||||||
|
self.on_connection_lost(reason, now);
|
||||||
|
}
|
||||||
|
self.maybe_connect(now);
|
||||||
|
self.pump(now);
|
||||||
|
self.maybe_log_stats(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_deadline(&self, now: Instant) -> Option<Instant> {
|
||||||
|
let deadline = match self.conn {
|
||||||
|
Some(_) => match self.pacer.poll(now) {
|
||||||
|
Action::Wait(at) => at,
|
||||||
|
_ => Some(now),
|
||||||
|
},
|
||||||
|
None => self.retry_at,
|
||||||
|
};
|
||||||
|
match (deadline, self.next_stats) {
|
||||||
|
(Some(a), Some(b)) => Some(a.min(b)),
|
||||||
|
(a, b) => a.or(b),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_source_gone(&mut self) {
|
||||||
|
self.pacer.on_source_gone();
|
||||||
|
if let Some(conn) = self.conn.take() {
|
||||||
|
self.close_gracefully(conn, "source gone");
|
||||||
|
}
|
||||||
|
self.retry_at = None;
|
||||||
|
self.reconnecting = false;
|
||||||
|
self.failed_attempts = 0;
|
||||||
|
self.last_connect_error = None;
|
||||||
|
self.backoff.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears our priority and closes the connection cleanly.
|
||||||
|
fn close_gracefully(&mut self, mut conn: Connection, reason: &str) {
|
||||||
|
let timeout = self.cfg.shutdown_timeout;
|
||||||
|
let _ = conn.stream.set_write_timeout(Some(timeout));
|
||||||
|
let msg = self.encoder.clear(self.cfg.priority);
|
||||||
|
match conn
|
||||||
|
.stream
|
||||||
|
.write_all(msg)
|
||||||
|
.and_then(|()| conn.stream.flush())
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
stats::add(&self.stats.bytes_sent, msg.len() as u64);
|
||||||
|
info!(
|
||||||
|
priority = self.cfg.priority,
|
||||||
|
"{reason}: sent Clear, disconnecting"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => warn!("{reason}: could not send Clear ({e}), disconnecting"),
|
||||||
|
}
|
||||||
|
conn.finish(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maybe_connect(&mut self, now: Instant) {
|
||||||
|
if self.conn.is_some() || !self.pacer.wants_connection(now) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.retry_at.is_some_and(|at| now < at) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match Connection::open(&self.cfg, &self.slot, &self.stats) {
|
||||||
|
Ok(mut conn) => {
|
||||||
|
if self.failed_attempts > 0 {
|
||||||
|
info!(peer = %conn.peer, "connected after {} failed attempts", self.failed_attempts);
|
||||||
|
} else {
|
||||||
|
info!(peer = %conn.peer, "connected");
|
||||||
|
}
|
||||||
|
if self.reconnecting {
|
||||||
|
stats::inc(&self.stats.reconnects);
|
||||||
|
}
|
||||||
|
self.reconnecting = false;
|
||||||
|
self.failed_attempts = 0;
|
||||||
|
self.last_connect_error = None;
|
||||||
|
self.retry_at = None;
|
||||||
|
|
||||||
|
let msg = self.encoder.register(&self.cfg.origin, self.cfg.priority);
|
||||||
|
let result = conn.stream.write_all(msg);
|
||||||
|
let sent = msg.len() as u64;
|
||||||
|
self.conn = Some(conn);
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
stats::add(&self.stats.bytes_sent, sent);
|
||||||
|
self.pacer.on_connected();
|
||||||
|
}
|
||||||
|
Err(e) => self.on_connection_lost(
|
||||||
|
format!("sending Register failed: {e}"),
|
||||||
|
Instant::now(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.failed_attempts += 1;
|
||||||
|
self.reconnecting = true;
|
||||||
|
let delay = self.backoff.on_failure(None);
|
||||||
|
self.retry_at = Some(now + delay);
|
||||||
|
let error = e.to_string();
|
||||||
|
if self.last_connect_error.as_deref() == Some(error.as_str()) {
|
||||||
|
debug!(
|
||||||
|
attempt = self.failed_attempts,
|
||||||
|
"cannot connect: {error}; next attempt in {delay:?}"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
"cannot connect: {error}; retrying with backoff, next attempt in {delay:?}"
|
||||||
|
);
|
||||||
|
self.last_connect_error = Some(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_connection_lost(&mut self, reason: String, now: Instant) {
|
||||||
|
let Some(conn) = self.conn.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let session = now.saturating_duration_since(conn.connected_at);
|
||||||
|
let got_reply = conn.shared.got_reply.load(Ordering::Relaxed);
|
||||||
|
conn.abort();
|
||||||
|
|
||||||
|
let delay = self.backoff.on_failure(Some(session));
|
||||||
|
self.retry_at = Some(now + delay);
|
||||||
|
self.reconnecting = true;
|
||||||
|
|
||||||
|
if session < REJECT_WINDOW && !got_reply {
|
||||||
|
match self.reject_log.check(now) {
|
||||||
|
Some(suppressed) => warn!(
|
||||||
|
suppressed,
|
||||||
|
"HyperHDR closed the connection right after connecting ({reason}). HyperHDR only \
|
||||||
|
accepts clients from its own /24 subnet unless their IP is whitelisted \
|
||||||
|
(Network Services) or internet access is enabled; see the HyperHDR log for \
|
||||||
|
\"not whitelisted, access denied\". Retrying in {delay:?}"
|
||||||
|
),
|
||||||
|
None => debug!("connection closed immediately again ({reason}); retrying in {delay:?}"),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!("disconnected after {session:.1?}: {reason}; reconnecting in {delay:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pump(&mut self, now: Instant) {
|
||||||
|
loop {
|
||||||
|
let Some(conn) = self.conn.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let action = self.pacer.poll(now);
|
||||||
|
let msg = match action {
|
||||||
|
Action::Wait(_) => return,
|
||||||
|
Action::SendFrame | Action::SendKeepalive => {
|
||||||
|
self.encoder
|
||||||
|
.image(self.frame.width, self.frame.height, &self.frame.rgb)
|
||||||
|
}
|
||||||
|
Action::Clear => self.encoder.clear(self.cfg.priority),
|
||||||
|
Action::Heartbeat => self.encoder.register(&self.cfg.origin, self.cfg.priority),
|
||||||
|
};
|
||||||
|
match conn.stream.write_all(msg) {
|
||||||
|
Ok(()) => {
|
||||||
|
stats::add(&self.stats.bytes_sent, msg.len() as u64);
|
||||||
|
match action {
|
||||||
|
Action::SendFrame => stats::inc(&self.stats.frames_sent),
|
||||||
|
Action::SendKeepalive => stats::inc(&self.stats.keepalives),
|
||||||
|
Action::Clear => info!(
|
||||||
|
priority = self.cfg.priority,
|
||||||
|
"no new frames for {:?}, cleared priority",
|
||||||
|
self.cfg.pacing.idle_clear.unwrap_or_default()
|
||||||
|
),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
self.pacer.on_sent(action, now);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let reason = if matches!(
|
||||||
|
e.kind(),
|
||||||
|
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
|
||||||
|
) {
|
||||||
|
format!(
|
||||||
|
"write timed out after {:?} (HyperHDR is not reading)",
|
||||||
|
self.cfg.write_timeout
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("write failed: {e}")
|
||||||
|
};
|
||||||
|
self.on_connection_lost(reason, Instant::now());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maybe_log_stats(&mut self, now: Instant) {
|
||||||
|
let (Some(interval), Some(next)) = (self.cfg.stats_interval, self.next_stats) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if now < next {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.next_stats = Some(now + interval);
|
||||||
|
let snapshot = self.stats.take();
|
||||||
|
info!("stats for the last {interval:?}: {snapshot}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
//! Client for the HyperHDR flatbuffers server.
|
//! Client for the HyperHDR flatbuffers server.
|
||||||
|
|
||||||
|
pub mod client;
|
||||||
pub mod generated;
|
pub mod generated;
|
||||||
|
pub mod proto;
|
||||||
|
|
||||||
|
pub use client::ClientConfig;
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
//! HyperHDR flatbuffers messages and their `[u32 big-endian length][flatbuffer]` framing.
|
||||||
|
|
||||||
|
use std::io::{self, Read};
|
||||||
|
|
||||||
|
use flatbuffers::{FlatBufferBuilder, InvalidFlatbuffer, UnionWIPOffset, WIPOffset};
|
||||||
|
|
||||||
|
use super::generated::{reply, request};
|
||||||
|
|
||||||
|
/// Builds framed requests, reusing one builder.
|
||||||
|
pub struct Encoder {
|
||||||
|
fbb: FlatBufferBuilder<'static>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Encoder {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Encoder {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
fbb: FlatBufferBuilder::with_capacity(1024),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register(&mut self, origin: &str, priority: i32) -> &[u8] {
|
||||||
|
self.fbb.reset();
|
||||||
|
let origin = self.fbb.create_string(origin);
|
||||||
|
let register = request::Register::create(
|
||||||
|
&mut self.fbb,
|
||||||
|
&request::RegisterArgs {
|
||||||
|
origin: Some(origin),
|
||||||
|
priority,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.finish(request::Command::Register, register.as_union_value())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Image{RawImage}` with `duration = -1` (shown until cleared or disconnected).
|
||||||
|
pub fn image(&mut self, width: u32, height: u32, rgb: &[u8]) -> &[u8] {
|
||||||
|
self.fbb.reset();
|
||||||
|
let data = self.fbb.create_vector(rgb);
|
||||||
|
let raw = request::RawImage::create(
|
||||||
|
&mut self.fbb,
|
||||||
|
&request::RawImageArgs {
|
||||||
|
data: Some(data),
|
||||||
|
width: i32::try_from(width).unwrap_or(i32::MAX),
|
||||||
|
height: i32::try_from(height).unwrap_or(i32::MAX),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let image = request::Image::create(
|
||||||
|
&mut self.fbb,
|
||||||
|
&request::ImageArgs {
|
||||||
|
data_type: request::ImageType::RawImage,
|
||||||
|
data: Some(raw.as_union_value()),
|
||||||
|
duration: -1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.finish(request::Command::Image, image.as_union_value())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self, priority: i32) -> &[u8] {
|
||||||
|
self.fbb.reset();
|
||||||
|
let clear = request::Clear::create(&mut self.fbb, &request::ClearArgs { priority });
|
||||||
|
self.finish(request::Command::Clear, clear.as_union_value())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(
|
||||||
|
&mut self,
|
||||||
|
command_type: request::Command,
|
||||||
|
command: WIPOffset<UnionWIPOffset>,
|
||||||
|
) -> &[u8] {
|
||||||
|
let req = request::Request::create(
|
||||||
|
&mut self.fbb,
|
||||||
|
&request::RequestArgs {
|
||||||
|
command_type,
|
||||||
|
command: Some(command),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.fbb.finish_size_prefixed(req, None);
|
||||||
|
// flatbuffers writes a little-endian length prefix; HyperHDR expects big-endian.
|
||||||
|
let (buf, start) = self.fbb.mut_finished_buffer();
|
||||||
|
let message = &mut buf[start..];
|
||||||
|
let (prefix, _) = message.split_at_mut(4);
|
||||||
|
let len = u32::from_le_bytes([prefix[0], prefix[1], prefix[2], prefix[3]]);
|
||||||
|
prefix.copy_from_slice(&len.to_be_bytes());
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A decoded `Request` (used by the mock server and tests).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Command<'a> {
|
||||||
|
Register {
|
||||||
|
origin: &'a str,
|
||||||
|
priority: i32,
|
||||||
|
},
|
||||||
|
RawImage {
|
||||||
|
width: i32,
|
||||||
|
height: i32,
|
||||||
|
duration: i32,
|
||||||
|
data: &'a [u8],
|
||||||
|
},
|
||||||
|
Nv12Image,
|
||||||
|
ImageWithoutData,
|
||||||
|
Clear {
|
||||||
|
priority: i32,
|
||||||
|
},
|
||||||
|
Color {
|
||||||
|
data: i32,
|
||||||
|
duration: i32,
|
||||||
|
},
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies and decodes a request body (without the length prefix).
|
||||||
|
pub fn decode_request(body: &[u8]) -> Result<Command<'_>, InvalidFlatbuffer> {
|
||||||
|
let req = request::root_as_request(body)?;
|
||||||
|
let command = if let Some(r) = req.command_as_register() {
|
||||||
|
Command::Register {
|
||||||
|
origin: r.origin(),
|
||||||
|
priority: r.priority(),
|
||||||
|
}
|
||||||
|
} else if let Some(image) = req.command_as_image() {
|
||||||
|
if let Some(raw) = image.data_as_raw_image() {
|
||||||
|
Command::RawImage {
|
||||||
|
width: raw.width(),
|
||||||
|
height: raw.height(),
|
||||||
|
duration: image.duration(),
|
||||||
|
data: raw.data().map_or(&[][..], |d| d.bytes()),
|
||||||
|
}
|
||||||
|
} else if image.data_as_nv12_image().is_some() {
|
||||||
|
Command::Nv12Image
|
||||||
|
} else {
|
||||||
|
Command::ImageWithoutData
|
||||||
|
}
|
||||||
|
} else if let Some(c) = req.command_as_clear() {
|
||||||
|
Command::Clear {
|
||||||
|
priority: c.priority(),
|
||||||
|
}
|
||||||
|
} else if let Some(c) = req.command_as_color() {
|
||||||
|
Command::Color {
|
||||||
|
data: c.data(),
|
||||||
|
duration: c.duration(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Command::Unknown
|
||||||
|
};
|
||||||
|
Ok(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A decoded `Reply`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Reply<'a> {
|
||||||
|
pub error: Option<&'a str>,
|
||||||
|
pub video: i32,
|
||||||
|
pub registered: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies and decodes a reply body (without the length prefix).
|
||||||
|
pub fn decode_reply(body: &[u8]) -> Result<Reply<'_>, InvalidFlatbuffer> {
|
||||||
|
let r = reply::root_as_reply(body)?;
|
||||||
|
Ok(Reply {
|
||||||
|
error: r.error(),
|
||||||
|
video: r.video(),
|
||||||
|
registered: r.registered(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes a framed `Reply` into `out`, the way `FlatBuffersParser` does.
|
||||||
|
pub fn encode_reply(
|
||||||
|
fbb: &mut FlatBufferBuilder<'_>,
|
||||||
|
error: Option<&str>,
|
||||||
|
registered: i32,
|
||||||
|
out: &mut Vec<u8>,
|
||||||
|
) {
|
||||||
|
fbb.reset();
|
||||||
|
let error = error.map(|e| fbb.create_string(e));
|
||||||
|
let r = reply::Reply::create(
|
||||||
|
fbb,
|
||||||
|
&reply::ReplyArgs {
|
||||||
|
error,
|
||||||
|
video: -1,
|
||||||
|
registered,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
fbb.finish(r, None);
|
||||||
|
let data = fbb.finished_data();
|
||||||
|
out.clear();
|
||||||
|
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||||
|
out.extend_from_slice(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum FrameError {
|
||||||
|
#[error("invalid message length {0}")]
|
||||||
|
InvalidLength(u32),
|
||||||
|
#[error(transparent)]
|
||||||
|
Io(#[from] io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads one framed message body into `buf`. Returns `Ok(false)` on a clean EOF between messages.
|
||||||
|
pub fn read_message(
|
||||||
|
r: &mut impl Read,
|
||||||
|
buf: &mut Vec<u8>,
|
||||||
|
max_len: usize,
|
||||||
|
) -> Result<bool, FrameError> {
|
||||||
|
let mut header = [0u8; 4];
|
||||||
|
let mut filled = 0;
|
||||||
|
while filled < header.len() {
|
||||||
|
match r.read(&mut header[filled..]) {
|
||||||
|
Ok(0) if filled == 0 => return Ok(false),
|
||||||
|
Ok(0) => return Err(io::Error::from(io::ErrorKind::UnexpectedEof).into()),
|
||||||
|
Ok(n) => filled += n,
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let len = u32::from_be_bytes(header);
|
||||||
|
if len == 0 || len as usize > max_len {
|
||||||
|
return Err(FrameError::InvalidLength(len));
|
||||||
|
}
|
||||||
|
buf.resize(len as usize, 0);
|
||||||
|
r.read_exact(buf)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn body(message: &[u8]) -> &[u8] {
|
||||||
|
let len = u32::from_be_bytes(message[..4].try_into().unwrap()) as usize;
|
||||||
|
assert_eq!(len, message.len() - 4, "big-endian length prefix");
|
||||||
|
&message[4..]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_roundtrip() {
|
||||||
|
let mut enc = Encoder::new();
|
||||||
|
let msg = enc.register("gamescope", 150).to_vec();
|
||||||
|
let req = request::root_as_request(body(&msg)).unwrap();
|
||||||
|
assert_eq!(req.command_type(), request::Command::Register);
|
||||||
|
assert_eq!(
|
||||||
|
decode_request(body(&msg)).unwrap(),
|
||||||
|
Command::Register {
|
||||||
|
origin: "gamescope",
|
||||||
|
priority: 150
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_roundtrip() {
|
||||||
|
let mut enc = Encoder::new();
|
||||||
|
let rgb: Vec<u8> = (0..3 * 5 * 3).map(|i| i as u8).collect();
|
||||||
|
let msg = enc.image(5, 3, &rgb).to_vec();
|
||||||
|
let req = request::root_as_request(body(&msg)).unwrap();
|
||||||
|
assert_eq!(req.command_type(), request::Command::Image);
|
||||||
|
let image = req.command_as_image().unwrap();
|
||||||
|
assert_eq!(image.data_type(), request::ImageType::RawImage);
|
||||||
|
assert_eq!(
|
||||||
|
decode_request(body(&msg)).unwrap(),
|
||||||
|
Command::RawImage {
|
||||||
|
width: 5,
|
||||||
|
height: 3,
|
||||||
|
duration: -1,
|
||||||
|
data: &rgb
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_roundtrip() {
|
||||||
|
let mut enc = Encoder::new();
|
||||||
|
let msg = enc.clear(150).to_vec();
|
||||||
|
assert_eq!(
|
||||||
|
decode_request(body(&msg)).unwrap(),
|
||||||
|
Command::Clear { priority: 150 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoder_reuse() {
|
||||||
|
let mut enc = Encoder::new();
|
||||||
|
let big = vec![7u8; 320 * 180 * 3];
|
||||||
|
let _ = enc.image(320, 180, &big);
|
||||||
|
let msg = enc.register("x", 99).to_vec();
|
||||||
|
assert_eq!(
|
||||||
|
decode_request(body(&msg)).unwrap(),
|
||||||
|
Command::Register {
|
||||||
|
origin: "x",
|
||||||
|
priority: 99
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let msg = enc.image(320, 180, &big).to_vec();
|
||||||
|
match decode_request(body(&msg)).unwrap() {
|
||||||
|
Command::RawImage { data, .. } => assert_eq!(data, &big[..]),
|
||||||
|
other => panic!("unexpected {other:?}"),
|
||||||
|
}
|
||||||
|
// 172800 bytes of pixels plus a small overhead.
|
||||||
|
assert!(msg.len() < 320 * 180 * 3 + 128, "{}", msg.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reply_roundtrip() {
|
||||||
|
let mut fbb = FlatBufferBuilder::new();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
encode_reply(&mut fbb, None, 150, &mut out);
|
||||||
|
let r = decode_reply(body(&out)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
r,
|
||||||
|
Reply {
|
||||||
|
error: None,
|
||||||
|
video: -1,
|
||||||
|
registered: 150
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
encode_reply(&mut fbb, Some("Unable to parse message"), -1, &mut out);
|
||||||
|
let r = decode_reply(body(&out)).unwrap();
|
||||||
|
assert_eq!(r.error, Some("Unable to parse message"));
|
||||||
|
assert_eq!(r.registered, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn garbage_is_rejected() {
|
||||||
|
assert!(decode_request(&[1, 2, 3]).is_err());
|
||||||
|
assert!(decode_request(&[0xFF; 64]).is_err());
|
||||||
|
assert!(decode_reply(&[0xFF; 16]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn framing() {
|
||||||
|
let mut enc = Encoder::new();
|
||||||
|
let mut stream = enc.register("a", 150).to_vec();
|
||||||
|
stream.extend_from_slice(enc.clear(150));
|
||||||
|
let mut cursor = io::Cursor::new(stream);
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
assert!(read_message(&mut cursor, &mut buf, 1000).unwrap());
|
||||||
|
assert!(matches!(
|
||||||
|
decode_request(&buf).unwrap(),
|
||||||
|
Command::Register { .. }
|
||||||
|
));
|
||||||
|
assert!(read_message(&mut cursor, &mut buf, 1000).unwrap());
|
||||||
|
assert!(matches!(
|
||||||
|
decode_request(&buf).unwrap(),
|
||||||
|
Command::Clear { .. }
|
||||||
|
));
|
||||||
|
assert!(!read_message(&mut cursor, &mut buf, 1000).unwrap());
|
||||||
|
|
||||||
|
let mut zero = io::Cursor::new(vec![0, 0, 0, 0]);
|
||||||
|
assert!(matches!(
|
||||||
|
read_message(&mut zero, &mut buf, 1000),
|
||||||
|
Err(FrameError::InvalidLength(0))
|
||||||
|
));
|
||||||
|
let mut long = io::Cursor::new(vec![0, 0, 3, 233, 0]);
|
||||||
|
assert!(matches!(
|
||||||
|
read_message(&mut long, &mut buf, 1000),
|
||||||
|
Err(FrameError::InvalidLength(1001))
|
||||||
|
));
|
||||||
|
let mut truncated = io::Cursor::new(vec![0, 0, 0, 8, 1, 2]);
|
||||||
|
assert!(matches!(
|
||||||
|
read_message(&mut truncated, &mut buf, 1000),
|
||||||
|
Err(FrameError::Io(_))
|
||||||
|
));
|
||||||
|
let mut half_header = io::Cursor::new(vec![0, 0]);
|
||||||
|
assert!(matches!(
|
||||||
|
read_message(&mut half_header, &mut buf, 1000),
|
||||||
|
Err(FrameError::Io(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user