fix(client): address PR #5 review — race condition, buffer corruption, input error

Critical fixes from Hoshe review:
1. Race condition: defer TCP connect to _process() with retry logic
   (MAX_CONNECT_RETRIES=20, 100ms interval) so server has time to bind.
2. Buffer corruption: disconnect on oversized message instead of
   clearing valid buffered data (_corrupt flag, fail-safe).
3. Silent input drop: send_input() returns Error so callers can detect
   encode/validation failures.

Warnings addressed:
- ServerProcess validates server_path exists before spawning
- SIGKILL and health check TODOs documented for future work
- Diagonal keybindings documented as intentional deferral
- send_message uses single put_data() call (no partial write risk)
- Static frame helpers documented as D-030 Layer 2 test-only

New tests (36 total, up from 33):
- Partial read scenario (chunked TCP delivery)
- Multi-message sequential decode (exercises buffer corruption fix)
- send_input error return on invalid action

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 21:14:52 +01:00
co-authored by Claude Opus 4.6
parent c4e1b07346
commit 3befd2309c
5 changed files with 175 additions and 33 deletions
+2
View File
@@ -1,6 +1,8 @@
extends Node
# Semantic actions — NO raw key codes cross the bridge
# Diagonal directions registered in project.godot with empty event arrays (intentional).
# Keybindings deferred until input design is finalized — likely numpad or composite WASD.
enum Action {
MOVE_NORTH, MOVE_NORTHEAST, MOVE_EAST, MOVE_SOUTHEAST,
MOVE_SOUTH, MOVE_SOUTHWEST, MOVE_WEST, MOVE_NORTHWEST,
+67 -19
View File
@@ -15,6 +15,12 @@ var _server: ServerProcess = null
var server_port: int = 9800
var server_path: String = "" # Path to server binary — set before connect_to_sim()
# Connection retry state — handles server startup delay (Critical fix #1)
const MAX_CONNECT_RETRIES: int = 20 # ~2 seconds at 60fps with 100ms delay
const CONNECT_RETRY_INTERVAL: float = 0.1 # Seconds between retry attempts
var _connect_retries: int = 0
var _retry_timer: float = 0.0
# Signals
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
signal snapshot_received(snapshot: Dictionary)
@@ -32,7 +38,8 @@ func _set_state(new_state: ConnectionState) -> void:
# Connect to simulation server.
# In test mode, immediately transitions to CONNECTED.
# In live mode, spawns server subprocess and connects via TCP.
# In live mode, spawns server subprocess and defers TCP connection to _process()
# to allow the server time to bind its port.
func connect_to_sim() -> void:
_set_state(ConnectionState.CONNECTING)
@@ -49,14 +56,10 @@ func connect_to_sim() -> void:
_set_state(ConnectionState.ERROR)
return
# Connect TCP bridge
_bridge = LocalBridge.new()
var err := _bridge.connect_to_server("127.0.0.1", server_port)
if err != OK:
push_error("SimBridge: failed to initiate TCP connection: %s" % error_string(err))
_set_state(ConnectionState.ERROR)
return
# State transitions to CONNECTED in _process() when TCP handshake completes
# Defer TCP connection to _process() — server needs time to bind its port
_connect_retries = 0
_retry_timer = 0.0
_bridge = null
# Disconnect from simulation server
func disconnect_from_sim() -> void:
@@ -66,19 +69,61 @@ func disconnect_from_sim() -> void:
if _server != null:
_server.stop()
_server = null
_connect_retries = 0
_set_state(ConnectionState.DISCONNECTED)
# Attempt TCP connection. Called from _process() during CONNECTING state.
func _try_connect() -> void:
_bridge = LocalBridge.new()
var err := _bridge.connect_to_server("127.0.0.1", server_port)
if err != OK:
push_warning("SimBridge: TCP connect attempt %d/%d failed: %s" % [
_connect_retries + 1, MAX_CONNECT_RETRIES, error_string(err)])
_bridge = null
# Poll transport layer every frame (non-test mode only)
func _process(_delta: float) -> void:
if test_mode or _bridge == null:
func _process(delta: float) -> void:
if test_mode:
return
# CONNECTING state: retry TCP connection until server is ready
if state == ConnectionState.CONNECTING:
if _bridge == null:
_retry_timer += delta
if _retry_timer >= CONNECT_RETRY_INTERVAL or _connect_retries == 0:
_retry_timer = 0.0
_connect_retries += 1
if _connect_retries > MAX_CONNECT_RETRIES:
push_error("SimBridge: TCP connection failed after %d retries" % MAX_CONNECT_RETRIES)
_set_state(ConnectionState.ERROR)
return
_try_connect()
return
# Bridge exists — poll for connection completion
_bridge.poll()
match _bridge.get_status():
StreamPeerTCP.STATUS_CONNECTED:
_set_state(ConnectionState.CONNECTED)
StreamPeerTCP.STATUS_CONNECTING:
pass # Still connecting, wait
StreamPeerTCP.STATUS_ERROR:
# Connection attempt failed — retry
_bridge = null
if _connect_retries >= MAX_CONNECT_RETRIES:
push_error("SimBridge: TCP connection failed after %d retries" % MAX_CONNECT_RETRIES)
_set_state(ConnectionState.ERROR)
StreamPeerTCP.STATUS_NONE:
_bridge = null # Reset and retry
return
if _bridge == null:
return
_bridge.poll()
match _bridge.get_status():
StreamPeerTCP.STATUS_CONNECTED:
if state != ConnectionState.CONNECTED:
_set_state(ConnectionState.CONNECTED)
# Receive: drain all complete messages from the bridge
var msg := _bridge.poll_message()
while msg.size() > 0:
@@ -91,7 +136,7 @@ func _process(_delta: float) -> void:
if err != OK:
push_error("SimBridge: failed to send message: %s" % error_string(err))
StreamPeerTCP.STATUS_CONNECTING:
pass # Still connecting, wait
pass # Should not happen in CONNECTED state
StreamPeerTCP.STATUS_ERROR:
if state != ConnectionState.ERROR:
push_error("SimBridge: TCP connection error")
@@ -104,20 +149,23 @@ func _process(_delta: float) -> void:
# Send input to simulation server.
# player_input: Dictionary with "action" (int from InputMapper.Action enum) and "timestamp_msec".
# In test mode, inputs are silently dropped. In live mode, encoded and buffered for transport.
func send_input(player_input: Dictionary) -> void:
# Returns OK on success, or an error code on failure.
func send_input(player_input: Dictionary) -> Error:
if state != ConnectionState.CONNECTED:
return
return ERR_CONNECTION_ERROR
if test_mode:
return
return OK
var action_name := _action_enum_to_wire(player_input.get("action", -1))
if action_name.is_empty():
return
# _action_enum_to_wire already emits push_warning for invalid actions
return ERR_INVALID_PARAMETER
var tick: int = player_input.get("timestamp_msec", 0)
var encoded := Protocol.encode_player_input(tick, action_name)
if encoded.size() == 0:
push_error("SimBridge: failed to encode player input (action=%s)" % action_name)
return
return ERR_CANT_CREATE
_outbound_buffer.append(encoded)
return OK
# Poll for snapshot from simulation.
# In test mode returns hardcoded data. In live mode, returns the last decoded snapshot (if any).