feat(multiplayer): bind admissions to durable leases

This commit is contained in:
Josh Creek
2026-09-03 13:45:39 +01:00
parent 3e0022ce9c
commit aac81c89b6
9 changed files with 301 additions and 64 deletions
+15 -5
View File
@@ -669,9 +669,9 @@ func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
}
if parts[1] == "connect" || parts[1] == "disconnect" {
var input struct {
PlayerID string `json:"player_id"`
Generation uint64 `json:"generation,omitempty"`
ExpectedGeneration uint64 `json:"expected_generation,omitempty"`
PlayerID string `json:"player_id"`
Generation uint64 `json:"generation,omitempty"`
ExpectedGeneration *uint64 `json:"expected_generation,omitempty"`
}
if !decodeBody(w, r, &input) {
return
@@ -687,9 +687,13 @@ func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
return
}
generation, err = s.ServerConnections.ClaimPlayerConnection(r.Context(), binding, input.PlayerID, input.ExpectedGeneration, key, now)
expectedGeneration := uint64(0)
if input.ExpectedGeneration != nil {
expectedGeneration = *input.ExpectedGeneration
}
generation, err = s.ServerConnections.ClaimPlayerConnection(r.Context(), binding, input.PlayerID, expectedGeneration, key, now)
} else {
if input.Generation == 0 || input.ExpectedGeneration != 0 {
if input.Generation == 0 || input.ExpectedGeneration != nil {
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
return
}
@@ -717,6 +721,12 @@ func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
return
}
if input.ExpectedGeneration == nil {
// Rolling-upgrade compatibility for the pre-lease reporter. New
// servers always send expected_generation and consume the JSON lease.
w.WriteHeader(http.StatusNoContent)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]uint64{"generation": generation})
return
+4 -1
View File
@@ -1513,13 +1513,16 @@ func TestServerConnectionAPIRequiresBoundWorkloadAndOpaqueAssignedPlayer(t *test
if recorder.connectCalls != 1 || recorder.binding != binding || recorder.playerID != "player-123456789" || recorder.expectedGeneration != 0 || recorder.key != "connect-player-123456789" {
t.Fatalf("connection receipt = %+v", recorder)
}
if got, body := request("connect", binding.ServerID, "player-legacy-123456", "workload-token", "connect-legacy-123456", ""); got != http.StatusNoContent || body != "" {
t.Fatalf("legacy connection status=%d body=%q", got, body)
}
if got, _ := request("connect", "server-000000000", "player-123456789", "workload-token", "connect-player-123456789", ""); got != http.StatusUnauthorized {
t.Fatalf("wrong server status = %d", got)
}
if got, _ := request("connect", binding.ServerID, "short", "workload-token", "connect-player-short-123", ""); got != http.StatusUnprocessableEntity {
t.Fatalf("short player status = %d", got)
}
if recorder.connectCalls != 1 {
if recorder.connectCalls != 2 {
t.Fatalf("invalid receipts reached backend: %d", recorder.connectCalls)
}
if got, _ := request("disconnect", binding.ServerID, "player-123456789", "workload-token", "disconnect-player-123456789", `,"generation":1`); got != http.StatusNoContent {