mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-13 21:22:06 +00:00
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.
This commit is contained in:
@@ -113,11 +113,13 @@ var _last_known_input_buffer_depth := -1 # client only: -1 = no snapshot with t
|
||||
const SNAPSHOT_LOSS_EWMA_ALPHA := 1.0 / 16.0
|
||||
var _snapshot_loss_ewma := 0.0
|
||||
var _expected_next_snapshot_tick := -1
|
||||
# §3.1 step 4. Not 120: InputLeadController.LEAD_MAX is 12, so anything
|
||||
# claiming to be further ahead of the current server tick than this is
|
||||
# broken or hostile, not just an honest client running a legitimately fast
|
||||
# lead.
|
||||
const MAX_SEQ_LEAD_TICKS := 20
|
||||
# An adversarial review found _snapshot_loss_ewma only updates on receipt —
|
||||
# during a TOTAL outage, exactly when this metric matters most, it freezes
|
||||
# at its last (probably low/healthy) value instead of climbing toward
|
||||
# 100%. Track wall-clock receipt time so get_net_debug_stats() can report
|
||||
# honestly once too long has passed with nothing arriving at all.
|
||||
var _last_snapshot_wall_ms := -1
|
||||
const SNAPSHOT_STALE_MS := 500.0 # ~30 ticks with nothing at all — treat as total loss, not "still fine"
|
||||
var _unknown_sender_input_count := 0 # server only, observability (§3.1 step 1)
|
||||
var _reset_gen := 0 # server only: bumped on every kickoff/goal reset so the client hard-snaps instead of interpolating across the teleport
|
||||
# Server only. _on_goal_scored's reset_ball()/reset_ships() only QUEUE
|
||||
@@ -241,18 +243,32 @@ func _on_input_received(peer_id: int, decoded: Dictionary) -> void:
|
||||
for slot in _slots:
|
||||
if slot.peer_id == peer_id:
|
||||
var seq: int = decoded["seq"]
|
||||
# §3.1 step 4. Not 120: input_lead is clamped to
|
||||
# InputLeadController.LEAD_MAX (12), so anything claiming to be
|
||||
# further ahead than this is broken or hostile, not just a fast
|
||||
# lead. This is also why InputJitterBuffer's ring can be fixed-
|
||||
# size — a client can never make the server allocate — but
|
||||
# rejecting the packet here still keeps garbage-far-future seq
|
||||
# values out of the ring entirely rather than letting them
|
||||
# silently overwrite a near-future slot some honest, in-range
|
||||
# packet is about to need.
|
||||
if seq > Engine.get_physics_frames() + MAX_SEQ_LEAD_TICKS:
|
||||
# §3.1 step 4, rebound after an adversarial review found the
|
||||
# original check (seq > Engine.get_physics_frames() + 20)
|
||||
# compared two unrelated epochs: get_physics_frames() counts
|
||||
# from the SERVER PROCESS's own start, while a client's
|
||||
# _input_seq starts at 0 when ITS match scene loads —
|
||||
# input_jitter_buffer.gd's own seeding logic exists specifically
|
||||
# because these share no baseline (see its header comment).
|
||||
# Bounding against server uptime meant this guard could never
|
||||
# fire on a long-running dedicated server (no real protection —
|
||||
# the stated "keeps garbage-far-future seq values out of the
|
||||
# ring" rationale wasn't actually achieved), and could silently
|
||||
# drop an honest client's input forever the moment accumulated
|
||||
# server tick loss closed whatever accidental head-start margin
|
||||
# existed. Bound against this slot's own last_applied_seq
|
||||
# instead — the client's own epoch, which the ring is already
|
||||
# anchored to — using the ring's own capacity as the bound,
|
||||
# exactly matching what InputJitterBuffer.consume()'s own
|
||||
# overflow-resync logic treats as "unrecoverably far ahead"
|
||||
# anyway. Falls back to seq itself (never rejects) before the
|
||||
# buffer has ever been seeded — there's no baseline yet to
|
||||
# bound against.
|
||||
var jb := slot.jitter_buffer
|
||||
var seq_bound: int = (jb.last_applied_seq if jb.last_applied_seq >= 0 else seq) + InputJitterBuffer.RING_SIZE
|
||||
if seq > seq_bound:
|
||||
return
|
||||
slot.jitter_buffer.ingest(seq, decoded["actions"])
|
||||
jb.ingest(seq, decoded["actions"])
|
||||
slot.last_client_send_ms = decoded["client_send_ms"]
|
||||
return
|
||||
# A connected-but-not-yet-slotted peer (or one whose slot somehow
|
||||
@@ -285,7 +301,7 @@ func _broadcast_snapshot() -> void:
|
||||
# total-garbage failure mode the moment that stops being true, and the
|
||||
# fix costs nothing.
|
||||
for slot in _slots:
|
||||
bodies.append(_ship_to_net_body_state(slot.ship) if is_instance_valid(slot.ship) else NetBodyState.new())
|
||||
bodies.append(_ship_to_net_body_state(slot.ship, slot.jitter_buffer.stalled) if is_instance_valid(slot.ship) else NetBodyState.new())
|
||||
if is_instance_valid(ball):
|
||||
bodies.append(_ball_to_net_body_state(ball))
|
||||
var segment := NetCodec.pack_snapshot_body_segment(server_tick, 0, _reset_gen, bodies)
|
||||
@@ -312,7 +328,7 @@ func _broadcast_snapshot() -> void:
|
||||
MatchSim.send_snapshot(slot.peer_id, bytes)
|
||||
|
||||
|
||||
func _ship_to_net_body_state(ship: Ship) -> NetBodyState:
|
||||
func _ship_to_net_body_state(ship: Ship, stalled: bool) -> NetBodyState:
|
||||
var s := NetBodyState.new()
|
||||
s.position = ship.global_position
|
||||
s.rotation = ship.global_transform.basis.get_rotation_quaternion()
|
||||
@@ -324,6 +340,12 @@ func _ship_to_net_body_state(ship: Ship) -> NetBodyState:
|
||||
# forward thrust drives the visible flame (see task 2.6).
|
||||
s.thrust_z = clampf(maxf(ship.controller.get_action().thrust.z if ship.controller else 0.0, 0.0), 0.0, 1.0)
|
||||
s.avel_range = NetCodec.SHIP_AVEL_RANGE
|
||||
# §3.2: InputJitterBuffer.stalled was computed all along but never
|
||||
# reached the wire — an adversarial review found this was the exact
|
||||
# signal that would have made the ring-overflow bug (this session's
|
||||
# critical fix) visible to the client, the debug overlay, and the CI
|
||||
# gate, and its absence is part of why none of them ever noticed.
|
||||
s.stalled = stalled
|
||||
return s
|
||||
|
||||
|
||||
@@ -433,23 +455,46 @@ func _send_local_input() -> void:
|
||||
# increments its send sequence by exactly one tick's worth), but a lead
|
||||
# change this tick skips extra sequence numbers (attack, more server-
|
||||
# side buffer margin) or duplicates the current one (release, delta 0 —
|
||||
# one tick of latency recovered). A duplicated tick can, in the narrow
|
||||
# case where an older redundant copy hasn't been superseded yet, smear
|
||||
# one of _input_history's older backup slots by one position — the
|
||||
# PRIMARY (freshest, most-recently-relevant) value for every seq is
|
||||
# unaffected, so this only ever degrades a backup copy, never the real
|
||||
# per-tick record; §3.3 itself only promises "skip or duplicate a
|
||||
# sequence number," not frame-perfect bookkeeping under a lead change.
|
||||
_input_seq += _input_lead_controller.update(_last_known_input_buffer_depth)
|
||||
# one tick of latency recovered).
|
||||
var delta := _input_lead_controller.update(_last_known_input_buffer_depth)
|
||||
_input_seq += delta
|
||||
# Redundancy (§3.1): carry the last MAX_REDUNDANCY ticks' actions,
|
||||
# newest-first, so a burst of up to (MAX_REDUNDANCY - 1) consecutive
|
||||
# packet losses still lets the server recover every dropped tick's
|
||||
# action from a later packet — InputJitterBuffer.ingest() discards
|
||||
# whichever of these the server already applied, so re-sending old
|
||||
# ticks every packet is harmless, not just tolerated.
|
||||
_input_history.push_front(action)
|
||||
if _input_history.size() > NetCodec.MAX_REDUNDANCY:
|
||||
_input_history.resize(NetCodec.MAX_REDUNDANCY)
|
||||
# ticks every packet is harmless, not just tolerated. NetCodec's wire
|
||||
# format has no per-entry seq field — actions[i] is implicitly
|
||||
# "seq - i" — so _input_history must actually BE that many consecutive
|
||||
# ticks, not just "the last few samples taken". A plain push_front on
|
||||
# every tick regardless of delta broke that: an adversarial review
|
||||
# found a lead change silently relabelled older entries (a duplicated
|
||||
# tick shifts everything back by one position without a matching seq
|
||||
# change, and a skip-ahead makes the whole history discontiguous with
|
||||
# the new seq), causing the server to replay already-applied ticks or
|
||||
# apply the wrong redundant copy for a given seq. Handle each case on
|
||||
# its own terms instead of always pushing.
|
||||
if delta == 1:
|
||||
_input_history.push_front(action)
|
||||
if _input_history.size() > NetCodec.MAX_REDUNDANCY:
|
||||
_input_history.resize(NetCodec.MAX_REDUNDANCY)
|
||||
elif delta == 0:
|
||||
# Release: seq didn't advance, so this tick's freshest sample
|
||||
# REPLACES the front entry (still "seq") rather than pushing
|
||||
# everything else back a position under a label that no longer
|
||||
# matches what's actually there.
|
||||
if _input_history.is_empty():
|
||||
_input_history.push_front(action)
|
||||
else:
|
||||
_input_history[0] = action
|
||||
else:
|
||||
# Attack: seq jumped ahead by more than one, so nothing previously
|
||||
# in history is contiguous with the new seq any more — the skipped
|
||||
# range was never sent, by design (that's what "buys more server-
|
||||
# side buffer margin" means). Reset the redundancy window to just
|
||||
# this tick's sample; it rebuilds naturally over the next few
|
||||
# ticks, the same way it does at connection start.
|
||||
_input_history = [action]
|
||||
var bytes := NetCodec.pack_input(_input_seq, _last_received_snapshot_tick, Time.get_ticks_msec(), _input_history)
|
||||
MatchSim.send_input(bytes)
|
||||
|
||||
@@ -464,6 +509,7 @@ func _on_snapshot_received(decoded: Dictionary) -> void:
|
||||
_snapshot_loss_ewma += (sample - _snapshot_loss_ewma) * SNAPSHOT_LOSS_EWMA_ALPHA
|
||||
_expected_next_snapshot_tick = server_tick + 1
|
||||
_last_received_snapshot_tick = server_tick
|
||||
_last_snapshot_wall_ms = Time.get_ticks_msec()
|
||||
# Per-client header (§2.4): unlike the shared body segment, this is
|
||||
# genuinely this recipient's own — input_buffer_depth is THIS client's
|
||||
# own slot's server-side InputJitterBuffer.depth() at send time, which
|
||||
@@ -535,11 +581,18 @@ func get_net_debug_stats() -> Dictionary:
|
||||
if NetworkManager.rtt_ms >= 0.0:
|
||||
var estimated_now_tick := _estimated_tick(NetworkManager.get_server_time_estimate_ms())
|
||||
snapshot_age_ms = (estimated_now_tick - float(_last_received_snapshot_tick)) * NetInterpolator.TICK_MS
|
||||
# _snapshot_loss_ewma only updates on receipt, so during a TOTAL outage
|
||||
# — exactly when this matters most — it would otherwise freeze at
|
||||
# whatever it last read (probably low/healthy) instead of climbing
|
||||
# toward 100%, an adversarial review found. Report honestly once too
|
||||
# long has passed with nothing arriving at all.
|
||||
var is_stale := _last_snapshot_wall_ms >= 0 and Time.get_ticks_msec() - _last_snapshot_wall_ms > SNAPSHOT_STALE_MS
|
||||
var snapshot_loss_pct := 100.0 if is_stale else _snapshot_loss_ewma * 100.0
|
||||
return {
|
||||
"input_buffer_depth": _last_known_input_buffer_depth,
|
||||
"input_lead": _input_lead_controller.lead,
|
||||
"snapshot_age_ms": snapshot_age_ms,
|
||||
"snapshot_loss_pct": _snapshot_loss_ewma * 100.0,
|
||||
"snapshot_loss_pct": snapshot_loss_pct,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user