From 726fe1ce2e5e8039d40b707bc9e538368d222ee5 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:24:19 +0100 Subject: [PATCH] feat: gate assignment publication on allocation --- multiplayer-next.md | 6 ++++-- multiplayer-todo.md | 4 ++-- server/domain/allocator.go | 33 ++++++++++++++++++++++++++++--- server/domain/allocator_test.go | 35 +++++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 7 deletions(-) diff --git a/multiplayer-next.md b/multiplayer-next.md index a4da0519..7d53a177 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -92,8 +92,10 @@ product policy are in [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md). Valve-approved SDR POP/certificate/public-UDP overlays. - [ ] Add the local-safe Agones adapter and separate process-ready (listen then Ready) from assignment-ready (Allocated manifest verified and registered). -- [ ] Allocate from Ready by region/build/protocol/transport; use separately - verified ENet and SDR dynamic/passthrough port mappings. +- [ ] **IN PROGRESS:** Allocate from Ready by region/build/protocol/transport; use separately + verified ENet and SDR dynamic/passthrough port mappings. The Go allocator + now owns assignment publication with idempotent replay/conflict handling; + Agones integration remains. - [ ] Deliver/verify the signed roster after allocation and expose client tickets only after backend `assignment_ready`. - [ ] Keep >=2 Ready processes across >=2 on-demand nodes/failure domains per diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 7706a5f5..8ebc0364 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1212,8 +1212,8 @@ the local/CI/community transport, not a silent production fallback. | 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]` | **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]` | **IN PROGRESS.** Pure Go allocator filters Ready servers by region/build/protocol/transport and atomically claims one with idempotent allocation replay; assignment is not exposed from Ready state | `server/domain/allocator.go` covers deterministic compatible selection, exhaustion, conflicting/identical replay and invalid server input; Agones `GameServerAllocation`, signed roster metadata, bounded cross-replica retry and live integration remain | -| 8.31 `[D:8.9,8.30]` | **IN PROGRESS.** Pure Go assignment gate requires Allocated state, exact allocation ID/match/server/region/build/protocol/transport compatibility, non-empty hosted endpoint and verified manifest signature before exposure | `server/domain/assignment.go` covers early-connect, tampered signature/manifest, wrong compatibility and empty endpoint rejection; Agones metadata watch, hosted-address registration, production signer and client-ticket publication remain | +| 8.30 `[D:8.18,8.26,8.28,8.29]` | **IN PROGRESS.** Pure Go allocator filters Ready servers by region/build/protocol/transport, atomically claims one with idempotent allocation replay, and now owns the assignment-publication boundary | `server/domain/allocator.go` covers deterministic compatible selection, exhaustion, conflicting/identical allocation replay, unknown allocations, and assignment replay/conflict; Agones `GameServerAllocation`, signed roster metadata, bounded cross-replica retry and live integration remain | +| 8.31 `[D:8.9,8.30]` | **IN PROGRESS.** Pure Go assignment gate requires Allocated state, exact allocation ID/match/server/region/build/protocol/transport compatibility, non-empty hosted endpoint and verified manifest signature before exposure; allocator publication cannot expose Ready state | `server/domain/assignment.go` and `allocator.go` plus adversarial fixtures cover early-connect, tampered signature/manifest, wrong compatibility, empty endpoint, unknown allocation and post-publication mutation rejection; Agones metadata watch, hosted-address registration, production signer and client-ticket publication remain | | 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 | | 8.33 `[D:8.26,8.32]` | On-demand-only live capacity and measured N+1: loss of largest node leaves two Ready slots plus headroom for surviving Allocated matches | Interruptible nodes cannot receive live matches; forced node loss neither overloads survivors nor prevents the next allocation | | 8.34 `[D:8.28,8.29]` | Native x86_64 benchmark of boot-to-process-ready and assignment-ready, p99 CPU/RSS/network and 60 Hz ticks; limits/node cap with 30% headroom | Measurements replace old estimates and certify density with no tick backlog | diff --git a/server/domain/allocator.go b/server/domain/allocator.go index 00c8cc46..78f92f08 100644 --- a/server/domain/allocator.go +++ b/server/domain/allocator.go @@ -49,16 +49,18 @@ type Allocator struct { mu sync.Mutex servers map[string]ReadyServer allocations map[string]Allocation + assignments map[string]Assignment requestHashes map[string][32]byte } var ( - ErrNoCapacity = fmt.Errorf("no compatible ready server") - ErrAllocationInput = fmt.Errorf("invalid allocation request") + ErrNoCapacity = fmt.Errorf("no compatible ready server") + ErrAllocationInput = fmt.Errorf("invalid allocation request") + ErrAllocationNotFound = fmt.Errorf("allocation not found") ) func NewAllocator(servers []ReadyServer) (*Allocator, error) { - a := &Allocator{servers: make(map[string]ReadyServer, len(servers)), allocations: make(map[string]Allocation), requestHashes: make(map[string][32]byte)} + a := &Allocator{servers: make(map[string]ReadyServer, len(servers)), allocations: make(map[string]Allocation), assignments: make(map[string]Assignment), requestHashes: make(map[string][32]byte)} for _, server := range servers { if server.ServerID == "" || server.Region == "" || server.Build == "" || server.Protocol <= 0 || (server.Transport != "enet" && server.Transport != "steam_sdr") || server.State != ServerReady { return nil, fmt.Errorf("%w: invalid ready server", ErrAllocationInput) @@ -106,6 +108,31 @@ func (a *Allocator) Allocate(request AllocationRequest, now time.Time) (Allocati return allocation, nil } +// PublishAssignment is the allocation-to-client boundary. It holds the same +// allocator lock as the claim and exposes no assignment until the allocated +// server, complete compatibility tuple, endpoint, and manifest signature all +// verify. The returned assignment is stable across an identical retry. +func (a *Allocator) PublishAssignment(allocationID string, manifest AllocationManifest, endpoint string, signature []byte, verify func([]byte, []byte) bool) (Assignment, error) { + a.mu.Lock() + defer a.mu.Unlock() + allocation, ok := a.allocations[allocationID] + if !ok { + return Assignment{}, ErrAllocationNotFound + } + assignment, err := VerifyAssignment(allocation, manifest, endpoint, signature, verify) + if err != nil { + return Assignment{}, err + } + if prior, exists := a.assignments[allocationID]; exists { + if prior != assignment { + return Assignment{}, ErrConflict + } + return prior, nil + } + a.assignments[allocationID] = assignment + return assignment, nil +} + func validateAllocationRequest(request AllocationRequest) error { if request.AllocationID == "" || request.MatchID == "" || request.Region == "" || request.Build == "" || request.Protocol <= 0 || (request.Transport != "enet" && request.Transport != "steam_sdr") { return ErrAllocationInput diff --git a/server/domain/allocator_test.go b/server/domain/allocator_test.go index 5526d45e..ba06c58a 100644 --- a/server/domain/allocator_test.go +++ b/server/domain/allocator_test.go @@ -83,3 +83,38 @@ func TestAllocatorConcurrentClaimsCannotDoubleAllocateOneServer(t *testing.T) { t.Fatalf("concurrent claims succeeded %d times", wins) } } + +func TestAllocatorPublishesOnlyVerifiedAssignmentAndReplaysIdentically(t *testing.T) { + a, err := NewAllocator([]ReadyServer{{ServerID: "server-1", Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet", State: ServerReady}}) + if err != nil { + t.Fatal(err) + } + allocation, err := a.Allocate(AllocationRequest{AllocationID: "allocation-1", MatchID: "match-1", Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet"}, time.Unix(1000, 0)) + if err != nil { + t.Fatal(err) + } + manifest := AllocationManifest{AllocationID: allocation.AllocationID, MatchID: allocation.MatchID, ServerID: allocation.ServerID, Region: allocation.Region, Build: allocation.Build, Protocol: allocation.Protocol, Transport: allocation.Transport, RosterDigest: "roster-1"} + digest := ManifestDigest(manifest) + verify := func(payload, signature []byte) bool { + return string(payload) == string(manifestBytes(manifest)) && string(signature) == string(digest[:]) + } + if _, err := a.PublishAssignment("unknown", manifest, "127.0.0.1:30001", digest[:], verify); !errors.Is(err, ErrAllocationNotFound) { + t.Fatalf("unknown allocation error = %v", err) + } + bad := manifest + bad.Build = "build-2" + if _, err := a.PublishAssignment(allocation.AllocationID, bad, "127.0.0.1:30001", digest[:], verify); !errors.Is(err, ErrManifestRejected) { + t.Fatalf("tampered assignment error = %v", err) + } + first, err := a.PublishAssignment(allocation.AllocationID, manifest, "127.0.0.1:30001", digest[:], verify) + if err != nil { + t.Fatal(err) + } + replay, err := a.PublishAssignment(allocation.AllocationID, manifest, "127.0.0.1:30001", digest[:], verify) + if err != nil || replay != first { + t.Fatalf("assignment replay = %+v err=%v", replay, err) + } + if _, err := a.PublishAssignment(allocation.AllocationID, manifest, "127.0.0.1:30002", digest[:], verify); !errors.Is(err, ErrConflict) { + t.Fatalf("endpoint mutation error = %v", err) + } +}