fix(multiplayer): reconcile authoritative initial connections

This commit is contained in:
Josh Creek
2026-09-03 00:02:04 +01:00
parent 781cbc35aa
commit aa446cfbfe
40 changed files with 809 additions and 87 deletions
+35 -5
View File
@@ -43,6 +43,9 @@ type ServerRegistrar interface {
type ServerShutdowner interface {
ShutdownServer(context.Context, domain.WorkloadBinding, string, string, time.Time) error
}
type ServerConnectionRecorder interface {
RecordPlayerConnected(context.Context, domain.WorkloadBinding, string, string, time.Time) error
}
type QueueBackend interface {
Create(context.Context, string, string, string, domain.QueueSpec, time.Time) (domain.QueueTicket, error)
@@ -118,6 +121,7 @@ type Service struct {
ResultSubmitter ResultSubmitter
ServerRegistrar ServerRegistrar
ServerShutdowner ServerShutdowner
ServerConnections ServerConnectionRecorder
Assignment AssignmentProvider
Roster RosterProvider
Now func() time.Time
@@ -555,10 +559,9 @@ func (s *Service) contractAssignment(w http.ResponseWriter, r *http.Request) {
func (s *Service) contractServerMutation(w http.ResponseWriter, r *http.Request) {
// Unlike contractAssignment, the documented shape here is two segments
// (/servers/{serverId}/result, /servers/{serverId}/register, or
// /servers/{serverId}/shutdown) — rejecting
// (/servers/{serverId}/{result|register|roster|connect|shutdown}) — rejecting
// any "/" would 404 every real call. Delegate shape validation to
// serverMutation, which already enforces exactly {id}/{result|register}.
// serverMutation, which already enforces the exact operation allowlist.
path := strings.TrimPrefix(r.URL.Path, "/api/v1/servers/")
parts := strings.Split(path, "/")
if path == "" || len(parts) < 2 || !controlPlaneResourceIDRE.MatchString(parts[0]) {
@@ -589,7 +592,7 @@ type serverRegistrationRequest struct {
func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/servers/"), "/")
if len(parts) != 2 || parts[0] == "" || (parts[1] != "result" && parts[1] != "register" && parts[1] != "roster" && parts[1] != "shutdown") {
if len(parts) != 2 || parts[0] == "" || (parts[1] != "result" && parts[1] != "register" && parts[1] != "roster" && parts[1] != "shutdown" && parts[1] != "connect") {
writeError(w, http.StatusNotFound, "not_found")
return
}
@@ -597,7 +600,7 @@ func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed")
return
}
if s.WorkloadVerify == nil || (parts[1] == "result" && s.ResultSubmitter == nil) || (parts[1] == "register" && s.ServerRegistrar == nil) || (parts[1] == "roster" && s.Roster == nil) || (parts[1] == "shutdown" && s.ServerShutdowner == nil) {
if s.WorkloadVerify == nil || (parts[1] == "result" && s.ResultSubmitter == nil) || (parts[1] == "register" && s.ServerRegistrar == nil) || (parts[1] == "roster" && s.Roster == nil) || (parts[1] == "shutdown" && s.ServerShutdowner == nil) || (parts[1] == "connect" && s.ServerConnections == nil) {
writeError(w, http.StatusServiceUnavailable, "server_unavailable")
return
}
@@ -663,6 +666,33 @@ func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
return
}
if parts[1] == "connect" {
var input struct {
PlayerID string `json:"player_id"`
}
if !decodeBody(w, r, &input) {
return
}
if !controlPlaneResourceIDRE.MatchString(input.PlayerID) {
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
return
}
if err := s.ServerConnections.RecordPlayerConnected(r.Context(), binding, input.PlayerID, key, now); err != nil {
if errors.Is(err, domain.ErrConflict) {
writeError(w, http.StatusConflict, "conflict")
} else {
// The request has already passed schema and workload checks. An
// unknown recorder error is infrastructure failure, not a terminal
// client fault; 503 keeps the game server's bounded retry alive.
writeError(w, http.StatusServiceUnavailable, "server_unavailable")
}
s.logEvent(observability.Event{Event: "server_connect", MatchID: binding.MatchID, ServerID: parts[0], Stage: "rejected", OccurredAt: now})
return
}
s.logEvent(observability.Event{Event: "server_connect", MatchID: binding.MatchID, ServerID: parts[0], Stage: "connected", OccurredAt: now, Fields: map[string]any{"player_id": input.PlayerID}})
w.WriteHeader(http.StatusNoContent)
return
}
if parts[1] == "shutdown" {
var input struct {
Reason string `json:"reason"`
+60
View File
@@ -70,6 +70,20 @@ type serverShutdownerSpy struct {
err error
}
type serverConnectionSpy struct {
calls int
binding domain.WorkloadBinding
playerID string
key string
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
return s.err
}
func (s *serverShutdownerSpy) ShutdownServer(_ context.Context, binding domain.WorkloadBinding, reason, key string, _ time.Time) error {
s.calls++
s.binding, s.reason, s.key = binding, reason, key
@@ -1458,6 +1472,52 @@ func TestServerShutdownAPIRequiresBoundWorkloadAndDelegatesAcknowledgement(t *te
response.Body.Close()
}
func TestServerConnectionAPIRequiresBoundWorkloadAndOpaqueAssignedPlayer(t *testing.T) {
now := time.Unix(1000, 0).UTC()
binding := domain.WorkloadBinding{AllocationID: "allocation-123456", MatchID: "match-1234567890", ServerID: "server-123456789"}
recorder := &serverConnectionSpy{}
service := &Service{Now: func() time.Time { return now }, WorkloadVerify: func(token string, _ time.Time) (domain.WorkloadBinding, error) {
if token != "workload-token" {
return domain.WorkloadBinding{}, errors.New("bad token")
}
return binding, nil
}, ServerConnections: recorder}
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))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", key)
response, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
response.Body.Close()
return response.StatusCode
}
if got := request(binding.ServerID, "player-123456789", "workload-token", "connect-player-123456789"); got != http.StatusNoContent {
t.Fatalf("connection status = %d", got)
}
if recorder.calls != 1 || recorder.binding != binding || recorder.playerID != "player-123456789" || 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 {
t.Fatalf("wrong server status = %d", got)
}
if got := request(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)
}
recorder.err = errors.New("database unavailable")
if got := request(binding.ServerID, "player-123456789", "workload-token", "connect-player-retry-123"); got != http.StatusServiceUnavailable {
t.Fatalf("recorder outage status = %d, want retryable 503", got)
}
}
func TestMetricsEndpointExportsBoundedAPILatencyAndSkipsItsOwnScrape(t *testing.T) {
metrics := observability.NewMetrics()
service := &Service{Metrics: metrics, Now: time.Now}
+13
View File
@@ -87,6 +87,19 @@ func ServerShutdownerFromStore(db *sql.DB) ServerShutdowner {
return postgresServerShutdowner{db: db}
}
type postgresServerConnections struct{ db *sql.DB }
func (p postgresServerConnections) RecordPlayerConnected(ctx context.Context, binding domain.WorkloadBinding, playerID, idempotencyKey string, now time.Time) error {
return store.RecordPlayerConnected(ctx, p.db, binding, playerID, idempotencyKey, now)
}
func ServerConnectionsFromStore(db *sql.DB) ServerConnectionRecorder {
if db == nil {
return nil
}
return postgresServerConnections{db: db}
}
// WorkloadVerifierFromSignedToken builds WorkloadVerify from a control-plane
// -owned signed token instead of a Kubernetes-projected JWT (see
// workload/signed_token.go for why: it needs no live cluster to verify).