Files
CosmicClash/Game/scripts/network_manager.gd
T
Josh Creek 2325313ad2 fix(multiplayer): adversarial review fixes for Phase 3
An Opus subagent's adversarial review of Phase 3 found a critical, silent,
permanent bug plus eight smaller real issues, all empirically verified
with real two- and three-process runs:

CRITICAL: InputJitterBuffer's 32-entry ring permanently bricked a
player's input once the un-consumed backlog exceeded the ring's
capacity - a fresh arrival would land in the exact slot consume() was
still waiting on, and since both counters only ever advance, the gap
never closed. Reproduced with a real SIGSTOP/SIGCONT host freeze:
client movement dropped from ~26m to 0.00m at ~0.7s, worse under real
loss (a lossy link lowered the fatal threshold to ~400ms), and
reachable via ordinary clock drift with no external trigger at all.
Fixed by tracking the highest seq ever ingested and having consume()
jump directly to what the ring can still provide once the gap exceeds
capacity, instead of starving through an unrecoverable span. Re-verified
with a 3s freeze (well past the original threshold): full recovery.

HIGH: InputLeadController's release logic was gated on its own past
attacks (lead > LEAD_MIN) rather than the real server-reported depth, so
a backlog it didn't itself cause was never drained. Fixed to gate on
actual depth vs target.

MEDIUM-HIGH: the rate limiter's "N consecutive over-budget seconds"
streak hard-reset to 0 on any clean window, letting a duty-cycled flood
(burst, one clean window, repeat) sustain ~33x budget indefinitely with
zero warnings. Replaced with a leaky-bucket accumulator immune to the
same evasion by construction.

