Files
CosmicClash/server/cmd/game-server-supervisor/main.go
T
Josh Creek 4cad0f0cce feat(multiplayer): report assignment-ready from the supervisor
Closes the second blocker named last commit. Re-traced the actual code
path rather than trusting the earlier assumption: server_boot.gd
verifies its mounted roster file synchronously in _ready(), before
NetworkManager.host() runs and before ServerControl.set_process_ready
is ever called -- so by the time the loopback /ready probe (and thus
Agones Ready, and thus process-ready registration) succeeds, Godot has
already verified its own roster. And the API's ASSIGNMENT_READY gate
(AdvanceServerRegistrationSQL) checks only durable `assignments` rows
server-side, nothing Godot reports. No new Godot-side state was needed
-- the earlier 'needs Godot's own roster-verification state exposed'
claim was overcautious and is corrected here.

The supervisor now calls registerControlPlane(ctx, true) right after
process-ready succeeds, with a bounded retry (default 5 attempts, 2s
apart, both configurable) rather than a single attempt: the durable
`assignments` rows the server-side gate checks may not have propagated
by the first attempt, and that is expected, not fatal. Unlike a
process-ready registration failure, a persistent assignment-ready
failure does NOT kill the child -- the process is already legitimately
listening and usable, and killing a healthy process over a lagging
control-plane read would be actively harmful; it's logged to stderr
instead.

Covered by two tests: the full process-ready-then-assignment-ready
sequence and body shapes, and a retry test that fails the assignment-
ready call twice with 409 (simulating the real gate not yet
satisfied) before succeeding on the third attempt, asserting Start()
still succeeds and the child is never killed.
2026-09-01 13:44:27 +01:00

90 lines
3.7 KiB
Go

package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/cosmic-clash/cosmic-clash/server/supervisor"
)
const usageText = `Usage: game-server-supervisor [options] -- <game-server-command> [args...]
The child command is started only after an allocated Agones endpoint has been
validated and, when configured, an explicit process-ready probe succeeds.
SIGTERM/SIGINT requests authenticated drain before the bounded grace deadline.
`
func main() {
args := os.Args[1:]
separator := -1
for i, arg := range args {
if arg == "--" {
separator = i
break
}
}
if separator < 0 || separator == len(args)-1 {
fmt.Fprint(os.Stderr, usageText)
os.Exit(2)
}
options := flag.NewFlagSet("game-server-supervisor", flag.ContinueOnError)
options.SetOutput(os.Stderr)
sdkBaseURL := options.String("sdk-base-url", "", "Agones SDK REST base URL; empty enables direct mode")
readyURL := options.String("ready-url", "", "explicit process-ready probe URL")
drainURL := options.String("drain-url", "", "loopback drain URL")
drainTokenEnv := options.String("drain-token-env", "COSMIC_CLASH_DRAIN_TOKEN", "environment variable containing the drain bearer token")
transport := options.String("transport", "enet", "enet or steam_sdr")
grace := options.Duration("drain-grace", supervisor.DefaultDrainGrace, "maximum graceful drain duration")
controlPlaneURL := options.String("control-plane-url", "", "matchmaking control-plane base URL; empty skips process-ready registration entirely")
workloadTokenPath := options.String("workload-token-path", "", "path to the projected workload service-account token, read fresh on every registration call")
serverIDEnv := options.String("server-id-env", "COSMIC_CLASH_SERVER_ID", "environment variable containing this GameServer's control-plane server ID (populate via the Kubernetes Downward API, fieldRef: metadata.name)")
matchIDEnv := options.String("match-id-env", "COSMIC_CLASH_MATCH_ID", "environment variable containing the allocated match ID; if unset/empty, falls back to the cosmic-clash.io/match-id annotation on the allocated GameServer")
protocolVersion := options.Int("protocol-version", 0, "protocol version reported at registration")
imageDigestEnv := options.String("image-digest-env", "COSMIC_CLASH_IMAGE_DIGEST", "environment variable containing this build's sha256 image digest")
assignmentReadyAttempts := options.Int("assignment-ready-attempts", 5, "retry attempts for assignment-ready registration after process-ready succeeds (a slow-to-propagate signed roster is not fatal)")
assignmentReadyBackoff := options.Duration("assignment-ready-backoff", 2*time.Second, "delay between assignment-ready retry attempts")
if err := options.Parse(args[:separator]); err != nil {
os.Exit(2)
}
token := ""
if *drainTokenEnv != "" {
token = os.Getenv(*drainTokenEnv)
}
s, err := supervisor.New(supervisor.Config{
Command: args[separator+1:],
SDKBaseURL: *sdkBaseURL,
ReadyURL: *readyURL,
DrainURL: *drainURL,
DrainToken: token,
Transport: *transport,
ReadyTimeout: 30 * time.Second,
ControlPlaneURL: *controlPlaneURL,
WorkloadTokenPath: *workloadTokenPath,
ServerID: os.Getenv(*serverIDEnv),
MatchID: os.Getenv(*matchIDEnv),
ProtocolVersion: *protocolVersion,
ImageDigest: os.Getenv(*imageDigestEnv),
AssignmentReadyAttempts: *assignmentReadyAttempts,
AssignmentReadyBackoff: *assignmentReadyBackoff,
})
if err != nil {
fmt.Fprintf(os.Stderr, "game-server-supervisor: %v\n", err)
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := s.Run(ctx, *grace); err != nil {
fmt.Fprintf(os.Stderr, "game-server-supervisor: %v\n", err)
os.Exit(1)
}
}