SaturnPlayer.get(name, uuid) stored non-Saturn players as null, which looks the same as "not fetched yet". Every call from the render mixins queued them again. With N non-Saturn players visible, that meant about 60·N lookups per second against the server, and an unbounded queue.
Negative results are remembered: a player the server says isn't on Saturn is checked again only after 60 s. A failed lookup is retried after 5 s.
No duplicates: each UUID is queued at most once (a PENDING set).
Nothing while disconnected: no lookups happen until the client is connected (ServiceClient.isConnected()).
Simpler worker: a single-thread daemon executor replaces the hand-rolled worker thread, whose RUNNING flag had a start/stop race.
Clear getPlayer contract: it returns null only when the server says "not a Saturn player" and throws on failure (not connected, or the request failed). Failures are retried rather than cached as "not Saturn".
Faster announcements:SaturnPlayer.set (used by the player notification and by auth) clears any pending re-check, so a newly announced player shows up straight away.
Thread safety (saturnclient#6)
SaturnPlayer.PLAYERS is now a ConcurrentHashMap, so getExternalUUIDAsString() can no longer throw a ConcurrentModificationException.
Cloaks.availableCloaks and Hats.availableHats are now CopyOnWriteArrayLists. They're written by the auth thread and purchase callbacks, and iterated by the UI.
ServiceClient.session and uuid, and SaturnPlayer.cloak and hat, are volatile.
Emote notifications are passed to the client thread with executeOnThread. playEmote is currently a no-op, but this keeps it safe when it's re-enabled.
Request timeouts (saturnclient#5) and reconnecting (saturnclient#4) are left for their own PRs.
Testing
./gradlew compileJava passes for all 8 versions.
Ran the 1.21.11 client with a real account against the production server. Auth completed, and your own player resolved (the Saturn icon showed in the tab list, see the companion saturnclient PR).
Not covered: the multi-player flood scenario. The test was singleplayer, so no other players were looked up.
Merge this before the saturnclient PR that bumps the submodule pointer.
Fixes saturnclientmc/saturnclient#2 and saturnclientmc/saturnclient#6.
## Summary
### Per-frame `get_player` flood (saturnclient#2)
`SaturnPlayer.get(name, uuid)` stored non-Saturn players as `null`, which looks the same as "not fetched yet". Every call from the render mixins queued them again. With N non-Saturn players visible, that meant about 60·N lookups per second against the server, and an unbounded queue.
- **Negative results are remembered:** a player the server says isn't on Saturn is checked again only after **60 s**. A failed lookup is retried after **5 s**.
- **No duplicates:** each UUID is queued at most once (a `PENDING` set).
- **Nothing while disconnected:** no lookups happen until the client is connected (`ServiceClient.isConnected()`).
- **Simpler worker:** a single-thread daemon executor replaces the hand-rolled worker thread, whose `RUNNING` flag had a start/stop race.
- **Clear `getPlayer` contract:** it returns `null` only when the server says "not a Saturn player" and **throws** on failure (not connected, or the request failed). Failures are retried rather than cached as "not Saturn".
- **Faster announcements:** `SaturnPlayer.set` (used by the `player` notification and by auth) clears any pending re-check, so a newly announced player shows up straight away.
### Thread safety (saturnclient#6)
- `SaturnPlayer.PLAYERS` is now a `ConcurrentHashMap`, so `getExternalUUIDAsString()` can no longer throw a `ConcurrentModificationException`.
- `Cloaks.availableCloaks` and `Hats.availableHats` are now `CopyOnWriteArrayList`s. They're written by the auth thread and purchase callbacks, and iterated by the UI.
- `ServiceClient.session` and `uuid`, and `SaturnPlayer.cloak` and `hat`, are `volatile`.
- Emote notifications are passed to the client thread with `executeOnThread`. `playEmote` is currently a no-op, but this keeps it safe when it's re-enabled.
Request timeouts (saturnclient#5) and reconnecting (saturnclient#4) are left for their own PRs.
## Testing
- `./gradlew compileJava` passes for all 8 versions.
- Ran the 1.21.11 client with a real account against the production server. Auth completed, and your own player resolved (the Saturn icon showed in the tab list, see the companion saturnclient PR).
- **Not covered:** the multi-player flood scenario. The test was singleplayer, so no other players were looked up.
Merge this before the saturnclient PR that bumps the submodule pointer.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
SaturnPlayer stored non-Saturn players as null, which looked the same as
"not fetched yet", so every render re-queued them and flooded the server
with get_player requests. Remember negative lookups and re-check them
after 60 s, retry failed lookups after 5 s, queue each player at most
once, skip lookups while disconnected, and replace the hand-rolled worker
thread with a single-thread executor.
getPlayer now returns null only when the server says the player isn't on
Saturn and throws on failure, so failures are retried instead of cached.
Make state shared between network callbacks and the render thread safe:
ConcurrentHashMap for players, CopyOnWriteArrayList for owned cosmetics,
volatile session/uuid and cosmetic fields, and run emote notifications
on the client thread.
Refs saturnclientmc/saturnclient#2, saturnclientmc/saturnclient#6
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Fixes saturnclientmc/saturnclient#2 and saturnclientmc/saturnclient#6.
Summary
Per-frame
get_playerflood (saturnclient#2)SaturnPlayer.get(name, uuid)stored non-Saturn players asnull, which looks the same as "not fetched yet". Every call from the render mixins queued them again. With N non-Saturn players visible, that meant about 60·N lookups per second against the server, and an unbounded queue.PENDINGset).ServiceClient.isConnected()).RUNNINGflag had a start/stop race.getPlayercontract: it returnsnullonly when the server says "not a Saturn player" and throws on failure (not connected, or the request failed). Failures are retried rather than cached as "not Saturn".SaturnPlayer.set(used by theplayernotification and by auth) clears any pending re-check, so a newly announced player shows up straight away.Thread safety (saturnclient#6)
SaturnPlayer.PLAYERSis now aConcurrentHashMap, sogetExternalUUIDAsString()can no longer throw aConcurrentModificationException.Cloaks.availableCloaksandHats.availableHatsare nowCopyOnWriteArrayLists. They're written by the auth thread and purchase callbacks, and iterated by the UI.ServiceClient.sessionanduuid, andSaturnPlayer.cloakandhat, arevolatile.executeOnThread.playEmoteis currently a no-op, but this keeps it safe when it's re-enabled.Request timeouts (saturnclient#5) and reconnecting (saturnclient#4) are left for their own PRs.
Testing
./gradlew compileJavapasses for all 8 versions.Merge this before the saturnclient PR that bumps the submodule pointer.
🤖 Generated with Claude Code