mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
2325313ad2
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.
249 lines
11 KiB
GDScript
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()
|