mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +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.
135 lines
6.6 KiB
GDScript
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")
|