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:
+42
-14
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user