Conversation
sven-n
left a comment
There was a problem hiding this comment.
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()mutatesCacheand nowLoadIndexwhile holding only a read lock, so concurrent callers legitimately race on those writes. It's benign today — both threads compute identical values, and callersCopyTothe array out immediately — and it predates this change, but the PR does widen the set of state mutated under the read lock. AnEnterUpgradeableReadLockfor the rebuild path might be worth a separate look.- There is no test coverage for
ServerListat all, and this is exactly the kind of index arithmetic that rots silently. A test asserting that the cached packet'sServerLoadInfo[i].LoadPercentagetracksCurrentConnectionsfor both season 0 and season 6 would lock it down — thoughServerList/ServerListItemareinternalwith noInternalsVisibleTo, so it isn't free.
Generated by Claude Code
| var serverBlock = response[i]; | ||
| serverBlock.ServerId = (byte)server.ServerId; | ||
| serverBlock.LoadPercentage = server.ServerLoadPercentage; | ||
| server.LoadIndex = ServerListResponseOld.GetRequiredSize(i) + 1; |
There was a problem hiding this comment.
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.
| 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
| var serverBlock = response[i]; | ||
| serverBlock.ServerId = server.ServerId; | ||
| serverBlock.LoadPercentage = server.ServerLoadPercentage; | ||
| server.LoadIndex = ServerListResponse.GetRequiredSize(i) + 2; |
There was a problem hiding this comment.
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.
| 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
Restore LoadIndex for both server-list packet formats so player-count changes update the cached load percentage in place instead of leaving it stale.