MEDIUM: the seq > server_tick + 20 guard compared two unrelated clock
epochs (server process uptime vs. client's own from-zero seq numbering),
so it never actually protected anything on a long-running server and
could silently drop an honest client's input forever. Bound against the
buffer's own last_applied_seq instead.

MEDIUM: InputJitterBuffer.stalled was computed but never reached the
wire - the one signal that would have made the ring-overflow bug visible
anywhere. Now wired through _ship_to_net_body_state.

MEDIUM: task 3.6's CI driver's assertions didn't depend on client input
reaching the server at all, so it kept passing with the ring-overflow
bug actively triggered. Added real ship-movement and non-stalled checks,
sampled while bots are still connected (an initial attempt sampled after
their own legitimate disconnect, which starves identically to the bug).

LOW-MEDIUM: a lead change silently mislabelled _input_history's older
entries, since the wire format has no per-entry seq field. Fixed by
handling each delta case (ordinary/release/attack) on its own terms.

LOW: bandwidth and snapshot-loss overlay metrics froze at their last
value during a total outage instead of decaying - exactly when they
matter most. Both now report honest post-outage values.

LOW: a guard comment on NetworkManager._ping misdescribed the actual
disconnect_peer() arguments in use. Corrected.

New permanent regression tests: test_ring_overflow_resyncs_to_fresh_data
_instead_of_starving_forever, test_release_drains_a_backlog_it_never_
caused_itself, and client-abuse-flood-dutycycle (reproduces the exact
duty-cycle evasion). Full regression suite, including the net-sim-latency
milestone gate, all abuse roles, and the CI driver, re-run clean after
every fix.
2026-08-20 15:28:44 +01:00

249 lines
11 KiB
GDScript

extends Node
# Autoload (project.godot [autoload] NetworkManager). Owns the ENet
# transport: hosting, joining, shutdown, and connection-state signals. Lives
# at a fixed autoload path so RPC NodePaths never depend on which scene is
# loaded (§1.3 of multiplayer-todo.md's derived decisions).
#
# server_relay = false is set the moment a peer exists: the default `true`
# lets any client rpc() any other client *through the server*, which this
# project's server-authoritative model must never allow — §2.1 calls this
# out as the single highest-value one-line security change in the document.
#
# IMPORTANT, learned the hard way (tests/net_smoke.gd): don't call
# shutdown()/close the peer the instant connected_to_server or peer_connected
# fires. ENet's connect handshake isn't fully settled on the *other* side the
# moment your own side's signal fires — the final ACK still needs a couple
# more poll() cycles to actually reach the wire. Closing immediately drops
# it and leaves the other side's handshake permanently incomplete (it will
# never see peer_connected/connected_to_server at all). Callers that shut
# down right after a fresh connection should let a frame or two pass first.
#
# Manual polling (task 1.3): SceneTree's automatic multiplayer poll runs on
# the *idle* frame, so an rpc() issued from _physics_process waits up to a
# full frame before it's actually pushed onto the wire — and the return leg
# pays the same tax again. set_multiplayer_poll_enabled(false) below turns
# that off; every caller that sends or expects to receive on a tight cadence
# must now call NetworkManager.poll() itself. The intended placement per
# multiplayer-todo.md §7 task 1.3 (client: end of _physics_process after
# sending input, plus top of both _process and _physics_process for receive;
# server: tick start to drain, tick end to flush) has no real per-tick caller
# yet — that lands with the input/snapshot pipeline (tasks 1.4+, Phase 2-3).
# Until then, anything driving a connection (tests/net_smoke.gd included)
# must poll() every frame itself or nothing will ever be sent or received.
signal client_connected(peer_id: int)
signal client_disconnected(peer_id: int)
signal connected_to_server()
signal connection_failed()
signal disconnected_from_server()
signal clock_updated(rtt_ms: float, offset_ms: float)
# Fires at the top of every shutdown() call, whether this process was
# hosting, joined, or already offline, and regardless of *why* (deliberate
# Leave/Cancel, or an incoming disconnect from the other side). Adversarial
# review found MatchNet.roster had no path that cleared it when a HOST
# stopped hosting — connected_to_server/disconnected_from_server only cover
# the client side — so a host -> lobby -> leave -> host-again cycle left a
# permanent phantom player. Listeners that need per-role cleanup should
# still use the more specific signals above; this one exists so "something
# is about to reset the connection, drop anything you were keeping" has
# exactly one place to hook regardless of role.
signal shutting_down()
const DEFAULT_PORT := 7777
const MAX_CLIENTS := 32
# Clock (task 1.8, §4.7): client pings the server once a second on the
# reliable control channel; clock_offset_ms is the min-RTT sample in a
# rolling window, because the lowest-RTT sample has the least queueing
# error. get_server_time_estimate_ms() is the thing every later phase
# (interpolation delay, tick_offset seeding) actually wants — everything
# else here exists to produce it.
const PING_INTERVAL_SEC := 1.0
const CLOCK_WINDOW_SEC := 5.0
var is_server := false
var is_client := false
var _peer: ENetMultiplayerPeer # keep a strong ref alongside multiplayer.multiplayer_peer
var rtt_ms := -1.0 # min-RTT sample currently in the window; -1 = no sample yet
var clock_offset_ms := 0.0 # add to a local Time.get_ticks_msec() reading to estimate the server's clock
var _clock_samples: Array[Dictionary] = []
var _ping_accum_sec := 0.0
# Jitter (task 3.7's debug overlay): RFC3550-style EWMA of the deviation
# between consecutive RAW (not min-filtered) RTT samples — rtt_ms itself is
# a min-RTT, deliberately insensitive to jitter by design (§4.7), so a
# separate, unfiltered running estimate is needed to actually see it.
const JITTER_EWMA_ALPHA := 1.0 / 16.0 # matches RFC3550's own smoothing factor
var jitter_ms := 0.0
var _last_raw_rtt_ms := -1.0
func _ready() -> void:
get_tree().set_multiplayer_poll_enabled(false)
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
multiplayer.connected_to_server.connect(_on_connected_to_server)
multiplayer.connection_failed.connect(_on_connection_failed)
multiplayer.server_disconnected.connect(_on_server_disconnected)
func _process(delta: float) -> void:
# is_client turns true the instant join() is called, before the ENet
# handshake actually completes (or fails) — a slow or refused connect
# attempt would otherwise leave this trying to rpc_id() on a peer
# that's still CONNECTING (or already failed), which Godot logs as
# "Trying to call an RPC via a multiplayer peer which is not
# connected." every single frame. Require the real transport state.
if not is_client or _peer == null or _peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED:
return
_ping_accum_sec += delta
if _ping_accum_sec >= PING_INTERVAL_SEC:
_ping_accum_sec = 0.0
# Capture the timestamp now, before NetSim (task 2.8) can add any
# simulated delay — see net_sim.gd's header comment for why.
var send_ms := Time.get_ticks_msec()
NetSim.send(func() -> void: _ping.rpc_id(1, send_ms), 1)
# Estimate of what the server's Time.get_ticks_msec() reads right now.
# Meaningless before the first pong lands (clock_offset_ms is 0.0 until then
# — callers needing round-trip-confirmed freshness should check rtt_ms >= 0).
func get_server_time_estimate_ms() -> float:
return float(Time.get_ticks_msec()) + clock_offset_ms
# The single entry point every per-tick caller uses instead of relying on
# SceneTree's (now disabled) automatic poll. Safe to call with no peer set —
# polling the default OfflineMultiplayerPeer is a no-op.
func poll() -> void:
multiplayer.poll()
func host(port: int = DEFAULT_PORT, max_clients: int = MAX_CLIENTS) -> Error:
shutdown()
var peer := ENetMultiplayerPeer.new()
var err := peer.create_server(port, max_clients)
if err != OK:
push_error("NetworkManager.host: create_server failed (%s)" % error_string(err))
return err
_peer = peer
multiplayer.multiplayer_peer = peer
multiplayer.server_relay = false
is_server = true
is_client = false
return OK
func join(address: String, port: int = DEFAULT_PORT) -> Error:
shutdown()
var peer := ENetMultiplayerPeer.new()
var err := peer.create_client(address, port)
if err != OK:
push_error("NetworkManager.join: create_client failed (%s)" % error_string(err))
return err
_peer = peer
multiplayer.multiplayer_peer = peer
multiplayer.server_relay = false
is_server = false
is_client = true
return OK
func shutdown() -> void:
shutting_down.emit()
# MultiplayerAPI's default multiplayer_peer is an OfflineMultiplayerPeer
# sentinel, never null — closing that sentinel is a no-op, but assigning
# multiplayer_peer = null (rather than a fresh OfflineMultiplayerPeer)
# leaves the API in a state distinct from its own default, which is a
# known source of confusing follow-on bugs (godotengine/godot#81540).
# Always reset to a real OfflineMultiplayerPeer, never raw null.
var peer := multiplayer.multiplayer_peer
if peer != null and not (peer is OfflineMultiplayerPeer):
peer.close()
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
_peer = null
is_server = false
is_client = false
rtt_ms = -1.0
clock_offset_ms = 0.0
_clock_samples.clear()
_ping_accum_sec = 0.0
jitter_ms = 0.0
_last_raw_rtt_ms = -1.0
@rpc("any_peer", "call_remote", "reliable")
func _ping(client_send_ms: int) -> void:
if not multiplayer.is_server():
return
# Same rule as the client's send above: read the server's clock now, at
# true receipt time, before NetSim can delay the reply — otherwise the
# server's own outbound leg would be silently absorbed out of both the
# RTT sample and the offset estimate instead of adding to them.
var server_now := Time.get_ticks_msec()
var sender_id := multiplayer.get_remote_sender_id()
# A single poll() call can process several queued RPCs from the same
# peer in one batch — an earlier one in that same batch (e.g. task 3.4's
# abuse-triggered match_sim.gd disconnect_peer() call, or the peer
# disconnecting for any other reason mid-batch) can leave this ping's
# sender no longer a valid peer by the time its own turn in the batch
# comes up. Empirically confirmed reachable with disconnect_peer()'s
# default arguments (a graceful, non-forced disconnect — match_sim.gd's
# own disconnect call tried force=true as an alternative and reverted
# it, since that left Godot's own peer-list bookkeeping inconsistent
# and produced far MORE of this exact class of error, not fewer:
# hundreds vs. one, verified). NetSim's inactive/passthrough path (the
# common case — no CLI flags) dispatches immediately with no
# validation of its own, so check here rather than relying on it.
if sender_id not in multiplayer.get_peers():
return
NetSim.send(func() -> void: _pong.rpc_id(sender_id, client_send_ms, server_now), sender_id)
@rpc("authority", "call_remote", "reliable")
func _pong(client_send_ms: int, server_now_ms: int) -> void:
var now_ms := Time.get_ticks_msec()
var sample_rtt := float(now_ms - client_send_ms)
var sample_offset := float(server_now_ms) + sample_rtt / 2.0 - float(now_ms)
if _last_raw_rtt_ms >= 0.0:
var deviation := absf(sample_rtt - _last_raw_rtt_ms)
jitter_ms += (deviation - jitter_ms) * JITTER_EWMA_ALPHA
_last_raw_rtt_ms = sample_rtt
_clock_samples.append({"t": now_ms, "rtt": sample_rtt, "offset": sample_offset})
var cutoff := now_ms - int(CLOCK_WINDOW_SEC * 1000.0)
_clock_samples = _clock_samples.filter(func(s: Dictionary) -> bool: return s["t"] >= cutoff)
var best: Dictionary = _clock_samples[0]
for sample: Dictionary in _clock_samples:
if sample["rtt"] < best["rtt"]:
best = sample
rtt_ms = best["rtt"]
clock_offset_ms = best["offset"]
clock_updated.emit(rtt_ms, clock_offset_ms)
func _on_peer_connected(peer_id: int) -> void:
client_connected.emit(peer_id)
func _on_peer_disconnected(peer_id: int) -> void:
client_disconnected.emit(peer_id)
func _on_connected_to_server() -> void:
connected_to_server.emit()
func _on_connection_failed() -> void:
is_client = false
connection_failed.emit()
func _on_server_disconnected() -> void:
is_server = false
is_client = false
disconnected_from_server.emit()