diff --git a/ProtocolLib/src/main/java/com/comphenix/protocol/collections/ExpireHashMap.java b/ProtocolLib/src/main/java/com/comphenix/protocol/collections/ExpireHashMap.java new file mode 100644 index 00000000..04e83313 --- /dev/null +++ b/ProtocolLib/src/main/java/com/comphenix/protocol/collections/ExpireHashMap.java @@ -0,0 +1,246 @@ +package com.comphenix.protocol.collections; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.PriorityQueue; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import com.google.common.base.Function; +import com.google.common.base.Objects; +import com.google.common.base.Preconditions; +import com.google.common.base.Ticker; +import com.google.common.collect.Maps; +import com.google.common.primitives.Longs; + +/** + * Represents a hash map where each association may expire after a given time has elapsed. + *

+ * Note that replaced key-value associations are only collected once the original expiration time has elapsed. + * + * @author Kristian Stangeland + * + * @param - type of the keys. + * @param - type of the values. + */ +public class ExpireHashMap { + private class ExpireEntry implements Comparable { + public final long expireTime; + public final K expireKey; + public final V expireValue; + + public ExpireEntry(long expireTime, K expireKey, V expireValue) { + this.expireTime = expireTime; + this.expireKey = expireKey; + this.expireValue = expireValue; + } + + @Override + public int compareTo(ExpireEntry o) { + return Longs.compare(expireTime, o.expireTime); + } + + @Override + public String toString() { + return "ExpireEntry [expireTime=" + expireTime + ", expireKey=" + expireKey + + ", expireValue=" + expireValue + "]"; + } + } + + private Map keyLookup = new HashMap(); + private PriorityQueue expireQueue = new PriorityQueue(); + + // View of keyLookup with direct values + private Map valueView = Maps.transformValues(keyLookup, new Function() { + @Override + public V apply(ExpireEntry entry) { + return entry.expireValue; + } + }); + + // Supplied by the constructor + private Ticker ticker; + + /** + * Construct a new hash map where each entry may expire at a given time. + */ + public ExpireHashMap() { + this(Ticker.systemTicker()); + } + + /** + * Construct a new hash map where each entry may expire at a given time. + * @param ticker - supplier of the current time. + */ + public ExpireHashMap(Ticker ticker) { + this.ticker = ticker; + } + + /** + * Retrieve the value associated with the given key, if it has not expired. + * @param key - the key. + * @return The value, or NULL if not found or it has expired. + */ + public V get(K key) { + evictExpired(); + + ExpireEntry entry = keyLookup.get(key); + return entry != null ? entry.expireValue : null; + } + + /** + * Associate the given key with the given value, until the expire delay have elapsed. + * @param key - the key. + * @param value - the value. + * @param expireDelay - the amount of time until this association expires. Must be greater than zero. + * @param expireUnit - the unit of the expiration. + * @return Any previously unexpired association with this key, or NULL. + */ + public V put(K key, V value, long expireDelay, TimeUnit expireUnit) { + Preconditions.checkNotNull(expireUnit, "expireUnit cannot be NULL"); + Preconditions.checkState(expireDelay > 0, "expireDelay cannot be equal or less than zero."); + evictExpired(); + + ExpireEntry entry = new ExpireEntry( + ticker.read() + TimeUnit.NANOSECONDS.convert(expireDelay, expireUnit), + key, value + ); + ExpireEntry previous = keyLookup.put(key, entry); + + // We enqueue its removal + expireQueue.add(entry); + return previous != null ? previous.expireValue : null; + } + + /** + * Determine if the given key is referring to an unexpired association in the map. + * @param key - the key. + * @return TRUE if it is, FALSE otherwise. + */ + public boolean containsKey(K key) { + evictExpired(); + return keyLookup.containsKey(key); + } + + /** + * Determine if the given value is referring to an unexpired association in the map. + * @param value - the value. + * @return TRUE if it is, FALSE otherwise. + */ + public boolean containsValue(V value) { + evictExpired(); + + // Linear scan is the best we've got + for (ExpireEntry entry : keyLookup.values()) { + if (Objects.equal(value, entry.expireValue)) { + return true; + } + } + return false; + } + + /** + * Remove a key and its associated value from the map. + * @param key - the key to remove. + * @return Value of the removed association, NULL otherwise. + */ + public V removeKey(K key) { + evictExpired(); + + ExpireEntry entry = keyLookup.remove(key); + return entry != null ? entry.expireValue : null; + } + + /** + * Retrieve the number of entries in the map. + * @return The number of entries. + */ + public int size() { + evictExpired(); + return keyLookup.size(); + } + + /** + * Retrieve a view of the keys in the current map. + * @return View of the keys. + */ + public Set keySet() { + evictExpired(); + return keyLookup.keySet(); + } + + /** + * Retrieve a view of all the values in the current map. + * @return All the values. + */ + public Collection values() { + evictExpired(); + return valueView.values(); + } + + /** + * Retrieve a view of all the entries in the set. + * @return All the entries. + */ + public Set> entrySet() { + evictExpired(); + return valueView.entrySet(); + } + + /** + * Retrieve a view of this expire map as an ordinary map that does not support insertion. + * @return The map. + */ + public Map asMap() { + evictExpired(); + return valueView; + } + + /** + * Clear all references to key-value pairs that have been removed or replaced before they were naturally evicted. + *

+ * This operation requires a linear scan of the current entries in the map. + */ + public void collect() { + // First evict what we can + evictExpired(); + + // Recreate the eviction queue - this is faster than removing entries in the old queue + expireQueue.clear(); + expireQueue.addAll(keyLookup.values()); + } + + /** + * Clear all the entries in the current map. + */ + public void clear() { + keyLookup.clear(); + expireQueue.clear(); + } + + /** + * Evict any expired entries in the map. + *

+ * This is called automatically by any of the read or write operations. + */ + protected void evictExpired() { + long currentTime = ticker.read(); + + // Remove expired entries + while (expireQueue.size() > 0 && expireQueue.peek().expireTime <= currentTime) { + ExpireEntry entry = expireQueue.poll(); + + if (entry == keyLookup.get(entry.expireKey)) { + keyLookup.remove(entry.expireKey); + } + } + } + + @Override + public String toString() { + return valueView.toString(); + } +} + \ No newline at end of file diff --git a/ProtocolLib/src/main/java/com/comphenix/protocol/error/DetailedErrorReporter.java b/ProtocolLib/src/main/java/com/comphenix/protocol/error/DetailedErrorReporter.java index 34849632..02acda82 100644 --- a/ProtocolLib/src/main/java/com/comphenix/protocol/error/DetailedErrorReporter.java +++ b/ProtocolLib/src/main/java/com/comphenix/protocol/error/DetailedErrorReporter.java @@ -25,6 +25,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Level; import java.util.logging.Logger; @@ -34,6 +35,7 @@ import org.apache.commons.lang.builder.ToStringStyle; import org.bukkit.Bukkit; import org.bukkit.plugin.Plugin; +import com.comphenix.protocol.collections.ExpireHashMap; import com.comphenix.protocol.error.Report.ReportBuilder; import com.comphenix.protocol.events.PacketAdapter; import com.comphenix.protocol.reflect.PrettyPrinter; @@ -83,7 +85,11 @@ public class DetailedErrorReporter implements ErrorReporter { // Map of global objects protected Map globalParameters = new HashMap(); - + + // Reports to ignore + private ExpireHashMap rateLimited = new ExpireHashMap(); + private Object rateLock = new Object(); + /** * Create a default error reporting system. */ @@ -218,7 +224,7 @@ public class DetailedErrorReporter implements ErrorReporter { @Override public void reportDebug(Object sender, Report report) { - if (logger.isLoggable(Level.FINE)) { + if (logger.isLoggable(Level.FINE) && canReport(report)) { reportLevel(Level.FINE, sender, report); } } @@ -233,11 +239,33 @@ public class DetailedErrorReporter implements ErrorReporter { @Override public void reportWarning(Object sender, Report report) { - if (logger.isLoggable(Level.WARNING)) { + if (logger.isLoggable(Level.WARNING) && canReport(report)) { reportLevel(Level.WARNING, sender, report); } } + /** + * Determine if we should print the given report. + *

+ * The default implementation will check for rate limits. + * @param report - the report to check. + * @return TRUE if we should print it, FALSE otherwise. + */ + protected boolean canReport(Report report) { + long rateLimit = report.getRateLimit(); + + // Check for rate limit + if (rateLimit > 0) { + synchronized (rateLock) { + if (rateLimited.containsKey(report)) { + return false; + } + rateLimited.put(report, true, rateLimit, TimeUnit.NANOSECONDS); + } + } + return true; + } + private void reportLevel(Level level, Object sender, Report report) { String message = "[" + pluginName + "] [" + getSenderName(sender) + "] " + report.getReportMessage(); @@ -294,6 +322,11 @@ public class DetailedErrorReporter implements ErrorReporter { } } + // Secondary rate limit + if (!canReport(report)) { + return; + } + StringWriter text = new StringWriter(); PrintWriter writer = new PrintWriter(text); diff --git a/ProtocolLib/src/main/java/com/comphenix/protocol/error/PluginContext.java b/ProtocolLib/src/main/java/com/comphenix/protocol/error/PluginContext.java new file mode 100644 index 00000000..3b0cba40 --- /dev/null +++ b/ProtocolLib/src/main/java/com/comphenix/protocol/error/PluginContext.java @@ -0,0 +1,106 @@ +package com.comphenix.protocol.error; + +import java.io.File; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.security.CodeSource; + +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; + +import com.google.common.base.Preconditions; + +public final class PluginContext { + // Determine plugin folder + private static File pluginFolder; + + private PluginContext() { + // Not constructable + } + + /** + * Retrieve the name of the plugin that called the last method(s) in the exception. + * @param ex - the exception. + * @return The name of the plugin, or NULL. + */ + public static String getPluginCaller(Exception ex) { + StackTraceElement[] elements = ex.getStackTrace(); + String current = getPluginName(elements[0]); + + for (int i = 1; i < elements.length; i++) { + String caller = getPluginName(elements[i]); + + if (caller != null && !caller.equals(current)) { + return caller; + } + } + return null; + } + + /** + * Lookup the plugin that this method invocation belongs to, and return its file name. + * @param element - the method invocation. + * @return Pluing name, or NULL if not found. + * + */ + public static String getPluginName(StackTraceElement element) { + try { + CodeSource codeSource = Class.forName(element.getClassName()).getProtectionDomain().getCodeSource(); + + if (codeSource != null) { + String encoding = codeSource.getLocation().getPath(); + File path = new File(URLDecoder.decode(encoding, "UTF-8")); + + if (folderContains(getPluginFolder(), path)) { + return path.getName(); + } + } + return null; // Cannot find it + + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("Cannot lookup plugin name.", e); + } catch (ClassNotFoundException e) { + throw new RuntimeException("Cannot lookup plugin name.", e); + } + } + + /** + * Determine if a folder contains the given file. + * @param folder - the folder. + * @param file - the file. + * @return TRUE if it does, FALSE otherwise. + */ + private static boolean folderContains(File folder, File file) { + Preconditions.checkNotNull(folder, "folder cannot be NULL"); + Preconditions.checkNotNull(file, "file cannot be NULL"); + + // Get absolute versions + folder = folder.getAbsoluteFile(); + file = file.getAbsoluteFile(); + + while (file != null) { + if (folder.equals(file)) + return true; + file = file.getParentFile(); + } + return false; + } + + /** + * Retrieve the folder that contains every plugin on the server. + * @return Folder with every plugin. + */ + private static File getPluginFolder() { + File folder = pluginFolder; + + if (folder == null) { + Plugin[] plugins = Bukkit.getPluginManager().getPlugins(); + + if (plugins.length > 0) { + folder = plugins[0].getDataFolder().getParentFile(); + pluginFolder = folder; + } + } + return folder; + } +} diff --git a/ProtocolLib/src/main/java/com/comphenix/protocol/error/Report.java b/ProtocolLib/src/main/java/com/comphenix/protocol/error/Report.java index b408d3b8..3bbe2c38 100644 --- a/ProtocolLib/src/main/java/com/comphenix/protocol/error/Report.java +++ b/ProtocolLib/src/main/java/com/comphenix/protocol/error/Report.java @@ -1,5 +1,8 @@ package com.comphenix.protocol.error; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; + import javax.annotation.Nullable; /** @@ -7,12 +10,15 @@ import javax.annotation.Nullable; * * @author Kristian */ -public class Report { +public class Report { private final ReportType type; private final Throwable exception; private final Object[] messageParameters; private final Object[] callerParameters; + // Used to rate limit reports that are similar + private final long rateLimit; + /** * Must be constructed through the factory method in Report. */ @@ -21,6 +27,7 @@ public class Report { private Throwable exception; private Object[] messageParameters; private Object[] callerParameters; + private long rateLimit; private ReportBuilder() { // Don't allow @@ -39,8 +46,8 @@ public class Report { } /** - * Set the current exception that occured. - * @param exception - exception that occured. + * Set the current exception that occurred. + * @param exception - exception that occurred. * @return This builder, for chaining. */ public ReportBuilder error(@Nullable Throwable exception) { @@ -68,12 +75,35 @@ public class Report { return this; } + /** + * Set the minimum number of nanoseconds to wait until a report of equal type and parameters + * is allowed to be printed again. + * @param rateLimit - number of nanoseconds, or 0 to disable. Cannot be negative. + * @return This builder, for chaining. + */ + public ReportBuilder rateLimit(long rateLimit) { + if (rateLimit < 0) + throw new IllegalArgumentException("Rate limit cannot be less than zero."); + this.rateLimit = rateLimit; + return this; + } + + /** + * Set the minimum time to wait until a report of equal type and parameters is allowed to be printed again. + * @param rateLimit - the time, or 0 to disable. Cannot be negative. + * @param rateUnit - the unit of the rate limit. + * @return This builder, for chaining. + */ + public ReportBuilder rateLimit(long rateLimit, TimeUnit rateUnit) { + return rateLimit(TimeUnit.NANOSECONDS.convert(rateLimit, rateUnit)); + } + /** * Construct a new report with the provided input. * @return A new report. */ public Report build() { - return new Report(type, exception, messageParameters, callerParameters); + return new Report(type, exception, messageParameters, callerParameters, rateLimit); } } @@ -85,7 +115,7 @@ public class Report { public static ReportBuilder newBuilder(ReportType type) { return new ReportBuilder().type(type); } - + /** * Construct a new report with the given type and parameters. * @param exception - exception that occured in the caller method. @@ -93,13 +123,28 @@ public class Report { * @param messageParameters - parameters used to construct the report message. * @param callerParameters - parameters from the caller method. */ - protected Report(ReportType type, @Nullable Throwable exception, @Nullable Object[] messageParameters, @Nullable Object[] callerParameters) { + protected Report(ReportType type, @Nullable Throwable exception, + @Nullable Object[] messageParameters, @Nullable Object[] callerParameters) { + this(type, exception, messageParameters, callerParameters, 0); + } + + /** + * Construct a new report with the given type and parameters. + * @param exception - exception that occurred in the caller method. + * @param type - the report type that will be used to construct the message. + * @param messageParameters - parameters used to construct the report message. + * @param callerParameters - parameters from the caller method. + * @param rateLimit - minimum number of nanoseconds to wait until a report of equal type and parameters is allowed to be printed again. + */ + protected Report(ReportType type, @Nullable Throwable exception, + @Nullable Object[] messageParameters, @Nullable Object[] callerParameters, long rateLimit) { if (type == null) throw new IllegalArgumentException("type cannot be NULL."); this.type = type; this.exception = exception; this.messageParameters = messageParameters; this.callerParameters = callerParameters; + this.rateLimit = rateLimit; } /** @@ -159,4 +204,37 @@ public class Report { public boolean hasCallerParameters() { return callerParameters != null && callerParameters.length > 0; } + + /** + * Retrieve desired minimum number of nanoseconds until a report of the same type and parameters should be reprinted. + *

+ * Note that this may be ignored or modified by the error reporter. Zero indicates no rate limit. + * @return The number of nanoseconds. Never negative. + */ + public long getRateLimit() { + return rateLimit; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + Arrays.hashCode(callerParameters); + result = prime * result + Arrays.hashCode(messageParameters); + result = prime * result + ((type == null) ? 0 : type.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj instanceof Report) { + Report other = (Report) obj; + return type == other.type && + Arrays.equals(callerParameters, other.callerParameters) && + Arrays.equals(messageParameters, other.messageParameters); + } + return false; + } } diff --git a/ProtocolLib/src/main/java/com/comphenix/protocol/wrappers/BukkitConverters.java b/ProtocolLib/src/main/java/com/comphenix/protocol/wrappers/BukkitConverters.java index 19d47e5e..07924998 100644 --- a/ProtocolLib/src/main/java/com/comphenix/protocol/wrappers/BukkitConverters.java +++ b/ProtocolLib/src/main/java/com/comphenix/protocol/wrappers/BukkitConverters.java @@ -28,7 +28,6 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; -import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.World; import org.bukkit.WorldType; diff --git a/ProtocolLib/src/main/java/com/comphenix/protocol/wrappers/WrappedGameProfile.java b/ProtocolLib/src/main/java/com/comphenix/protocol/wrappers/WrappedGameProfile.java index 3c332ddb..2da52be1 100644 --- a/ProtocolLib/src/main/java/com/comphenix/protocol/wrappers/WrappedGameProfile.java +++ b/ProtocolLib/src/main/java/com/comphenix/protocol/wrappers/WrappedGameProfile.java @@ -1,28 +1,35 @@ package com.comphenix.protocol.wrappers; import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import net.minecraft.util.com.mojang.authlib.GameProfile; +import net.minecraft.util.com.mojang.authlib.properties.Property; -import org.apache.commons.lang.StringUtils; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; +import com.comphenix.protocol.ProtocolLibrary; +import com.comphenix.protocol.error.PluginContext; +import com.comphenix.protocol.error.Report; +import com.comphenix.protocol.error.ReportType; import com.comphenix.protocol.injector.BukkitUnwrapper; import com.comphenix.protocol.reflect.accessors.Accessors; import com.comphenix.protocol.reflect.accessors.ConstructorAccessor; import com.comphenix.protocol.reflect.accessors.FieldAccessor; import com.comphenix.protocol.utility.MinecraftReflection; import com.comphenix.protocol.wrappers.collection.ConvertedMultimap; +import com.google.common.base.Charsets; import com.google.common.base.Objects; import com.google.common.collect.Multimap; -import net.minecraft.util.com.mojang.authlib.GameProfile; -import net.minecraft.util.com.mojang.authlib.properties.Property; - /** * Represents a wrapper for a game profile. * @author Kristian */ public class WrappedGameProfile extends AbstractWrapper { + public static final ReportType REPORT_INVALID_UUID = new ReportType("Plugin %s created a profile with '%s' as an UUID."); + // Version 1.7.2 and 1.7.8 respectively private static final ConstructorAccessor CREATE_STRING_STRING = Accessors.getConstructorAccessorOrNull(GameProfile.class, String.class, String.class); private static final FieldAccessor GET_UUID_STRING = Accessors.getFieldAcccessorOrNull(GameProfile.class, "id", String.class); @@ -82,21 +89,20 @@ public class WrappedGameProfile extends AbstractWrapper { * Construct a new game profile with the given properties. *

* Note that this constructor is very lenient when parsing UUIDs for backwards compatibility reasons. - * Thus - "", " ", "0" and "0-0-0-0" are all equivalent to the the UUID "00000000-0000-0000-0000-000000000000". + * IDs that cannot be parsed as an UUID will be hashed and form a version 3 UUID instead. + *

+ * This method is deprecated for Minecraft 1.7.8 and above. * @param id - the UUID of the player. * @param name - the name of the player. */ + @Deprecated public WrappedGameProfile(String id, String name) { super(GameProfile.class); if (CREATE_STRING_STRING != null) { setHandle(CREATE_STRING_STRING.invoke(id, name)); } else { - try { - setHandle(new GameProfile(parseUUID(id), name)); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("Cannot construct profile [" + id + ", " + name + "]", e); - } + setHandle(new GameProfile(parseUUID(id), name)); } } @@ -134,33 +140,19 @@ public class WrappedGameProfile extends AbstractWrapper { * @throws IllegalArgumentException If we cannot parse the text. */ private static UUID parseUUID(String id) { - if (id == null) - return null; - // Interpret as zero - if (StringUtils.isBlank(id)) - id = "0"; - - int missing = 4 - StringUtils.countMatches(id, "-"); - - // Lenient - add missing data - if (missing > 0) { - if (id.length() < 12) { - id += StringUtils.repeat("-0", missing); - } else if (id.length() >= 32) { - StringBuilder builder = new StringBuilder(id); - int position = 8; // Initial position - - while (missing > 0 && position < builder.length()) { - builder.insert(position, "-"); - position += 5; // 4 in length, plus the hyphen - missing--; - } - id = builder.toString(); - } else { - throw new IllegalArgumentException("Invalid partial UUID: " + id); - } + try { + return id != null ? UUID.fromString(id) : null; + } catch (IllegalArgumentException e) { + // Warn once every hour (per plugin) + ProtocolLibrary.getErrorReporter().reportWarning( + WrappedGameProfile.class, + Report.newBuilder(REPORT_INVALID_UUID). + rateLimit(1, TimeUnit.HOURS). + messageParam(PluginContext.getPluginCaller(new Exception()), id) + ); + + return UUID.nameUUIDFromBytes(id.getBytes(Charsets.UTF_8)); } - return UUID.fromString(id); } /**