// 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, writer: Mutex>, 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 { 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 { 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 { 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 { 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, 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(()) } }