fix(multiplayer): enforce drain at admission

This commit is contained in:
Josh Creek
2026-09-02 19:04:56 +01:00
parent 670466dbd7
commit 0bca441ca1
3 changed files with 34 additions and 4 deletions
+14 -4
View File
@@ -169,9 +169,10 @@ static func reservation_identity_matches(slot_identity: String, incoming_identit
func _on_peer_disconnected(peer_id: int) -> void:
if not multiplayer.is_server():
return
if not admissions_open:
await _reject(multiplayer.get_remote_sender_id(), "server is draining")
return
_cleanup_disconnected_peer(peer_id)
func _cleanup_disconnected_peer(peer_id: int) -> void:
NetworkManager.invalidate_peer(peer_id)
_remove_player(peer_id)
@@ -210,7 +211,8 @@ func _remove_player(peer_id: int) -> void:
# from inside signal-handling: by then poll() has fully returned, every
# disconnect event in this batch has been dispatched, and get_peers()
# reflects the settled, genuinely-still-connected set.
call_deferred("_broadcast_player_left", peer_id)
if is_inside_tree():
call_deferred("_broadcast_player_left", peer_id)
func _broadcast_player_left(peer_id: int) -> void:
@@ -239,6 +241,10 @@ static func _sanitize_shutdown_reason(raw: String) -> String:
return clean if not clean.is_empty() else "server_shutdown"
static func admission_rejection(is_open: bool) -> String:
return "" if is_open else "server is draining"
# Balances a new joiner onto whichever team currently has fewer players
# (ties go to team 0). Server only.
func _pick_balanced_team() -> int:
@@ -261,6 +267,10 @@ func _hello(protocol_version: int, tick_hz: int, player_name: String, supplied_j
var peer_id := multiplayer.get_remote_sender_id()
if roster.has(peer_id):
return # duplicate hello from an already-accepted peer; ignore
var admission_error := admission_rejection(admissions_open)
if not admission_error.is_empty():
await _reject(peer_id, admission_error)
return
if protocol_version != NetCodec.PROTOCOL_VERSION:
await _reject(peer_id, "protocol version mismatch: server=%d client=%d" % [NetCodec.PROTOCOL_VERSION, protocol_version])
+18
View File
@@ -52,6 +52,24 @@ func test_server_shutdown_message_is_bounded_and_emitted() -> void:
assert_eq(instance.last_server_shutdown_reason.length(), 96, "bounded shutdown reason is retained for UI")
func test_drain_fences_new_hello_admissions() -> void:
assert_eq(MatchNet.admission_rejection(true), "", "an active server accepts new hello requests")
assert_eq(MatchNet.admission_rejection(false), "server is draining", "a draining server rejects new hello requests")
func test_draining_disconnect_still_releases_roster_and_join_token() -> void:
var match_net := MatchNet.new()
var token := "opaque-join-token"
match_net.admissions_open = false
match_net.roster[42] = MatchNet.PlayerInfo.new(42, "Alice", 0, false, "player-1")
match_net._active_join_peers[token] = 42
match_net._join_history[token] = {"generation": 1}
match_net._cleanup_disconnected_peer(42)
assert_true(not match_net.roster.has(42), "drain does not retain a disconnected roster entry")
assert_true(not match_net._active_join_peers.has(token), "drain releases the disconnected peer's join token")
assert_true(float(match_net._join_history[token].get("lost_at", 0.0)) > 0.0, "disconnect records the reclaim boundary during drain")
func test_reservation_reclaim_requires_stable_identity() -> void:
assert_true(MatchNet.reservation_identity_matches("player-a", "player-a", "Alice", "Impostor"), "the verified identity can reclaim despite a changed display name")
assert_true(not MatchNet.reservation_identity_matches("player-a", "player-b", "Alice", "Alice"), "a same-name peer cannot reclaim another identity's slot")
+2
View File
@@ -1636,3 +1636,5 @@ Assignment handoff now has a non-circular recovery path. Match-scoped lifecycle
Replica-independent client convergence now supersedes the earlier "at-least-once WebSocket delivery" wording in tasks 8.25/8.40 and the allocation-outbox progress notes. The database outbox guarantees ordered, replayable invocation of a replica's transient publication adapter, not receipt by a socket that may be absent or attached to another replica. Active clients now perform bounded five-second owner-scoped REST recovery; ticket recovery exposes the active `proposal_id` or `match_id`, so a missed proposal, allocation, assignment, or result notification cannot strand the client without the next resource key. A terminal proposal can be replaced by a later recovered proposal identity, while an open proposal cannot be overwritten. Network and malformed-JSON failures during recovery remain visible and retryable instead of falsely terminating matchmaking. WebSocket events remain the low-latency path; REST snapshots are the correctness path. Store/API and the 200-test Godot harness cover projection, transient failure, replacement, and hostile identity/state combinations, with live PostgreSQL execution still subject to the Docker storage gate recorded above.
The production allocator now uses the API it actually implements: Kubernetes custom-resource paths at `https://kubernetes.default.svc`, rather than sending those paths to the distinct mTLS Agones Allocator Service. Its HTTPS client trusts the mounted cluster CA, rereads the projected service-account token for every request so rotation is honored, applies a ten-second request timeout, and refuses to forward the credential to another origin. The allocator pod explicitly mounts its token; namespaced RBAC permits only GameServer `list` and GameServerAllocation `create`; and its default-deny policy permits portable API-server egress only on TCP 443. Focused Go/auth, static policy, and `kubectl kustomize` checks pass. The real kind/Agones runtime gate remains open because kind and Helm are unavailable here and Docker storage is exhausted; no live-cluster success is claimed.
Drain admission now fails at the handshake boundary: a new `_hello` is rejected with the actual RPC peer ID after `admissions_open` closes. Disconnects no longer perform the admission check (or try to reject an already-gone sender); they always invalidate transport state, release the signed join token, record the reconnect boundary, and remove the roster entry. Godot regressions cover the admission decision and cleanup while draining. Task 8.36's live lifecycle/PDB gates remain open.