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.
99 lines
4.6 KiB
GDScript
99 lines
4.6 KiB
GDScript
class_name InputLeadController
|
|
extends RefCounted
|
|
|
|
# Client-owned input_lead control loop (multiplayer-todo.md §3.3, task 3.3).
|
|
# Standalone RefCounted, same reason as input_jitter_buffer.gd — scene-free
|
|
# so it's directly unit-testable against scripted depth traces.
|
|
#
|
|
# §3.3's own rationale for why this is the CLIENT's job alone, not shared
|
|
# with any server-side adaptation: three control loops acting on one plant
|
|
# (buffer occupancy) with different time constants is a textbook
|
|
# oscillation, and on a jittery link it presents to the player as
|
|
# intermittent sticky controls that are nearly impossible to attribute.
|
|
# The server (InputJitterBuffer, §3.2) only ever reports input_buffer_depth
|
|
# — it does nothing adaptive with it.
|
|
#
|
|
# "Lead" is realized concretely as extra distance between this client's own
|
|
# outgoing sequence numbers and what the server has actually consumed:
|
|
# skipping a sequence number (jumping the client's own seq counter by more
|
|
# than 1 for one tick) buys the server one more tick of buffered depth
|
|
# before it would starve; duplicating one (not incrementing the seq counter
|
|
# for one tick — the same seq gets sent again) narrows that margin by one
|
|
# tick of latency. The server's own ring buffer doesn't need to know this
|
|
# happened: a skipped seq just means "the redundant copies of it never
|
|
# existed, it's an ordinary drop" (already handled), and a duplicated seq
|
|
# is a same-seq resend, already discarded harmlessly once consumed
|
|
# (InputJitterBuffer.ingest()'s "seq <= last_applied_seq" check).
|
|
#
|
|
# Fast attack, slow release — a symmetric ±1-per-N-ticks slew would take
|
|
# two full seconds to absorb a single wifi spike, during which the player
|
|
# steers and the ship does not turn, "the most rage-inducing failure mode
|
|
# in any netcode" per §3.3's own words.
|
|
|
|
const LEAD_MIN := 1
|
|
const LEAD_MAX := 12
|
|
# "Never change it more than once per 30 ticks" (§3.3) — the floor that
|
|
# binds the fast-attack side; slow-release's own 60-tick cadence already
|
|
# exceeds it, so this one constant covers both.
|
|
const MIN_CHANGE_INTERVAL_TICKS := 30
|
|
const RELEASE_INTERVAL_TICKS := 60
|
|
const CLEAN_SURPLUS_TICKS := 120 # 2s at 60Hz
|
|
# §3.3: "target_depth = 1 (16.7 ms), not 2." Release only fires when the
|
|
# server-reported depth is genuinely ABOVE this — see update()'s own
|
|
# comment for why gating on `lead` alone (an adversarial review's original
|
|
# finding here) was wrong.
|
|
const TARGET_DEPTH := 1
|
|
|
|
var lead := LEAD_MIN
|
|
|
|
var _ticks_since_change := 0
|
|
var _clean_surplus_ticks := 0
|
|
|
|
|
|
# Call once per client physics tick with the most recently known server-
|
|
# reported input_buffer_depth for THIS client's own slot (echoed in every
|
|
# snapshot, §3.2) — or -1 if no snapshot carrying that field has arrived
|
|
# yet. Returns the seq delta the caller should add for this tick's
|
|
# outgoing packet: ordinarily 1 (ship normally increments its send
|
|
# sequence by exactly one tick's worth), or 1+N / 0 on a tick where a lead
|
|
# change actually fires (skip N extra / duplicate the current one).
|
|
func update(input_buffer_depth: int) -> int:
|
|
_ticks_since_change += 1
|
|
if input_buffer_depth < 0:
|
|
return 1
|
|
|
|
if input_buffer_depth <= 0:
|
|
# A starve: the server's ring was empty for this player when it
|
|
# built that snapshot. React immediately, not after 2 seconds of
|
|
# evidence like release requires — but still debounced against
|
|
# MIN_CHANGE_INTERVAL_TICKS so a burst of consecutive starve
|
|
# reports doesn't compound into repeated, overlapping jumps.
|
|
_clean_surplus_ticks = 0
|
|
if _ticks_since_change >= MIN_CHANGE_INTERVAL_TICKS and lead < LEAD_MAX:
|
|
var new_lead := mini(lead + 3, LEAD_MAX)
|
|
var delta := new_lead - lead
|
|
lead = new_lead
|
|
_ticks_since_change = 0
|
|
return 1 + delta
|
|
return 1
|
|
|
|
# Release must react to the ACTUAL server-reported depth, not to this
|
|
# controller's own memory of past attacks. An adversarial review found
|
|
# the original gate here was `lead > LEAD_MIN` — a self-tracked counter
|
|
# of this controller's own past decisions — so any backlog it did NOT
|
|
# itself create (a server hitch, persistent client/server clock drift,
|
|
# a burst re-delivery) was never drained: `lead` stayed at its starting
|
|
# value the whole time even while `input_buffer_depth` sat well above
|
|
# target, permanently adding latency with the control loop reporting
|
|
# itself perfectly healthy. Gate on the real signal instead.
|
|
if input_buffer_depth > TARGET_DEPTH:
|
|
_clean_surplus_ticks += 1
|
|
else:
|
|
_clean_surplus_ticks = 0
|
|
|
|
if _clean_surplus_ticks >= CLEAN_SURPLUS_TICKS and _ticks_since_change >= RELEASE_INTERVAL_TICKS and lead > LEAD_MIN:
|
|
lead -= 1
|
|
_ticks_since_change = 0
|
|
return 0 # duplicate this tick's seq — one tick of latency recovered
|
|
return 1
|