Skip to content

Make player and island metadata maps thread-safe - #3082

Merged
tastybento merged 1 commit into
developfrom
fix/metadata-thread-safety
Sep 19, 2026
Merged

tastybento merged 1 commit into
developfrom
fix/metadata-thread-safety

Conversation

@tastybento

Copy link
Copy Markdown
Member

Problem

A server admin reported console spam on every PlayerMoveEvent with BentoBox 3.22.3 and Border 4.8.5 (https://mclo.gs/h623Vin):

java.util.NoSuchElementException
	at java.util.HashMap$HashIterator.nextNode(HashMap.java:1608)
	at java.util.HashMap$KeyIterator.next(HashMap.java:1629)
	at world.bentobox.bentobox.database.objects.Players.isImmutable(Players.java:272)
	at world.bentobox.bentobox.database.objects.Players.getMetaData(Players.java:259)
	at world.bentobox.bentobox.api.user.User.getMetaData(User.java:1226)
	at world.bentobox.bentobox.api.metadata.MetaDataAble.getMetaData(MetaDataAble.java:31)
	at world.bentobox.border.listeners.PlayerListener.isOn(PlayerListener.java:229)

Players.getMetaData() called isImmutable(map) on every read, and that helper wrote to the map each time (it re-put the first existing key to see whether an UnsupportedOperationException was thrown). The map was a plain HashMap with no synchronization, so every metadata read anywhere in the ecosystem was a concurrent-unsafe write. If any other thread touched the map at the same time, the HashMap could end up with size > 0 but an empty bucket table. From then on !map.isEmpty() was true but keySet().iterator().next() found nothing and threw, on every read, until restart.

Fix

  • Players and Island copy metadata into a ConcurrentHashMap once, on first access or on setMetaData, and never mutate on read. The instanceof ConcurrentMap check replaces the write-probe, and also covers immutable maps coming from deserialization.
  • setMetaData copies the caller's map instead of adopting it, so immutable maps are accepted (previously Players.setMetaData threw IllegalArgumentException) and the caller's map is never mutated.
  • The Island copy constructor uses the same helper.
  • MetaDataAble.putMetaData(key, null) now removes the key, because a concurrent map cannot hold null values. Null keys/values are dropped when copying.

Tests

  • New PlayersTest covering lazy init, immutable-map copy, caller-map isolation, null handling, and a multi-threaded hammer test that exercised the old corruption path.
  • IslandTest gains equivalent immutable-map, caller-isolation and copy-constructor tests.
  • Full suite: 3518 tests, 0 failures.

🤖 Generated with Claude Code

Players.getMetaData() tested whether the backing map was immutable by
writing to it on every read. The map was a plain HashMap, so any
concurrent access from another thread could corrupt it, after which
keySet().iterator().next() threw NoSuchElementException on every read.
Addons like Border read player metadata on every PlayerMoveEvent, so a
corrupted map spams the console until restart.

Players and Island now copy metadata into a ConcurrentHashMap once, on
first access or on setMetaData, and never mutate on read. Immutable
maps from deserialization are handled by the same copy. The Island
copy constructor uses the same helper. putMetaData(key, null) now
removes the key because a concurrent map cannot hold null values.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@tastybento
tastybento merged commit 19a1174 into develop Sep 19, 2026
0 of 2 checks passed
@tastybento
tastybento deleted the fix/metadata-thread-safety branch September 19, 2026 18:46
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