diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index 8b6ecf80..e3287bbf 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -466,10 +466,14 @@ func _set_websocket_status(status: String) -> void: websocket_status_changed.emit(status) 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.proposal_id.is_empty(): - recover_proposal(state.proposal_id) + var resource_id := state.proposal_id if not state.proposal_id.is_empty() else state.ticket_id + if _operation.is_empty(): + _run_resync(resource_id) 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: diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index 3d369323..8523176e 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -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") +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: assert_true(ControlPlaneClient.is_retryable_mutation_response(0), "transport failure is retryable") assert_true(ControlPlaneClient.is_retryable_mutation_response(408), "request timeout is retryable") diff --git a/multiplayer-next.md b/multiplayer-next.md index 409be8cc..57b3b3ef 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -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 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.