Skip to content

fix: stop handing matchmaker-owned GameServers back to the fleet#60

Merged
hbrombeer merged 1 commit into
mainfrom
worktree-matchmaker-managed-mode
Jul 12, 2026
Merged

fix: stop handing matchmaker-owned GameServers back to the fleet#60
hbrombeer merged 1 commit into
mainfrom
worktree-matchmaker-managed-mode

Conversation

@hbrombeer

Copy link
Copy Markdown
Member

Fixes the blocker found while spiking cross-cluster allocation for service-match.

The bug

A GameServer allocated by an external matchmaker is Allocated but empty for a few seconds — the players are still on their way in (assign → proxy → client connect). The plugin currently reads "empty" as "free":

  • scheduleFallback() runs every 10 s and forces onlinePlayers.isEmpty() ? ready() : allocate()
  • the last-player-left listener calls ready() too

Observed live (spike against vcluster-dahendriik): an external GameServerAllocation flipped a server to Allocated, and 4 seconds later the plugin logged SDK.Ready() complete and returned it to the Ready pool. The fleet then reports ALLOCATED=0.

Consequences:

  1. The next allocation can hand the same server to a second match — two matches, one server, player-visible.
  2. The reaper goes blind: it hunts Allocated servers whose grounds/match-id is stale, and this orphan is Ready.
  3. What the design treats as a rare residual double-allocation window becomes structural.

This also answers an open question in the design doc ("does the gamemode base idle-shut-down when empty?") — it does the opposite.

The fix

New GameServerOwnership in common, read from GROUNDS_MATCHMAKING. When set, the plugin keeps its hands off the Agones state on both paper and minestom: no readiness loop, no player listeners, no ready(). Ending the match stays the gamemode's job (SDK.Shutdown), so the server never re-enters the pool and the fleet replaces it with a fresh one.

Why an env var and not our own GameServer labels: the grounds/match-id label is patched on at allocation time, so a server polling for it would race the very window this is meant to close. The env var is set at pod creation — the server knows from birth which mode it is in.

Fails safe, not open: an unparseable value stays SELF_MANAGED. Silently disabling the readiness loop on an ordinary lobby would strand it outside the Ready pool and make it unjoinable.

Blast radius

None for anything running today. Without GROUNDS_MATCHMAKING, behaviour is byte-for-byte what it is now — lobbies and standalone gamemodes keep managing themselves, which is correct for them ("empty" really does mean "free" there).

Verification

  • 3 unit tests on the parsing (unset / 1 / true / garbage), green
  • ./gradlew build green; paper and minestom both compile

Draft: forge still needs to render GROUNDS_MATCHMAKING onto the Fleet template for gamemodes with a matchmaking: block. Nothing consumes the flag until then, so this is safe to merge ahead of it.

A GameServer allocated by an external matchmaker is Allocated but empty
for a few seconds — its players are still in flight (assign -> proxy ->
client connect). Today the plugin reads that as "nobody here, I'm free":
the readiness loop runs every 10s and calls ready() on an empty server,
and the last-player-left listener does the same.

Observed live: an external GameServerAllocation flipped a server to
Allocated, and 4 seconds later the plugin logged "SDK.Ready() complete"
and put it back in the Ready pool. The next allocation would then hand
the same server to a second match — two matches on one server, player
visible. The reaper cannot catch it either, because it hunts *Allocated*
servers with a stale match-id and this orphan is Ready.

So: when a matchmaker owns the lifecycle, keep hands off the state. New
GameServerOwnership reads GROUNDS_MATCHMAKING (which forge will set on
the Fleet template for gamemodes that declare a matchmaking: block) and,
when set, disables both the readiness loop and the player listeners on
paper and minestom. Ending the match stays the gamemode's job
(SDK.Shutdown), so the server never re-enters the pool and the fleet
replaces it with a fresh one.

An env var rather than reading our own GameServer labels: the match-id
label is patched on at allocation time, so a server polling for it would
race the very window this closes. The env var is set at pod creation.

Unparseable values stay SELF_MANAGED — failing open would strand an
ordinary lobby outside the Ready pool and make it unjoinable.

Lobbies and standalone gamemodes are untouched: without the env var the
behaviour is exactly what it is today.
@sonarqubecloud

Copy link
Copy Markdown

@hbrombeer hbrombeer marked this pull request as ready for review July 12, 2026 16:31
@hbrombeer hbrombeer merged commit a4fcd1c into main Jul 12, 2026
3 checks passed
@hbrombeer hbrombeer deleted the worktree-matchmaker-managed-mode branch July 12, 2026 16:32
hbrombeer added a commit that referenced this pull request Jul 13, 2026
…sible (#61)

Matchmaker-managed servers never entered the pool. Agones only moves a
GameServer out of Scheduled when it calls ready(), and #60 removed the call
entirely — so the fleet sat there looking perfectly healthy while, to an
allocator, it did not exist. Every allocation came back UnAllocated and no
match could ever be placed. Verified live: the duel fleet's GameServer stayed
in Starting indefinitely.

Keeping our hands off the state was too broad a reading. What must not happen
is re-readying a server that is ALREADY allocated — its players are seconds
away, and handing it back lets a second match land on it. That is the bug #60
fixed, and it stays fixed: no readiness loop, no player listeners, no ready()
on empty.

So the server readies exactly once, to enter the pool, and never touches its
state again. Ending the match remains the gamemode's job.

The KDoc that said "no ready()" is corrected too — it is what drove the
mistake.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant