mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 19:13:43 +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.
143 lines
6.2 KiB
GDScript
143 lines
6.2 KiB
GDScript
class_name InputJitterBuffer
|
|
extends RefCounted
|
|
|
|
# Per-player server-side input state (multiplayer-todo.md §3, task 3.2).
|
|
# Deliberately a standalone RefCounted with no scene/RPC dependency — same
|
|
# reason net_codec.gd and net_interpolator.gd are pure classes — so task
|
|
# 3.5's unit tests can drive it with scripted arrival traces with no live
|
|
# match. NetworkedMatch owns one instance per connected slot and is the only
|
|
# thing that talks to the network layer; this class only knows about
|
|
# sequence numbers and ShipActions.
|
|
#
|
|
# Ring is fixed-size and slot-tagged (§3.1 step 5's "a client can never make
|
|
# the server allocate"): ingest() writes seq % RING_SIZE regardless of how
|
|
# large or malicious seq is, and consume() only ever trusts a slot whose
|
|
# stored seq exactly matches the one it expects — a stale or wrapped-around
|
|
# entry is indistinguishable from an empty one. Range/rate validation of seq
|
|
# against the current server tick is the CALLER's job (task 3.4), not this
|
|
# class's, since only the caller knows the current server tick.
|
|
|
|
const RING_SIZE := 32
|
|
# 500ms at 60Hz (multiplayer-todo.md §3.2's own numbers) — a duration, not a
|
|
# tick-rate-derived constant, so left as a literal rather than pulling in
|
|
# SimConstants for one number.
|
|
const STARVE_ZERO_TICKS := 30
|
|
|
|
var last_applied_seq := -1 # -1: consume() has never been called yet
|
|
var last_action := ShipAction.new()
|
|
var starved_ticks := 0
|
|
var stalled := false
|
|
|
|
var _ring_action: Array = []
|
|
var _ring_seq: PackedInt32Array = PackedInt32Array()
|
|
# True once ingest() has ever been called for real. Consumption is a no-op
|
|
# (no starvation counted, no advancement) until then — see ingest()'s own
|
|
# comment for why an un-seeded buffer would otherwise never converge with
|
|
# what the client is actually sending.
|
|
var _seeded := false
|
|
# Highest seq ever seen by ingest(), regardless of whether it's still in the
|
|
# ring — consume()'s only way to tell "the data is gone because the ring
|
|
# overflowed" apart from "the data just hasn't arrived yet". See consume()'s
|
|
# own comment for why this exists: an adversarial review found that without
|
|
# it, a backlog bigger than RING_SIZE (a host stall, or persistent client/
|
|
# server clock drift) permanently zeroed a connected player's input for the
|
|
# rest of the match.
|
|
var _highest_ingested_seq := -1
|
|
|
|
|
|
func _init() -> void:
|
|
_ring_action.resize(RING_SIZE)
|
|
_ring_seq.resize(RING_SIZE)
|
|
for i in RING_SIZE:
|
|
_ring_seq[i] = -1
|
|
|
|
|
|
# newest_seq/actions match NetCodec.unpack_input's own "seq"/"actions"
|
|
# fields directly: actions[i] is the action for sequence (newest_seq - i),
|
|
# newest-first. Already-consumed or stale entries are silently discarded
|
|
# (§3.1 step 5) — this is what makes redundant re-delivery of an already-
|
|
# applied tick harmless.
|
|
func ingest(newest_seq: int, actions: Array) -> void:
|
|
if not _seeded:
|
|
# The server starts calling consume() every tick the instant this
|
|
# slot exists — well before this player's first packet has had time
|
|
# to arrive (connection handshake, arena/ship spawn, first
|
|
# _physics_process tick on the client all take real time first). An
|
|
# un-seeded last_applied_seq of -1 would have consume() "expecting"
|
|
# sequence 0, 1, 2, ... via pure starvation the whole time, racing
|
|
# arbitrarily far ahead of whatever the client's own from-1
|
|
# numbering has actually reached by the time real packets show up —
|
|
# and since both sides only ever advance monotonically with no
|
|
# resync mechanism, that gap would never close. Seed to align
|
|
# "expected" with reality the moment real data first exists.
|
|
last_applied_seq = newest_seq - actions.size()
|
|
_seeded = true
|
|
if newest_seq > _highest_ingested_seq:
|
|
_highest_ingested_seq = newest_seq
|
|
for i in actions.size():
|
|
var seq: int = newest_seq - i
|
|
if seq <= last_applied_seq:
|
|
continue
|
|
var idx := seq % RING_SIZE
|
|
_ring_seq[idx] = seq
|
|
_ring_action[idx] = actions[i]
|
|
|
|
|
|
# Contiguous run of not-yet-applied entries starting right after
|
|
# last_applied_seq — reported as input_buffer_depth in every snapshot
|
|
# (§3.3) and consumed client-side by the input_lead control loop (task 3.3).
|
|
func depth() -> int:
|
|
if not _seeded or last_applied_seq < 0:
|
|
return 0
|
|
var d := 0
|
|
var seq := last_applied_seq + 1
|
|
while d < RING_SIZE and _ring_seq[seq % RING_SIZE] == seq:
|
|
d += 1
|
|
seq += 1
|
|
return d
|
|
|
|
|
|
# Called once per server physics tick, before the step (§3.2). A no-op
|
|
# (returns the zero-initialized last_action, no starvation counted) until
|
|
# this player's first real packet has ever arrived — see ingest()'s comment.
|
|
func consume() -> ShipAction:
|
|
if not _seeded:
|
|
return last_action
|
|
var expected := last_applied_seq + 1
|
|
var idx := expected % RING_SIZE
|
|
|
|
# Ring-overflow resync. A fixed-size ring can only ever hold RING_SIZE
|
|
# ticks of not-yet-consumed data at once — if the caller has fallen
|
|
# further behind the newest data actually arriving than that (a host
|
|
# stall, or persistent client/server clock drift), every tick between
|
|
# "expected" and "_highest_ingested_seq - RING_SIZE" has already been
|
|
# irrecoverably overwritten by more recent arrivals landing on the same
|
|
# ring slots. Waiting for it tick-by-tick would starve — and, past
|
|
# STARVE_ZERO_TICKS, zero this player's ship — for the ENTIRE gap even
|
|
# though fresh, real input already exists in the ring right now. An
|
|
# adversarial review found and reproduced this exact failure (a ~0.7s
|
|
# host freeze permanently zeroed a connected player's input for the
|
|
# rest of the match, with no self-recovery). Skip the unrecoverable
|
|
# span and resync directly to what the ring can still actually provide.
|
|
if _highest_ingested_seq - expected >= RING_SIZE:
|
|
last_applied_seq = _highest_ingested_seq - RING_SIZE
|
|
expected = last_applied_seq + 1
|
|
idx = expected % RING_SIZE
|
|
|
|
if _ring_seq[idx] == expected:
|
|
last_action = _ring_action[idx]
|
|
starved_ticks = 0
|
|
stalled = false
|
|
else:
|
|
# Repeat-last, not zero: inputs are heavily autocorrelated at 60Hz,
|
|
# and the client already predicted with the real input either way,
|
|
# so repeating minimises expected divergence (§3.2). Only zero after
|
|
# a sustained stall, so a disconnecting player's ship doesn't fly
|
|
# into a wall at full throttle forever.
|
|
starved_ticks += 1
|
|
if starved_ticks > STARVE_ZERO_TICKS:
|
|
last_action = ShipAction.new()
|
|
stalled = true
|
|
last_applied_seq = expected
|
|
return last_action
|