//! Per-connection Fire2 stream handling. //! //! This is the layer fixtures cannot test: a socket delivers bytes, not frames. //! Frames arrive split across reads and coalesced into one, several arrive per //! connection, and the connection outlives every individual RPC. //! //! The loop mirrors the Python oracle's exactly — read a 16-byte header, derive //! the total length from it, read the rest, consume, dispatch, write every //! returned frame in order — because that behaviour is part of what was proven //! against the real client. use std::io::{self, Read, Write}; use std::net::TcpStream; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use openfut_adapter_fifa17::blaze::{Adapter, Session}; use openfut_protocol_blaze::fire2::{Frame, Header, HEADER_LEN}; use openfut_protocol_blaze::heat2::{self, Struct}; use crate::capture::{Capture, Direction}; use crate::config::HostConfig; use crate::trace::{self, Tracer}; use crate::Hooks; /// Why a connection ended. Logged so a live run can be compared with Python's. #[derive(Debug, PartialEq, Eq)] pub enum CloseReason { /// Client closed cleanly between frames. ClientClosed, /// Stream ended part-way through a frame. EofMidFrame { want: usize, have: usize }, /// A header claimed a payload larger than the configured ceiling. AbsurdPayload { claimed: u32 }, /// No bytes within the idle timeout. IdleTimeout, /// Socket error. Io(String), } impl std::fmt::Display for CloseReason { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { CloseReason::ClientClosed => write!(f, "client closed"), CloseReason::EofMidFrame { want, have } => { write!(f, "EOF mid-frame (want {want}, have {have})") } CloseReason::AbsurdPayload { claimed } => { write!(f, "absurd payload_len {claimed}, dropping connection") } CloseReason::IdleTimeout => write!(f, "idle timeout"), CloseReason::Io(e) => write!(f, "io error: {e}"), } } } pub(crate) fn unix_now() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } /// Mint a Blaze-shaped session key. /// /// The client never validates the format, so this only has to be stable within /// a connection and distinct between them. pub(crate) fn mint_session_key() -> String { use rand::Rng; let mut rng = rand::thread_rng(); const ALPHA: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789$*"; let tail: String = (0..44) .map(|_| ALPHA[rng.gen_range(0..ALPHA.len())] as char) .collect(); openfut_adapter_fifa17::blaze::session::format_session_key(rng.gen::(), &tail) } /// Serve one connection until it closes. pub fn handle( mut stream: TcpStream, conn_id: u64, cfg: &HostConfig, adapter: &Adapter, tracer: &Tracer, hooks: &Hooks, capture: &Capture, ) -> CloseReason { let peer = stream .peer_addr() .map(|a| a.to_string()) .unwrap_or_else(|_| "".into()); let _ = stream.set_read_timeout(Some(Duration::from_secs(cfg.idle_timeout_secs))); // Blaze is request/response with server pushes; Nagle would add latency to // every burst for no benefit. let _ = stream.set_nodelay(true); let mut session = Session::new((hooks.session_key)(), cfg.adapter.identity.account_locale); trace::log(&format!( "conn-{conn_id:04} CONNECT from {peer} (session key minted: [REDACTED])" )); tracer.write_line(&format!("conn-{conn_id:04} OPEN")); let mut buf: Vec = Vec::with_capacity(16 * 1024); let mut frame_no: u64 = 0; let reason = loop { // ---- header match fill_to(&mut stream, &mut buf, HEADER_LEN) { Ok(true) => {} Ok(false) => { break if buf.is_empty() { CloseReason::ClientClosed } else { CloseReason::EofMidFrame { want: HEADER_LEN, have: buf.len(), } }; } Err(e) => break classify(e), } let header = match Header::parse(&buf[..HEADER_LEN]) { Ok(h) => h, // Unreachable: fill_to guaranteed 16 bytes. Treated as a close // rather than a panic because this is a network path. Err(e) => break CloseReason::Io(e.to_string()), }; if header.payload_len > cfg.max_payload_bytes { trace::log(&format!( "conn-{conn_id:04} REJECT payload_len {} exceeds {} — {}", header.payload_len, cfg.max_payload_bytes, hex_prefix(&buf) )); break CloseReason::AbsurdPayload { claimed: header.payload_len, }; } // ---- body let total = header.frame_len(); match fill_to(&mut stream, &mut buf, total) { Ok(true) => {} Ok(false) => { break CloseReason::EofMidFrame { want: total, have: buf.len(), } } Err(e) => break classify(e), } let (frame, used) = match Frame::parse(&buf[..total]) { Ok(v) => v, Err(e) => break CloseReason::Io(e.to_string()), }; buf.drain(..used); frame_no += 1; // Capture the exact bytes as received, before anything interprets them. // `&buf[..total]` was already consumed above, so re-encode from the // parsed frame — which is byte-identical by construction and covered by // the protocol crate's round-trip tests. capture.record(conn_id, Direction::Rx, &frame.encode()); trace::log(&format!( "conn-{conn_id:04} RX #{frame_no} {}", trace::describe(&frame.header) )); tracer.frame(conn_id, "RX", &frame_no.to_string(), &frame); // A body that will not decode is NOT fatal: the oracle logs it and // dispatches with no fields, letting the RPC fall back to defaults. // An empty Struct is equivalent to the oracle's `None` on every path // dispatch takes (verified: every read is guarded or defaulted). let body = if frame.payload.is_empty() { Struct::new() } else { match heat2::decode(&frame.payload) { Ok(s) => s, Err(e) => { trace::log(&format!( "conn-{conn_id:04} RX #{frame_no} TDF DECODE FAILED: {e}" )); Struct::new() } } }; let out = adapter.dispatch(&frame.header, &body, &mut session, (hooks.now)()); for (k, resp) in out.iter().enumerate() { let bytes = resp.encode(); if let Err(e) = stream.write_all(&bytes) { trace::log(&format!("conn-{conn_id:04} TX #{frame_no}.{k} FAILED: {e}")); break; } // AFTER the write: a TX record means these bytes were sent, not // merely intended. Capturing here also keeps the client's latency // untouched — it already has the bytes. // // NOT COVERED BY A TEST: moving this above the write is // indistinguishable while writes succeed, and only diverges when // one fails (it would record a frame that never reached the // client). Mutation-tested and confirmed undetected. The invariant // is held by this placement and this comment; do not move it. capture.record(conn_id, Direction::Tx, &bytes); trace::log(&format!( "conn-{conn_id:04} TX #{frame_no}.{k} {} ({}B total)", trace::describe(&resp.header), bytes.len() )); tracer.frame(conn_id, "TX", &format!("{frame_no}.{k}"), resp); } if let Err(e) = stream.flush() { break CloseReason::Io(e.to_string()); } }; trace::log(&format!( "conn-{conn_id:04} CLOSE after {frame_no} frame(s): {reason}" )); tracer.write_line(&format!("conn-{conn_id:04} CLOSE frames={frame_no}")); reason } /// Read until `buf` holds at least `n` bytes. `Ok(false)` on clean EOF. fn fill_to(stream: &mut TcpStream, buf: &mut Vec, n: usize) -> io::Result { let mut chunk = [0u8; 65536]; while buf.len() < n { match stream.read(&mut chunk) { Ok(0) => return Ok(false), Ok(got) => buf.extend_from_slice(&chunk[..got]), Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue, Err(e) => return Err(e), } } Ok(true) } fn classify(e: io::Error) -> CloseReason { match e.kind() { io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut => CloseReason::IdleTimeout, _ => CloseReason::Io(e.to_string()), } } fn hex_prefix(buf: &[u8]) -> String { buf.iter() .take(16) .map(|b| format!("{b:02x}")) .collect::>() .join(" ") } #[cfg(test)] mod tests { use super::*; #[test] fn minted_keys_have_the_blaze_shape_and_differ() { let a = mint_session_key(); let b = mint_session_key(); assert_eq!(a.len(), 16 + 1 + 44); assert_ne!(a, b, "a session key must be distinct per connection"); assert!(a[..16].chars().all(|c| c.is_ascii_hexdigit())); assert_eq!(&a[16..17], "_"); } #[test] fn close_reasons_render_readably() { assert!(CloseReason::ClientClosed.to_string().contains("closed")); assert!(CloseReason::EofMidFrame { want: 20, have: 5 } .to_string() .contains("want 20")); assert!(CloseReason::AbsurdPayload { claimed: 99 } .to_string() .contains("99")); } }