Skip to content

Fix stale connect-server load percentages - #932

Open
VDraven wants to merge 3 commits into
MUnique:masterfrom
VDraven:codex/fix-server-load-cache
Open

VDraven wants to merge 3 commits into
MUnique:masterfrom
VDraven:codex/fix-server-load-cache

Conversation

@VDraven

@VDraven VDraven commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Restore LoadIndex for both server-list packet formats so player-count changes update the cached load percentage in place instead of leaving it stale.

@sven-n sven-n left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The fix is correct and worth merging. I verified both offsets against the generated packet structs, and confirmed the bug it fixes is real. Two small robustness notes below; neither is a blocker.

The bug is real

ServerListItem.ServerLoadPercentage has always contained an in-place cache-patching path guarded by LoadIndex != -1, but LoadIndex was never assigned anywhere in the tree — on master, grep -rn LoadIndex src/ tests/ returns only the declaration, the -1 initializer, and the two reads in that setter. So the write path was dead code: once Serialize() had populated the cache, every CurrentConnectionsChanged updated only the private field, and clients kept receiving the load percentage frozen at whatever it was when the packet was first built — until an add/remove happened to invalidate the cache. This PR wires it up.

Offset arithmetic — verified

Checked against src/Network/Packets/ConnectServer/ConnectServerPackets.cs:

block start LoadPercentage within block expected PR expression
ServerListResponseOld 6 + i*2 +1 2i + 7 GetRequiredSize(i) + 1 = (2i+6)+1
ServerListResponse 7 + i*4 +2 4i + 9 GetRequiredSize(i) + 2 = (4i+7)+2

Both branches land on the right byte. LoadIndex is recomputed for every item on each rebuild, and InvalidateCache() nulls Cache before the item set can change, so there is no window where a stale index is applied to a live cache under normal execution. Removed items keep a stale LoadIndex, but they are unreachable — CurrentConnectionsChanged resolves items through ServerList.GetItem, and RegisterGameServer always constructs a fresh ServerListItem.

Also good: the missing trailing newline on the file is fixed.

Suggestion: bounds-check the write in the setter

ServerListItem.ServerLoadPercentage reads _owner.Cache and LoadIndex with no lock and no barrier, while Serialize() writes both. On a weak memory model (ARM64) a thread can observe the new Cache reference paired with a stale LoadIndex. If servers unregistered in between, the new packet is shorter than the old one and the stale index points past its end — e.g. 3 servers puts item C at index 17 in a 19-byte packet; unregister two; the rebuild is 11 bytes with C at index 9; a concurrent update writing cache[17] throws IndexOutOfRangeException out of CurrentConnectionsChanged. It's narrow, but one line in ServerListItem.cs makes it impossible (that file isn't in the diff, so I can't attach this inline):

if (cache != null && this.LoadIndex >= 0 && this.LoadIndex < cache.Length)

Follow-up thoughts (not for this PR)

  • Serialize() mutates Cache and now LoadIndex while holding only a read lock, so concurrent callers legitimately race on those writes. It's benign today — both threads compute identical values, and callers CopyTo the array out immediately — and it predates this change, but the PR does widen the set of state mutated under the read lock. An EnterUpgradeableReadLock for the rebuild path might be worth a separate look.
  • There is no test coverage for ServerList at all, and this is exactly the kind of index arithmetic that rots silently. A test asserting that the cached packet's ServerLoadInfo[i].LoadPercentage tracks CurrentConnections for both season 0 and season 6 would lock it down — though ServerList/ServerListItem are internal with no InternalsVisibleTo, so it isn't free.

Generated by Claude Code

Comment thread src/ConnectServer/ServerList.cs Outdated
var serverBlock = response[i];
serverBlock.ServerId = (byte)server.ServerId;
serverBlock.LoadPercentage = server.ServerLoadPercentage;
server.LoadIndex = ServerListResponseOld.GetRequiredSize(i) + 1;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Index verified correct: the indexer is 6 + i * ServerLoadInfo.Length and LoadPercentage sits at +1 within the block, so GetRequiredSize(i) + 1 == 2i + 7.

Worth a short comment though — GetRequiredSize(i) reads as "size of a packet holding i servers", and it only happens to equal "offset of server block i" because both are header + i * ServerLoadInfo.Length. Both sides are generated from ConnectServerPackets.xml; if that definition ever grows a trailing field, GetRequiredSize shifts while the indexer base does not, and this would silently start patching the wrong byte with no test to catch it.

Suggested change
server.LoadIndex = ServerListResponseOld.GetRequiredSize(i) + 1;
// GetRequiredSize(i) is also the offset of server block i; +1 is the LoadPercentage field within the block.
server.LoadIndex = ServerListResponseOld.GetRequiredSize(i) + 1;

Generated by Claude Code

Comment thread src/ConnectServer/ServerList.cs Outdated
var serverBlock = response[i];
serverBlock.ServerId = server.ServerId;
serverBlock.LoadPercentage = server.ServerLoadPercentage;
server.LoadIndex = ServerListResponse.GetRequiredSize(i) + 2;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same here, and also verified correct: indexer is 7 + i * ServerLoadInfo.Length with Length == 4, and LoadPercentage is at +2 (the ServerId is a ushort in this format), so GetRequiredSize(i) + 2 == 4i + 9.

Suggested change
server.LoadIndex = ServerListResponse.GetRequiredSize(i) + 2;
// GetRequiredSize(i) is also the offset of server block i; +2 is the LoadPercentage field within the block.
server.LoadIndex = ServerListResponse.GetRequiredSize(i) + 2;

Generated by Claude Code

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.

2 participants