Skip to content

Add backend player presence tracking foundation - #1550

Merged
BenCodez merged 27 commits into
masterfrom
agent/backend-player-presence-foundation
Aug 16, 2026
Merged

Add backend player presence tracking foundation#1550
BenCodez merged 27 commits into
masterfrom
agent/backend-player-presence-foundation

Conversation

@BenCodez

@BenCodez BenCodez commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Summary

  • add backend-driven player login/logout presence events to the existing global-message system for non-PLUGINMESSAGING transports
  • keep the original login notification for PLUGINMESSAGING and use the player-facing proxy's native online-player/server state
  • track online players by UUID and case-insensitive name, including their configured backend server name
  • use a per-player connection UUID so delayed logouts cannot clear a newer connection
  • on non-PLUGINMESSAGING transports, announce backend start/stop, periodically replay the same idempotent start, and send a 30-second heartbeat
  • on non-PLUGINMESSAGING transports, run proxy-side presence maintenance every 30 seconds and expire silent backends after 90 seconds
  • support on-demand, atomic presence snapshots split into 100-player chunks
  • validate backend identities against proxy configuration and bound retained backend, player, event, and snapshot state
  • deduplicate and rate-limit snapshot requests before replacing pending state or scheduling Bukkit work
  • preserve backend replay fences across expiry so stale logins and heartbeats cannot revive dead presence
  • confirm cross-backend moves with a destination snapshot instead of comparing backend clocks
  • five seconds after a presence-enabled proxy starts, ask every configured backend to reannounce its active generation, then automatically request and retry a full online-player snapshot
  • keep generation fencing in memory only; after a voting-proxy restart, treat every player as offline until fresh backend snapshots arrive
  • complete cached-reward login handling exactly once after a snapshot confirms the destination connection

Design notes

For non-PLUGINMESSAGING presence, the configured backend Server name remains the stable backend identity; this does not introduce a configured backend instance ID. Every presence-reporting lifecycle automatically generates a collision-resistant backendIncarnationId. While the current incarnation is available, only a BackendStarted envelope with a later backendStartedAt may replace it; once the current incarnation stops or expires, a replacement is accepted even if its clock moved backward. Retired incarnation IDs are rejected. Heartbeats, player events, shutdowns, and snapshots cannot claim a backend generation that is not current.

Each envelope carries backendStartedAt and presenceTimestamp, recorded when the lifecycle/player event is produced or snapshot data has finished being captured. Event timestamps are monotonic within one backend incarnation and reject reordered events from that incarnation. For competing incarnations of the same configured backend, backendStartedAt prevents an overlapping older process from replacing an available newer process after a voting-proxy restart; timestamps never order different backend names. Generation state is intentionally memory-only.

Presence writes no generation or player-state files. After a voting-proxy restart, the tracker begins empty and unknown players are treated as offline. Five seconds after startup, the proxy sends a targeted PresenceResyncRequest to every configured backend. Each backend reannounces its active generation and resets only its snapshot-request cooldown; the proxy then uses the existing generation-bound request to obtain a complete online-player snapshot. Periodic backend reannouncements and bounded snapshot retries remain as fallback recovery.

On a presence-enabled transport, a login that conflicts with presence on another backend cannot directly replace it. The tracker atomically detects a conflicting destination, records its event-ordering fence, and returns a typed result with that conflict's event-sequence token. Pending handoffs refuse to replace a newer token, and snapshot creation or attachment atomically verifies that the token is still the current destination claim. Attachment also requires the pending snapshot's event watermark to include the conflict token; an older recovery snapshot completes without consuming the handoff, which then retries with a fresh snapshot. Only a matching snapshot confirms the handoff. This ordering fences an older destination snapshot during rapid A→B→C transfers while allowing C's snapshot to become authoritative. A separate bounded cross-backend transition fence survives a delayed source logout, so removing A cannot erase C's newer claim and let B's stale snapshot win. If the destination is inside its snapshot cooldown, the handoff remains queued and maintenance attaches it to the next allowed snapshot. A matching destination logout invalidates its exact connection claim even while cooldown leaves no snapshot pending; once a claim exists for that destination, a logout with a different connection ID is rejected even if a snapshot is pending. Proxy-local event watermarks and destination logout tombstones protect snapshots from login/logout races without relying on cross-server clock synchronization.

Snapshot collection runs on the Bukkit server thread because broker listeners may be asynchronous. Requests target the active incarnation, are deduplicated before pending state changes, and have matching 30-second proxy/backend cooldowns. Snapshot chunks have aggregate player and payload limits, incomplete snapshots expire after two minutes, and a backward proxy-clock adjustment immediately expires a pending request and resets its cooldown origin. Pending snapshots are cancelled before retained tombstones can exceed their bound.

With PLUGINMESSAGING, the backend presence protocol is disabled. Backends send only the original login notification used for cached-reward delivery; they do not send extended presence logins, logouts, lifecycle events, heartbeats, resync requests, or snapshots. The player-facing Bungee/Velocity proxy supplies authoritative online-player and current-server state, and the proxy schedules no presence startup resync or maintenance in this mode.

Transport security and compatibility

This feature adds no PresenceServerSecret or PresenceServerSecrets configuration and performs no second presence-specific HMAC layer. On presence-enabled transports, each backend's unique configured Server value in BungeeSettings.yml is its trusted identity. All participants with access to that transport must therefore be trusted not to impersonate another backend; a compromised trusted backend is outside this feature's threat model. PLUGINMESSAGING carries only the original login notification and other existing plugin messages, so its existing PluginMessageEncryption option remains available and unchanged. Other transports retain their existing security model. Generation fencing, identifiers, timestamps, and bounds provide protocol integrity but do not independently authenticate a backend beyond that trust boundary.

PLUGINMESSAGING backends always send the original login envelope, and the proxy always preserves that compatibility path for cached rewards. The proxy prefers its native current-server lookup for delivery and falls back to the backend's claimed server if native state is not yet available. Legacy login envelopes remain accepted on the other transports as well; new presence-enabled backends send the extended login.

This is the presence-foundation PR only. Existing vote routing, Votifier handling, vote-party behavior, and multi-proxy behavior remain unchanged. The dedicated voting-proxy toggle and vote routing through this tracker can be added in the follow-up PR.

Validation

  • added tracker regressions for stale login/heartbeat replay after expiry, incarnation ordering after proxy restart, snapshot request deduplication/cooldown, backward clock adjustment, cross-clock snapshot-confirmed handoff, atomic conflict classification, destination-claim token binding, pre-claim snapshot rejection, cooldown destination-logout invalidation, mismatched pending-snapshot logout rejection, rapid A→B→C handoff fencing, source-logout ordering, and destination logout races
  • added proxy coverage proving a cooldown-blocked handoff remains queued and is attached to the next allowed snapshot
  • added wire/proxy coverage for the five-second targeted startup resync and proving PLUGINMESSAGING does not schedule it
  • preserve legacy login compatibility on every transport, resolve PLUGINMESSAGING delivery through native proxy server state, and ignore extended presence logins in that mode
  • removed all presence-secret configuration, HMAC wire helpers, and HMAC tests
  • added transport-policy regressions proving PLUGINMESSAGING disables backend presence while standalone transports retain it
  • added restart coverage proving unknown players remain offline until a fresh backend snapshot arrives
  • removed the generation-state file, persistence hooks, and all file-specific tests
  • YAML configuration files parse successfully, CRLF files retain repository line endings, and the diff passes whitespace validation
  • GitHub Java 21 Maven CI passed

AI disclosure: This content was written with assistance from ChatGPT.

@BenCodez
BenCodez marked this pull request as ready for review August 16, 2026 03:51

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 51842d2764

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +685 to +687
String server = plugin.getBungeeSettings().getServer();
sendPresenceMessage(VotingPluginWire.backendStarted(server));
sendPresenceMessage(VotingPluginWire.backendHeartbeat(server));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Seed presence for players connected during backend startup

When the plugin is enabled or hot-reloaded while players are already connected, this announces BackendStarted, which clears that backend's proxy-side presence, but it neither reports Bukkit.getOnlinePlayers() nor initiates a snapshot. Those players do not receive a new AdvancedCoreLoginEvent, so they remain absent from the tracker until they reconnect or an external caller manually requests a snapshot; populate the sessions and report the existing players as part of startup.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head (9ac50dfc). startPresenceReporting() now schedules seedOnlinePlayerPresence(), which scans Bukkit.getOnlinePlayers(), creates connection sessions, and reports each existing player after the lifecycle starts.

AI disclosure: This reply was written with assistance from ChatGPT.

Comment on lines +197 to +202
for (SnapshotPresence snapshot : snapshotPlayers.values()) {
long lastPlayerEvent = lastPlayerEventSequences.getOrDefault(snapshot.uuid, 0L);
if (lastPlayerEvent > pending.eventWatermark) {
continue;
}
putPresence(new PlayerPresence(snapshot.uuid, snapshot.playerName, normalizedServer,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve newer name owners when applying snapshots

If a different UUID logs in under the same case-insensitive player name after beginSnapshot, this check only considers the snapshot player's UUID. putPresence then resolves the name collision by removing the other UUID even when that presence has a post-watermark event, allowing a stale snapshot to evict the newer live session. Before inserting, also compare the current name owner's last event sequence with the snapshot watermark.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head (9ac50dfc). Snapshot application now checks the case-insensitive name owner's lastEventSequence against the snapshot watermark before inserting, so a stale snapshot cannot evict a newer UUID that owns the same name.

AI disclosure: This reply was written with assistance from ChatGPT.

Comment on lines +689 to +692
if (presenceHeartbeatTask != null) {
presenceHeartbeatTask.cancel(false);
}
presenceHeartbeatTask = plugin.getTimer().scheduleAtFixedRate(new Runnable() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop in-flight heartbeats before announcing shutdown

During plugin disable, cancel(false) does not stop a heartbeat invocation that has already begun. If that invocation passes its enabled check, is paused, and sends after BackendStopped, the proxy processes the stop and then marks the backend available again; because presence expiration is not scheduled here, the stopped backend can remain available indefinitely. Serialize the final heartbeat with shutdown or disable presence reporting before an invocation can pass the send check.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head (9ac50dfc). Heartbeat sends and shutdown are serialized by presenceLifecycleLock; shutdown sets presenceReporting = false before cancelling the task and sending BackendStopped, so an in-flight heartbeat cannot pass the enabled check and revive the backend afterward.

AI disclosure: This reply was written with assistance from ChatGPT.

Comment on lines +67 to +71
long sequence = ++eventSequence;
markBackendAvailable(normalizedServer, now);
PlayerPresence current = playersByUuid.get(playerUuid);
if (current == null || !current.getServer().equalsIgnoreCase(normalizedServer)
|| !current.getConnectionId().equals(connectionId)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mark a backend available only after accepting its logout

When a delayed logout arrives after BackendStopped or backend expiry removed the player, this call marks the backend available before determining that the logout is stale and returning false. The rejected event therefore resurrects an unavailable backend and refreshes its timeout even though no current session was changed; move the availability update after the server and connection checks succeed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head (9ac50dfc). Fenced logout events now validate generation, timestamp, server, and connection before calling markBackendAvailable; rejected stale logouts leave backend availability and lastSeen unchanged.

AI disclosure: This reply was written with assistance from ChatGPT.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Security Review

Here are some automated security review suggestions for this pull request.

Reviewed commit: 51842d2764

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

}

private void markBackendAvailable(String server, long now) {
BackendState state = backends.computeIfAbsent(serverKey(server), key -> new BackendState(server));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Security: Bind heartbeat identities before allocating backend state

In a proxy deployment, a compromised backend plugin/server or messaging broker can send BackendHeartbeat envelopes containing unlimited distinct server values. Each valid value permanently allocates a new BackendState; it is not checked against configured servers, capped, or removed by expireBackends. This permits low-rate, persistent proxy-heap growth until the shared proxy process is exhausted, crossing the backend-to-proxy trust boundary. Bind the server identity to the authenticated transport source/configuration and cap and evict stale state.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head (9ac50dfc). Modern heartbeat identities are restricted to configured backend names, authenticated with that backend's unique HMAC secret, and accepted only for the current incarnation. Tracker backend state is capped, with unavailable entries evicted under capacity pressure.

AI disclosure: This reply was written with assistance from ChatGPT.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 237077c1df

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +155 to +156
pending.chunkCount = chunkCount;
pending.chunks.put(chunkIndex, new ArrayList<>(players));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce the snapshot player cap before retaining chunks

When a proxy has an active snapshot request, a malformed or compromised backend can declare up to 10,000 chunks and send as many as 100,000 players in each; every chunk passes the per-chunk check and is retained here, while the MAX_SNAPSHOT_PLAYERS aggregate limit is checked only after all chunks arrive. The proxy can therefore exhaust its heap long before reaching that check, so reject a chunk when the pending snapshot's cumulative player count would exceed the limit.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head (9ac50dfc). applySnapshotChunk() now checks pending.playerCount + players.size() before retaining a chunk. Exceeding the aggregate snapshot player limit cancels the pending snapshot immediately.

AI disclosure: This reply was written with assistance from ChatGPT.

Comment on lines +121 to +122
pendingSnapshots.put(serverKey(normalizedServer), new PendingSnapshot(requestId, eventSequence));
prunePlayerEventSequences();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expire incomplete snapshot requests

If a backend remains healthy but one snapshot chunk is lost, this pending request has no deadline or cancellation path and remains indefinitely unless another snapshot is manually requested or the backend later stops/expires. While it remains, prunePlayerEventSequences() must retain every post-watermark logout tombstone, so normal player churn can grow lastPlayerEventSequences without bound after a single incomplete response; record an expiry and discard stale pending snapshots.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head (9ac50dfc). Pending snapshots record their request time and expire after two minutes. Expiry runs during heartbeat, snapshot, and backend-expiration activity, removes the pending request, and releases retained tombstones.

AI disclosure: This reply was written with assistance from ChatGPT.

Comment on lines +711 to +714
presenceHeartbeatTask = plugin.getTimer().scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
sendActivePresenceMessage(VotingPluginWire.backendHeartbeat(server));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh the heartbeat identity after a server-name reload

When BungeeSettings.yml changes Server and the plugin configuration is reloaded, VotingPluginMain.reloadPlugin() reloads the settings but leaves the existing BungeeHandler running. This task therefore keeps heartbeating the server name captured during initial load, while subsequent login, logout, snapshot, and shutdown messages read the new name dynamically; the proxy can expire the new backend and retain the obsolete one. Restart presence reporting or read the current configured identity consistently after reload.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head (9ac50dfc). reloadPresenceReporting() compares the active server name and captured presence secret with the reloaded configuration; a change stops the old lifecycle and starts a new incarnation, so heartbeats and player events use one consistent identity.

AI disclosure: This reply was written with assistance from ChatGPT.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 477aeb2256

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1822 to 1824
} else {
getBungeeHandler().reloadPresenceReporting();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop presence reporting when Bungee support is disabled

When a reload changes UseBungeecoord from true to false, this branch is skipped, so the existing handler remains in presenceReporting mode and continues sending heartbeats every 30 seconds. Quit events are also gated by the newly false setting, leaving the proxy's existing player sessions stale while the heartbeats prevent backend expiry; explicitly stop or close the handler in the disabled branch.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head (9ac50dfc). The reload path now explicitly calls disablePresenceReporting() when UseBungeecoord becomes false, serially stopping heartbeats, announcing shutdown, and clearing backend sessions.

AI disclosure: This reply was written with assistance from ChatGPT.

Comment on lines +188 to +189
public static JsonEnvelope backendStopped(String server) {
return base(SUB_BACKEND_STOPPED).put(K_SERVER, safe(server)).build();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add an incarnation ID to backend lifecycle messages

During an overlapping or rapidly restarted backend deployment, a delayed BackendStopped from the previous process is indistinguishable from one sent by the current process because both carry only the configured server name. If the old stop arrives after the replacement's BackendStarted and login messages, the proxy accepts it and clears the replacement's players and availability; include a per-process incarnation ID in lifecycle, heartbeat, and player messages and reject events from obsolete incarnations.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head (9ac50dfc). Lifecycle, heartbeat, player, snapshot-request, and snapshot envelopes now carry an automatically generated per-process backendIncarnationId. The tracker accepts replacement only through BackendStarted and rejects retired incarnations.

AI disclosure: This reply was written with assistance from ChatGPT.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8c9c4936d2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if (removed != null) {
uuidByPlayerName.remove(nameKey(removed.getPlayerName()), uuid);
}
lastPlayerEventSequences.put(uuid, sequence);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound logout tombstones while snapshots are pending

When any snapshot is pending, a configured but compromised backend can repeatedly send valid login/logout pairs for distinct UUIDs; every logout adds a post-watermark entry here, while the 100,000-player cap checks only playersByUuid. prunePlayerEventSequences() deliberately retains these tombstones until the snapshot expires, so a high-rate sender can exhaust proxy heap within the two-minute window. Apply an aggregate cap to live players plus tombstones, or cancel pending snapshots before retaining more tombstones.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head (9ac50dfc). Live player events and retained tombstones share maxTrackedPlayerEvents. If a new event would exceed that aggregate bound while snapshots are pending, the pending snapshots are cancelled and tombstones are pruned before any additional state is retained.

AI disclosure: This reply was written with assistance from ChatGPT.

Comment on lines +89 to +90
if (current != null && current.getServer().equalsIgnoreCase(normalizedServer)
&& current.getLastSeen() > presenceTimestamp) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject older logins across backend moves

When a player has moved to another backend and an older login from the former backend is delayed by the transport, the timestamp check is skipped because the current presence's server differs from normalizedServer. The stale event then reaches putPresence() and overwrites the newer destination-server presence, leaving the tracker pointed at the old backend until another event or snapshot repairs it; compare ordering for the UUID regardless of which server currently owns it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head (9ac50dfc). A login that conflicts with a player/name currently owned by another backend cannot overwrite it directly. The proxy requests a destination snapshot; only a matching live session in that snapshot confirms the handoff, so a delayed old-backend login does not reclaim the player.

AI disclosure: This reply was written with assistance from ChatGPT.

Comment on lines +605 to +606
} else if (backendStartedAt < state.backendStartedAt) {
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid ordering backend generations by wall-clock time

The new generation fence still treats backendStartedAt as a strictly increasing identifier, although it is populated from System.currentTimeMillis(). If a backend restarts after its clock moves backward—or two incarnations obtain the same millisecond—the replacement's lifecycle, heartbeat, and player events are rejected as older (or as the stopped generation). In the inspected proxy code expireBackendPresence() has no caller, so this can leave the replacement untracked indefinitely; use a collision-resistant incarnation identity with a restart-order mechanism rather than comparing process wall clocks.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head (9ac50dfc). Backend generations are identified by collision-resistant incarnation UUIDs and advanced by proxy-local receipt of authenticated BackendStarted; backendStartedAt is retained as metadata but is not ordered across processes, so clock rollback and same-millisecond restarts do not reject a replacement.

AI disclosure: This reply was written with assistance from ChatGPT.


// Transport listeners may run off the Bukkit thread. Snapshot Bukkit state on
// the server thread before replying.
plugin.getBukkitScheduler().runTask(plugin, new Runnable() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rate-limit authenticated snapshot requests

With shared Redis or MQTT credentials, a compromised backend can publish repeated snapshot requests naming another configured server and its observed backendStartedAt; the validation only checks envelope fields, and every accepted request schedules a new Bukkit main-thread task. Each task scans all online players and emits snapshot chunks even though the proxy will discard unsolicited responses, so a flood can queue enough main-thread work and broker traffic to stall the victim backend. Authenticate requests as proxy-originated and rate-limit or deduplicate them before scheduling.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head (9ac50dfc). Snapshot requests are HMAC-authenticated with a unique secret for the target backend and must match its active incarnation. The backend deduplicates request IDs and enforces a 30-second monotonic cooldown before scheduling Bukkit work; the proxy applies the same cooldown before sending.

AI disclosure: This reply was written with assistance from ChatGPT.

AI disclosure: This content was written with assistance from ChatGPT.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 418ff08fe2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +224 to +228
retireCurrentIncarnation(state);
long sequence = ++eventSequence;
removePlayersOnServer(normalizedServer, sequence);
pendingSnapshots.remove(key);
state.backendIncarnationId = backendIncarnationId;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Authenticate lifecycle messages before switching incarnation

On shared MQTT, Redis, or database transports, any backend with the common credentials can publish BackendStarted for another configured server with a fresh UUID. This branch treats every non-retired incarnation as a replacement, clears the legitimate backend's players, and makes its subsequent heartbeats and player events fail the generation check until it restarts. Bind the claimed server to a trusted transport identity or otherwise authenticate lifecycle messages before replacing the current incarnation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head (9ac50dfc). Every modern presence envelope is signed with HMAC-SHA256 using a unique PresenceServerSecret for that configured backend. The proxy verifies the signature and claimed server before backendStarted() can replace an incarnation, preventing another backend with only its own secret from switching this server's lifecycle.

AI disclosure: This reply was written with assistance from ChatGPT.

Comment on lines +1736 to +1737
requestId = backendPlayerPresenceTracker.beginSnapshot(server, requestId, backendIncarnationId,
backendStartedAt, now);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Deduplicate snapshot requests before replacing pending state

When this public method is called twice for the same backend within 30 seconds, the second beginSnapshot replaces the first pending request, but BungeeHandler's new cooldown rejects the second request. The first response then has the wrong request ID and is discarded (or, if it already completed, the second request remains pending without a response), even though this method returns a valid ID. Reuse or reject an existing pending request, or apply the same cooldown before overwriting it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head (9ac50dfc). beginSnapshot() now rejects an existing pending request before changing state, and fenced requests also use a persisted 30-second proxy cooldown. A second call therefore cannot replace the first request ID or create a request the backend will discard.

AI disclosure: This reply was written with assistance from ChatGPT.

Comment on lines +111 to +113
if (fenced ? !markBackendAvailable(normalizedServer, backendIncarnationId, backendStartedAt,
presenceTimestamp, now)
: !markBackendAvailable(normalizedServer, now)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject stale logins before refreshing backend availability

Although logout now validates the event before marking the backend available, this login path still updates lastSeen and sets available before checking whether the player or name owner has a newer timestamp. A delayed same-generation login can therefore extend the lifetime of a dead backend despite being rejected; after expireBackends has cleared its players, the same stale login is accepted outright and resurrects stale presence. Perform the ordering checks before refreshing backend state and retain a backend-level timestamp fence across expiry.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head (9ac50dfc). Fenced login ordering checks run before capacity allocation or markBackendAvailable. Each backend retains a monotonic player-event timestamp fence even after timeout expiry, so a rejected stale login cannot refresh lastSeen or resurrect cleared presence.

AI disclosure: This reply was written with assistance from ChatGPT.

Comment on lines +117 to +119
PlayerPresence current = playersByUuid.get(playerUuid);
if (current != null && current.getLastSeen() > presenceTimestamp) {
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid comparing timestamps produced by different backends

When a player legitimately moves between backends whose clocks are skewed, this comparison treats their independently generated wall-clock timestamps as one global ordering. If the source backend's clock is ahead of the destination's, every destination login is rejected until its clock passes the old value, leaving the player assigned to the source even though the destination event is newer. Cross-backend moves need proxy-local ordering or another handoff mechanism rather than comparing backend capture times.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head (9ac50dfc). Timestamps are compared only within the producing backend incarnation. Cross-backend conflicts use proxy-local event watermarks plus a destination snapshot handoff, including source/destination logout race protection, so clock skew between backends does not determine ownership.

AI disclosure: This reply was written with assistance from ChatGPT.

Comment on lines +754 to +757
sendPresenceMessage(VotingPluginWire.backendStarted(server, presenceIncarnationId, presenceStartedAt,
now));
sendPresenceMessage(VotingPluginWire.backendHeartbeat(server, presenceIncarnationId, presenceStartedAt,
nextPresenceTimestamp()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replay BackendStarted when plugin messaging gains a carrier

With the default PLUGINMESSAGING transport, starting or restarting an empty backend cannot deliver these one-shot lifecycle messages because Bukkit plugin messages require an online player as a carrier. When the first player later joins, only its login and subsequent heartbeats are sent; if the proxy still holds the previous incarnation, neither message may replace it, so the new backend and player remain rejected indefinitely. Send BackendStarted again when the first carrier becomes available or queue it until delivery succeeds.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head (9ac50dfc). The backend now replays the same authenticated BackendStarted incarnation once the first plugin-message carrier is available, before sending that player's modern login. Startup seeding uses the same replay path for players already online.

AI disclosure: This reply was written with assistance from ChatGPT.

Comment on lines 1380 to 1381
debug("Login: " + player + "/" + uuid + " " + server);
login(player, uuid, server);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip legacy vote handling for rejected presence logins

When an extended login is rejected by the generation, server, or timestamp checks, the handler still invokes the legacy login flow unconditionally. If the named player is online on a newer backend, a delayed login from the old backend can therefore run checkOnlineVotes with the stale server, send cached rewards there, mark them delivered, and remove them from the cache even though the tracker rejected the event. Run the legacy path only for three-field legacy envelopes or after the new presence event has been accepted.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head (9ac50dfc). The legacy login() reward path now runs only for a genuine three-field legacy envelope on a backend without authenticated presence configured, or after a modern presence login is accepted. Rejected/stale modern logins—and unsigned legacy bypasses once a secret is configured—cannot drain cached rewards.

AI disclosure: This reply was written with assistance from ChatGPT.

AI disclosure: This commit was written with assistance from ChatGPT.

Copy link
Copy Markdown
Owner Author

@codex review

AI disclosure: This content was written with assistance from ChatGPT.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9ac50dfcaa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return;
}
sendPresenceMessage(VotingPluginWire.backendStarted(presenceServer, presenceIncarnationId,
presenceStartedAt, nextPresenceTimestamp()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reuse the original timestamp for the carrier replay

When the initial BackendStarted was accepted but this carrier replay is delayed until after the backend expires, nextPresenceTimestamp() makes it look like a newer lifecycle event. expireBackends() leaves the incarnation active and only marks it unavailable, so backendStarted() accepts this replay and revives the dead backend; replay the original start timestamp so an already-seen start remains idempotent while an initially undeliverable plugin message can still initialize the backend.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f01d48f8. The first-carrier replay now sends BackendStarted with the original presenceStartedAt value as both the generation metadata and lifecycle timestamp. If the original start never had a plugin-message carrier, the proxy can initialize from this replay; if it already accepted the start, the existing lifecycle timestamp fence rejects the replay without refreshing availability. The existing replayedLifecycleMessageCannotReviveExpiredBackend regression covers the receiver behavior, and Java 21 Maven CI passes.

AI disclosure: This reply was written with assistance from ChatGPT.

AI disclosure: This commit was written with assistance from ChatGPT.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f01d48f867

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +877 to +878
if (presenceStartedReplayedWithCarrier || !presenceReporting || presenceServer == null
|| presenceIncarnationId == null || !hasPresenceServerSecret()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replay backend start after a proxy restart

When the dedicated proxy restarts while this backend remains running, its new tracker has no current generation, so every subsequent heartbeat and modern login is rejected by isCurrentBackendGeneration. The final tree still keeps presenceStartedReplayedWithCarrier true for the backend's lifetime, meaning the only message capable of recreating that generation is never sent again; presence tracking and the accepted-login reward flow remain disabled until every backend plugin restarts. Reannounce the same idempotent BackendStarted periodically or provide a proxy-restart handshake.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cadc7262. Every authenticated heartbeat now first reannounces the same BackendStarted incarnation with its original lifecycle timestamp. A fresh proxy can therefore reconstruct the generation, while a proxy that already saw it rejects the duplicate lifecycle event without refreshing stale state.

Java 21 Maven CI passes.

AI disclosure: This reply was written with assistance from ChatGPT.

Comment on lines +1484 to +1487
|| !backendPlayerPresenceTracker.applySnapshotChunk(snapshot.server,
snapshot.requestId, snapshot.chunkIndex, snapshot.chunkCount, snapshot.players,
snapshot.backendIncarnationId, snapshot.backendStartedAt,
snapshot.presenceTimestamp, System.currentTimeMillis())) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Complete login handling after a snapshot-confirmed handoff

When a destination login arrives before the source backend's logout, the conflict path rejects the login and requests this snapshot. A successful snapshot can then move the player to the destination, but this listener only updates the tracker and never invokes login(player, uuid, server), so cached online votes/rewards that the rejected login would normally deliver remain queued until a later reconnect. Retain the pending handoff identity and run the login flow once the destination snapshot confirms it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cadc7262. A rejected cross-backend login is retained against the active, bounded snapshot request. When the snapshot completes, the pending entry is removed first and login() runs exactly once only if the tracker confirms the same player UUID, destination server, backend generation, and connection ID. Direct accepted logins, backend replacement/stop/expiry, failed requests, and the two-minute timeout discard stale handoffs.

Java 21 Maven CI passes.

AI disclosure: This reply was written with assistance from ChatGPT.

AI disclosure: This commit was written with assistance from ChatGPT.
AI disclosure: This commit was written with assistance from ChatGPT.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3c908d7f39

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1924 to +1926
public Set<String> expireBackendPresence(long timeoutMillis) {
long now = System.currentTimeMillis();
Set<String> expired = backendPlayerPresenceTracker.expireBackends(now, timeoutMillis);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Schedule backend presence expiration

When a backend crashes or loses connectivity without sending BackendStopped, its last heartbeat and all owned players remain available indefinitely. Across the inspected Bungee and Velocity implementations, a repo-wide search finds no call to this newly added expireBackendPresence method, so the timeout logic in the tracker is never exercised; schedule it from both dedicated proxy modes with a timeout greater than the 30-second heartbeat interval.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cadc7262. Presence maintenance is scheduled centrally through the proxy runtime's platform timer, so both Bungee and Velocity run it every 30 seconds. Backends silent for more than 90 seconds (three heartbeat intervals) are expired along with their players and pending handoffs.

Java 21 Maven CI passes.

AI disclosure: This reply was written with assistance from ChatGPT.

Comment on lines +844 to +846
reannouncePresenceStarted();
sendPresenceMessage(VotingPluginWire.backendHeartbeat(presenceServer, presenceIncarnationId,
presenceStartedAt, nextPresenceTimestamp()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Repopulate players after proxy recovery

When the proxy restarts while this backend and its players remain online, the fresh periodic BackendStarted replay now restores the backend generation, but this recovery path sends only the start and heartbeat. seedOnlinePlayerPresence() runs only during backend startup/reload, and the proxy's accepted-start listener does not request a snapshot, so players who were already connected remain absent from the new tracker until they reconnect or an external caller manually requests a snapshot; trigger a snapshot or replay the active sessions when recovering the proxy.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cadc7262. When a fresh proxy accepts an authenticated backend generation, it immediately requests a complete presence snapshot. The recovery request is retained and retried by the 30-second maintenance task after bounded request expiry until a complete matching snapshot restores already-connected players; successful snapshots and backend stop/expiry clear recovery state.

Java 21 Maven CI passes.

AI disclosure: This reply was written with assistance from ChatGPT.

AI disclosure: This commit was written with assistance from ChatGPT.

Copy link
Copy Markdown
Owner Author

@codex review

Please review the latest head (cadc7262), including proxy-restart recovery, snapshot-confirmed deferred login delivery, scheduled stale-backend expiration, and automatic recovery snapshots.

AI disclosure: This comment was written with assistance from ChatGPT.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cadc72621a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if (!enabled) {
return;
}
expireBackendPresence(PRESENCE_BACKEND_TIMEOUT_MILLIS);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Request a snapshot when an expired backend resumes

After a transport outage longer than 90 seconds, this expiration clears the backend's players, but a later same-incarnation BackendStarted replay is rejected as an idempotent lifecycle timestamp while its following heartbeat merely marks the backend available. Because the completed recovery snapshot previously removed the server from pendingBackendRecoverySnapshots, no new snapshot is requested and players who stayed connected remain absent until they reconnect; detect the unavailable-to-available heartbeat transition or retain a recovery request after expiration.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6ee8b3e7. Expiration now keeps the backend in recovery-snapshot state instead of removing it. While unavailable, snapshot requests remain safely blocked by the tracker; after a valid same-incarnation heartbeat marks it available again, the 30-second maintenance task requests a fresh snapshot and retains retry state until a complete matching snapshot restores players.

Java 21 Maven CI passes.

AI disclosure: This reply was written with assistance from ChatGPT.

private void sendPresenceHeartbeat() {
synchronized (presenceLifecycleLock) {
if (presenceReporting && presenceServer != null && presenceIncarnationId != null) {
reannouncePresenceStarted();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fence old lifecycle replays during overlapping restarts

When the proxy restarts during an overlapping backend deployment, the new process's start may be accepted first and then the old process's periodic reannouncement here can arrive second; since the fresh tracker has not retired the old UUID, backendStarted() replaces the new incarnation and retires it. When the old process subsequently stops, the surviving new process remains rejected indefinitely because its UUID is now retired. The new periodic reannouncement is fresh evidence for this ordering failure; recovery needs an ordering fence that prevents an older process from reclaiming the generation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6ee8b3e7. Competing incarnations of the same configured backend name are now ordered by their immutable backendStartedAt process-start value. An older periodic replay is rejected and retired when a newer incarnation is already current, so its later stop cannot displace the surviving process. This ordering is never applied across different backend names, and the regression covers the newer start arriving first followed by the old replay and delayed stop.

Java 21 Maven CI passes.

AI disclosure: This reply was written with assistance from ChatGPT.

AI disclosure: This commit was written with assistance from ChatGPT.

Copy link
Copy Markdown
Owner Author

@codex review

Please review the latest head (6ee8b3e7), especially same-incarnation recovery after timeout and overlapping-restart lifecycle ordering.

AI disclosure: This comment was written with assistance from ChatGPT.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6ee8b3e706

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +300 to +302
if (backendStartedAt <= state.backendStartedAt) {
retireIncarnation(state, backendIncarnationId);
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid ordering backend incarnations by wall-clock time

When a genuine replacement starts with a clock that is behind the current process—or in the same millisecond—this comparison retires the replacement UUID; after the old process stops or times out, all reannouncements and heartbeats from the surviving replacement remain rejected because that UUID is now retired, leaving the backend unavailable until another restart. The newly added backendStartedAt <= state.backendStartedAt fence is fresh evidence for the previously discussed wall-clock ordering failure; use a restart-order mechanism that does not assume process clocks are strictly increasing.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 27649fed. The backendStartedAt comparison has been removed; equal and rolled-back backend clocks can replace the current incarnation. The proxy now atomically persists its bounded current/retired incarnation UUID ordering per configured backend and restores it before message listeners start. Restored active generations begin unavailable, prove liveness by heartbeat, and recover players by snapshot. The regression exercises an equal-timestamp replacement, persists it, restarts the proxy again, rejects the old process replay, and accepts the surviving replacement heartbeat.

The state file is bounded to 1 MiB and contains no player data or authentication secrets.

Java 21 Maven CI passes.

AI disclosure: This reply was written with assistance from ChatGPT.

AI disclosure: This commit was written with assistance from ChatGPT.

Copy link
Copy Markdown
Owner Author

@codex review

Please review the latest head (27649fed), especially the durable proxy-local incarnation ordering, atomic bounded state store, and equal/rolled-back backend-clock regression.

AI disclosure: This comment was written with assistance from ChatGPT.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 27649fed7a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +62 to +63
if (fileSize <= 0L || fileSize > MAX_FILE_BYTES) {
throw new IOException("Backend generation state file has an invalid size");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Align the persisted-state size with its allowed entry bounds

When a deployment approaches the permitted 1,024 backends and 64 retired incarnations per backend, save() can produce a file that this check always rejects on the next startup: the retired UUID payload alone can occupy 1,048,576 bytes, before server names, current UUIDs, and metadata are added. The proxy then restores no incarnation fences, allowing an obsolete periodic BackendStarted replay to become current; either reduce the entry bounds, reject oversized snapshots before replacement, or raise/derive the loader limit from the maximum serialized form.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in dfbfd858. The loader cap is now derived from the exact serialized maximum: header plus 1,024 entries, 128 UTF-16 code units per server name at worst-case modified-UTF size, current UUID/metadata, and 64 retired UUIDs. save() validates the same limits before touching the temporary file. A regression writes all 1,024 maximum-retirement entries, verifies the file exceeds the old 1 MiB cap, then restores all 1,024 successfully.

Java 21 Maven CI passes.

AI disclosure: This reply was written with assistance from ChatGPT.

Comment on lines +147 to +148
} catch (AtomicMoveNotSupportedException e) {
Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve atomicity when atomic moves are unsupported

On filesystems/providers that throw AtomicMoveNotSupportedException, this fallback performs an ordinary replacement with no atomicity guarantee. If the proxy or host stops during that replacement, the previous valid ordering fence may be lost or replaced by an unreadable file, after which startup proceeds with an empty tracker and can accept an obsolete incarnation replay. Since this state is specifically the durable ordering fence, retain a recoverable previous copy or fail the update rather than silently degrading to a non-atomic replacement.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in dfbfd858. The non-atomic replacement fallback was removed. If ATOMIC_MOVE is unsupported, the temporary file is deleted, the existing valid ordering file remains untouched, and the save reports an error instead of silently weakening the restart fence.

Java 21 Maven CI passes.

AI disclosure: This reply was written with assistance from ChatGPT.

AI disclosure: This commit was written with assistance from ChatGPT.
AI disclosure: This commit was created with assistance from ChatGPT.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cec4447000

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +291 to +298
} else if (!backendIncarnationId.equals(state.backendIncarnationId)) {
if (state.retiredIncarnations.contains(backendIncarnationId)) {
return false;
}
// Only a BackendStarted event can advance the proxy-local restart order. The
// current and retired UUIDs are persisted by the proxy so this ordering
// survives proxy restarts without comparing backend clocks.
retireCurrentIncarnation(state);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve incarnation fences across proxy restarts

When the proxy restarts during an overlapping backend deployment, its tracker is empty, so if the surviving new incarnation is observed first and an older process reannounces second, this branch treats the old UUID as a replacement and retires the new one; after the old process stops, all future traffic from the surviving process is rejected until another backend restart. Fresh evidence after the earlier fix is that the final tree removed BackendGenerationStateStore, despite the adjacent comment still claiming these UUIDs are persisted, so the restart fence no longer survives.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b4626577 without bringing files back. While the current incarnation is available, a competing BackendStarted may replace it only when its backendStartedAt is later. Therefore, after a voting-proxy restart, observing the surviving newer process first causes a periodic reannouncement from the overlapping older process to be rejected, and the older process's later stop cannot clear the survivor. If the current process has stopped or expired, a replacement is accepted regardless of clock regression, preserving recovery after a graceful stop or heartbeat timeout. The stale persistence comment was replaced, and regression coverage exercises both restart ordering and clock-regressed replacement after stop.

Java 21 Maven CI passes.

AI disclosure: This reply was written with assistance from ChatGPT.

Comment on lines +1827 to +1829
if (requestId == null) {
return null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain handoffs blocked by the snapshot cooldown

When a player transfers within 30 seconds after this destination's previous snapshot completed, beginSnapshot() rejects the new request because of its cooldown and there is no active request ID, so this return drops the pending handoff entirely. The destination login has already been rejected as conflicting; once the source logout arrives, the player disappears from tracked presence and the deferred cached-reward login is never completed until another reconnect. Queue the handoff and request its snapshot after the cooldown rather than discarding it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b4626577. A conflicting login now queues its handoff before attempting the destination snapshot. If the 30-second snapshot cooldown blocks creation and there is no active request, the handoff remains unassigned instead of being dropped. Presence maintenance retries unassigned handoffs, attaches them to the next allowed snapshot, and also requeues an assigned handoff if its snapshot expires. Added proxy coverage proving the cooldown-blocked handoff is retained and causes a snapshot request once the interval elapses.

Java 21 Maven CI passes.

AI disclosure: This reply was written with assistance from ChatGPT.

Comment on lines +1007 to +1008
private boolean isExpired(long now) {
return now >= requestedAt && now - requestedAt >= SNAPSHOT_TIMEOUT_MILLIS;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use monotonic time for snapshot expiration

If the proxy wall clock moves backward after a snapshot request whose response is lost, this condition keeps the request pending until wall time reaches the old value again. Because beginSnapshot() refuses to replace a pending request and also rejects when lastSnapshotRequestedAt > now, recovery and handoff snapshots can remain blocked for the entire clock offset, potentially hours. Track timeout and cooldown durations with a monotonic clock or explicitly expire state after a backward jump.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b4626577. Pending snapshots now expire immediately when the observed proxy wall clock moves behind requestedAt, and the snapshot cooldown permits a new request after that backward jump, resetting lastSnapshotRequestedAt to the new clock origin. This prevents a lost request from remaining pinned for the clock offset. Added regression coverage that starts a snapshot, moves the clock backward, verifies the request expires, and verifies a replacement request is accepted immediately.

Java 21 Maven CI passes.

AI disclosure: This reply was written with assistance from ChatGPT.

Copy link
Copy Markdown
Owner Author

@codex review

Please review the latest head (cec44470), especially the final no-file presence design:

  • no generation or player-state files and no persistence hooks remain;
  • a voting-proxy restart begins with an empty tracker and unknown players are treated as offline;
  • the next valid BackendStarted establishes the in-memory generation and requests a complete snapshot, with the existing bounded retries;
  • PLUGINMESSAGING continues to use legacy login notifications and native proxy online/current-server state;
  • non-PLUGINMESSAGING transports keep generation fencing and snapshots in memory only.

Java 21 Maven CI passes.

AI disclosure: This review request was written with assistance from ChatGPT.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cec4447000

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Set<String> expired = new LinkedHashSet<>();
for (Map.Entry<String, BackendState> entry : backends.entrySet()) {
BackendState state = entry.getValue();
if (state.available && now - state.lastSeen > timeoutMillis) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use monotonic time for backend expiry

In non-PLUGINMESSAGING modes, if the proxy host's wall clock moves backward after a backend crashes, now - state.lastSeen remains negative until the clock catches up. The silent backend therefore stays available and its players remain tracked far beyond the configured 90-second timeout, potentially routing votes or rewards using stale presence; measure backend silence with a monotonic clock or explicitly handle backward jumps.

Useful? React with 👍 / 👎.

AI disclosure: This commit was written with assistance from ChatGPT.
AI disclosure: This commit was written with assistance from ChatGPT.

Copy link
Copy Markdown
Owner Author

@codex review

Please review latest head (acde9a97), focusing on the no-file restart and recovery behavior:

  • unknown players remain offline until a fresh snapshot;
  • while an incarnation is available, backendStartedAt rejects an overlapping older process after proxy restart, while stopped/expired generations still permit clock-regressed replacement;
  • cooldown-blocked handoffs remain queued and maintenance attaches them to the next allowed snapshot;
  • backward proxy-clock jumps expire pending snapshots and reset the request cooldown;
  • no presence persistence files or hooks were reintroduced.

Java 21 Maven CI passes.

AI disclosure: This review request was written with assistance from ChatGPT.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: acde9a97b1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1856 to +1858
prunePendingPresenceHandoffs(now);
handoff.requestId = null;
pendingPresenceHandoffs.put(handoff.playerUuid, handoff);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fence snapshots for superseded handoffs

When the same player moves A→B→C before B's snapshot returns, queuing C here overwrites the B handoff but neither invalidates B's active snapshot nor records the C conflict in the tracker's event watermark. The B snapshot can therefore install presence on B; when C's snapshot arrives, applyCompleteSnapshot skips it because B is now a current event newer than C's request watermark. Both deferred login completions are then discarded without delivering cached rewards, and the player remains attributed to B until another presence event. Cancel or fence snapshots belonging to superseded handoffs, or include the newer conflict in snapshot ordering.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 08afe049. A cross-backend login conflict now records a per-player event sequence and destination server before its snapshot begins. In an A→B→C transfer, B's request watermark predates C's conflict marker, so B's later snapshot cannot install stale presence; C's request watermark includes that marker, so C's matching snapshot can become authoritative and complete the remaining deferred login. Added a regression that applies B's snapshot before C's and verifies the player stays on A until C wins.

This commit also adds the requested five-second startup resync for presence-capable transports; PLUGINMESSAGING remains excluded.

Java 21 Maven CI passes.

AI disclosure: This reply was written with assistance from ChatGPT.

AI disclosure: This commit was written with assistance from ChatGPT.

Copy link
Copy Markdown
Owner Author

@codex review

Please review latest head (08afe049), focusing on:

  • the one-shot five-second PresenceResyncRequest sent to every configured backend on presence-capable proxy startup;
  • backend validation/rate-limiting, generation reannouncement, and snapshot-cooldown reset before the existing generation-bound snapshot flow;
  • no resync listener, task, or other presence protocol under PLUGINMESSAGING;
  • rapid A→B→C handoffs, where the newer destination conflict marker must fence B's older snapshot while allowing C's snapshot;
  • no presence persistence files or secrets were reintroduced.

Java 21 Maven CI passes.

AI disclosure: This review request was written with assistance from ChatGPT.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 08afe0498d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +231 to +232
long sequence = ++eventSequence;
removePresence(playerUuid, sequence);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the newer destination fence after source logout

When A's logout arrives after the C conflict marker but before the B/C snapshots, this removal advances the player's event sequence and replaces its destination marker with A. B's older snapshot then sees no current presence and a newer marker for a different server, so it installs B; that installation subsequently causes C's snapshot to be skipped, leaving the player attributed to B indefinitely. Preserve the newer destination marker when removing the older source connection, or track these fences separately.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8a222829. Cross-backend transition ordering is now retained separately from ordinary player-event/logout tombstones. In the reported A→B→C sequence, A's delayed logout removes A's live presence but does not overwrite C's newer transition fence; B's request watermark predates that fence and its stale snapshot is skipped, while C's request includes the fence and can still become authoritative.

Added sourceLogoutPreservesNewerDestinationFence, which reproduces the source logout between the B/C requests and responses, applies B first, verifies the player remains unknown/offline, then verifies C wins. The fence is bounded and pruned with the existing retained player-event state.

Java 21 Maven CI passes.

AI disclosure: This reply was written with assistance from ChatGPT.

AI disclosure: This commit was written with assistance from ChatGPT.

Copy link
Copy Markdown
Owner Author

@codex review

Please review latest head (8a222829), focusing on cross-backend handoff ordering when the source logout arrives between destination snapshot requests and responses:

  • a separate bounded transition fence must survive A's logout in A→B→C;
  • B's older snapshot must remain unable to install the player;
  • C's matching snapshot must remain eligible and become authoritative;
  • destination-logouts and existing event-state pruning/bounds must remain correct;
  • no presence files, secrets, or PLUGINMESSAGING presence behavior were introduced.

Java 21 Maven CI passes.

AI disclosure: This review request was written with assistance from ChatGPT.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8a222829b2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1559 to +1560
boolean conflictingBackend = backendPlayerPresenceTracker.hasConflictingPresence(player, uuid, server);
accepted = backendPlayerPresenceTracker.playerOnline(player, uuid, server, event.connectionId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make conflict detection atomic with login insertion

When transport callbacks execute concurrently, the presence state can change between these two synchronized tracker calls. For example, after A's logout makes the precheck return false, B's older snapshot can install the player before C's playerOnline call; that call then rejects C as a cross-backend conflict and records its fence, but the cached conflictingBackend == false prevents requesting C's confirming snapshot or retaining its handoff. Return the rejection reason from the same atomic playerOnline operation instead of checking conflict separately.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 55ceb234. The proxy no longer calls hasConflictingPresence() before playerOnline(). The tracker now performs validation, conflict detection, destination-fence insertion, and result classification in one synchronized playerOnlineResult() operation. It returns ACCEPTED, CONFLICTING_PRESENCE, or REJECTED; only the atomic conflict result causes the proxy to retain the handoff and request the confirming destination snapshot.

The old split precheck API was removed. Regression coverage now verifies that a valid conflicting login returns CONFLICTING_PRESENCE after recording its fence, while a stale replay returns ordinary REJECTED; existing proxy coverage verifies conflict results retain/retry the handoff through snapshot cooldown.

Java 21 Maven CI passes.

AI disclosure: This reply was written with assistance from ChatGPT.

AI disclosure: This commit was written with assistance from ChatGPT.

Copy link
Copy Markdown
Owner Author

@codex review

Please review latest head (55ceb234), focusing on atomic presence-login conflict handling:

  • validation, conflict detection, destination-fence insertion, and rejection classification now happen in one synchronized tracker operation;
  • the proxy requests and retains a handoff only for the returned CONFLICTING_PRESENCE result;
  • stale/invalid rejections must not create handoffs or snapshots;
  • existing A→B→C, source/destination logout, cooldown retry, pruning, and bounds behavior must remain correct;
  • no presence files, secrets, or PLUGINMESSAGING presence behavior were introduced.

Java 21 Maven CI passes.

AI disclosure: This review request was written with assistance from ChatGPT.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 55ceb23429

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1564 to +1567
if (result == BackendPlayerPresenceTracker.PlayerOnlineResult.CONFLICTING_PRESENCE) {
requestBackendPresenceSnapshot(server,
new PendingPresenceHandoff(player, uuid, server, event.connectionId,
event.backendIncarnationId, event.backendStartedAt, System.currentTimeMillis()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bind handoff requests to the conflict sequence

When transport callbacks for rapid A→B→C moves run concurrently, B can return CONFLICTING_PRESENCE, C can then record the newer destination fence, and B can resume here afterward and overwrite C's pending handoff while starting its snapshot with a watermark that already includes C's fence. Because that fence is not newer than B's snapshot watermark, B's snapshot may install B; C's later snapshot then sees B's installed presence as newer and skips C, leaving presence and deferred reward delivery on the wrong backend. Return an event sequence/token with the conflict result and reject queueing or snapshot creation when that token is no longer the current destination claim.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f533c1fe. PlayerOnlineResult now carries the conflict event-sequence token. The proxy stores that token in PendingPresenceHandoff, refuses to replace a higher-sequence handoff with an older one, and uses claim-aware tracker methods for both creating a snapshot and attaching to an already-active request. Those tracker methods validate the token against the current destination claim under the same tracker lock that captures the snapshot watermark.

If B resumes after C records a newer claim, B can neither queue over C nor create/reuse a snapshot; its stale handoff is discarded without removing C. If B acquired the tracker lock before C, B's watermark predates C's fence, so the existing snapshot ordering still rejects B when it arrives. Destination claims are separately bounded, survive source logout and snapshot cooldown, and are invalidated by a newer destination, destination logout, accepted presence, completion, or expiry.

Added supersededDestinationClaimCannotStartOrReuseSnapshot, including a delayed source logout with no pending snapshot, to verify B's token is rejected and C's remains usable.

Java 21 Maven CI passes.

AI disclosure: This reply was written with assistance from ChatGPT.

AI disclosure: This commit was written with assistance from ChatGPT.

Copy link
Copy Markdown
Owner Author

@codex review

Please review latest head (f533c1fe), focusing on destination-claim token binding under concurrent A→B→C callbacks:

  • each conflict result carries its event-sequence token;
  • a lower-token handoff cannot overwrite a higher-token handoff;
  • snapshot creation and reuse atomically reject tokens that are no longer the current destination claim;
  • source logout and cooldown preserve the current claim, while newer destinations and destination logout invalidate it;
  • claim retention is bounded and cleanup does not weaken snapshot-ordering fences;
  • no presence files, secrets, or PLUGINMESSAGING presence behavior were introduced.

Java 21 Maven CI passes.

AI disclosure: This review request was written with assistance from ChatGPT.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f533c1fe3f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +442 to +447
public synchronized UUID getPendingSnapshotRequestIdForDestinationClaim(String server, UUID playerUuid,
long conflictSequence, long now) {
if (!isCurrentDestinationClaim(playerUuid, server, conflictSequence)) {
return null;
}
return getPendingSnapshotRequestId(server, now);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not reuse snapshots that predate the destination claim

When a recovery or other snapshot for this server was already pending before the conflicting login, this returns that request solely because the claim is current, even though its eventWatermark is lower than conflictSequence. applyCompleteSnapshot then skips this player because the newer cross-backend fence exceeds the snapshot watermark, while completePendingPresenceHandoffs still removes the handoff and releases its claim, so the destination login and cached-reward delivery are lost until another reconnect. Only attach the handoff to a pending snapshot whose watermark includes its conflict token; otherwise leave it unassigned and retry after the older snapshot completes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0a409de8. getPendingSnapshotRequestIdForDestinationClaim() now reads the pending snapshot under the tracker lock and returns its request only when pending.eventWatermark >= conflictSequence. A recovery snapshot that began before the conflict therefore cannot consume or receive the handoff; the handoff remains unassigned, the older snapshot completes normally, and maintenance requests a fresh claim-bound snapshot after cooldown.

Added destinationClaimDoesNotReuseSnapshotWithOlderWatermark, which begins the destination snapshot before the conflict, verifies the claim-aware lookup rejects it, completes it, then verifies a fresh post-cooldown snapshot can be created for the same still-current claim.

Java 21 Maven CI passes.

AI disclosure: This reply was written with assistance from ChatGPT.

Comment on lines +817 to +819
CrossBackendFence claim = destinationClaims.get(uuid);
if (claim != null && claim.server.equalsIgnoreCase(server)) {
destinationClaims.remove(uuid);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate claims on destination logout during cooldown

When a conflicting destination login is queued while snapshot cooldown leaves no request pending, a logout from that destination does not reach this invalidation: playerOffline sees the live presence still on the source and returns false because pendingSnapshots has no destination entry. The stale claim and handoff therefore survive the destination session's logout and maintenance later requests a snapshot for a handoff that is no longer valid; recognize a matching destination claim as sufficient to record the logout and remove it even when no snapshot exists yet.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0a409de8. Destination claims now retain the exact connection ID as well as server and conflict sequence. playerOffline() accepts a logout for that matching claimed connection even when cooldown leaves no snapshot pending, records the destination tombstone, and invalidates the claim while preserving the live source presence. A stale or different connection does not satisfy this claim path.

Added destinationLogoutInvalidatesClaimWithoutPendingSnapshot, which establishes destination cooldown, records a source→destination conflict with no pending request, applies the destination logout, verifies the source remains authoritative, verifies the token is invalid, and proves it cannot later create a snapshot.

Java 21 Maven CI passes.

AI disclosure: This reply was written with assistance from ChatGPT.

AI disclosure: This commit was written with assistance from ChatGPT.

Copy link
Copy Markdown
Owner Author

@codex review

Please review latest head (0a409de8), focusing on the two claim-bound recovery fixes:

  • a handoff may attach only to a pending snapshot whose event watermark includes its conflict sequence;
  • an older recovery snapshot must complete without consuming the unassigned handoff, which retries later;
  • destination claims include the exact connection ID;
  • a matching destination logout invalidates the claim even when cooldown leaves no snapshot pending, while preserving source presence;
  • stale/different-connection logouts must not invalidate the current claim;
  • existing claim bounds, A→B→C ordering, no-file recovery, and PLUGINMESSAGING exclusion must remain intact.

Java 21 Maven CI passes.

AI disclosure: This review request was written with assistance from ChatGPT.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0a409de8fb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

DestinationClaim claim = destinationClaims.get(playerUuid);
boolean matchingDestinationClaim = claim != null
&& claim.server.equalsIgnoreCase(normalizedServer) && claim.connectionId.equals(connectionId);
if (!fenced || (!pendingSnapshots.containsKey(key) && !matchingDestinationClaim)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject mismatched logouts while a claim snapshot is pending

When the destination claim's snapshot is already pending, a delayed logout for a different connection ID still passes this condition solely because pendingSnapshots.containsKey(key) is true. It then records an event newer than the snapshot watermark, causing the snapshot to skip the claimed connection; completion subsequently removes the handoff and releases its claim without running the deferred login, even though the current claimed session never logged out. Fresh evidence after the earlier fix is that the new matchingDestinationClaim check remains bypassed by the pending-snapshot alternative; require a matching claim connection whenever a destination claim exists.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in dd9efd52. When a destination claim exists for the logout's server, playerOffline() now requires the logout connection ID to match that claim before it can record a tombstone. The mere presence of a pending snapshot no longer bypasses this check. Pending snapshots still permit an unclaimed destination tombstone, preserving recovery behavior where no claim is involved.

Added mismatchedDestinationLogoutCannotFenceClaimedSnapshot: it creates a pending claim-bound snapshot, sends a later logout with a different connection ID, verifies the claim remains current, and verifies the snapshot installs the claimed connection on the destination.

Java 21 Maven CI passes.

AI disclosure: This reply was written with assistance from ChatGPT.

AI disclosure: This commit was written with assistance from ChatGPT.

Copy link
Copy Markdown
Owner Author

@codex review

Please review latest head (dd9efd52), focusing on stale logout handling for an active destination claim:

  • a pending snapshot must not bypass connection-ID validation when its destination has a current claim;
  • only the exact claimed connection can invalidate that claim;
  • an unrelated pending snapshot without a claim must still support the existing destination-tombstone behavior;
  • a mismatched logout must not fence out the claimed snapshot or its deferred login;
  • all earlier claim-token, watermark, cooldown, A→B→C, no-file, and PLUGINMESSAGING behavior must remain intact.

Java 21 Maven CI passes.

AI disclosure: This review request was written with assistance from ChatGPT.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: dd9efd5214

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@BenCodez
BenCodez merged commit c916763 into master Aug 16, 2026
4 checks passed
@BenCodez
BenCodez deleted the agent/backend-player-presence-foundation branch August 16, 2026 20:38
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