Files
CosmicClash/Game/tests/cases/test_input_lead_controller.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

135 lines
6.6 KiB
GDScript

extends "res://tests/test_case.gd"
const InputLeadController = preload("res://scripts/input_lead_controller.gd")
func test_starts_at_minimum() -> void:
var c := InputLeadController.new()
assert_eq(c.lead, InputLeadController.LEAD_MIN, "initial lead")
func test_unknown_depth_is_a_normal_tick() -> void:
var c := InputLeadController.new()
assert_eq(c.update(-1), 1, "no snapshot info yet -> ordinary +1 seq increment")
assert_eq(c.lead, InputLeadController.LEAD_MIN, "lead unchanged with no info")
func test_healthy_depth_is_a_normal_tick_and_no_immediate_release() -> void:
var c := InputLeadController.new()
for i in 10:
assert_eq(c.update(1), 1, "healthy depth -> ordinary +1 tick %d" % i)
assert_eq(c.lead, InputLeadController.LEAD_MIN, "release needs 2s clean, not 10 ticks")
# §3.3: "on any starve, increase by up to 3 immediately" — but debounced by
# MIN_CHANGE_INTERVAL_TICKS so it isn't literally same-tick.
func test_starve_triggers_fast_attack_after_debounce_floor() -> void:
var c := InputLeadController.new()
var deltas: Array[int] = []
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS:
deltas.append(c.update(0))
# Every tick before the debounce floor is an ordinary +1 (no jump yet).
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS - 1:
assert_eq(deltas[i], 1, "no lead change before the debounce floor, tick %d" % i)
assert_eq(deltas[InputLeadController.MIN_CHANGE_INTERVAL_TICKS - 1], 4, "attack fires on the debounce-floor tick: +1 ordinary + 3 skip")
assert_eq(c.lead, InputLeadController.LEAD_MIN + 3, "lead jumped by 3")
func test_repeated_starvation_climbs_toward_max_and_clamps() -> void:
var c := InputLeadController.new()
# Enough sustained starvation to trigger several attack steps.
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS * 6:
c.update(0)
assert_eq(c.lead, InputLeadController.LEAD_MAX, "clamps at LEAD_MAX under sustained starvation, never exceeds it")
func test_release_requires_both_clean_surplus_and_its_own_interval() -> void:
var c := InputLeadController.new()
# Force lead above minimum first via one attack step.
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS:
c.update(0)
var lead_after_attack := c.lead
assert_true(lead_after_attack > InputLeadController.LEAD_MIN, "lead raised above minimum before testing release")
# Fewer than CLEAN_SURPLUS_TICKS of surplus depth (above TARGET_DEPTH):
# must not release yet.
for i in InputLeadController.CLEAN_SURPLUS_TICKS - 1:
c.update(InputLeadController.TARGET_DEPTH + 1)
assert_eq(c.lead, lead_after_attack, "no release before 2s of clean surplus has elapsed")
# One more surplus tick crosses the clean-surplus threshold AND the
# release interval (both are already satisfied by now since the
# debounce timer has been running the whole time) -> releases by 1.
var delta := c.update(InputLeadController.TARGET_DEPTH + 1)
assert_eq(delta, 0, "release tick duplicates rather than incrementing seq")
assert_eq(c.lead, lead_after_attack - 1, "lead released by exactly 1")
func test_release_stops_at_minimum() -> void:
var c := InputLeadController.new()
# Sustained surplus depth, but lead is already at LEAD_MIN — must never
# push it below the floor regardless of how much surplus is reported.
for i in InputLeadController.CLEAN_SURPLUS_TICKS * 3:
var delta := c.update(InputLeadController.TARGET_DEPTH + 1)
assert_true(delta == 1, "lead already at minimum, never duplicates a seq trying to release further, tick %d" % i)
assert_eq(c.lead, InputLeadController.LEAD_MIN, "stays at minimum")
func test_starve_resets_clean_surplus_counter() -> void:
var c := InputLeadController.new()
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS:
c.update(0) # raise lead above minimum via one attack step
var lead_after_attack := c.lead
# Some, but not all, of a clean surplus window — and well under the
# 30-tick attack debounce floor too, so the interrupting starve below
# can't accidentally retrigger a second attack step of its own.
var partial_clean_ticks := 10
for i in partial_clean_ticks:
c.update(InputLeadController.TARGET_DEPTH + 1)
c.update(0) # a lone starve tick, resetting _clean_surplus_ticks
assert_eq(c.lead, lead_after_attack, "the lone starve tick was too soon after the last change to trigger another attack")
# A full clean window from this fresh starting point is required before
# release fires — one tick short must not be enough.
for i in InputLeadController.CLEAN_SURPLUS_TICKS - 1:
c.update(InputLeadController.TARGET_DEPTH + 1)
assert_eq(c.lead, lead_after_attack, "the starve interruption forced a fresh 2s clean window, so no release yet")
c.update(InputLeadController.TARGET_DEPTH + 1)
assert_eq(c.lead, lead_after_attack - 1, "release finally fires once a full fresh clean window has elapsed since the interruption")
# An adversarial review found the original release gate was `lead >
# LEAD_MIN` — this controller's own memory of past attacks — so a backlog
# it did NOT itself create (a server hitch, persistent client/server clock
# drift, a burst re-delivery) was never drained: lead stayed at 1 forever
# even while the server kept reporting a deep, real backlog. This
# reproduces that scenario directly: lead never attacks (depth is never
# reported as a starve, <= 0), yet release must still fire from sustained
# real surplus alone.
func test_release_drains_a_backlog_it_never_caused_itself() -> void:
var c := InputLeadController.new()
assert_eq(c.lead, InputLeadController.LEAD_MIN, "starts at minimum, never attacked")
# A large, externally-caused surplus (e.g. right after the server's own
# ring-overflow resync) reported for well over 2s — lead never moves
# via attack since depth is never <= 0.
for i in InputLeadController.CLEAN_SURPLUS_TICKS + InputLeadController.RELEASE_INTERVAL_TICKS:
c.update(10)
assert_eq(c.lead, InputLeadController.LEAD_MIN, "lead cannot release below its own floor even under large surplus")
# Raise it above the floor via one real attack, then confirm sustained
# external surplus (not self-caused) still drains it back down.
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS:
c.update(0)
var lead_after_attack := c.lead
assert_true(lead_after_attack > InputLeadController.LEAD_MIN, "attack raised lead")
var released := false
for i in InputLeadController.CLEAN_SURPLUS_TICKS + InputLeadController.RELEASE_INTERVAL_TICKS:
if c.update(10) == 0:
released = true
break
assert_true(released, "sustained externally-caused surplus (depth=10) must eventually trigger a release")
assert_true(c.lead < lead_after_attack, "lead actually decreased in response to real depth, not just internal bookkeeping")