Files
CosmicClash/Game/tests/cases/test_agones_sdk.gd
T
Josh Creek 0de97381b7 fix(agones): stop a dead health loop from passing as a healthy server
Every allocated GameServer reached Ready and was recycled by Agones ~20s
later. Health pings are the game process's job by design -- the
supervisor has no health implementation at all -- so a server that stops
pinging is exactly what Agones is built to reclaim.

start_health() armed a Timer on a node that might not be inside the
SceneTree. A Timer only ticks inside the tree, so the node reported
itself configured, sent nothing, and said nothing about it. It now
returns a bool, refuses loudly when unconfigured, and defers to _ready()
when called before parenting, so the SDK arms its own timer and no
caller has to get the ordering right. server_boot.gd defers the add like
every sibling does (§9 gotcha 27) and logs when AGONES_SDK_HTTP_PORT is
missing, which previously read identically to a healthy start.

Also bounded the in-flight latch: it is set across an await, so a request
that never completes would silence health permanently. Defence in depth
rather than an observed fault.

Tests target the contract rather than the mechanism: a test that parents
the SDK correctly and asserts pings passes with the bug present, because
the defect was in the wiring. The unit tests assert start_health()
cannot claim success out of tree, and were confirmed to fail against the
previous code. The smoke gains a counting sidecar and asserts a
*repeating* ping -- it reports "health pings in 3.0s = 1, want at least
2" when the loop is broken, which is the production symptom exactly. It
is also now actually run: nothing referenced it before.

Two diagnostic fixes, both of which changed conclusions during this work:

The kind gate only built the game-server image when the tag was absent,
so a local rerun silently verified whatever was built last. That is why
local runs and CI disagreed about the same commit. It now builds by
default, with KIND_REUSE_GAME_SERVER_IMAGE=1 as the opt-in fast path.

The failure dump logged only not-ready pods, and used --all-containers
with a shared tail. A GameServer recycled after reaching Ready leaves no
unready pod behind, and the Agones sidecar out-logs the game server, so
the relevant output was never captured. It now dumps every pod, per
container, current and previous, plus the GameServer and Fleet resources
-- Agones' own state machine is what rejects these.
2026-09-05 21:35:50 +01:00

57 lines
2.9 KiB
GDScript

extends "res://tests/test_case.gd"
const AgonesSDKScript = preload("res://scripts/agones_sdk.gd")
func test_sdk_requires_loopback_sidecar_url() -> void:
var sdk = AgonesSDKScript.new()
assert_true(not sdk.configure_for_testing("https://agones.example"), "remote sidecar URL is rejected")
assert_true(not sdk.is_available(), "rejected sidecar is unavailable")
assert_true(sdk.configure_for_testing("http://127.0.0.1:9358"), "loopback sidecar URL is accepted")
assert_true(sdk.is_available(), "accepted sidecar is available")
sdk.queue_free()
func test_annotation_validation_rejects_header_injection_and_oversized_values() -> void:
assert_true(AgonesSDKScript.annotation_is_valid("match", "result"), "ordinary annotation is accepted")
assert_true(not AgonesSDKScript.annotation_is_valid("bad\nkey", "value"), "annotation key newline is rejected")
assert_true(not AgonesSDKScript.annotation_is_valid("key", "bad\rvalue"), "annotation value newline is rejected")
assert_true(not AgonesSDKScript.annotation_is_valid("key", "x".repeat(4097)), "oversized annotation is rejected")
# Regression: every allocated GameServer reached Ready and was then recycled by
# Agones ~20s later, because start_health() armed a Timer on a node that was
# never parented. A Timer only ticks inside the SceneTree, so the process
# reported healthy while sending no pings at all, and nothing said so.
#
# These are deliberately synchronous: test_runner.gd calls test methods without
# awaiting, so anything needing a live tree or an HTTP round trip belongs in
# tests/agones_sdk_smoke.gd instead. What is asserted here is the contract that
# makes the silent case impossible.
func test_start_health_refuses_when_not_configured() -> void:
var sdk = AgonesSDKScript.new()
assert_true(not sdk.start_health(), "health cannot start before a sidecar URL is known")
assert_true(not sdk.health_is_running(), "no timer is armed without configuration")
sdk.queue_free()
func test_start_health_reports_failure_when_outside_the_tree() -> void:
# The exact shape of the production bug: configured, so is_available() is
# true and the node looks ready to work, but unparented.
var sdk = AgonesSDKScript.new()
assert_true(sdk.configure_for_testing("http://127.0.0.1:9358"), "fixture configures")
assert_true(sdk.is_available(), "an unparented node still reports available")
assert_true(not sdk.start_health(), "start_health() must not claim success outside the tree")
assert_true(not sdk.health_is_running(), "no health loop is running outside the tree")
sdk.queue_free()
func test_health_is_not_running_until_a_timer_exists() -> void:
# health_is_running() is what a caller should trust, rather than
# is_available(), which only says a URL was parsed.
var sdk = AgonesSDKScript.new()
assert_true(not sdk.health_is_running(), "a fresh SDK is not pinging")
sdk.configure_for_testing("http://127.0.0.1:9358")
assert_true(not sdk.health_is_running(), "configuration alone does not start pinging")
sdk.queue_free()