Files
CosmicClash/Game/scripts/match_net.gd
T
Josh Creek cf73074e27 fix(multiplayer): resolve composition regression from second adversarial review
A second adversarial review of the previous fix commit found two of its
nine fixes silently defeated each other: the seq-range guard (fix for a
MEDIUM epoch-mismatch finding) capped the exact variable the ring-overflow
resync (fix for the original CRITICAL finding) depends on, making the
resync unreachable in production and recreating permanent input death at
a lower failure threshold, reachable via ordinary server tick loss alone.

- CRITICAL: rebind the seq-range guard to InputJitterBuffer's own
  highest_ingested_seq (now public) instead of the consumer-side
  last_applied_seq, so it tracks the client's send epoch rather than a
  value that can lag arbitrarily far behind during a stall.
- HIGH: InputLeadController's release logic still ANDed the old
  `lead > LEAD_MIN` gate onto the new depth-driven condition, so a
  backlog the controller never caused still couldn't drain. Split into
  two independent decisions: the seq-duplicate action follows real
  depth alone; lead's own bookkeeping separately never drops below its
  floor.
- MEDIUM: widen the CI driver's movement/stalled sampling margin
  (run_seconds - 2.0, was - 0.5) and assert the peer is still in
  multiplayer.get_peers() at sample time, since the old margin let the
  check pass on residual starvation grace after a bot had already
  disconnected.
- LOW: measure horizontal-only displacement in the human smoke test's
  movement check — the old 3D-distance bar was beatable by pure
  gravity settling with fully dead input.
- LOW: fix a real "clean stderr" violation (match_net.gd broadcasting
  a departure notice to a peer whose ENet channels are already torn
  down, including a second peer disconnecting in the same poll batch)
  by deferring the notification to the next idle frame.
- Wire the server's per-slot stalled bit into the client debug overlay
  for real — a prior commit message claimed this already reached the
  overlay when only the CI gate actually read it.

Re-verified end-to-end against the real production RPC path (not just
unit tests in isolation, which is how the composition bug got past the
first round): a 2-bot CI match with a 1.5s host SIGSTOP freeze injected
mid-run, well past the 0.6s threshold the review reproduced the bug at,
now recovers cleanly on repeated runs with zero stderr noise.
2026-08-20 18:26:12 +01:00

284 lines
11 KiB
GDScript

