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.
154 lines
7.2 KiB
GDScript
154 lines
7.2 KiB
GDScript
extends "res://tests/test_case.gd"
|
|
|
|
const InputJitterBuffer = preload("res://scripts/input_jitter_buffer.gd")
|
|
const ShipAction = preload("res://scripts/ship_action.gd")
|
|
|
|
|
|
func _action(thrust_z: float) -> ShipAction:
|
|
var a := ShipAction.new()
|
|
a.thrust = Vector3(0.0, 0.0, thrust_z)
|
|
return a
|
|
|
|
|
|
func test_sequential_ingest_and_consume() -> void:
|
|
var buf := InputJitterBuffer.new()
|
|
buf.ingest(0, [_action(0.1)])
|
|
assert_almost_eq(buf.consume().thrust.z, 0.1, 0.0001, "tick 0")
|
|
buf.ingest(1, [_action(0.2)])
|
|
assert_almost_eq(buf.consume().thrust.z, 0.2, 0.0001, "tick 1")
|
|
assert_eq(buf.last_applied_seq, 1, "last_applied_seq after 2 ticks")
|
|
assert_eq(buf.starved_ticks, 0, "no starvation on a clean sequential stream")
|
|
|
|
|
|
# §3.1's own acceptance criterion: "a 3-packet burst loss produces no
|
|
# starvation." Redundancy-4 means a single surviving packet after 3 losses
|
|
# still carries all 4 of the most recent ticks' actions.
|
|
func test_redundancy_survives_3_packet_burst_loss() -> void:
|
|
var buf := InputJitterBuffer.new()
|
|
buf.ingest(0, [_action(0.0)])
|
|
assert_almost_eq(buf.consume().thrust.z, 0.0, 0.0001, "seq 0")
|
|
|
|
# Packets for seq 1, 2, 3 are "lost" (never ingested individually) — only
|
|
# the seq=4 packet, carrying seq 4,3,2,1 (newest-first, redundancy 4),
|
|
# actually arrives.
|
|
buf.ingest(4, [_action(0.4), _action(0.3), _action(0.2), _action(0.1)])
|
|
|
|
assert_almost_eq(buf.consume().thrust.z, 0.1, 0.0001, "seq 1 recovered from redundancy")
|
|
assert_eq(buf.starved_ticks, 0, "seq 1 was not a starve")
|
|
assert_almost_eq(buf.consume().thrust.z, 0.2, 0.0001, "seq 2 recovered from redundancy")
|
|
assert_almost_eq(buf.consume().thrust.z, 0.3, 0.0001, "seq 3 recovered from redundancy")
|
|
assert_almost_eq(buf.consume().thrust.z, 0.4, 0.0001, "seq 4 recovered from redundancy")
|
|
assert_eq(buf.starved_ticks, 0, "no starvation anywhere across the whole burst-loss window")
|
|
|
|
|
|
func test_starvation_repeats_last_action_then_zeroes_after_500ms() -> void:
|
|
var buf := InputJitterBuffer.new()
|
|
buf.ingest(0, [_action(0.7)])
|
|
buf.consume()
|
|
|
|
# Nothing else ever arrives — every consume() from here on starves.
|
|
for i in InputJitterBuffer.STARVE_ZERO_TICKS:
|
|
var a := buf.consume()
|
|
assert_almost_eq(a.thrust.z, 0.7, 0.0001, "repeat-last during starve, tick %d" % i)
|
|
assert_true(not buf.stalled, "not yet stalled at tick %d" % i)
|
|
|
|
# One more tick past STARVE_ZERO_TICKS (30 = 500ms at 60Hz) crosses the
|
|
# "> 30" threshold and zeroes rather than keeps repeating forever.
|
|
var stalled_action := buf.consume()
|
|
assert_almost_eq(stalled_action.thrust.z, 0.0, 0.0001, "zeroed after sustained stall")
|
|
assert_true(buf.stalled, "stalled flag set after 500ms of starvation")
|
|
|
|
|
|
func test_late_stale_packet_is_discarded_harmlessly() -> void:
|
|
var buf := InputJitterBuffer.new()
|
|
buf.ingest(5, [_action(0.5)])
|
|
buf.consume() # seeded to 4 by ingest() (newest_seq - 1 action), one consume reaches 5
|
|
assert_eq(buf.last_applied_seq, 5, "consumed up through seq 5")
|
|
|
|
# A reordered/duplicated packet for an already-consumed seq arrives late.
|
|
buf.ingest(3, [_action(0.3)])
|
|
assert_eq(buf.depth(), 0, "a stale packet below last_applied_seq must not appear as buffered depth")
|
|
|
|
buf.ingest(6, [_action(0.6)])
|
|
assert_almost_eq(buf.consume().thrust.z, 0.6, 0.0001, "the genuinely-next seq still consumes correctly")
|
|
|
|
|
|
func test_depth_reports_contiguous_buffered_run() -> void:
|
|
var buf := InputJitterBuffer.new()
|
|
buf.ingest(0, [_action(0.0)])
|
|
buf.consume() # last_applied_seq = 0
|
|
|
|
assert_eq(buf.depth(), 0, "nothing buffered ahead yet")
|
|
buf.ingest(3, [_action(0.3), _action(0.2), _action(0.1)])
|
|
assert_eq(buf.depth(), 3, "seq 1,2,3 all buffered and contiguous with last_applied_seq")
|
|
|
|
# A gap (seq 5 arrives but seq 4 never does) caps depth at the gap, not
|
|
# the highest seq seen.
|
|
buf.ingest(5, [_action(0.5)])
|
|
assert_eq(buf.depth(), 3, "seq 5 sits past a gap at seq 4, so it doesn't extend the contiguous run")
|
|
|
|
|
|
func test_ring_wraparound_does_not_confuse_a_stale_slot_with_a_fresh_one() -> void:
|
|
var buf := InputJitterBuffer.new()
|
|
buf.ingest(0, [_action(0.0)])
|
|
buf.consume()
|
|
|
|
# Advance last_applied_seq well past one full lap of the ring (32
|
|
# entries) purely via starvation, with nothing re-ingested — every
|
|
# ring slot's stored seq is now far behind "expected" at each step, so
|
|
# none of them should ever be misread as valid.
|
|
for i in InputJitterBuffer.RING_SIZE * 2:
|
|
buf.consume()
|
|
assert_eq(buf.last_applied_seq, InputJitterBuffer.RING_SIZE * 2, "advanced purely by starvation")
|
|
assert_true(buf.stalled, "long starvation run ends stalled")
|
|
|
|
# Now a fresh packet lands at the seq the ring slot for "expected" was
|
|
# LAST used for, one full lap ago — if slot-tagging didn't work, this
|
|
# would be misread as already-fresh data from the stale write.
|
|
var expected := buf.last_applied_seq + 1
|
|
buf.ingest(expected, [_action(0.9)])
|
|
var a := buf.consume()
|
|
assert_almost_eq(a.thrust.z, 0.9, 0.0001, "correctly reads the fresh same-slot-index seq, not a stale wraparound ghost")
|
|
assert_eq(buf.starved_ticks, 0, "starvation clears once fresh data resumes")
|
|
|
|
|
|
# The under-full direction (above) was covered before an adversarial review
|
|
# found the OVER-full direction was not: a backlog bigger than RING_SIZE
|
|
# (a host stall, or persistent client/server clock drift) made consume()
|
|
# starve — and, past STARVE_ZERO_TICKS, zero the player's ship — forever,
|
|
# because both last_applied_seq and the client's own seq only ever advance
|
|
# with no resync, so the gap never closed even though fresh, real input
|
|
# kept arriving the whole time.
|
|
func test_ring_overflow_resyncs_to_fresh_data_instead_of_starving_forever() -> void:
|
|
var buf := InputJitterBuffer.new()
|
|
buf.ingest(0, [_action(0.0)])
|
|
buf.consume() # last_applied_seq = 0
|
|
|
|
# A burst of packets arriving all at once, exactly what poll() delivers
|
|
# in one batch once a stalled server resumes — the client kept sending
|
|
# normally the whole time (a real packet every tick, last-4 redundancy,
|
|
# newest-first), nothing consumed in between. 50 ticks' worth, well
|
|
# past one full lap of the 32-entry ring.
|
|
for seq in range(1, 51):
|
|
var window: Array = []
|
|
for k in 4:
|
|
window.append(_action(float(seq - k) * 0.01))
|
|
buf.ingest(seq, window)
|
|
assert_eq(buf.last_applied_seq, 0, "nothing consumed yet, only ingested")
|
|
|
|
# The gap (50 - 1 = 49) exceeds RING_SIZE (32): everything older than
|
|
# "50 - RING_SIZE" has already been irrecoverably overwritten by more
|
|
# recent arrivals landing on the same ring slots. A single consume()
|
|
# must resync directly to the oldest data the ring can still actually
|
|
# provide, not starve through the entire abandoned span.
|
|
var a := buf.consume()
|
|
var expected_resync_seq := 50 - InputJitterBuffer.RING_SIZE + 1
|
|
assert_eq(buf.last_applied_seq, expected_resync_seq, "resynced to exactly RING_SIZE behind the newest data")
|
|
assert_almost_eq(a.thrust.z, float(expected_resync_seq) * 0.01, 0.0001, "recovered the resynced tick's real action from the ring, not a stale ghost or a zeroed one")
|
|
assert_eq(buf.starved_ticks, 0, "resyncing to real data is not starvation")
|
|
assert_true(not buf.stalled, "a recovered player must not be reported as stalled")
|
|
|
|
# Normal sequential consumption resumes correctly from the resync point.
|
|
var next := buf.consume()
|
|
assert_almost_eq(next.thrust.z, float(expected_resync_seq + 1) * 0.01, 0.0001, "next tick continues in order from the resync point")
|