feat(simulation): error handling and recovery — panic supervision, state hash, structured errors (#85)

Protocol v17: add state_hash (desync detection) and sim_errors
(structured error reporting) to ObserverSnapshot. Add SimError,
SimErrorKind, SimErrorBuffer types. Wrap main loop app.update() in
catch_unwind — on panic, send a final SimError snapshot before exit.
Report recoverable deserialization errors to client via SimErrorBuffer.
Compute per-tick state hash from player position + NPC count + tick.
Update all test fixtures and golden files for protocol v17.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 17:46:27 +01:00
co-authored by Claude Opus 4.6
parent c2cae1f618
commit d31ac1cc70
23 changed files with 557 additions and 7 deletions
+23 -2
View File
@@ -91,14 +91,20 @@ impl Default for HandshakeState {
}
}
/// Receive inputs from bridge and push to InputQueue
/// Receive inputs from bridge and push to InputQueue.
/// Protocol errors (malformed input) are recoverable: the frame is skipped
/// and a SimError is pushed to the SimErrorBuffer for client reporting (#85).
pub fn receive_bridge_inputs(
bridge: Option<Res<BridgeResource>>,
mut input_queue: ResMut<crate::simulation::input::InputQueue>,
mut running: ResMut<ServerRunning>,
handshake: Res<HandshakeState>,
mut error_buffer: ResMut<SimErrorBuffer>,
time: Option<Res<crate::simulation::time::SimulationTime>>,
) {
let Some(bridge) = bridge else { return };
let current_tick = time.as_ref().map(|t| t.tick).unwrap_or(0);
match bridge.receive_inputs() {
Ok(inputs) => {
if !inputs.is_empty() && *handshake == HandshakeState::Pending {
@@ -134,8 +140,22 @@ pub fn receive_bridge_inputs(
running.0 = false;
}
Err(BridgeError::DeserializationWithDump(ref msg)) => {
// Recoverable: skip this frame's input, don't shut down
// Recoverable: skip this frame's input, report to client (#85)
tracing::error!("Skipping malformed input frame: {}", msg);
error_buffer.push(SimError {
kind: SimErrorKind::ProtocolError,
message: format!("Malformed input frame: {}", msg),
tick: current_tick,
});
}
Err(ref e @ BridgeError::Deserialization(_)) => {
// Recoverable deserialization error without dump
tracing::error!("Skipping malformed input: {}", e);
error_buffer.push(SimError {
kind: SimErrorKind::ProtocolError,
message: format!("Deserialization error: {}", e),
tick: current_tick,
});
}
Err(e) => {
tracing::error!("Bridge receive error: {}", e);
@@ -191,6 +211,7 @@ impl Plugin for BridgePlugin {
app.init_resource::<SnapshotBuffer>()
.init_resource::<ServerRunning>()
.init_resource::<HandshakeState>()
.init_resource::<SimErrorBuffer>()
.init_resource::<crate::perception::query::VisibilityGeometry>()
.init_resource::<crate::perception::query::ActivePerceptionMode>()
.add_systems(