feat(multiplayer): reject outdated clients with distinct messaging

Closes §8.43's 'version-mismatch-specific client messaging' gap. Per
the user's explicit go-ahead to design new server behavior for this
(rather than only wiring up something that already existed, the
pattern every other fix this session followed): before this, there
was no server-side protocol rejection at all. queue_create accepted
any protocol_version >= 1 unconditionally, so an outdated client could
only ever discover a mismatch by waiting in the queue forever
unmatched -- the matcher's own compatibility check requires every
formed player to share an identical protocol_version -- with no error
and no explanation given to the player.

Server: Service.MinProtocolVersion (opt-in, zero by default so every
existing caller keeps accepting protocol_version 1 unconditionally)
rejects a below-floor queue_create with 426 Upgrade Required /
client_outdated before the request ever reaches the candidate
provider. Wired via cmd/control-plane's new --min-protocol-version
flag (validated non-negative at startup).

Client: ControlPlaneClient recognises HTTPClient.RESPONSE_UPGRADE_REQUIRED
on queue_create specifically and sets a distinct 'Your client is out
of date -- please update to continue searching' message instead of
the server's raw generic error string, and clears _last_queue_create
so can_retry_queue_create() never offers 'Retry Search' for a failure
that retrying with the same build can never fix.

Verified: go build/vet/test -race clean across every server package.
TestQueueCreateEnforcesMinProtocolVersion covers below-floor rejection
(candidate provider never reached), exactly-at-floor acceptance, and
the error body naming client_outdated; TestQueueCreateMinProtocolVersionZeroIsDisabled
proves the opt-in default doesn't change behavior for every existing
caller. Godot: test_outdated_client_receives_a_distinct_message_and_no_retry_offer
proves the distinct message and suppressed retry offer. Full Godot
suite (217/217, 0 failed, no crash), full make verify-multiplayer-local
gate, zero new crash reports.
This commit is contained in:
Josh Creek
2026-09-04 18:07:29 +01:00
parent 24620be5c1
commit 7d50612abb
6 changed files with 137 additions and 1 deletions
+5
View File
@@ -34,6 +34,7 @@ func main() {
rateWindow := flag.Duration("rate-limit-window", time.Minute, "fixed window for the per-replica request limiter")
rateMaxKeys := flag.Int("rate-limit-max-keys", 10000, "maximum credential/IP keys retained by the per-replica request limiter")
trustedProxyCIDRs := flag.String("trusted-proxy-cidrs", os.Getenv("COSMIC_CLASH_TRUSTED_PROXY_CIDRS"), "comma-separated immediate proxy CIDRs allowed to supply X-Forwarded-For")
minProtocolVersion := flag.Int("min-protocol-version", 0, "reject queue_create below this protocol_version with 426 Upgrade Required instead of queueing a client the matcher can never pair with anyone; zero disables the floor")
flag.Parse()
if *role != "api" {
fatalf("unsupported role %q (only api is implemented)", *role)
@@ -44,6 +45,9 @@ func main() {
if *redisTTL <= 0 {
fatalf("--redis-ttl must be positive")
}
if *minProtocolVersion < 0 {
fatalf("--min-protocol-version must be non-negative")
}
rateLimiter, err := api.NewRateLimiter(*rateLimit, *rateWindow, *rateMaxKeys)
if err != nil {
fatalf("invalid request limiter configuration: %v", err)
@@ -78,6 +82,7 @@ func main() {
service := newAPIService(db, *workloadSecret, candidateIndex)
service.RateLimiter = rateLimiter
service.ClientIPs = clientIPs
service.MinProtocolVersion = *minProtocolVersion
admission := api.NewAdmissionGate(*degraded)
service.Admission = admission
server := &http.Server{Addr: *listen, Handler: service.Handler(), ReadHeaderTimeout: 5 * time.Second}