fix(bridge): add Disconnected error variant, fix test race condition

Replace string-matching disconnect detection with explicit
BridgeError::Disconnected variant. Add TcpBridge::accept_on(listener)
that takes a pre-bound TcpListener, eliminating the 100ms sleep hack
in TCP tests. Send errors now also trigger ServerRunning=false.
Add trace logging to generate_snapshot for entity count visibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 21:15:56 +01:00
co-authored by Claude Opus 4.6
parent 058d0352ab
commit 2cd8786036
4 changed files with 65 additions and 94 deletions
+1 -1
View File
@@ -99,7 +99,7 @@ impl SimBridge for LocalBridge {
tracing::trace!("received {} inputs", inputs.len());
Ok(inputs)
}
None => Err(BridgeError::Transport("client disconnected (EOF)".into())),
None => Err(BridgeError::Disconnected),
}
}
}
+13 -2
View File
@@ -23,6 +23,8 @@ pub enum BridgeError {
Io(#[from] std::io::Error),
#[error("transport error: {0}")]
Transport(String),
#[error("client disconnected")]
Disconnected,
}
/// Abstracts transport layer (D-020)
@@ -86,6 +88,11 @@ pub fn generate_snapshot(
kind,
});
}
tracing::trace!(
"generate_snapshot: tick={}, entities={}",
time.tick,
visible.len()
);
buffer.snapshot = Some(ObserverSnapshot {
tick: time.tick,
entities: visible,
@@ -105,7 +112,7 @@ pub fn receive_bridge_inputs(
input_queue.push(input);
}
}
Err(BridgeError::Transport(ref msg)) if msg.contains("disconnected") => {
Err(BridgeError::Disconnected) => {
tracing::info!("Client disconnected, shutting down");
running.0 = false;
}
@@ -119,11 +126,13 @@ pub fn receive_bridge_inputs(
pub fn send_bridge_snapshot(
bridge: Option<Res<BridgeResource>>,
mut buffer: ResMut<SnapshotBuffer>,
mut running: ResMut<ServerRunning>,
) {
let Some(bridge) = bridge else { return };
if let Some(snapshot) = buffer.snapshot.take() {
if let Err(e) = bridge.send_snapshot(&snapshot) {
tracing::error!("Bridge send error: {}", e);
running.0 = false;
}
}
}
@@ -150,7 +159,9 @@ impl Plugin for BridgePlugin {
Update,
(
receive_bridge_inputs.before(crate::simulation::input::process_player_input),
generate_snapshot.after(crate::simulation::movement::validate_movement),
generate_snapshot
.after(crate::simulation::movement::validate_movement)
.before(crate::simulation::time::advance_tick),
send_bridge_snapshot.after(generate_snapshot),
),
);
+31 -1
View File
@@ -56,6 +56,36 @@ impl TcpBridge {
})
}
/// 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
);
let reader_stream = stream.try_clone().map_err(|e| {
BridgeError::Transport(format!("failed to clone stream for reader: {}", e))
})?;
Ok(Self {
reader: Mutex::new(BufReader::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);
@@ -108,7 +138,7 @@ impl SimBridge for TcpBridge {
tracing::trace!("received {} inputs", inputs.len());
Ok(inputs)
}
None => Err(BridgeError::Transport("client disconnected (EOF)".into())),
None => Err(BridgeError::Disconnected),
}
}
}