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(
+4
View File
@@ -314,6 +314,8 @@ mod tests {
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
sim_errors: vec![],
}
}
@@ -450,6 +452,8 @@ mod tests {
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
sim_errors: vec![],
};
let text = format_snapshot_text(&snap);
assert!(text.contains("Tick 0"));
+67 -2
View File
@@ -17,7 +17,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
/// negotiation is unnecessary. Client should reject snapshots with version !=
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
/// period, then the default is removed once both sides are updated.
pub const PROTOCOL_VERSION: u8 = 16;
pub const PROTOCOL_VERSION: u8 = 17;
/// Handshake message sent as the very first framed message after connection (#555).
/// Client reads this before entering the normal tick loop and validates
@@ -51,10 +51,12 @@ pub struct HandshakeMessage {
/// player_knowledge (#264, partial KG dump for journal/knowledge panel).
/// v15 adds: save_result (#553, save/load operation result for client confirmation).
/// v16 adds: triangle_crisis_events (#250, D-087 triangle escalation for future client rendering).
/// v17 adds: state_hash (#85, desync detection — fast hash of player pos + NPC count + tick),
/// sim_errors (#85, structured error reporting to client).
/// Future fields: ambient sound events, HUD state (D-020 expansion).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserverSnapshot {
/// Protocol version for forward compatibility. Current: 16.
/// Protocol version for forward compatibility. Current: 17.
pub version: u8,
/// Simulation tick when this snapshot was produced
pub tick: u64,
@@ -157,6 +159,19 @@ pub struct ObserverSnapshot {
/// a narrative event or HUD indicator. Empty when no crises occur.
#[serde(default)]
pub triangle_crisis_events: Vec<TriangleCrisisEventWire>,
/// Fast hash of key mutable state for desync detection (#85).
/// Hash inputs: player position, NPC count, tick number.
/// Client compares against its own computed hash — mismatch indicates
/// client and server state have diverged. No auto-recovery in v0.1;
/// client logs mismatches for debugging.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_hash: Option<u64>,
/// Simulation errors reported this tick (#85).
/// Non-fatal errors (protocol errors, desync) are collected during
/// the tick and sent to the client for logging/display.
/// Empty in normal operation. Client may display a warning toast.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sim_errors: Vec<SimError>,
}
/// Game time data for client display (D-031)
@@ -714,6 +729,56 @@ impl From<crate::content::template::TriangleCrisisEvent> for TriangleCrisisEvent
}
}
/// Structured simulation error for client reporting (#85).
///
/// Sent inside `ObserverSnapshot.sim_errors` for recoverable errors
/// (protocol errors, desync warnings). For fatal errors (panics),
/// a final snapshot is sent with the error before the server exits.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimError {
/// Error category for client-side handling.
pub kind: SimErrorKind,
/// Human-readable error description.
pub message: String,
/// Tick when the error occurred (0 if unavailable).
pub tick: u64,
}
/// Categories of simulation errors (#85).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SimErrorKind {
/// Simulation system panic — fatal, server will exit after sending this.
Panic,
/// Protocol/deserialization error — recoverable, server continues.
ProtocolError,
/// Client-server state hash mismatch — informational, no auto-recovery.
DesyncDetected,
}
/// Buffer for collecting simulation errors during a tick (#85).
/// Drained by `compute_observer_snapshot` into `ObserverSnapshot.sim_errors`.
#[derive(Resource, Debug, Default)]
pub struct SimErrorBuffer {
errors: Vec<SimError>,
}
impl SimErrorBuffer {
/// Push a new error into the buffer.
pub fn push(&mut self, error: SimError) {
self.errors.push(error);
}
/// Drain all buffered errors, returning them and clearing the buffer.
pub fn drain(&mut self) -> Vec<SimError> {
std::mem::take(&mut self.errors)
}
/// Check if there are pending errors.
pub fn has_errors(&self) -> bool {
!self.errors.is_empty()
}
}
/// Snapshot buffer resource for staging outgoing ObserverSnapshots
#[derive(Resource, Debug, Default)]
pub struct SnapshotBuffer {
+105 -1
View File
@@ -176,11 +176,43 @@ fn main() {
// Targets ~20 ticks/sec (2 game-minutes/sec). The TCP bridge uses
// non-blocking reads, so without throttling this loop would spin.
// Remaining frame budget is available for NPC AI and pathfinding.
//
// Panic supervision (#85): each tick is wrapped in catch_unwind.
// On panic, the server sends a structured SimError to the client
// before shutting down, rather than an abrupt disconnect.
let target_frame_time = std::time::Duration::from_millis(50);
loop {
let frame_start = std::time::Instant::now();
app.update();
// Wrap app.update() in catch_unwind to handle system panics (#85).
// AssertUnwindSafe is required because App is not UnwindSafe.
let tick_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
app.update();
}));
match tick_result {
Ok(()) => {}
Err(panic_payload) => {
// Extract panic message for error reporting
let panic_msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = panic_payload.downcast_ref::<String>() {
s.clone()
} else {
"unknown panic".to_string()
};
tracing::error!("Simulation panic caught: {}", panic_msg);
// Attempt to send a final SimError snapshot to the client.
// Best-effort: if the bridge is unavailable, we just log and exit.
send_panic_error(&app, &panic_msg);
tracing::error!("Server shutting down after panic");
break;
}
}
if !app.world().resource::<ServerRunning>().0 {
break;
}
@@ -200,6 +232,78 @@ fn main() {
tracing::info!("Simulation server shutting down");
}
/// Best-effort: send a final SimError snapshot to the client on panic (#85).
///
/// Builds a minimal ObserverSnapshot with the panic error and sends it
/// through the bridge. If the bridge is unavailable or sending fails,
/// the error is logged but not fatal (we're already crashing).
fn send_panic_error(app: &App, panic_msg: &str) {
use settled_reach_server::bridge::types::*;
use settled_reach_server::simulation::time::{DayPhase, TickRate};
let world = app.world();
// Try to read current tick from SimulationTime
let tick = world
.get_resource::<settled_reach_server::simulation::time::SimulationTime>()
.map(|t| t.tick)
.unwrap_or(0);
let bridge = match world.get_resource::<BridgeResource>() {
Some(b) => b,
None => {
tracing::error!("Cannot send panic error: no BridgeResource");
return;
}
};
// Build a minimal snapshot carrying the panic error
let snapshot = ObserverSnapshot {
version: PROTOCOL_VERSION,
tick,
game_time: GameTime {
day: 0,
time_of_day: 0,
day_phase: DayPhase::Morning,
tick_rate: TickRate::Paused,
},
player_facing: FacingDirection::North,
player_stance: MovementStance::default(),
player_inventory: vec![],
entities: vec![],
visible_tiles: vec![],
nearby_interactions: vec![],
current_monologue: None,
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
character_pressure: None,
rng_seed: None,
poi_list: vec![],
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
sim_errors: vec![SimError {
kind: SimErrorKind::Panic,
message: format!("Simulation panic: {}", panic_msg),
tick,
}],
};
if let Err(e) = bridge.send_snapshot(&snapshot) {
tracing::error!("Failed to send panic error to client: {}", e);
} else {
tracing::info!("Sent panic SimError to client at tick {}", tick);
}
}
/// Print bevy_ecs schedule graph and exit.
/// Invoked by --dump-schedule CLI flag (#346).
///
+25
View File
@@ -9,6 +9,8 @@
use bevy_ecs::prelude::*;
use std::collections::BTreeSet;
use std::hash::{Hash, Hasher};
use crate::bridge::types::*;
use crate::knowledge::graph::filter_by_access;
use crate::knowledge::types::{AccessRule, KnowledgeState};
@@ -103,6 +105,8 @@ pub fn compute_observer_snapshot(
mut crisis_queue: ResMut<TriangleCrisisEventQueue>,
sim_rng: Option<Res<SimRng>>,
pressure_query: Query<&crate::simulation::pressure::CharacterPressure, With<PlayerCharacter>>,
error_buffer: Option<ResMut<SimErrorBuffer>>,
npc_count_query: Query<Entity, With<crate::npc::Npc>>,
) {
let Ok((
observer_entity,
@@ -396,6 +400,25 @@ pub fn compute_observer_snapshot(
.map(TriangleCrisisEventWire::from)
.collect();
// Compute state hash for desync detection (#85).
// Hash inputs: player position (x, y, z), NPC count, tick number.
// Uses DefaultHasher for speed — not cryptographic, just comparison.
let state_hash = {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
time.tick.hash(&mut hasher);
observer_pos.x.hash(&mut hasher);
observer_pos.y.hash(&mut hasher);
observer_pos.z.hash(&mut hasher);
let npc_count = npc_count_query.iter().count() as u64;
npc_count.hash(&mut hasher);
Some(hasher.finish())
};
// Drain sim errors collected this tick (#85)
let sim_errors = error_buffer
.map(|mut buf| buf.drain())
.unwrap_or_default();
buffer.snapshot = Some(ObserverSnapshot {
version: crate::bridge::types::PROTOCOL_VERSION,
tick: time.tick,
@@ -424,6 +447,8 @@ pub fn compute_observer_snapshot(
player_knowledge,
save_result,
triangle_crisis_events,
state_hash,
sim_errors,
});
}