fix(multiplayer): defer reconnect recovery during mutations

This commit is contained in:
Josh Creek
2026-09-01 21:53:07 +01:00
parent 47aa196b59
commit fa2c93ec06
3 changed files with 19 additions and 3 deletions
+7 -3
View File
@@ -466,10 +466,14 @@ func _set_websocket_status(status: String) -> void:
websocket_status_changed.emit(status) websocket_status_changed.emit(status)
if status == "CONNECTED": if status == "CONNECTED":
if not state.ticket_id.is_empty() and state.phase not in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE]: if not state.ticket_id.is_empty() and state.phase not in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE]:
if not state.proposal_id.is_empty(): var resource_id := state.proposal_id if not state.proposal_id.is_empty() else state.ticket_id
recover_proposal(state.proposal_id) if _operation.is_empty():
_run_resync(resource_id)
else: else:
recover_queue(state.ticket_id) # A reconnect must not lose its authoritative recovery merely because
# the previous mutation has not acknowledged yet. The deferred path
# runs after that request completes and avoids an ERR_BUSY drop.
_pending_resync_resource_id = resource_id
func _idempotency_key(prefix: String) -> String: func _idempotency_key(prefix: String) -> String:
@@ -55,6 +55,16 @@ func test_websocket_event_validation_requires_contract_specific_fields() -> void
assert_true(not ControlPlaneClient._valid_websocket_event(assignment), "incomplete assignment event is rejected") assert_true(not ControlPlaneClient._valid_websocket_event(assignment), "incomplete assignment event is rejected")
func test_websocket_reconnect_defers_recovery_while_http_mutation_is_in_flight() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.state.begin_queue("ticket-reconnect", "casual"), "queue setup succeeds")
client._operation = "queue_heartbeat"
client._set_websocket_status("CONNECTED")
assert_eq(client._pending_resync_resource_id, "ticket-reconnect", "reconnect recovery is retained until the mutation completes")
client.free()
func test_retryable_mutation_policy_only_retries_safe_failures() -> void: func test_retryable_mutation_policy_only_retries_safe_failures() -> void:
assert_true(ControlPlaneClient.is_retryable_mutation_response(0), "transport failure is retryable") assert_true(ControlPlaneClient.is_retryable_mutation_response(0), "transport failure is retryable")
assert_true(ControlPlaneClient.is_retryable_mutation_response(408), "request timeout is retryable") assert_true(ControlPlaneClient.is_retryable_mutation_response(408), "request timeout is retryable")
+2
View File
@@ -1532,3 +1532,5 @@ The client matchmaking projection now preserves the server's `enqueued_at` times
The ranked profile projection now also carries the active season's authoritative end timestamp from PostgreSQL through the API and Godot client. Ranked matchmaking displays a bounded days-remaining countdown, while providers without an active season remain compatible and omit the countdown. The ranked profile projection now also carries the active season's authoritative end timestamp from PostgreSQL through the API and Godot client. Ranked matchmaking displays a bounded days-remaining countdown, while providers without an active season remain compatible and omit the countdown.
The versioned OpenAPI contract now declares the implemented `/profile/ranked` surface and its server-authoritative ranked profile schema, including optional active-season metadata. Contract tests reject omission of this operation, extra response fields, and credential leakage. The versioned OpenAPI contract now declares the implemented `/profile/ranked` surface and its server-authoritative ranked profile schema, including optional active-season metadata. Contract tests reject omission of this operation, extra response fields, and credential leakage.
The Godot client now defers reconnect-triggered authoritative recovery when an HTTP mutation is still in flight, closing the `ERR_BUSY` recovery-drop race. An adversarial client test verifies that the active ticket remains queued for recovery rather than silently staying stale.