feat(multiplayer): persist connection generation leases

This commit is contained in:
Josh Creek
2026-09-03 13:38:13 +01:00
parent 2e9da3032c
commit 3e0022ce9c
10 changed files with 286 additions and 80 deletions
+38 -19
View File
@@ -71,16 +71,25 @@ type serverShutdownerSpy struct {
}
type serverConnectionSpy struct {
calls int
binding domain.WorkloadBinding
playerID string
key string
err error
connectCalls int
disconnectCalls int
binding domain.WorkloadBinding
playerID string
key string
expectedGeneration uint64
generation uint64
err error
}
func (s *serverConnectionSpy) RecordPlayerConnected(_ context.Context, binding domain.WorkloadBinding, playerID, key string, _ time.Time) error {
s.calls++
s.binding, s.playerID, s.key = binding, playerID, key
func (s *serverConnectionSpy) ClaimPlayerConnection(_ context.Context, binding domain.WorkloadBinding, playerID string, expectedGeneration uint64, key string, _ time.Time) (uint64, error) {
s.connectCalls++
s.binding, s.playerID, s.expectedGeneration, s.key = binding, playerID, expectedGeneration, key
return expectedGeneration + 1, s.err
}
func (s *serverConnectionSpy) RecordPlayerDisconnected(_ context.Context, binding domain.WorkloadBinding, playerID string, generation uint64, key string, _ time.Time) error {
s.disconnectCalls++
s.binding, s.playerID, s.generation, s.key = binding, playerID, generation, key
return s.err
}
@@ -1485,35 +1494,45 @@ func TestServerConnectionAPIRequiresBoundWorkloadAndOpaqueAssignedPlayer(t *test
server := httptest.NewServer(service.Handler())
defer server.Close()
request := func(serverID, playerID, token, key string) int {
body := fmt.Sprintf(`{"player_id":%q}`, playerID)
req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/servers/"+serverID+"/connect", strings.NewReader(body))
request := func(operation, serverID, playerID, token, key, bodySuffix string) (int, string) {
body := fmt.Sprintf(`{"player_id":%q%s}`, playerID, bodySuffix)
req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/servers/"+serverID+"/"+operation, strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", key)
response, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
responseBody, _ := io.ReadAll(response.Body)
response.Body.Close()
return response.StatusCode
return response.StatusCode, string(responseBody)
}
if got := request(binding.ServerID, "player-123456789", "workload-token", "connect-player-123456789"); got != http.StatusNoContent {
if got, body := request("connect", binding.ServerID, "player-123456789", "workload-token", "connect-player-123456789", `,"expected_generation":0`); got != http.StatusOK || !strings.Contains(body, `"generation":1`) {
t.Fatalf("connection status = %d", got)
}
if recorder.calls != 1 || recorder.binding != binding || recorder.playerID != "player-123456789" || recorder.key != "connect-player-123456789" {
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 := request("server-000000000", "player-123456789", "workload-token", "connect-player-123456789"); got != http.StatusUnauthorized {
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(binding.ServerID, "short", "workload-token", "connect-player-short-123"); got != http.StatusUnprocessableEntity {
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.calls != 1 {
t.Fatalf("invalid receipts reached backend: %d", recorder.calls)
if recorder.connectCalls != 1 {
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 {
t.Fatalf("disconnect status = %d", got)
}
if recorder.disconnectCalls != 1 || recorder.generation != 1 {
t.Fatalf("disconnect receipt = %+v", recorder)
}
if got, _ := request("disconnect", binding.ServerID, "player-123456789", "workload-token", "disconnect-zero-123456", ""); got != http.StatusUnprocessableEntity || recorder.disconnectCalls != 1 {
t.Fatalf("zero-generation disconnect status=%d calls=%d", got, recorder.disconnectCalls)
}
recorder.err = errors.New("database unavailable")
if got := request(binding.ServerID, "player-123456789", "workload-token", "connect-player-retry-123"); got != http.StatusServiceUnavailable {
if got, _ := request("connect", binding.ServerID, "player-123456789", "workload-token", "connect-player-retry-123", `,"expected_generation":1`); got != http.StatusServiceUnavailable {
t.Fatalf("recorder outage status = %d, want retryable 503", got)
}
}