mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-15 06:22:03 +00:00
fix(multiplayer): recover queue revision conflicts
This commit is contained in:
@@ -298,6 +298,10 @@ static func is_retryable_mutation_response(response_code: int) -> bool:
|
|||||||
return response_code == 0 or response_code == HTTPClient.RESPONSE_REQUEST_TIMEOUT or response_code == HTTPClient.RESPONSE_TOO_MANY_REQUESTS or response_code >= 500
|
return response_code == 0 or response_code == HTTPClient.RESPONSE_REQUEST_TIMEOUT or response_code == HTTPClient.RESPONSE_TOO_MANY_REQUESTS or response_code >= 500
|
||||||
|
|
||||||
|
|
||||||
|
static func should_recover_queue_after_conflict(operation: String, response_code: int, ticket_id: String) -> bool:
|
||||||
|
return response_code == HTTPClient.RESPONSE_CONFLICT and operation in ["queue_heartbeat", "queue_cancel"] and not ticket_id.is_empty()
|
||||||
|
|
||||||
|
|
||||||
static func normalize_ticket(payload: Dictionary) -> Dictionary:
|
static func normalize_ticket(payload: Dictionary) -> Dictionary:
|
||||||
var result := payload.duplicate(true)
|
var result := payload.duplicate(true)
|
||||||
if result.has("enqueued_at") and result["enqueued_at"] is String:
|
if result.has("enqueued_at") and result["enqueued_at"] is String:
|
||||||
@@ -373,6 +377,7 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head
|
|||||||
_last_mutation_retryable = _last_mutation.get("operation", "") == operation and is_retryable_mutation_response(response_code)
|
_last_mutation_retryable = _last_mutation.get("operation", "") == operation and is_retryable_mutation_response(response_code)
|
||||||
var detail := String(parsed.get("error", "request rejected"))
|
var detail := String(parsed.get("error", "request rejected"))
|
||||||
var recover_proposal_after_conflict := response_code == HTTPClient.RESPONSE_CONFLICT and (operation == "proposal_accept" or operation == "proposal_decline") and not state.proposal_id.is_empty()
|
var recover_proposal_after_conflict := response_code == HTTPClient.RESPONSE_CONFLICT and (operation == "proposal_accept" or operation == "proposal_decline") and not state.proposal_id.is_empty()
|
||||||
|
var recover_queue_after_conflict := should_recover_queue_after_conflict(operation, response_code, state.ticket_id)
|
||||||
if response_code == HTTPClient.RESPONSE_UNAUTHORIZED:
|
if response_code == HTTPClient.RESPONSE_UNAUTHORIZED:
|
||||||
access_token = ""
|
access_token = ""
|
||||||
auth_expired = true
|
auth_expired = true
|
||||||
@@ -396,6 +401,9 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head
|
|||||||
if recover_proposal_after_conflict:
|
if recover_proposal_after_conflict:
|
||||||
_pending_resync_resource_id = state.proposal_id
|
_pending_resync_resource_id = state.proposal_id
|
||||||
call_deferred("_run_pending_resync")
|
call_deferred("_run_pending_resync")
|
||||||
|
if recover_queue_after_conflict:
|
||||||
|
_pending_resync_resource_id = state.ticket_id
|
||||||
|
call_deferred("_run_pending_resync")
|
||||||
return
|
return
|
||||||
var payload: Dictionary = parsed
|
var payload: Dictionary = parsed
|
||||||
_last_mutation_retryable = false
|
_last_mutation_retryable = false
|
||||||
|
|||||||
@@ -99,6 +99,14 @@ func test_retryable_mutation_policy_only_retries_safe_failures() -> void:
|
|||||||
assert_true(not ControlPlaneClient.is_retryable_mutation_response(409), "revision/idempotency conflict is not blindly replayed")
|
assert_true(not ControlPlaneClient.is_retryable_mutation_response(409), "revision/idempotency conflict is not blindly replayed")
|
||||||
|
|
||||||
|
|
||||||
|
func test_queue_revision_conflicts_schedule_authoritative_recovery() -> void:
|
||||||
|
assert_true(ControlPlaneClient.should_recover_queue_after_conflict("queue_heartbeat", 409, "ticket-1"), "stale heartbeat recovers the queue ticket")
|
||||||
|
assert_true(ControlPlaneClient.should_recover_queue_after_conflict("queue_cancel", 409, "ticket-1"), "stale cancellation recovers the queue ticket")
|
||||||
|
assert_true(not ControlPlaneClient.should_recover_queue_after_conflict("queue_create", 409, "ticket-1"), "create conflict uses its own idempotency path")
|
||||||
|
assert_true(not ControlPlaneClient.should_recover_queue_after_conflict("queue_heartbeat", 503, "ticket-1"), "transient outage remains retryable instead of being treated as a revision conflict")
|
||||||
|
assert_true(not ControlPlaneClient.should_recover_queue_after_conflict("queue_cancel", 409, ""), "missing ticket cannot trigger recovery")
|
||||||
|
|
||||||
|
|
||||||
func test_assignment_endpoint_split_never_accepts_url_or_bad_port() -> void:
|
func test_assignment_endpoint_split_never_accepts_url_or_bad_port() -> void:
|
||||||
var endpoint := ControlPlaneClient._split_assignment_endpoint("127.0.0.1:31001")
|
var endpoint := ControlPlaneClient._split_assignment_endpoint("127.0.0.1:31001")
|
||||||
assert_eq(endpoint["host"], "127.0.0.1", "assignment host is separated from the port")
|
assert_eq(endpoint["host"], "127.0.0.1", "assignment host is separated from the port")
|
||||||
|
|||||||
@@ -1558,3 +1558,5 @@ All client resync entry points now apply the open-proposal boundary: a terminal
|
|||||||
Ticket projections now validate playlist metadata on every update, rejecting unknown values before either phase or playlist state can mutate. An adversarial higher-revision update test covers this boundary.
|
Ticket projections now validate playlist metadata on every update, rejecting unknown values before either phase or playlist state can mutate. An adversarial higher-revision update test covers this boundary.
|
||||||
|
|
||||||
Client sessions now fail closed at the expiry boundary and proactively clear credentials before reconnects or authenticated requests. Boundary and malformed-expiry tests cover the lifecycle guard.
|
Client sessions now fail closed at the expiry boundary and proactively clear credentials before reconnects or authenticated requests. Boundary and malformed-expiry tests cover the lifecycle guard.
|
||||||
|
|
||||||
|
Queue heartbeat and cancellation revision conflicts now schedule the same authoritative ticket recovery as proposal conflicts, preventing stale client actions from leaving the visible queue state unresolved. Adversarial operation/status/identity coverage is included.
|
||||||
|
|||||||
Reference in New Issue
Block a user