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
+84 -2
View File
@@ -23,6 +23,41 @@ func TestSupervisorDefaultHTTPClientHasRequestDeadline(t *testing.T) {
}
}
func TestAllocatedChildReceivesConnectionReportingEnvironmentWithoutCommandSecrets(t *testing.T) {
s, err := New(Config{
Command: []string{"game-server"}, ControlPlaneURL: "https://control.example",
ServerID: "server-1", ProtocolVersion: 1, ImageDigest: "sha256:" + strings.Repeat("a", 64),
})
if err != nil {
t.Fatal(err)
}
s.lastGameServer.ObjectMeta.Annotations = map[string]string{"cosmic-clash.io/workload-token": "signed-workload-token"}
environment, err := s.controlPlaneChildEnvironment()
if err != nil {
t.Fatal(err)
}
joined := strings.Join(environment, "\n")
if !strings.Contains(joined, ChildControlPlaneURLEnv+"=https://control.example") || !strings.Contains(joined, ChildWorkloadTokenEnv+"=signed-workload-token") || strings.Contains(joined, ChildAdmissionSignalEnv) {
t.Fatalf("child connection-reporting environment = %v", environment)
}
if strings.Contains(strings.Join(s.config.Command, " "), "signed-workload-token") {
t.Fatal("workload token leaked into child command arguments")
}
s.config.AdmissionURL = "http://127.0.0.1:7780/initial-connect-ready"
environment, err = s.controlPlaneChildEnvironment()
if err != nil || !strings.Contains(strings.Join(environment, "\n"), ChildAdmissionSignalEnv+"=1") {
t.Fatalf("child admission signal environment = %v err=%v", environment, err)
}
}
func TestSupervisorRejectsUnsafeControlPlaneOrigins(t *testing.T) {
for _, raw := range []string{"control.example", "https://user:secret@control.example", "https://control.example/path", "https://control.example?token=secret"} {
if _, err := New(Config{Command: []string{"game-server"}, ControlPlaneURL: raw, ServerID: "server-1", ProtocolVersion: 1, ImageDigest: "sha256:" + strings.Repeat("a", 64)}); err == nil {
t.Fatalf("unsafe control-plane URL accepted: %q", raw)
}
}
}
func TestWithAllocatedConfigOverridesAuthoritativeChildFlags(t *testing.T) {
command := []string{
"game-server", "--", "--allocated-mode", "--match-id=stale-match",
@@ -265,6 +300,7 @@ func TestControlPlaneRegistrationReportsProcessReadyThenAssignmentReady(t *testi
func TestAssignmentReadyRegistrationRetriesUntilTheControlPlaneCatchesUp(t *testing.T) {
var mu sync.Mutex
assignmentReadyAttempts := 0
admissionCalled := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/gameserver":
@@ -288,6 +324,15 @@ func TestAssignmentReadyRegistrationRetriesUntilTheControlPlaneCatchesUp(t *test
return
}
w.WriteHeader(http.StatusNoContent)
case r.URL.Path == "/initial-connect-ready":
mu.Lock()
defer mu.Unlock()
if assignmentReadyAttempts != 3 || r.Header.Get("Authorization") != "Bearer control-token-123456" {
w.WriteHeader(http.StatusConflict)
return
}
admissionCalled = true
w.WriteHeader(http.StatusAccepted)
default:
w.WriteHeader(http.StatusNotFound)
}
@@ -301,13 +346,14 @@ func TestAssignmentReadyRegistrationRetriesUntilTheControlPlaneCatchesUp(t *test
s, err := New(Config{
Command: []string{"/bin/sh", "-c", "true"}, SDKBaseURL: server.URL, ReadyURL: server.URL + "/ready-probe", ReadyTimeout: time.Second, PollInterval: time.Millisecond,
ControlPlaneURL: server.URL, WorkloadTokenPath: tokenPath, ServerID: "server-1", MatchID: "match-1", ProtocolVersion: 1, ImageDigest: "sha256:aa",
DrainURL: server.URL + "/drain", AdmissionURL: server.URL + "/initial-connect-ready", DrainToken: "control-token-123456",
AssignmentReadyAttempts: 5, AssignmentReadyBackoff: time.Millisecond,
})
if err != nil {
t.Fatal(err)
}
// Start must still succeed -- a slow-to-propagate assignment-ready must
// never be treated as a Start() failure (which would kill the child).
// A transient conflict is retried inside Start; the assignment only becomes
// visible after the durable transition eventually succeeds.
if err := s.Start(context.Background()); err != nil {
t.Fatalf("Start failed despite assignment-ready eventually succeeding: %v", err)
}
@@ -319,6 +365,42 @@ func TestAssignmentReadyRegistrationRetriesUntilTheControlPlaneCatchesUp(t *test
if assignmentReadyAttempts != 3 {
t.Fatalf("assignment-ready attempts = %d, want exactly 3 (2 conflicts then success)", assignmentReadyAttempts)
}
if !admissionCalled {
t.Fatal("initial-connect clock was not armed after durable assignment readiness")
}
}
func TestPersistentAssignmentReadyFailureFailsClosed(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/gameserver":
_, _ = w.Write([]byte(`{"object_meta":{"annotations":{"cosmic-clash.io/match-id":"match-1","cosmic-clash.io/workload-token":"workload-token"}},"status":{"address":"127.0.0.1","ports":[{"name":"game","port":31001}]}}`))
case "/ready-probe", "/ready":
w.WriteHeader(http.StatusOK)
case "/v1/servers/server-1/register":
body, _ := io.ReadAll(r.Body)
if strings.Contains(string(body), `"assignment_ready":true`) {
w.WriteHeader(http.StatusConflict)
return
}
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
s, err := New(Config{
Command: []string{"/bin/sh", "-c", "sleep 30"}, SDKBaseURL: server.URL, ReadyURL: server.URL + "/ready-probe",
ControlPlaneURL: server.URL, ServerID: "server-1", ProtocolVersion: 1, ImageDigest: "sha256:aa",
ReadyTimeout: time.Second, PollInterval: time.Millisecond, AssignmentReadyAttempts: 2, AssignmentReadyBackoff: time.Millisecond,
})
if err != nil {
t.Fatal(err)
}
if err := s.Start(context.Background()); err == nil || !strings.Contains(err.Error(), "assignment-ready registration did not succeed") {
t.Fatalf("persistent assignment-ready failure did not fail closed: %v", err)
}
_ = s.Wait()
}
func TestControlPlaneRegistrationFallsBackToGameServerAnnotationForMatchID(t *testing.T) {