extends Node
# Autoload (project.godot [autoload] MatchNet). Handshake + roster layer on
# top of NetworkManager's raw transport (§2.5, §1.3 of multiplayer-todo.md).
# hello/welcome, strict protocol_version and physics_ticks_per_second
# gating, player_joined/player_left, and — since lobby.tscn (task 1.5) needs
# somewhere durable to keep it across the lobby→match scene transition —
# each player's team and ready state. Slot assignment (fixed spawn index
# within a team) is NOT here; that's match spawn's job in Phase 2, derived
# from this roster's team field at spawn time, not stored redundantly here.
const NetCodec = preload("res://scripts/net_codec.gd")
const SimConstants = preload("res://scripts/sim_constants.gd")
signal player_joined(peer_id: int, player_name: String)
signal player_left(peer_id: int)
signal player_state_changed(peer_id: int, team: int, ready: bool)
signal rejected(reason: String) # client-side only: the server refused our hello
signal welcomed() # client-side only: our hello was accepted
const TEAM_COUNT := 2
# player_name is the one client-supplied value in _hello that gets broadcast
# verbatim to every other peer (protocol_version/tick_hz are checked, never
# relayed). MAX_INPUT_LENGTH is a reject threshold, checked before touching
# the string at all — a legitimate client only ever sends local_player_name,
# which the UI already keeps short, so anything past this is a bug or an
# attacker, not a real name to truncate politely. Adversarial review found
# an unbounded name relayed to every peer head-of-line-blocks the reliable
# control channel hard enough that a concurrently-joining client's own
# _welcome never arrived — this is what closes that.
const MAX_INPUT_LENGTH := 256
const MAX_PLAYER_NAME_LENGTH := 24
class PlayerInfo:
var peer_id: int
var player_name: String
var team: int = 0
var ready: bool = false
func _init(p_peer_id: int, p_player_name: String, p_team: int = 0, p_ready: bool = false) -> void:
peer_id = p_peer_id
player_name = p_player_name
team = p_team
ready = p_ready
var roster: Dictionary = {} # peer_id (int) -> PlayerInfo. Never contains peer 1 (the server; §1.1 decision 2 — dedicated servers are never a player).
var local_player_name := "Player"
# Test hook (tests/match_net_smoke.gd): set false before connecting to
# suppress the automatic real hello, so a test can send a deliberately
# mismatched one instead to exercise the rejection path.
var _auto_hello := true
func _ready() -> void:
NetworkManager.client_disconnected.connect(_on_peer_disconnected)
NetworkManager.connected_to_server.connect(_on_connected_to_server)
NetworkManager.disconnected_from_server.connect(_on_disconnected_from_server)
NetworkManager.shutting_down.connect(_on_shutting_down)
func _on_connected_to_server() -> void:
roster.clear()
if _auto_hello:
_hello.rpc_id(1, NetCodec.PROTOCOL_VERSION, SimConstants.TICK_HZ, local_player_name)
func _on_disconnected_from_server() -> void:
roster.clear()
# Covers the case _on_disconnected_from_server doesn't: a HOST calling
# NetworkManager.shutdown() itself (Leave, or hosting again after already
# hosting) never fires disconnected_from_server — that signal only fires
# from an incoming multiplayer.server_disconnected event, which a server
# never receives about itself. Without this, roster (and every peer's team/
# ready state in it) would persist forever across a host/re-host cycle in
# the same process.
func _on_shutting_down() -> void:
roster.clear()
# Server only: a raw ENet disconnect (crash, timeout) that never sent a
# proper hello just needs its (possibly absent) roster entry cleaned up.
# The normal leave path also goes through here after the server erases it,
# guarded by roster.erase()'s own has-check below.
func _on_peer_disconnected(peer_id: int) -> void:
if not multiplayer.is_server():
return
_remove_player(peer_id)
func _remove_player(peer_id: int) -> void:
if not roster.has(peer_id):
return
roster.erase(peer_id)
player_left.emit(peer_id)
# rpc() broadcasts to every peer in multiplayer.get_peers() — including,
# transiently, the very peer that just disconnected: this fires from
# NetworkManager's client_disconnected signal, and empirically that
# peer's own ENetConnection can still be momentarily present in the
# broadcast's target set with its channels already torn down, which
# logs "Unable to send packet on channel 0, max channels: 0" on every
# single disconnect (found by a second adversarial review — harmless to
# the game, since the departing peer obviously doesn't need to hear
# about its own departure, but it meant "clean stderr" wasn't actually
# clean for any test in this project).
#
# A first attempt filtered the broadcast down to rpc_id() calls that
# explicitly skip `peer_id`. That's necessary but not sufficient: when
# two peers disconnect within the same poll() batch (both bots quitting
# at the end of a CI run land within the same tick), get_peers() here
# can still list the SECOND peer as connected while its own disconnect
# event just hasn't been dispatched yet in this same batch — sending to
# it hits the identical error, one hop later. Defer the whole
# notification to the next idle frame instead of sending synchronously
# from inside signal-handling: by then poll() has fully returned, every
# disconnect event in this batch has been dispatched, and get_peers()
# reflects the settled, genuinely-still-connected set.
call_deferred("_broadcast_player_left", peer_id)
func _broadcast_player_left(peer_id: int) -> void:
for other_peer_id in multiplayer.get_peers():
if other_peer_id != peer_id:
_player_left.rpc_id(other_peer_id, peer_id)
# Balances a new joiner onto whichever team currently has fewer players
# (ties go to team 0). Server only.
func _pick_balanced_team() -> int:
var counts := []
counts.resize(TEAM_COUNT)
counts.fill(0)
for info: PlayerInfo in roster.values():
counts[info.team] += 1
var best_team := 0
for team in range(TEAM_COUNT):
if counts[team] < counts[best_team]:
best_team = team
return best_team
@rpc("any_peer", "call_remote", "reliable")
func _hello(protocol_version: int, tick_hz: int, player_name: String) -> void:
if not multiplayer.is_server():
return
var peer_id := multiplayer.get_remote_sender_id()
if roster.has(peer_id):
return # duplicate hello from an already-accepted peer; ignore
if protocol_version != NetCodec.PROTOCOL_VERSION:
await _reject(peer_id, "protocol version mismatch: server=%d client=%d" % [NetCodec.PROTOCOL_VERSION, protocol_version])
return
if tick_hz != SimConstants.TICK_HZ:
await _reject(peer_id, "physics tick rate mismatch: server=%d client=%d" % [SimConstants.TICK_HZ, tick_hz])
return
if player_name.length() > MAX_INPUT_LENGTH:
await _reject(peer_id, "player name too long")
return
var clean_name := _sanitize_player_name(player_name)
# Tell the new peer about everyone already here before anyone is told
# about them, so no client ever observes an unknown peer_id in a
# player_joined it didn't get a prior player_joined for.
for existing_id: int in roster.keys():
var existing: PlayerInfo = roster[existing_id]
_player_joined.rpc_id(peer_id, existing_id, existing.player_name, existing.team, existing.ready)
var team := _pick_balanced_team()
roster[peer_id] = PlayerInfo.new(peer_id, clean_name, team, false)
player_joined.emit(peer_id, clean_name) # local: the broadcast below is call_remote, never loops back to the server itself
_welcome.rpc_id(peer_id)
_player_joined.rpc(peer_id, clean_name, team, false) # broadcast, includes the new peer itself
# Strips control/formatting characters (so a name can't corrupt a log line
# or blow out UI layout with e.g. embedded newlines) and clamps to display
# length. Input is already bounded to MAX_INPUT_LENGTH by the caller before
# this runs, so this never iterates an attacker-sized string. static: pure
# function of its argument, doesn't touch roster/multiplayer — also lets
# tests/cases/test_match_net.gd call it with no Node instantiation.
static func _sanitize_player_name(raw: String) -> String:
var clean := ""
for c in raw:
var code := c.unicode_at(0)
if code >= 0x20 and code != 0x7F:
clean += c
clean = clean.strip_edges()
if clean.length() > MAX_PLAYER_NAME_LENGTH:
clean = clean.substr(0, MAX_PLAYER_NAME_LENGTH)
if clean.is_empty():
clean = "Player"
return clean
func _reject(peer_id: int, reason: String) -> void:
_rejected.rpc_id(peer_id, reason)
# §9 gotcha 26: a reliable RPC just queued still needs a beat of polling
# to actually reach the wire before we pull the connection out from
# under it.
await get_tree().create_timer(0.3).timeout
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
# Client-callable requests. Both are fire-and-forget: the authoritative
# change comes back through _state_changed once the server applies it, same
# as everyone else's — a client never mutates its own roster entry directly.
func request_set_team(team: int) -> void:
_set_team.rpc_id(1, team)
func request_set_ready(ready: bool) -> void:
_set_ready.rpc_id(1, ready)
@rpc("any_peer", "call_remote", "reliable")
func _set_team(team: int) -> void:
if not multiplayer.is_server():
return
var peer_id := multiplayer.get_remote_sender_id()
if not roster.has(peer_id) or team < 0 or team >= TEAM_COUNT:
return
var info: PlayerInfo = roster[peer_id]
if info.team == team:
return
info.team = team
info.ready = false # switching teams un-readies — the roster you were ready against just changed
player_state_changed.emit(peer_id, info.team, info.ready)
_state_changed.rpc(peer_id, info.team, info.ready)
@rpc("any_peer", "call_remote", "reliable")
func _set_ready(ready: bool) -> void:
if not multiplayer.is_server():
return
var peer_id := multiplayer.get_remote_sender_id()
if not roster.has(peer_id):
return
var info: PlayerInfo = roster[peer_id]
if info.ready == ready:
return
info.ready = ready
player_state_changed.emit(peer_id, info.team, info.ready)
_state_changed.rpc(peer_id, info.team, info.ready)
@rpc("authority", "call_remote", "reliable")
func _state_changed(peer_id: int, team: int, ready: bool) -> void:
if not roster.has(peer_id):
return
var info: PlayerInfo = roster[peer_id]
info.team = team
info.ready = ready
player_state_changed.emit(peer_id, team, ready)
@rpc("authority", "call_remote", "reliable")
func _welcome() -> void:
welcomed.emit()
@rpc("authority", "call_remote", "reliable")
func _rejected(reason: String) -> void:
rejected.emit(reason)
@rpc("authority", "call_remote", "reliable")
func _player_joined(peer_id: int, player_name: String, team: int, ready: bool) -> void:
roster[peer_id] = PlayerInfo.new(peer_id, player_name, team, ready)
player_joined.emit(peer_id, player_name)
@rpc("authority", "call_remote", "reliable")
func _player_left(peer_id: int) -> void:
if not roster.has(peer_id):
return
roster.erase(peer_id)
player_left.emit(peer_id)