fix(engine): TCP partial-read frame desync + per-tick inbound drain (T-1045)

FrameAccumulator state machine in framing.rs holds partial prefix/payload
bytes across non-blocking receive() calls — a frame split across TCP
segments no longer desyncs the stream (read_exact previously discarded
partially-consumed bytes on WouldBlock). receive_bridge_inputs now drains
all ready frames per tick (capped) instead of exactly one, covering input
batches + atlas requests in the same window.

Review hardening: EOF mid-frame escalates to Disconnected like clean EOF
(peer died with a truncated stream) instead of logging an Io error every
tick. Tests: frame split inside prefix / inside payload, multi-frame
drain, mid-frame-EOF disconnect. Corrupt-stream escalation tracked as
T-1072.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 13:42:24 +02:00
co-authored by Claude Fable 5
parent eec12c1ac2
commit 49a74e93d4
6 changed files with 586 additions and 70 deletions
+42 -14
View File
@@ -5,15 +5,33 @@
use super::{decode_inbound, BridgeError, Inbound, ObserverSnapshot, SimBridge};
use crate::atlas::layer_proxy::AtlasLayerResponse;
use crate::bridge::framing::{read_framed, write_framed};
use std::io::{BufReader, BufWriter};
use crate::bridge::framing::{read_framed, write_framed, FrameAccumulator};
use std::io::BufWriter;
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::Mutex;
/// Read half of the bridge: the raw stream plus the partial-frame accumulator.
/// The accumulator must persist across `receive()` calls (T-1045) — the socket
/// is non-blocking, so a frame split across TCP segments is reassembled over
/// multiple ticks instead of desyncing the stream.
struct ReadHalf {
stream: TcpStream,
accum: FrameAccumulator,
}
impl ReadHalf {
fn new(stream: TcpStream) -> Self {
Self {
stream,
accum: FrameAccumulator::new(),
}
}
}
/// TcpBridge: TCP transport for client-server IPC
/// Same semantics as LocalBridge but over TCP localhost
pub struct TcpBridge {
reader: Mutex<BufReader<TcpStream>>,
reader: Mutex<ReadHalf>,
writer: Mutex<BufWriter<TcpStream>>,
local_addr: SocketAddr,
}
@@ -45,8 +63,9 @@ impl TcpBridge {
local_addr
);
// Set non-blocking so receive_inputs doesn't stall the game loop.
// receive_inputs catches WouldBlock from read_framed and returns Ok(vec![]).
// Set non-blocking so receive() doesn't stall the game loop. A
// WouldBlock mid-frame is handled by the ReadHalf accumulator;
// receive() maps "no complete frame" to Ok(None).
stream
.set_nonblocking(true)
.map_err(|e| BridgeError::Transport(format!("failed to set non-blocking: {}", e)))?;
@@ -57,7 +76,7 @@ impl TcpBridge {
})?;
Ok(Self {
reader: Mutex::new(BufReader::new(reader_stream)),
reader: Mutex::new(ReadHalf::new(reader_stream)),
writer: Mutex::new(BufWriter::new(stream)),
local_addr,
})
@@ -91,7 +110,7 @@ impl TcpBridge {
})?;
Ok(Self {
reader: Mutex::new(BufReader::new(reader_stream)),
reader: Mutex::new(ReadHalf::new(reader_stream)),
writer: Mutex::new(BufWriter::new(stream)),
local_addr,
})
@@ -117,7 +136,7 @@ impl TcpBridge {
})?;
Ok(Self {
reader: Mutex::new(BufReader::new(reader_stream)),
reader: Mutex::new(ReadHalf::new(reader_stream)),
writer: Mutex::new(BufWriter::new(stream)),
local_addr,
})
@@ -137,15 +156,16 @@ impl SimBridge for TcpBridge {
.map_err(|e| BridgeError::MutexPoisoned(format!("reader: {}", e)))?;
// Toggle to blocking for reliable startup message read.
// The client sends StartupMessage immediately after handshake validation,
// so this read should complete quickly.
// so this read should complete quickly. Runs before the first receive(),
// so the frame accumulator is empty and read_framed sees a fresh stream.
reader
.get_mut()
.stream
.set_nonblocking(false)
.map_err(BridgeError::Io)?;
let result = read_framed(reader.get_mut());
let result = read_framed(&mut reader.stream);
// Restore non-blocking for the tick loop
reader
.get_mut()
.stream
.set_nonblocking(true)
.map_err(BridgeError::Io)?;
match result? {
@@ -204,11 +224,19 @@ impl SimBridge for TcpBridge {
.lock()
.map_err(|e| BridgeError::MutexPoisoned(format!("reader: {}", e)))?;
match read_framed(reader.get_mut()) {
let ReadHalf { stream, accum } = &mut *reader;
match accum.poll_frame(stream) {
Ok(Some(payload)) => decode_inbound(&payload).map(Some),
Ok(None) => Err(BridgeError::Disconnected),
// Non-blocking socket: no data available this tick — not an error.
// Non-blocking socket: no complete frame yet — not an error. Any
// partial frame stays in the accumulator for the next tick.
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
// EOF mid-frame: the peer died leaving a truncated stream. No data
// can ever complete this frame — escalate like the clean-EOF case
// above instead of surfacing an Io error every tick forever.
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
Err(BridgeError::Disconnected)
}
Err(e) => Err(BridgeError::Io(e)),
}
}