From 9810ee543f6ee31dd1bb48b0dcb54b593f100d05 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:42:19 +0100 Subject: [PATCH] fix: propagate allocated transport ports --- multiplayer-todo.md | 2 +- server/supervisor/supervisor.go | 27 +++++++++++++++-- server/supervisor/supervisor_test.go | 45 ++++++++++++++++++++++++++-- 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 5fd76a05..314f9a0f 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1211,7 +1211,7 @@ the local/CI/community transport, not a silent production fallback. | 8.26 `[D:8.1,8.6,8.12]` | Portable Helm/Kustomize Fleets per build/EU/NA region; isolate provider edge/network/DNS/secret and SDR POP/cert/public-UDP overlays | Two provider fixtures render; labels select region/build/protocol/transport; each fixture documents Valve approval and externally reachable UDP mapping | | 8.27 `[D:8.26]` | **IN PROGRESS.** Go supervisor package provides local-safe Agones REST discovery, dynamic `SDR_LISTEN_PORT`/`SDR_IP` injection, explicit process-ready probing and Ready transition; direct mode bypasses Agones | `server/supervisor/` covers allocated/direct startup and dynamic endpoint/Ready ordering; Godot Agones adapter, metadata watch, Health/annotation/Shutdown and emulator integration remain | | 8.28 `[D:8.6,8.27]` | **IN PROGRESS.** Supervisor separates explicit process-ready from Agones Ready and never scrapes stdout; allocated mode refuses to mark Ready without a configured readiness probe | `server/supervisor/` tests prove Ready follows the probe and direct mode remains functional; Godot readiness endpoint, detached-container and Health-reclaim integration remain | -| 8.29 `[D:8.26,8.27]` | Separate ENet and Hosted-SDR dynamic/passthrough mappings; supervisor exports local `SDR_LISTEN_PORT` and external `SDR_IP`; validate POP/cert/firewall/NAT | Two isolated matches share a node; Agones-reported public endpoint receives relay traffic on the bound socket; ENet fixture remains independent | +| 8.29 `[D:8.26,8.27]` | **IN PROGRESS.** Supervisor discovers the Agones endpoint, propagates the actual dynamic `--port`, and exports `SDR_LISTEN_PORT`/`SDR_IP` only for Hosted-SDR while preserving an isolated ENet path | `server/supervisor/` tests cover dynamic port argument/env propagation and SDR-vs-ENet separation; real Agones dynamic/passthrough mapping, POP/cert/firewall/NAT and multi-match fixture remain | | 8.30 `[D:8.18,8.26,8.28,8.29]` | Atomic `GameServerAllocation` from Ready filtered by region/build/protocol/transport, attaching signed roster/non-secret config with bounded race retry | Duplicate commands yield one Allocated server; exhaustion or retry leaves no orphan; no client assignment is exposed merely because process is Ready | | 8.31 `[D:8.9,8.30]` | **Assignment-ready stage:** watch Allocated metadata, verify manifest/bindings, register hosted address, acknowledge backend; only then mint/expose client tickets | Modified/wrong manifest never reaches assignment-ready; clients cannot connect early; secrets never appear in metadata/args/logs | | 8.32 `[D:8.2,8.26,8.30]` | FleetAutoscaler with >=2 Ready processes across >=2 on-demand nodes/failure domains per queue-enabled region; pre-pull current/rollback; scale **Allocated** count to zero, never the Ready floor | Warm allocation meets p95 5 s/p99 10 s; disabled regions alone scale fully to zero; one-node loss retains certified Ready/headroom | diff --git a/server/supervisor/supervisor.go b/server/supervisor/supervisor.go index 08f1325a..ec8f8cf4 100644 --- a/server/supervisor/supervisor.go +++ b/server/supervisor/supervisor.go @@ -30,6 +30,7 @@ type Config struct { Environment []string SDKBaseURL string ReadyURL string + Transport string ReadyTimeout time.Duration PollInterval time.Duration HTTPClient *http.Client @@ -51,6 +52,12 @@ func New(config Config) (*Supervisor, error) { if config.PollInterval <= 0 { config.PollInterval = 100 * time.Millisecond } + if config.Transport == "" { + config.Transport = "enet" + } + if config.Transport != "enet" && config.Transport != "steam_sdr" { + return nil, fmt.Errorf("unsupported transport %q", config.Transport) + } if config.HTTPClient == nil { config.HTTPClient = http.DefaultClient } @@ -68,9 +75,14 @@ func (s *Supervisor) Start(ctx context.Context) error { if err != nil { return err } - env = append(env, "SDR_LISTEN_PORT="+strconv.Itoa(port), "SDR_IP="+address+":"+strconv.Itoa(port)) + if s.config.Transport == "steam_sdr" { + env = append(env, "SDR_LISTEN_PORT="+strconv.Itoa(port), "SDR_IP="+address+":"+strconv.Itoa(port)) + } + command := withPort(s.config.Command, port) + s.cmd = exec.CommandContext(ctx, command[0], command[1:]...) + } else { + s.cmd = exec.CommandContext(ctx, s.config.Command[0], s.config.Command[1:]...) } - s.cmd = exec.CommandContext(ctx, s.config.Command[0], s.config.Command[1:]...) s.cmd.Env = env if err := s.cmd.Start(); err != nil { return err @@ -85,6 +97,17 @@ func (s *Supervisor) Start(ctx context.Context) error { return s.sdkPost(ctx, "/ready") } +func withPort(command []string, port int) []string { + result := append([]string(nil), command...) + for i, arg := range result { + if strings.HasPrefix(arg, "--port=") { + result[i] = "--port=" + strconv.Itoa(port) + return result + } + } + return append(result, "--port="+strconv.Itoa(port)) +} + func (s *Supervisor) Wait() error { if s.cmd == nil { return fmt.Errorf("supervisor has not started") diff --git a/server/supervisor/supervisor_test.go b/server/supervisor/supervisor_test.go index 73ff1e64..bd488b76 100644 --- a/server/supervisor/supervisor_test.go +++ b/server/supervisor/supervisor_test.go @@ -35,8 +35,9 @@ func TestAllocatedStartInjectsDynamicEndpointAndCallsReadyAfterProbe(t *testing. ready = true path := filepath.Join(t.TempDir(), "env.txt") - command := []string{"/bin/sh", "-c", "env > " + path} - s, err := New(Config{Command: command, SDKBaseURL: server.URL, ReadyURL: server.URL + "/ready-probe", ReadyTimeout: time.Second, PollInterval: time.Millisecond}) + argsPath := filepath.Join(t.TempDir(), "args.txt") + command := []string{"/bin/sh", "-c", "env > " + path + "; printf '%s' \"$@\" > " + argsPath, "shell"} + s, err := New(Config{Command: command, SDKBaseURL: server.URL, ReadyURL: server.URL + "/ready-probe", Transport: "steam_sdr", ReadyTimeout: time.Second, PollInterval: time.Millisecond}) if err != nil { t.Fatal(err) } @@ -53,11 +54,51 @@ func TestAllocatedStartInjectsDynamicEndpointAndCallsReadyAfterProbe(t *testing. if !strings.Contains(string(contents), "SDR_LISTEN_PORT=31001") || !strings.Contains(string(contents), "SDR_IP=203.0.113.9:31001") { t.Fatalf("dynamic endpoint not injected: %s", contents) } + args, err := os.ReadFile(argsPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(args), "--port=31001") { + t.Fatalf("dynamic port argument not injected: %s", args) + } if !readyCalled { t.Fatal("Agones Ready was called before process-ready probe") } } +func TestAllocatedENetDoesNotReceiveSDRVariables(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/gameserver" { + _, _ = w.Write([]byte(`{"status":{"address":"127.0.0.1","ports":[{"name":"game","port":31002}]}}`)) + return + } + if r.URL.Path == "/ready-probe" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + path := filepath.Join(t.TempDir(), "env.txt") + s, err := New(Config{Command: []string{"/bin/sh", "-c", "env > " + path}, SDKBaseURL: server.URL, ReadyURL: server.URL + "/ready-probe", Transport: "enet", ReadyTimeout: time.Second}) + if err != nil { + t.Fatal(err) + } + if err := s.Start(context.Background()); err != nil { + t.Fatal(err) + } + if err := s.Wait(); err != nil { + t.Fatal(err) + } + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(contents), "SDR_LISTEN_PORT=") || strings.Contains(string(contents), "SDR_IP=") { + t.Fatalf("ENet received SDR variables: %s", contents) + } +} + func TestDirectModeDoesNotRequireAgonesReadiness(t *testing.T) { s, err := New(Config{Command: []string{"/bin/sh", "-c", "exit 0"}}) if err != nil {