feat(multiplayer): acknowledge supervisor shutdown

This commit is contained in:
Josh Creek
2026-09-01 16:54:40 +01:00
parent cc12260225
commit 75cb8faac4
4 changed files with 136 additions and 1 deletions
+56
View File
@@ -499,6 +499,12 @@ func (s *Supervisor) Run(ctx context.Context, drainGrace time.Duration) error {
drainErr = s.Drain(drainCtx)
drainCancel()
}
var shutdownErr error
if s.config.ControlPlaneURL != "" && drainErr == nil {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
shutdownErr = s.acknowledgeShutdown(shutdownCtx, "server_draining")
shutdownCancel()
}
timer := time.NewTimer(drainGrace)
defer timer.Stop()
select {
@@ -506,6 +512,9 @@ func (s *Supervisor) Run(ctx context.Context, drainGrace time.Duration) error {
if drainErr != nil {
return fmt.Errorf("child exited after drain failure: %w", drainErr)
}
if shutdownErr != nil {
return fmt.Errorf("child exited after shutdown acknowledgement failure: %w", shutdownErr)
}
return err
case <-timer.C:
if s.cmd != nil && s.cmd.Process != nil {
@@ -515,6 +524,9 @@ func (s *Supervisor) Run(ctx context.Context, drainGrace time.Duration) error {
if drainErr != nil {
return fmt.Errorf("drain failed and child was force-killed: %w", drainErr)
}
if shutdownErr != nil {
return fmt.Errorf("shutdown acknowledgement failed and child was force-killed: %w", shutdownErr)
}
return fmt.Errorf("child force-killed after drain deadline")
}
}
@@ -542,6 +554,50 @@ func (s *Supervisor) Drain(ctx context.Context) error {
return nil
}
// acknowledgeShutdown records the supervisor's planned termination after the
// local game process has been told to drain. It uses the same bound workload
// credential as registration. The idempotency key makes a repeated call safe.
func (s *Supervisor) acknowledgeShutdown(ctx context.Context, reason string) error {
if s.config.ControlPlaneURL == "" {
return nil
}
matchID := s.matchID()
if matchID == "" {
return fmt.Errorf("shutdown acknowledgement has no match ID")
}
token, err := s.workloadToken()
if err != nil {
return err
}
body, err := json.Marshal(struct {
Reason string `json:"reason"`
}{reason})
if err != nil {
return err
}
endpoint := strings.TrimRight(s.config.ControlPlaneURL, "/") + "/v1/servers/" + url.PathEscape(s.config.ServerID) + "/shutdown"
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+token)
key := "supervisor-shutdown-" + s.config.ServerID + "-" + matchID + "-" + reason
if len(key) > 128 {
key = key[:128]
}
request.Header.Set("Idempotency-Key", key)
response, err := s.client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode/100 != 2 {
return fmt.Errorf("control-plane shutdown returned %s", response.Status)
}
return nil
}
func (s *Supervisor) assignedEndpoint(ctx context.Context) (int, string, error) {
var server GameServer
if err := s.sdkGet(ctx, "/gameserver", &server); err != nil {