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.
This commit is contained in:
Josh Creek
2026-08-20 15:28:44 +01:00
parent 10040f7339
commit 2325313ad2
11 changed files with 384 additions and 71 deletions
+19 -1
View File
@@ -38,6 +38,11 @@ const LEAD_MAX := 12
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
@@ -72,7 +77,20 @@ func update(input_buffer_depth: int) -> int:
return 1 + delta
return 1
_clean_surplus_ticks += 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