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>
259 lines
9.8 KiB
Rust
259 lines
9.8 KiB
Rust
// TcpBridge - TCP localhost IPC implementation
|
|
// Implements D-020 subprocess/IPC architecture
|
|
// Deterministic client-server communication via TCP sockets
|
|
// Used for Godot client which lacks Unix socket support
|
|
|
|
use super::{decode_inbound, BridgeError, Inbound, ObserverSnapshot, SimBridge};
|
|
use crate::atlas::layer_proxy::AtlasLayerResponse;
|
|
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<ReadHalf>,
|
|
writer: Mutex<BufWriter<TcpStream>>,
|
|
local_addr: SocketAddr,
|
|
}
|
|
|
|
impl TcpBridge {
|
|
/// Server-side: bind TCP listener and accept one connection.
|
|
/// Binds to the specified address (e.g., "127.0.0.1:0" for OS-assigned port).
|
|
/// Returns the bridge with the actual bound address available via local_addr().
|
|
pub fn accept(addr: &str) -> Result<Self, BridgeError> {
|
|
tracing::info!("TcpBridge binding to {}", addr);
|
|
|
|
let listener = TcpListener::bind(addr)
|
|
.map_err(|e| BridgeError::Transport(format!("failed to bind TCP socket: {}", e)))?;
|
|
|
|
let local_addr = listener
|
|
.local_addr()
|
|
.map_err(|e| BridgeError::Transport(format!("failed to get local address: {}", e)))?;
|
|
|
|
tracing::info!("TcpBridge listening on {}", local_addr);
|
|
|
|
// Accept one connection
|
|
let (stream, peer_addr) = listener
|
|
.accept()
|
|
.map_err(|e| BridgeError::Transport(format!("failed to accept connection: {}", e)))?;
|
|
|
|
tracing::info!(
|
|
"TcpBridge accepted connection from {} on {}",
|
|
peer_addr,
|
|
local_addr
|
|
);
|
|
|
|
// 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)))?;
|
|
|
|
// Clone stream for reader and writer
|
|
let reader_stream = stream.try_clone().map_err(|e| {
|
|
BridgeError::Transport(format!("failed to clone stream for reader: {}", e))
|
|
})?;
|
|
|
|
Ok(Self {
|
|
reader: Mutex::new(ReadHalf::new(reader_stream)),
|
|
writer: Mutex::new(BufWriter::new(stream)),
|
|
local_addr,
|
|
})
|
|
}
|
|
|
|
/// Server-side: accept one connection on an existing TcpListener.
|
|
/// Avoids race conditions in tests by separating bind from accept.
|
|
pub fn accept_on(listener: TcpListener) -> Result<Self, BridgeError> {
|
|
let local_addr = listener
|
|
.local_addr()
|
|
.map_err(|e| BridgeError::Transport(format!("failed to get local address: {}", e)))?;
|
|
|
|
tracing::info!("TcpBridge accepting on {}", local_addr);
|
|
|
|
let (stream, peer_addr) = listener
|
|
.accept()
|
|
.map_err(|e| BridgeError::Transport(format!("failed to accept connection: {}", e)))?;
|
|
|
|
tracing::info!(
|
|
"TcpBridge accepted connection from {} on {}",
|
|
peer_addr,
|
|
local_addr
|
|
);
|
|
|
|
stream
|
|
.set_nonblocking(true)
|
|
.map_err(|e| BridgeError::Transport(format!("failed to set non-blocking: {}", e)))?;
|
|
|
|
let reader_stream = stream.try_clone().map_err(|e| {
|
|
BridgeError::Transport(format!("failed to clone stream for reader: {}", e))
|
|
})?;
|
|
|
|
Ok(Self {
|
|
reader: Mutex::new(ReadHalf::new(reader_stream)),
|
|
writer: Mutex::new(BufWriter::new(stream)),
|
|
local_addr,
|
|
})
|
|
}
|
|
|
|
/// Client-side: connect to TCP address (for tests).
|
|
pub fn connect(addr: &str) -> Result<Self, BridgeError> {
|
|
tracing::info!("TcpBridge connecting to {}", addr);
|
|
|
|
let stream = TcpStream::connect(addr).map_err(|e| {
|
|
BridgeError::Transport(format!("failed to connect to TCP socket: {}", e))
|
|
})?;
|
|
|
|
let local_addr = stream
|
|
.local_addr()
|
|
.map_err(|e| BridgeError::Transport(format!("failed to get local address: {}", e)))?;
|
|
|
|
tracing::trace!("TcpBridge connected to {}", addr);
|
|
|
|
// Clone stream for reader and writer
|
|
let reader_stream = stream.try_clone().map_err(|e| {
|
|
BridgeError::Transport(format!("failed to clone stream for reader: {}", e))
|
|
})?;
|
|
|
|
Ok(Self {
|
|
reader: Mutex::new(ReadHalf::new(reader_stream)),
|
|
writer: Mutex::new(BufWriter::new(stream)),
|
|
local_addr,
|
|
})
|
|
}
|
|
|
|
/// Get the local address (useful for OS-assigned port discovery in tests).
|
|
pub fn local_addr(&self) -> SocketAddr {
|
|
self.local_addr
|
|
}
|
|
}
|
|
|
|
impl SimBridge for TcpBridge {
|
|
fn receive_startup(&self) -> Result<super::StartupMessage, BridgeError> {
|
|
let mut reader = self
|
|
.reader
|
|
.lock()
|
|
.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. Runs before the first receive(),
|
|
// so the frame accumulator is empty and read_framed sees a fresh stream.
|
|
reader
|
|
.stream
|
|
.set_nonblocking(false)
|
|
.map_err(BridgeError::Io)?;
|
|
let result = read_framed(&mut reader.stream);
|
|
// Restore non-blocking for the tick loop
|
|
reader
|
|
.stream
|
|
.set_nonblocking(true)
|
|
.map_err(BridgeError::Io)?;
|
|
match result? {
|
|
Some(payload) => {
|
|
let msg: super::StartupMessage = rmp_serde::from_slice(&payload)?;
|
|
tracing::info!("received startup message: world_seed={}", msg.world_seed);
|
|
Ok(msg)
|
|
}
|
|
None => Err(BridgeError::Disconnected),
|
|
}
|
|
}
|
|
|
|
fn send_handshake(&self) -> Result<(), BridgeError> {
|
|
// D-192: HandshakeMessage carries no version. Send an empty marker so the
|
|
// client knows to begin the startup sequence (send StartupMessage next).
|
|
use super::types::HandshakeMessage;
|
|
let msg = HandshakeMessage {};
|
|
let payload = rmp_serde::to_vec_named(&msg)?;
|
|
let mut writer = self
|
|
.writer
|
|
.lock()
|
|
.map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?;
|
|
// Toggle to blocking for reliable handshake delivery.
|
|
let stream = writer.get_mut();
|
|
stream.set_nonblocking(false).map_err(BridgeError::Io)?;
|
|
let result = write_framed(stream, &payload);
|
|
stream.set_nonblocking(true).map_err(BridgeError::Io)?;
|
|
result?;
|
|
tracing::info!("sent handshake");
|
|
Ok(())
|
|
}
|
|
|
|
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
|
|
let payload = rmp_serde::to_vec_named(snapshot)?;
|
|
|
|
let mut writer = self
|
|
.writer
|
|
.lock()
|
|
.map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?;
|
|
|
|
// Toggle to blocking for reliable write delivery.
|
|
// Single-threaded bevy guarantees no concurrent reads during this window.
|
|
let stream = writer.get_mut();
|
|
stream.set_nonblocking(false).map_err(BridgeError::Io)?;
|
|
let result = write_framed(stream, &payload);
|
|
stream.set_nonblocking(true).map_err(BridgeError::Io)?;
|
|
result?;
|
|
|
|
tracing::trace!("sent snapshot: tick={}", snapshot.tick);
|
|
Ok(())
|
|
}
|
|
|
|
fn receive(&self) -> Result<Option<Inbound>, BridgeError> {
|
|
let mut reader = self
|
|
.reader
|
|
.lock()
|
|
.map_err(|e| BridgeError::MutexPoisoned(format!("reader: {}", e)))?;
|
|
|
|
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 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)),
|
|
}
|
|
}
|
|
|
|
fn send_atlas_response(&self, resp: &AtlasLayerResponse) -> Result<(), BridgeError> {
|
|
let payload = rmp_serde::to_vec_named(resp)?;
|
|
let mut writer = self
|
|
.writer
|
|
.lock()
|
|
.map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?;
|
|
// Toggle to blocking for reliable write delivery (same as send_snapshot).
|
|
let stream = writer.get_mut();
|
|
stream.set_nonblocking(false).map_err(BridgeError::Io)?;
|
|
let result = write_framed(stream, &payload);
|
|
stream.set_nonblocking(true).map_err(BridgeError::Io)?;
|
|
result?;
|
|
Ok(())
|
|
}
|
|
}
|