diff --git a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java index 336b4437b..9ec4d0a14 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java @@ -41,6 +41,7 @@ import com.velocitypowered.proxy.server.ServerMap; import com.velocitypowered.proxy.util.AddressUtil; import com.velocitypowered.proxy.util.EncryptionUtils; import com.velocitypowered.proxy.util.VelocityChannelRegistrar; +import com.velocitypowered.proxy.util.bossbar.BossBarManager; import com.velocitypowered.proxy.util.bossbar.VelocityBossBar; import com.velocitypowered.proxy.util.ratelimit.Ratelimiter; import com.velocitypowered.proxy.util.ratelimit.Ratelimiters; @@ -111,6 +112,7 @@ public class VelocityServer implements ProxyServer { private final AtomicBoolean shutdownInProgress = new AtomicBoolean(false); private boolean shutdown = false; private final VelocityPluginManager pluginManager; + private final BossBarManager bossBarManager; private final Map connectionsByUuid = new ConcurrentHashMap<>(); private final Map connectionsByName = new ConcurrentHashMap<>(); @@ -129,6 +131,7 @@ public class VelocityServer implements ProxyServer { cm = new ConnectionManager(this); servers = new ServerMap(this); this.options = options; + this.bossBarManager = new BossBarManager(); } public KeyPair getServerKeyPair() { @@ -501,9 +504,15 @@ public class VelocityServer implements ProxyServer { return true; } + /** + * Unregisters the given player from the proxy. + * + * @param connection the connection to unregister + */ public void unregisterConnection(ConnectedPlayer connection) { connectionsByName.remove(connection.getUsername().toLowerCase(Locale.US), connection); connectionsByUuid.remove(connection.getUniqueId(), connection); + bossBarManager.onDisconnect(connection); } @Override @@ -615,6 +624,10 @@ public class VelocityServer implements ProxyServer { getAllPlayers().iterator()); } + public BossBarManager getBossBarManager() { + return bossBarManager; + } + public static Gson getPingGsonInstance(ProtocolVersion version) { return version.compareTo(ProtocolVersion.MINECRAFT_1_16) >= 0 ? POST_1_16_PING_SERIALIZER : PRE_1_16_PING_SERIALIZER; diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java index 5da230765..c505b489d 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java @@ -64,6 +64,7 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ThreadLocalRandom; +import net.kyori.adventure.bossbar.BossBar; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.TranslatableComponent; @@ -313,6 +314,16 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { connection.write(TitlePacket.resetForProtocolVersion(this.getProtocolVersion())); } + @Override + public void hideBossBar(@NonNull BossBar bar) { + this.server.getBossBarManager().removeBossBar(this, bar); + } + + @Override + public void showBossBar(@NonNull BossBar bar) { + this.server.getBossBarManager().addBossBar(this, bar); + } + @Override public ConnectionRequestBuilder createConnectionRequest(RegisteredServer server) { return new ConnectionRequestBuilderImpl(server); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/BossBar.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/BossBar.java index e1d8d9453..63ef23ed8 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/BossBar.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/BossBar.java @@ -173,4 +173,11 @@ public class BossBar implements MinecraftPacket { public boolean handle(MinecraftSessionHandler handler) { return handler.handle(this); } + + public static BossBar createRemovePacket(UUID id) { + BossBar packet = new BossBar(); + packet.setUuid(id); + packet.setAction(REMOVE); + return packet; + } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/util/bossbar/BossBarManager.java b/proxy/src/main/java/com/velocitypowered/proxy/util/bossbar/BossBarManager.java new file mode 100644 index 000000000..ffc2e35f2 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/util/bossbar/BossBarManager.java @@ -0,0 +1,261 @@ +package com.velocitypowered.proxy.util.bossbar; + +import com.velocitypowered.api.network.ProtocolVersion; +import com.velocitypowered.proxy.connection.client.ConnectedPlayer; +import com.velocitypowered.proxy.protocol.ProtocolUtils; +import com.velocitypowered.proxy.util.collect.Enum2IntMap; +import com.velocitypowered.proxy.util.collect.concurrent.SyncMap; +import com.velocitypowered.proxy.util.concurrent.Once; +import java.util.Set; +import java.util.UUID; +import java.util.WeakHashMap; +import net.kyori.adventure.bossbar.BossBar; +import net.kyori.adventure.bossbar.BossBar.Color; +import net.kyori.adventure.bossbar.BossBar.Flag; +import net.kyori.adventure.bossbar.BossBar.Overlay; +import net.kyori.adventure.text.Component; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Manages all boss bars known to the proxy. + */ +public class BossBarManager implements BossBar.Listener { + private static final Enum2IntMap COLORS_TO_PROTOCOL = + new Enum2IntMap.Builder<>(Color.class) + .put(Color.PINK, 0) + .put(Color.BLUE, 1) + .put(Color.RED, 2) + .put(Color.GREEN, 3) + .put(Color.YELLOW, 4) + .put(Color.PURPLE, 5) + .put(Color.WHITE, 6) + .build(); + private static final Enum2IntMap OVERLAY_TO_PROTOCOL = + new Enum2IntMap.Builder<>(Overlay.class) + .put(Overlay.PROGRESS, 0) + .put(Overlay.NOTCHED_6, 1) + .put(Overlay.NOTCHED_10, 2) + .put(Overlay.NOTCHED_12, 3) + .put(Overlay.NOTCHED_20, 4) + .build(); + private static final Enum2IntMap FLAG_BITS_TO_PROTOCOL = + new Enum2IntMap.Builder<>(Flag.class) + .put(Flag.DARKEN_SCREEN, 0x1) + .put(Flag.PLAY_BOSS_MUSIC, 0x2) + .put(Flag.CREATE_WORLD_FOG, 0x4) + .build(); + private final SyncMap bars; + + public BossBarManager() { + this.bars = SyncMap.of(WeakHashMap::new, 16); + } + + private @Nullable BossBarHolder getHandler(BossBar bar) { + return this.bars.get(bar); + } + + private BossBarHolder getOrCreateHandler(BossBar bar) { + BossBarHolder holder = this.bars.computeIfAbsent(bar, (k) -> new BossBarHolder(bar)); + holder.register(); + return holder; + } + + /** + * Called when a player disconnects from the proxy. Removes the player from any boss bar + * subscriptions. + * + * @param player the player to remove + */ + public void onDisconnect(ConnectedPlayer player) { + for (BossBarHolder holder : bars.values()) { + holder.subscribers.remove(player); + } + } + + /** + * Adds the specified player to the boss bar's viewers and spawns the boss bar, registering the + * boss bar if needed. + * @param player the intended viewer + * @param bar the boss bar to show + */ + public void addBossBar(ConnectedPlayer player, BossBar bar) { + BossBarHolder holder = this.getOrCreateHandler(bar); + if (holder.subscribers.add(player)) { + player.getConnection().write(holder.createAddPacket(player.getProtocolVersion())); + } + } + + /** + * Removes the specified player to the boss bar's viewers and despawns the boss bar. + * @param player the intended viewer + * @param bar the boss bar to hide + */ + public void removeBossBar(ConnectedPlayer player, BossBar bar) { + BossBarHolder holder = this.getHandler(bar); + if (holder != null && holder.subscribers.remove(player)) { + player.getConnection().write(holder.createRemovePacket()); + } + } + + @Override + public void bossBarNameChanged(@NonNull BossBar bar, @NonNull Component oldName, + @NonNull Component newName) { + BossBarHolder holder = this.getHandler(bar); + if (holder == null) { + return; + } + com.velocitypowered.proxy.protocol.packet.BossBar pre116Packet = holder.createTitleUpdate( + newName, ProtocolVersion.MINECRAFT_1_15_2); + com.velocitypowered.proxy.protocol.packet.BossBar rgbPacket = holder.createTitleUpdate( + newName, ProtocolVersion.MINECRAFT_1_16); + for (ConnectedPlayer player : holder.subscribers) { + if (player.getProtocolVersion().compareTo(ProtocolVersion.MINECRAFT_1_16) >= 0) { + player.getConnection().write(rgbPacket); + } else { + player.getConnection().write(pre116Packet); + } + } + } + + @Override + public void bossBarPercentChanged(@NonNull BossBar bar, float oldPercent, float newPercent) { + BossBarHolder holder = this.getHandler(bar); + if (holder == null) { + return; + } + com.velocitypowered.proxy.protocol.packet.BossBar packet = holder + .createPercentUpdate(newPercent); + for (ConnectedPlayer player : holder.subscribers) { + player.getConnection().write(packet); + } + } + + @Override + public void bossBarColorChanged(@NonNull BossBar bar, @NonNull Color oldColor, + @NonNull Color newColor) { + BossBarHolder holder = this.getHandler(bar); + if (holder == null) { + return; + } + com.velocitypowered.proxy.protocol.packet.BossBar packet = holder.createColorUpdate(newColor); + for (ConnectedPlayer player : holder.subscribers) { + player.getConnection().write(packet); + } + } + + @Override + public void bossBarOverlayChanged(@NonNull BossBar bar, @NonNull Overlay oldOverlay, + @NonNull Overlay newOverlay) { + BossBarHolder holder = this.getHandler(bar); + if (holder == null) { + return; + } + com.velocitypowered.proxy.protocol.packet.BossBar packet = holder + .createOverlayUpdate(newOverlay); + for (ConnectedPlayer player : holder.subscribers) { + player.getConnection().write(packet); + } + } + + @Override + public void bossBarFlagsChanged(@NonNull BossBar bar, @NonNull Set oldFlags, + @NonNull Set newFlags) { + BossBarHolder holder = this.getHandler(bar); + if (holder == null) { + return; + } + com.velocitypowered.proxy.protocol.packet.BossBar packet = holder.createFlagsUpdate(newFlags); + for (ConnectedPlayer player : holder.subscribers) { + player.getConnection().write(packet); + } + } + + private class BossBarHolder { + private final UUID id = UUID.randomUUID(); + private final BossBar bar; + private final Set subscribers = SyncMap.setOf(WeakHashMap::new, 16); + private final Once registrationOnce = new Once(); + + BossBarHolder(BossBar bar) { + this.bar = bar; + } + + void register() { + registrationOnce.run(() -> this.bar.addListener(BossBarManager.this)); + } + + com.velocitypowered.proxy.protocol.packet.BossBar createRemovePacket() { + return com.velocitypowered.proxy.protocol.packet.BossBar.createRemovePacket(this.id); + } + + com.velocitypowered.proxy.protocol.packet.BossBar createAddPacket(ProtocolVersion version) { + com.velocitypowered.proxy.protocol.packet.BossBar packet = new com.velocitypowered + .proxy.protocol.packet.BossBar(); + packet.setUuid(this.id); + packet.setAction(com.velocitypowered.proxy.protocol.packet.BossBar.ADD); + packet.setName(ProtocolUtils.getJsonChatSerializer(version).serialize(bar.name())); + packet.setColor(COLORS_TO_PROTOCOL.get(bar.color())); + packet.setOverlay(bar.overlay().ordinal()); + packet.setPercent(bar.percent()); + packet.setFlags(serializeFlags(bar.flags())); + return packet; + } + + com.velocitypowered.proxy.protocol.packet.BossBar createPercentUpdate(float newPercent) { + com.velocitypowered.proxy.protocol.packet.BossBar packet = new com.velocitypowered + .proxy.protocol.packet.BossBar(); + packet.setUuid(this.id); + packet.setAction(com.velocitypowered.proxy.protocol.packet.BossBar.UPDATE_PERCENT); + packet.setPercent(newPercent); + return packet; + } + + com.velocitypowered.proxy.protocol.packet.BossBar createColorUpdate(Color color) { + com.velocitypowered.proxy.protocol.packet.BossBar packet = new com.velocitypowered + .proxy.protocol.packet.BossBar(); + packet.setUuid(this.id); + packet.setAction(com.velocitypowered.proxy.protocol.packet.BossBar.UPDATE_NAME); + packet.setColor(COLORS_TO_PROTOCOL.get(color)); + packet.setFlags(serializeFlags(bar.flags())); + return packet; + } + + com.velocitypowered.proxy.protocol.packet.BossBar createTitleUpdate(Component name, + ProtocolVersion version) { + com.velocitypowered.proxy.protocol.packet.BossBar packet = new com.velocitypowered + .proxy.protocol.packet.BossBar(); + packet.setUuid(this.id); + packet.setAction(com.velocitypowered.proxy.protocol.packet.BossBar.UPDATE_NAME); + packet.setName(ProtocolUtils.getJsonChatSerializer(version).serialize(name)); + return packet; + } + + com.velocitypowered.proxy.protocol.packet.BossBar createFlagsUpdate(Set newFlags) { + com.velocitypowered.proxy.protocol.packet.BossBar packet = new com.velocitypowered + .proxy.protocol.packet.BossBar(); + packet.setUuid(this.id); + packet.setAction(com.velocitypowered.proxy.protocol.packet.BossBar.UPDATE_PROPERTIES); + packet.setColor(COLORS_TO_PROTOCOL.get(this.bar.color())); + packet.setFlags(this.serializeFlags(newFlags)); + return packet; + } + + com.velocitypowered.proxy.protocol.packet.BossBar createOverlayUpdate(Overlay overlay) { + com.velocitypowered.proxy.protocol.packet.BossBar packet = new com.velocitypowered + .proxy.protocol.packet.BossBar(); + packet.setUuid(this.id); + packet.setAction(com.velocitypowered.proxy.protocol.packet.BossBar.UPDATE_PROPERTIES); + packet.setOverlay(OVERLAY_TO_PROTOCOL.get(overlay)); + return packet; + } + + private byte serializeFlags(Set flags) { + byte val = 0x0; + for (Flag flag : flags) { + val |= FLAG_BITS_TO_PROTOCOL.get(flag); + } + return val; + } + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/util/collect/Enum2IntMap.java b/proxy/src/main/java/com/velocitypowered/proxy/util/collect/Enum2IntMap.java new file mode 100644 index 000000000..714a0c8c1 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/util/collect/Enum2IntMap.java @@ -0,0 +1,56 @@ +package com.velocitypowered.proxy.util.collect; + +import java.util.EnumSet; + +public class Enum2IntMap> { + private final int[] mappings; + + private Enum2IntMap(int[] mappings) { + this.mappings = mappings; + } + + public int get(E key) { + return mappings[key.ordinal()]; + } + + public static class Builder> { + private final int[] mappings; + private final EnumSet populated; + private int defaultValue = -1; + + public Builder(Class klazz) { + this.mappings = new int[klazz.getEnumConstants().length]; + this.populated = EnumSet.noneOf(klazz); + } + + public Builder put(E key, int value) { + this.mappings[key.ordinal()] = value; + this.populated.add(key); + return this; + } + + public Builder remove(E key, int value) { + this.populated.remove(key); + return this; + } + + public Builder defaultValue(int defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public int get(E key) { + if (this.populated.contains(key)) { + return this.mappings[key.ordinal()]; + } + return this.defaultValue; + } + + public Enum2IntMap build() { + for (E unpopulated : EnumSet.complementOf(this.populated)) { + this.mappings[unpopulated.ordinal()] = this.defaultValue; + } + return new Enum2IntMap<>(this.mappings.clone()); + } + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/util/collect/concurrent/SyncMap.java b/proxy/src/main/java/com/velocitypowered/proxy/util/collect/concurrent/SyncMap.java new file mode 100644 index 000000000..18068a51b --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/util/collect/concurrent/SyncMap.java @@ -0,0 +1,233 @@ +package com.velocitypowered.proxy.util.collect.concurrent; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentMap; +import java.util.function.Function; + +/** + * A concurrent map, internally backed by a non-thread-safe map but carefully managed in a matter + * such that any changes are thread-safe. Internally, the map is split into a {@code read} and a + * {@code dirty} map. The read map only satisfies read requests, while the dirty map satisfies all + * other requests. + * + *

The map is optimized for two common use cases:

+ * + *
    + *
  • The entry for the given map is only written once but read many + * times, as in a cache that only grows.
  • + * + *
  • Heavy concurrent modification of entries for a disjoint set of + * keys.
  • + *
+ * + *

In both cases, this map significantly reduces lock contention compared + * to a traditional map paired with a read and write lock, along with maps + * with an exclusive lock (such as with {@link Collections#synchronizedMap(Map)}.

+ * + *

Null values are not accepted. Null keys are supported if the backing collection + * supports them.

+ * + *

Based on: https://golang.org/src/sync/map.go

+ * + * @param the key type + * @param the value type + */ +public interface SyncMap extends ConcurrentMap { + + /** + * Creates a sync map, backed by a {@link HashMap}. + * + * @param the key type + * @param the value type + * @return a sync map + */ + static SyncMap hashmap() { + return of(HashMap>::new, 16); + } + + /** + * Creates a sync map, backed by a {@link HashMap} with a provided initial capacity. + * + * @param initialCapacity the initial capacity of the hash map + * @param the key type + * @param the value type + * @return a sync map + */ + static SyncMap hashmap(final int initialCapacity) { + return of(HashMap>::new, initialCapacity); + } + + /** + * Creates a mutable set view of a sync map, backed by a {@link HashMap}. + * + * @param the key type + * @return a mutable set view of a sync map + */ + static Set hashset() { + return setOf(HashMap>::new, 16); + } + + /** + * Creates a mutable set view of a sync map, backed by a {@link HashMap} with a provided initial + * capacity. + * + * @param initialCapacity the initial capacity of the hash map + * @param the key type + * @return a mutable set view of a sync map + */ + static Set hashset(final int initialCapacity) { + return setOf(HashMap>::new, initialCapacity); + } + + /** + * Creates a sync map, backed by the provided {@link Map} implementation with a provided initial + * capacity. + * + * @param function the map creation function + * @param initialCapacity the map initial capacity + * @param the key type + * @param the value type + * @return a sync map + */ + static SyncMap of(final Function>> function, + final int initialCapacity) { + return new SyncMapImpl<>(function, initialCapacity); + } + + /** + * Creates a mutable set view of a sync map, backed by the provided {@link Map} implementation + * with a provided initial capacity. + * + * @param function the map creation function + * @param initialCapacity the map initial capacity + * @param they key type + * @return a mutable set view of a sync map + */ + static Set setOf(final Function>> function, + final int initialCapacity) { + return Collections.newSetFromMap(new SyncMapImpl<>(function, initialCapacity)); + } + + /** + * {@inheritDoc} + *

Iterations over a sync map are thread-safe, and the keys iterated over will not change for a + * single iteration attempt, however they may not necessarily reflect the state of the map at the + * time the iterator was created.

+ * + *

Performance note: if entries have been appended to the map, iterating over the entry set + * will automatically promote them to the read map.

+ */ + @Override + Set> entrySet(); + + /** + * {@inheritDoc} + *

This implementation is {@code O(n)} in nature due to the need to check for any expunged + * entries. Likewise, as with other concurrent collections, the value obtained by this method may + * be out of date by the time this method returns.

+ * + * @return the size of all the mappings contained in this map + */ + @Override + int size(); + + /** + * {@inheritDoc} + *

+ * This method clears the map by resetting the internal state to a state similar to as if a new + * map had been created. If there are concurrent iterations in progress, they will reflect the + * state of the map prior to being cleared. + *

+ */ + @Override + void clear(); + + /** + * The expunging value the backing map wraps for its values. + * + * @param the backing value type + */ + interface ExpungingValue { + + /** + * Returns the backing element, which may be {@code null} if it has been expunged. + * + * @return the backing element if it has not been expunged + */ + V get(); + + /** + * Attempts to place the entry in the map if it is absent. + * + * @param value the value to place in the map + * @return a {@link Entry} with false key and null value if the value was expunged, a true key + * and the previous value in the map otherwise + */ + Entry putIfAbsent(V value); + + /** + * Returns {@code true} if this element has been expunged. + * + * @return whether or not this element has been expunged + */ + boolean isExpunged(); + + /** + * Returns {@code true} if this element has a value (it is neither expunged nor {@code null}. + * + * @return whether or not this element has a value + */ + boolean exists(); + + /** + * Sets the backing element, which can be set to {@code null}. + * + * @param element the backing element + * @return the previous element stored, or {@code null} if the entry had been expunged + */ + V set(final V element); + + /** + * Tries to replace the backing element, which can be set to {@code null}. This operation has no + * effect if the entry was expunged. + * + * @param expected the element to check for + * @param newElement the new element to be stored + * @return {@code true} if successful, {@code false} otherwise + */ + boolean replace(final Object expected, final V newElement); + + /** + * Clears the entry stored in this value. Has no effect if {@code null} is stored in the map or + * the entry was expunged. + */ + V clear(); + + /** + * Tries to set the backing element. If the entry is expunged, this operation will fail. + * + * @param element the new element + * @return {@code true} if the entry was not expunged, {@code false} otherwise + */ + boolean trySet(final V element); + + /** + * Tries to mark the item as expunged, if its value is {@code null}. + * + * @return whether or not the item has been expunged + */ + boolean tryMarkExpunged(); + + /** + * Tries to set the backing element, which can be set to {@code null}, if the entry was + * expunged. + * + * @param element the new element + * @return {@code true} if the entry was unexpunged, {@code false} otherwise + */ + boolean tryUnexpungeAndSet(final V element); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/util/collect/concurrent/SyncMapImpl.java b/proxy/src/main/java/com/velocitypowered/proxy/util/collect/concurrent/SyncMapImpl.java new file mode 100644 index 000000000..30b00bcea --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/util/collect/concurrent/SyncMapImpl.java @@ -0,0 +1,562 @@ +package com.velocitypowered.proxy.util.collect.concurrent; + +import java.util.AbstractMap; +import java.util.AbstractSet; +import java.util.Iterator; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; +import java.util.function.Consumer; +import java.util.function.Function; + +/* package */ final class SyncMapImpl extends AbstractMap implements SyncMap { + + private final Object lock = new Object(); + private final Function>> function; + private volatile Map> read; + private volatile boolean readAmended; + private int readMisses; + private Map> dirty; + private EntrySet entrySet; + + /* package */ SyncMapImpl(final Function>> function, + final int initialCapacity) { + this.function = function; + this.read = function.apply(initialCapacity); + } + + @Override + public int size() { + if (this.readAmended) { + synchronized (this.lock) { + if (this.readAmended) { + return this.getSize(this.dirty); + } + } + } + return this.getSize(this.read); + } + + private int getSize(Map> map) { + int size = 0; + for (ExpungingValue value : map.values()) { + if (value.exists()) { + size++; + } + } + return size; + } + + private ExpungingValue getValue(final Object key) { + ExpungingValue entry = this.read.get(key); + if (entry == null && this.readAmended) { + entry = this.getValueReadMissed(key); + } + return entry; + } + + private ExpungingValue getValueReadMissed(final Object key) { + ExpungingValue entry = null; + synchronized (this.lock) { + if (this.readAmended && (entry = this.read.get(key)) == null && this.dirty != null) { + entry = this.dirty.get(key); + this.missLocked(); + } + } + return entry; + } + + @Override + public boolean containsKey(final Object key) { + ExpungingValue entry = this.getValue(key); + return entry != null && entry.exists(); + } + + @Override + public V get(final Object key) { + ExpungingValue entry = this.getValue(key); + if (entry == null) { + return null; + } + return entry.get(); + } + + @Override + public V put(final K key, final V value) { + Objects.requireNonNull(value, "value"); + + ExpungingValue entry = this.read.get(key); + V previous = entry != null ? entry.get() : null; + if (entry != null && entry.trySet(value)) { + return previous; + } + return this.putDirty(key, value, false); + } + + private V putDirty(final K key, final V value, boolean onlyIfExists) { + ExpungingValue entry; + V previous = null; + synchronized (this.lock) { + if (!onlyIfExists) { + entry = this.read.get(key); + if (entry != null && entry.tryUnexpungeAndSet(value)) { + // If we had an expunged entry, then this.dirty != null and we need to insert the entry there too. + this.dirty.put(key, entry); + return null; + } + } + + if (this.dirty != null && (entry = this.dirty.get(key)) != null) { + previous = entry.set(value); + } else if (!onlyIfExists) { + if (!this.readAmended) { + this.dirtyLocked(); + this.readAmended = true; + } + assert this.dirty != null; + this.dirty.put(key, new ExpungingValueImpl<>(value)); + previous = null; + } + } + return previous; + } + + @Override + public V remove(final Object key) { + ExpungingValue entry = this.read.get(key); + if (entry == null && this.readAmended) { + synchronized (this.lock) { + if (this.readAmended && (entry = this.read.get(key)) == null && this.dirty != null) { + entry = this.dirty.remove(key); + } + } + } + return entry != null ? entry.clear() : null; + } + + @Override + public boolean remove(final Object key, final Object value) { + Objects.requireNonNull(value, "value"); + + ExpungingValue entry = this.read.get(key); + boolean absent = entry == null; + if (absent && this.readAmended) { + synchronized (this.lock) { + if (this.readAmended && (absent = (entry = this.read.get(key)) == null) + && this.dirty != null) { + absent = (entry = this.dirty.get(key)) == null; + if (!absent && entry.replace(value, null)) { + this.dirty.remove(key); + return true; + } + } + } + } + if (!absent) { + entry.replace(value, null); + } + return false; + } + + @Override + public V putIfAbsent(K key, V value) { + Objects.requireNonNull(value, "value"); + + // Go in for a clean hit if we can. + ExpungingValue entry = this.read.get(key); + if (entry != null) { + Entry result = entry.putIfAbsent(value); + if (result.getKey() == Boolean.TRUE) { + return result.getValue(); + } + } + + synchronized (this.lock) { + entry = this.read.get(key); + if (entry != null && entry.tryUnexpungeAndSet(value)) { + this.dirty.put(key, entry); + return null; + } else if (this.dirty != null && (entry = this.dirty.get(key)) != null) { + Entry result = entry.putIfAbsent(value); + this.missLocked(); + + // The only time an entry would be expunged is if it were in the read map, and we've already checked for + // that earlier. + assert result.getKey() == Boolean.TRUE; + return result.getValue(); + } else { + if (!this.readAmended) { + this.dirtyLocked(); + this.readAmended = true; + } + assert this.dirty != null; + this.dirty.put(key, new ExpungingValueImpl<>(value)); + return null; + } + } + } + + @Override + public V replace(K key, V value) { + Objects.requireNonNull(value, "value"); + + ExpungingValue entry = this.read.get(key); + V previous = entry != null ? entry.get() : null; + if (entry != null && entry.trySet(value)) { + return previous; + } + return this.putDirty(key, value, true); + } + + @Override + public boolean replace(K key, V oldValue, V newValue) { + Objects.requireNonNull(oldValue, "oldValue"); + Objects.requireNonNull(newValue, "newValue"); + + // Try a clean hit + ExpungingValue entry = this.read.get(key); + if (entry != null && entry.replace(oldValue, newValue)) { + return true; + } + + // Failed, go to the slow path. This is considerably simpler than the others that need to consider expunging. + synchronized (this.lock) { + if (this.dirty != null) { + entry = this.dirty.get(key); + if (entry.replace(oldValue, newValue)) { + return true; + } + } + } + + return false; + } + + @Override + public void clear() { + synchronized (this.lock) { + this.read = this.function.apply(16); + this.dirty = null; + this.readMisses = 0; + this.readAmended = false; + } + } + + @Override + public Set> entrySet() { + if (this.entrySet != null) { + return this.entrySet; + } + return this.entrySet = new EntrySet(); + } + + private void promoteIfNeeded() { + if (this.readAmended) { + synchronized (this.lock) { + if (this.readAmended && this.dirty != null) { + this.promoteLocked(); + } + } + } + } + + private void promoteLocked() { + if (this.dirty != null) { + this.read = this.dirty; + } + this.dirty = null; + this.readMisses = 0; + this.readAmended = false; + } + + private void missLocked() { + this.readMisses++; + int length = this.dirty != null ? this.dirty.size() : 0; + if (this.readMisses > length) { + this.promoteLocked(); + } + } + + private void dirtyLocked() { + if (this.dirty == null) { + this.dirty = this.function.apply(this.read.size()); + for (final Entry> entry : this.read.entrySet()) { + if (!entry.getValue().tryMarkExpunged()) { + this.dirty.put(entry.getKey(), entry.getValue()); + } + } + } + } + + private static class ExpungingValueImpl implements SyncMap.ExpungingValue { + + /** + * A marker object used to indicate that the value in this map has been expunged. + */ + private static final Object EXPUNGED = new Object(); + + // The raw type is required here, which is sad, but type erasure has forced our hand in this + // regard. (Besides, using an Object type and casting to the desired value allows us to reuse + // this field to see if the value has been expunged.) + // + // Type-safety is ensured by ensuring the special EXPUNGED value is never returned and using + // generics on the set and constructor calls. + private static final AtomicReferenceFieldUpdater valueUpdater = + AtomicReferenceFieldUpdater.newUpdater(ExpungingValueImpl.class, Object.class, "value"); + + private volatile Object value; + + private ExpungingValueImpl(final V value) { + this.value = value; + } + + @Override + public V get() { + Object value = valueUpdater.get(this); + if (value == EXPUNGED) { + return null; + } + return (V) value; + } + + @Override + public Entry putIfAbsent(V value) { + for (; ; ) { + Object existingVal = valueUpdater.get(this); + if (existingVal == EXPUNGED) { + return new SimpleImmutableEntry<>(Boolean.FALSE, null); + } + + if (existingVal != null) { + return new SimpleImmutableEntry<>(Boolean.TRUE, (V) existingVal); + } + + if (valueUpdater.compareAndSet(this, null, value)) { + return new SimpleImmutableEntry<>(Boolean.TRUE, null); + } + } + } + + @Override + public boolean isExpunged() { + return valueUpdater.get(this) == EXPUNGED; + } + + @Override + public boolean exists() { + Object val = valueUpdater.get(this); + return val != null && val != EXPUNGED; + } + + @Override + public V set(final V value) { + Object oldValue = valueUpdater.getAndSet(this, value); + return oldValue == EXPUNGED ? null : (V) oldValue; + } + + @Override + public boolean trySet(final V newValue) { + for (; ; ) { + Object foundValue = valueUpdater.get(this); + if (foundValue == EXPUNGED) { + return false; + } + + if (valueUpdater.compareAndSet(this, foundValue, newValue)) { + return true; + } + } + } + + @Override + public boolean tryMarkExpunged() { + Object val = valueUpdater.get(this); + while (val == null) { + if (valueUpdater.compareAndSet(this, null, EXPUNGED)) { + return true; + } + val = valueUpdater.get(this); + } + return false; + } + + @Override + public boolean tryUnexpungeAndSet(V element) { + return valueUpdater.compareAndSet(this, EXPUNGED, element); + } + + @Override + public boolean replace(final Object expected, final V newValue) { + for (; ; ) { + Object val = valueUpdater.get(this); + if (val == EXPUNGED || !Objects.equals(val, expected)) { + return false; + } + + if (valueUpdater.compareAndSet(this, val, newValue)) { + return true; + } + } + } + + @Override + public V clear() { + for (; ; ) { + Object val = valueUpdater.get(this); + if (val == null || val == EXPUNGED) { + return null; + } + if (valueUpdater.compareAndSet(this, val, null)) { + return (V) val; + } + } + } + } + + private class MapEntry implements Entry { + + private final K key; + + private MapEntry(final Entry> entry) { + this.key = entry.getKey(); + } + + @Override + public K getKey() { + return this.key; + } + + @Override + public V getValue() { + return SyncMapImpl.this.get(this.key); + } + + @Override + public V setValue(final V value) { + return SyncMapImpl.this.put(this.key, value); + } + + @Override + public String toString() { + return "SyncMapImpl.MapEntry{key=" + this.getKey() + ", value=" + this.getValue() + "}"; + } + + @Override + public boolean equals(final Object other) { + if (this == other) { + return true; + } + if (!(other instanceof Map.Entry)) { + return false; + } + final Entry that = (Entry) other; + return Objects.equals(this.getKey(), that.getKey()) + && Objects.equals(this.getValue(), that.getValue()); + } + + @Override + public int hashCode() { + return Objects.hash(this.getKey(), this.getValue()); + } + } + + private class EntrySet extends AbstractSet> { + + @Override + public int size() { + return SyncMapImpl.this.size(); + } + + @Override + public boolean contains(final Object entry) { + if (!(entry instanceof Map.Entry)) { + return false; + } + final Entry mapEntry = (Entry) entry; + final V value = SyncMapImpl.this.get(mapEntry.getKey()); + return value != null && Objects.equals(mapEntry.getValue(), value); + } + + @Override + public boolean remove(final Object entry) { + if (!(entry instanceof Map.Entry)) { + return false; + } + final Entry mapEntry = (Entry) entry; + return SyncMapImpl.this.remove(mapEntry.getKey()) != null; + } + + @Override + public void clear() { + SyncMapImpl.this.clear(); + } + + @Override + public Iterator> iterator() { + SyncMapImpl.this.promoteIfNeeded(); + return new EntryIterator(SyncMapImpl.this.read.entrySet().iterator()); + } + } + + private class EntryIterator implements Iterator> { + + private final Iterator>> backingIterator; + private Entry next; + private Entry current; + + private EntryIterator(final Iterator>> backingIterator) { + this.backingIterator = backingIterator; + Entry> entry = this.getNextValue(); + this.next = (entry != null ? new MapEntry(entry) : null); + } + + private Entry> getNextValue() { + Entry> entry = null; + while (this.backingIterator.hasNext() && entry == null) { + final ExpungingValue value = (entry = this.backingIterator.next()).getValue(); + if (!value.exists()) { + entry = null; + } + } + return entry; + } + + @Override + public boolean hasNext() { + return this.next != null; + } + + @Override + public Entry next() { + this.current = this.next; + Entry> entry = this.getNextValue(); + this.next = (entry != null ? new MapEntry(entry) : null); + if (this.current == null) { + throw new NoSuchElementException(); + } + return this.current; + } + + @Override + public void remove() { + if (this.current == null) { + return; + } + SyncMapImpl.this.remove(this.current.getKey()); + } + + @Override + public void forEachRemaining(final Consumer> action) { + if (this.next != null) { + action.accept(this.next); + } + this.backingIterator.forEachRemaining(entry -> { + if (entry.getValue().exists()) { + action.accept(new MapEntry(entry)); + } + }); + } + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/util/concurrent/Once.java b/proxy/src/main/java/com/velocitypowered/proxy/util/concurrent/Once.java new file mode 100644 index 000000000..aa02f4b56 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/util/concurrent/Once.java @@ -0,0 +1,46 @@ +package com.velocitypowered.proxy.util.concurrent; + +import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; + +/** + * A class that guarantees that a given initialization shall only happen once. The implementation + * is (almost) a direct Java port of the Go {@code sync.Once} type (see the + * Go documentation) and thus has similar + * semantics. + */ +public final class Once { + private static final AtomicIntegerFieldUpdater COMPLETED_UPDATER = + AtomicIntegerFieldUpdater.newUpdater(Once.class, "completed"); + + private static final int NOT_STARTED = 0; + private static final int COMPLETED = 1; + + private volatile int completed = NOT_STARTED; + private final Object lock = new Object(); + + /** + * Calls {@code runnable.run()} exactly once if this instance is being called for the first time, + * otherwise the invocation shall wait until {@code runnable.run()} completes. Future calls to + * this method once {@code runnable.run()} completes are no-ops - a new instance should be used + * instead. + * + * @param runnable the runnable to run + */ + public void run(Runnable runnable) { + if (COMPLETED_UPDATER.get(this) == NOT_STARTED) { + slowRun(runnable); + } + } + + private void slowRun(Runnable runnable) { + synchronized (lock) { + if (this.completed == NOT_STARTED) { + try { + runnable.run(); + } finally { + COMPLETED_UPDATER.set(this, COMPLETED); + } + } + } + } +} diff --git a/proxy/src/test/java/com/velocitypowered/proxy/util/collect/concurrent/SyncMapTest.java b/proxy/src/test/java/com/velocitypowered/proxy/util/collect/concurrent/SyncMapTest.java new file mode 100644 index 000000000..f98b21d92 --- /dev/null +++ b/proxy/src/test/java/com/velocitypowered/proxy/util/collect/concurrent/SyncMapTest.java @@ -0,0 +1,586 @@ +package com.velocitypowered.proxy.util.collect.concurrent; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.velocitypowered.proxy.util.collect.concurrent.SyncMap.ExpungingValue; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.Spliterator; +import org.junit.jupiter.api.Test; + +public class SyncMapTest { + @Test + public void testInitialization() { + final SyncMap map = SyncMap.hashmap(); + assertEquals(0, map.size()); + assertTrue(map.isEmpty()); + assertFalse(map.containsKey("foo")); + assertFalse(map.containsValue("bar")); + assertNull(map.get("foo")); + assertNull(map.remove("foo")); + assertFalse(map.remove("foo", "bar")); + } + + @Test + public void testPutDisallowsNullValues() { + final SyncMap map = SyncMap.hashmap(); + assertThrows(NullPointerException.class, () -> map.put("test", null)); + } + + @Test + public void testMutation_put_get() { + final SyncMap map = SyncMap.hashmap(); + assertNull(map.put("foo", "bar")); + assertEquals("bar", map.get("foo")); + assertEquals("bar", map.put("foo", "baz")); + assertEquals("baz", map.get("foo")); + assertEquals(1, map.size()); + assertFalse(map.isEmpty()); + assertTrue(map.containsKey("foo")); + assertTrue(map.containsValue("baz")); + } + + @Test + public void testMutation_put_getAllowsNull() { + final SyncMap map = SyncMap.hashmap(); + assertNull(map.put(null, "bar")); + assertEquals("bar", map.get(null)); + } + + @Test + public void testMutation_repeatedPut() { + final SyncMap map = SyncMap.hashmap(); + assertNull(map.put("foo", "bar")); + + // Make sure to promote the map + for (int i = 0; i < 10; i++) { + assertEquals("bar", map.get("foo")); + } + + assertEquals("bar", map.put("foo", "baz")); + assertEquals("baz", map.get("foo")); + } + + @Test + public void testMutation_handlesDirtyAndReadPut() { + final SyncMap map = SyncMap.hashmap(); + assertNull(map.put("foo", "bar")); + + // Make sure to promote the dirty map + for (int i = 0; i < 10; i++) { + assertEquals("bar", map.get("foo")); + } + + // Add a new entry to the dirty map. The entry in the read must not be affected. + assertNull(map.put("abc", "123")); + assertEquals("123", map.get("abc")); + assertEquals("bar", map.get("foo")); + } + + @Test + public void testMutation_ensuresExpungedEntriesProperlyHandled() { + final SyncMap map = SyncMap.hashmap(); + assertNull(map.put("foo", "bar")); + + // Make sure to promote the dirty map + for (int i = 0; i < 10; i++) { + assertEquals("bar", map.get("foo")); + } + + // Remove the entry in the read map. + assertEquals("bar", map.remove("foo")); + assertEquals(0, map.size()); + + // Add a new entry to the dirty map. At this point, the entry should be expunged. + assertNull(map.put("abc", "123")); + + // Make sure the entry still doesn't exist. + assertNull(map.get("foo")); + + // Promote the dirty map one last time + for (int i = 0; i < 10; i++) { + assertEquals("123", map.get("abc")); + } + + // Make sure the entry really doesn't exist. + assertNull(map.get("foo")); + } + + @Test + public void testMutation_putExpungedProperlyHandled() { + final SyncMap map = SyncMap.hashmap(); + assertNull(map.put("foo", "bar")); + + // Make sure to promote the dirty map + for (int i = 0; i < 10; i++) { + assertEquals("bar", map.get("foo")); + } + + // Remove the entry in the read map. + assertEquals("bar", map.remove("foo")); + assertEquals(0, map.size()); + + // Add a new entry to the dirty map. At this point, the entry should be marked as expunged. + assertNull(map.put("abc", "123")); + + // Bring the old entry back to life. + assertNull(map.put("foo", "baz")); + assertEquals("baz", map.get("foo")); + + // Cause the dirty map to be promoted. + for (int i = 0; i < 10; i++) { + assertEquals("123", map.get("abc")); + } + + // Make sure the entries we added still exist (the dirty map was properly updated). + assertEquals("123", map.get("abc")); + assertEquals("baz", map.get("foo")); + } + + @Test + public void testPutIfAbsentDisallowsNullValues() { + final SyncMap map = SyncMap.hashmap(); + assertThrows(NullPointerException.class, () ->map.putIfAbsent("test", null)); + } + + @Test + public void testMutation_putIfAbsentBasic() { + final SyncMap map = SyncMap.hashmap(); + assertNull(map.putIfAbsent("foo", "bar")); + assertEquals("bar", map.putIfAbsent("foo", "baz")); + assertEquals("bar", map.get("foo")); + + assertEquals("bar", map.remove("foo")); + assertNull(map.putIfAbsent("foo", "bar")); + } + + @Test + public void testMutation_putIfAbsentPromoted() { + final SyncMap map = SyncMap.hashmap(); + assertNull(map.putIfAbsent("foo", "bar")); + + // Force a promotion of the dirty map + for (int i = 0; i < 10; i++) { + assertEquals("bar", map.get("foo")); + } + + // Remove the entry in the read map. + assertEquals("bar", map.putIfAbsent("foo", "baz")); + assertEquals("bar", map.get("foo")); + } + + @Test + public void testReplaceSingleDisallowsNullValues() { + final SyncMap map = SyncMap.hashmap(); + assertThrows(NullPointerException.class, () -> map.replace("test", null)); + } + + @Test + public void testMutation_replaceSingleBasic() { + final SyncMap map = SyncMap.hashmap(); + assertNull(map.replace("foo", "bar")); + assertEquals(0, map.size()); + + assertNull(map.put("foo", "bar")); + assertEquals("bar", map.replace("foo", "baz")); + assertEquals("baz", map.get("foo")); + } + + @Test + public void testMutation_replaceSingleHandlesPromotion() { + final SyncMap map = SyncMap.hashmap(); + assertNull(map.put("foo", "bar")); + // Force a promotion of the dirty map + for (int i = 0; i < 10; i++) { + assertEquals("bar", map.get("foo")); + } + + assertEquals("bar", map.replace("foo", "baz")); + assertEquals("baz", map.get("foo")); + } + + @Test + public void testReplaceSpecificDisallowsNullValues1() { + final SyncMap map = SyncMap.hashmap(); + assertThrows(NullPointerException.class, () -> map.replace("test", null, + "abc")); + } + + @Test + public void testReplaceSpecificDisallowsNullValues2() { + final SyncMap map = SyncMap.hashmap(); + assertThrows(NullPointerException.class, () -> map.replace("test", "abc", + null)); + } + + @Test + public void testMutation_replaceSpecificBasic() { + final SyncMap map = SyncMap.hashmap(); + assertNull(map.put("foo", "bar")); + assertFalse(map.replace("foo", "baz", "bar")); + assertEquals("bar", map.get("foo")); + assertTrue(map.replace("foo", "bar", "baz")); + assertEquals("baz", map.get("foo")); + } + + @Test + public void testMutation_replaceSpecificPromoted() { + final SyncMap map = SyncMap.hashmap(); + assertNull(map.put("foo", "bar")); + // Force a promotion of the dirty map + for (int i = 0; i < 10; i++) { + assertEquals("bar", map.get("foo")); + } + + assertFalse(map.replace("foo", "baz", "bar")); + assertEquals("bar", map.get("foo")); + assertTrue(map.replace("foo", "bar", "baz")); + assertEquals("baz", map.get("foo")); + } + + @Test + public void testMutation_putIfAbsentHandleExpunged() { + final SyncMap map = SyncMap.hashmap(); + assertNull(map.putIfAbsent("foo", "bar")); + + // Force a promotion of the dirty map + for (int i = 0; i < 10; i++) { + assertEquals("bar", map.get("foo")); + } + + // Remove the entry in the read map. + assertEquals("bar", map.remove("foo")); + assertEquals(0, map.size()); + + // Add a new entry to the dirty map. At this point, the existing entry should be marked as expunged. + assertNull(map.put("abc", "123")); + + // Bring the old entry back to life + assertNull(map.putIfAbsent("foo", "baz")); + assertEquals("baz", map.get("foo")); + } + + @Test + public void testMutation_putAll() { + final SyncMap map = SyncMap.hashmap(); + final Map test = Maps.newHashMap(); + test.put("1", "2"); + test.put("3", "4"); + test.put("5", "6"); + + map.putAll(test); + assertEquals("2", map.get("1")); + assertEquals("4", map.get("3")); + assertEquals("6", map.get("5")); + } + + @Test + public void testMutation_remove() { + final SyncMap map = SyncMap.hashmap(); + map.put("foo", "bar"); + map.put("abc", "123"); + assertEquals(2, map.size()); + assertEquals("bar", map.remove("foo")); + assertTrue(map.remove("abc", "123")); + assertNull(map.get("foo")); + assertNull(map.get("abc")); + assertEquals(0, map.size()); + } + + @Test + public void testMutation_unexpunge() { + final SyncMap map = SyncMap.hashmap(); + assertNull(map.put("foo", "bar")); + assertEquals("bar", map.remove("foo")); + assertNull(map.put("foo", "baz")); + assertEquals("baz", map.get("foo")); + } + + @Test + public void testMutation_clear() { + final SyncMap map = SyncMap.hashmap(); + map.put("example", "random"); + map.clear(); + assertNull(map.get("example")); + assertEquals(0, map.size()); + assertTrue(map.isEmpty()); + } + + @Test + public void testKeySetPermittedMutations() { + final SyncMap map = SyncMap.hashmap(); + map.put("1", "2"); + map.put("3", "4"); + map.put("5", "6"); + + final Set keys = map.keySet(); + assertEquals(3, keys.size()); + assertFalse(keys.isEmpty()); + assertTrue(keys.contains("1")); + assertFalse(keys.contains("2")); + assertTrue(keys.remove("1")); + assertFalse(keys.remove("2")); + assertFalse(keys.contains("1")); + assertEquals(2, keys.size()); + } + + @Test + public void testKeySetAdditionProhibited() { + final SyncMap map = SyncMap.hashmap(); + final Set keys = map.keySet(); + assertThrows(UnsupportedOperationException.class, () -> keys.add("foo")); + } + + @Test + public void testKeySetAddAllProhibited() { + final SyncMap map = SyncMap.hashmap(); + final Set keys = map.keySet(); + assertThrows(UnsupportedOperationException.class, () -> keys.addAll(Lists + .newArrayList("bar", "baz"))); + } + + @Test + public void testKeyMutation_iterator() { + final SyncMap map = SyncMap.of( + LinkedHashMap>::new, 3); + map.put("1", "2"); + map.put("3", "4"); + map.put("5", "6"); + + final Set keys = map.keySet(); + final Iterator keyIterator = keys.iterator(); + assertTrue(keyIterator.hasNext()); + assertEquals("1", keyIterator.next()); + keyIterator.remove(); + assertFalse(keys.contains("1")); + + final String[] expected = {"3", "5"}; + final List remaining = new ArrayList<>(); + keyIterator.forEachRemaining(remaining::add); + assertArrayEquals(expected, remaining.toArray()); + } + + @Test + public void testKeyMutation_spliterator() { + final SyncMap map = SyncMap.of(LinkedHashMap>::new, 3); + map.put("1", "2"); + map.put("3", "4"); + map.put("5", "6"); + + final Set keys = map.keySet(); + final Spliterator keySpliterator = keys.spliterator(); + assertTrue(keySpliterator.tryAdvance(value -> assertEquals("1", value))); + + final String[] expected = {"3", "5"}; + final List remaining = new ArrayList<>(); + keySpliterator.forEachRemaining(remaining::add); + assertArrayEquals(expected, remaining.toArray()); + + assertEquals(3, keySpliterator.estimateSize()); + } + + @Test + public void testValuesPermittedMutation() { + final SyncMap map = SyncMap.hashmap(); + map.put("1", "2"); + map.put("3", "4"); + map.put("5", "6"); + + final Collection values = map.values(); + assertEquals(3, values.size()); + assertFalse(values.isEmpty()); + assertTrue(values.contains("2")); + assertFalse(values.contains("1")); + assertTrue(values.remove("2")); + assertFalse(values.remove("1")); + assertFalse(values.contains("2")); + assertEquals(2, values.size()); + } + + @Test + public void testValuesAdditionProhibited() { + final SyncMap map = SyncMap.hashmap(); + final Collection values = map.values(); + assertThrows(UnsupportedOperationException.class, () -> values.add("foo")); // Causes UOE + } + + @Test + public void testValuesAddAllProhibited() { + final SyncMap map = SyncMap.hashmap(); + final Collection values = map.values(); + assertThrows(UnsupportedOperationException.class, () -> values.addAll(Lists + .newArrayList("bar", "baz"))); // Causes UOE + } + + @Test + public void testValueMutation_iterator() { + final SyncMap map = SyncMap.of(LinkedHashMap>::new, 3); + map.put("1", "2"); + map.put("3", "4"); + map.put("5", "6"); + + final Collection values = map.values(); + final Iterator valueIterator = values.iterator(); + assertTrue(valueIterator.hasNext()); + assertEquals("2", valueIterator.next()); + valueIterator.remove(); + assertFalse(values.contains("2")); + + final String[] expected = {"4", "6"}; + final List remaining = new ArrayList<>(); + valueIterator.forEachRemaining(remaining::add); + assertArrayEquals(expected, remaining.toArray()); + } + + @Test + public void testValueMutation_spliterator() { + final SyncMap map = SyncMap.of(LinkedHashMap>::new, 3); + map.put("1", "2"); + map.put("3", "4"); + map.put("5", "6"); + + final Collection values = map.values(); + final Spliterator valueSpliterator = values.spliterator(); + assertTrue(valueSpliterator.tryAdvance(value -> assertEquals("2", value))); + + final String[] expected = {"4", "6"}; + final List remaining = new ArrayList<>(); + valueSpliterator.forEachRemaining(remaining::add); + assertArrayEquals(expected, remaining.toArray()); + + assertEquals(3, valueSpliterator.estimateSize()); + } + + @Test + public void testEntryMutation() { + final SyncMap map = SyncMap.hashmap(); + map.put("1", "2"); + map.put("3", "4"); + map.put("5", "6"); + + final Map.Entry goodEntry = this.exampleEntry("1", "2"); + final Map.Entry badEntry = this.exampleEntry("abc", "123"); + + final Set> entries = map.entrySet(); + assertEquals(3, entries.size()); + assertFalse(entries.isEmpty()); + assertTrue(entries.contains(goodEntry)); + assertFalse(entries.contains(badEntry)); + assertTrue(entries.remove(goodEntry)); + assertFalse(entries.remove(badEntry)); + assertFalse(entries.contains(goodEntry)); + assertEquals(2, entries.size()); + } + + @Test + public void testEntryAddProhibited() { + final SyncMap map = SyncMap.hashmap(); + final Map.Entry badEntry = this.exampleEntry("abc", "123"); + assertThrows(UnsupportedOperationException.class, () -> map.entrySet().add(badEntry)); + } + + @Test + public void testEntryAddAllProhibited() { + final SyncMap map = SyncMap.hashmap(); + final Map.Entry badEntry = this.exampleEntry("abc", "123"); + final Map.Entry badEntry2 = this.exampleEntry("1", "2"); + assertThrows(UnsupportedOperationException.class, () -> map.entrySet().addAll(Lists + .newArrayList(badEntry, badEntry2))); + } + + @Test + public void testEntryMutation_iterator() { + final SyncMap map = SyncMap.of(LinkedHashMap>::new, 3); + map.put("1", "2"); + map.put("3", "4"); + map.put("5", "6"); + + final Map.Entry firstEntry = this.exampleEntry("1", "2"); + final Map.Entry secondEntry = this.exampleEntry("3", "4"); + final Map.Entry thirdEntry = this.exampleEntry("5", "6"); + + final Set> entries = map.entrySet(); + final Iterator> entryIterator = entries.iterator(); + assertTrue(entryIterator.hasNext()); + assertEquals(entryIterator.next(), firstEntry); + entryIterator.remove(); + assertFalse(entries.contains(firstEntry)); + + final List> remaining = new ArrayList<>(); + entryIterator.forEachRemaining(remaining::add); + assertEquals(Lists.newArrayList(secondEntry, thirdEntry), remaining); + } + + @Test + public void testEntryMutation_spliterator() { + final SyncMap map = SyncMap.of(LinkedHashMap>::new, 3); + map.put("1", "2"); + map.put("3", "4"); + map.put("5", "6"); + + final Map.Entry firstEntry = this.exampleEntry("1", "2"); + final Map.Entry secondEntry = this.exampleEntry("3", "4"); + final Map.Entry thirdEntry = this.exampleEntry("5", "6"); + + final Set> entries = map.entrySet(); + final Spliterator> entrySpliterator = entries.spliterator(); + assertTrue(entrySpliterator.tryAdvance(value -> assertEquals(firstEntry, value))); + + final List> remaining = new ArrayList<>(); + entrySpliterator.forEachRemaining(remaining::add); + assertEquals(Lists.newArrayList(secondEntry, thirdEntry), remaining); + + assertEquals(3, entrySpliterator.estimateSize()); + } + + private Map.Entry exampleEntry(final String key, final String value) { + return new Map.Entry() { + @Override + public String getKey() { + return key; + } + + @Override + public String getValue() { + return value; + } + + @Override + public String setValue(String value) { + return value; + } + + @Override + public String toString() { + return "SyncMapImpl.MapEntry{key=" + this.getKey() + ", value=" + this.getValue() + "}"; + } + + @Override + public boolean equals(final Object other) { + if(this == other) return true; + if(!(other instanceof Map.Entry)) return false; + final Map.Entry that = (Map.Entry) other; + return Objects.equals(this.getKey(), that.getKey()) + && Objects.equals(this.getValue(), that.getValue()); + } + + @Override + public int hashCode() { + return Objects.hash(this.getKey(), this.getValue()); + } + }; + } +}