diff --git a/common/src/main/java/me/lucko/luckperms/common/plugin/AbstractLuckPermsPlugin.java b/common/src/main/java/me/lucko/luckperms/common/plugin/AbstractLuckPermsPlugin.java index e96abf04b..9e975a58e 100644 --- a/common/src/main/java/me/lucko/luckperms/common/plugin/AbstractLuckPermsPlugin.java +++ b/common/src/main/java/me/lucko/luckperms/common/plugin/AbstractLuckPermsPlugin.java @@ -50,7 +50,7 @@ import me.lucko.luckperms.common.sender.Sender; import me.lucko.luckperms.common.storage.Storage; import me.lucko.luckperms.common.storage.StorageFactory; import me.lucko.luckperms.common.storage.StorageType; -import me.lucko.luckperms.common.storage.implementation.file.FileWatcher; +import me.lucko.luckperms.common.storage.implementation.file.watcher.FileWatcher; import me.lucko.luckperms.common.tasks.SyncTask; import me.lucko.luckperms.common.treeview.PermissionRegistry; import me.lucko.luckperms.common.verbose.VerboseHandler; @@ -232,7 +232,7 @@ public abstract class AbstractLuckPermsPlugin implements LuckPermsPlugin { // close file watcher if (this.fileWatcher != null) { - this.fileWatcher.close(); + //this.fileWatcher.close(); } // unregister api diff --git a/common/src/main/java/me/lucko/luckperms/common/plugin/LuckPermsPlugin.java b/common/src/main/java/me/lucko/luckperms/common/plugin/LuckPermsPlugin.java index 16266c8ce..e95f51ee3 100644 --- a/common/src/main/java/me/lucko/luckperms/common/plugin/LuckPermsPlugin.java +++ b/common/src/main/java/me/lucko/luckperms/common/plugin/LuckPermsPlugin.java @@ -49,7 +49,7 @@ import me.lucko.luckperms.common.plugin.logging.PluginLogger; import me.lucko.luckperms.common.plugin.util.AbstractConnectionListener; import me.lucko.luckperms.common.sender.Sender; import me.lucko.luckperms.common.storage.Storage; -import me.lucko.luckperms.common.storage.implementation.file.FileWatcher; +import me.lucko.luckperms.common.storage.implementation.file.watcher.FileWatcher; import me.lucko.luckperms.common.tasks.SyncTask; import me.lucko.luckperms.common.treeview.PermissionRegistry; import me.lucko.luckperms.common.verbose.VerboseHandler; diff --git a/common/src/main/java/me/lucko/luckperms/common/storage/implementation/file/CombinedConfigurateStorage.java b/common/src/main/java/me/lucko/luckperms/common/storage/implementation/file/CombinedConfigurateStorage.java index 70374eee1..b901620fa 100644 --- a/common/src/main/java/me/lucko/luckperms/common/storage/implementation/file/CombinedConfigurateStorage.java +++ b/common/src/main/java/me/lucko/luckperms/common/storage/implementation/file/CombinedConfigurateStorage.java @@ -30,6 +30,7 @@ import me.lucko.luckperms.common.bulkupdate.comparison.Constraint; import me.lucko.luckperms.common.node.model.HeldNodeImpl; import me.lucko.luckperms.common.plugin.LuckPermsPlugin; import me.lucko.luckperms.common.storage.implementation.file.loader.ConfigurateLoader; +import me.lucko.luckperms.common.storage.implementation.file.watcher.FileWatcher; import me.lucko.luckperms.common.util.Iterators; import net.luckperms.api.node.HeldNode; diff --git a/common/src/main/java/me/lucko/luckperms/common/storage/implementation/file/FileWatcher.java b/common/src/main/java/me/lucko/luckperms/common/storage/implementation/file/FileWatcher.java deleted file mode 100644 index 25adc0320..000000000 --- a/common/src/main/java/me/lucko/luckperms/common/storage/implementation/file/FileWatcher.java +++ /dev/null @@ -1,209 +0,0 @@ -/* - * This file is part of LuckPerms, licensed under the MIT License. - * - * Copyright (c) lucko (Luck) - * Copyright (c) contributors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package me.lucko.luckperms.common.storage.implementation.file; - -import me.lucko.luckperms.common.plugin.LuckPermsPlugin; -import me.lucko.luckperms.common.util.Iterators; - -import java.io.IOException; -import java.nio.file.Path; -import java.nio.file.StandardWatchEventKinds; -import java.nio.file.WatchEvent; -import java.nio.file.WatchKey; -import java.nio.file.WatchService; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; - -public class FileWatcher { - @SuppressWarnings("unchecked") - private static final WatchEvent.Kind[] KINDS = new WatchEvent.Kind[]{ - StandardWatchEventKinds.ENTRY_CREATE, - StandardWatchEventKinds.ENTRY_DELETE, - StandardWatchEventKinds.ENTRY_MODIFY - }; - - private final Path basePath; - private final Map watchedLocations; - - // the watchservice instance - private final WatchService watchService; - - private boolean initialised = false; - - public FileWatcher(LuckPermsPlugin plugin, Path basePath) throws IOException { - this.watchedLocations = Collections.synchronizedMap(new HashMap<>()); - this.basePath = basePath; - this.watchService = basePath.getFileSystem().newWatchService(); - - plugin.getBootstrap().getScheduler().asyncLater(this::initLocations, 5, TimeUnit.SECONDS); - plugin.getBootstrap().getScheduler().asyncRepeating(this::tick, 1, TimeUnit.SECONDS); - } - - public WatchedLocation getWatcher(Path path) { - Path relativePath = this.basePath.relativize(path); - return this.watchedLocations.computeIfAbsent(relativePath, p -> new WatchedLocation(this, p)); - } - - public void close() { - if (this.watchService == null) { - return; - } - - try { - this.watchService.close(); - } catch (IOException e) { - e.printStackTrace(); - } - } - - private void initLocations() { - for (WatchedLocation loc : this.watchedLocations.values()) { - try { - loc.setup(); - } catch (IOException e) { - e.printStackTrace(); - } - } - this.initialised = true; - } - - private void tick() { - List expired = new ArrayList<>(); - for (Map.Entry ent : this.watchedLocations.entrySet()) { - boolean valid = ent.getValue().tick(); - if (!valid) { - new RuntimeException("WatchKey no longer valid: " + ent.getKey().toString()).printStackTrace(); - expired.add(ent.getKey()); - } - } - expired.forEach(this.watchedLocations::remove); - } - - private static boolean isFileTemporary(String fileName) { - return fileName.endsWith(".tmp") || fileName.endsWith(".swp") || fileName.endsWith(".swx") || fileName.endsWith(".swpz"); - } - - /** - * Encapsulates a "watcher" in a specific directory. - */ - public final class WatchedLocation { - // the parent watcher - private final FileWatcher watcher; - - // the absolute path to the directory being watched - private final Path absolutePath; - - // the times of recent changes - private final Map lastChange = Collections.synchronizedMap(new HashMap<>()); - - // if the key is registered - private boolean ready = false; - - // the watch key - private WatchKey key = null; - - // the callback functions - private final List> callbacks = new CopyOnWriteArrayList<>(); - - private WatchedLocation(FileWatcher watcher, Path relativePath) { - this.watcher = watcher; - this.absolutePath = this.watcher.basePath.resolve(relativePath); - } - - private synchronized void setup() throws IOException { - if (this.ready) { - return; - } - - this.key = this.absolutePath.register(this.watcher.watchService, KINDS); - this.ready = true; - } - - private boolean tick() { - if (!this.ready) { - // await init - if (!FileWatcher.this.initialised) { - return true; - } - - try { - setup(); - return true; - } catch (IOException e) { - e.printStackTrace(); - return false; - } - } - - // remove old change entries. - long expireTime = System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(4); - this.lastChange.values().removeIf(lastChange -> lastChange < expireTime); - - List> watchEvents = this.key.pollEvents(); - for (WatchEvent event : watchEvents) { - Path context = (Path) event.context(); - - if (context == null) { - continue; - } - - String fileName = context.toString(); - - // ignore temporary changes - if (isFileTemporary(fileName)) { - continue; - } - - // ignore changes already registered to the system - if (this.lastChange.containsKey(fileName)) { - continue; - } - this.lastChange.put(fileName, System.currentTimeMillis()); - - // process the change - Iterators.tryIterate(this.callbacks, cb -> cb.accept(context)); - } - - // reset the watch key. - return this.key.reset(); - } - - public void recordChange(String fileName) { - this.lastChange.put(fileName, System.currentTimeMillis()); - } - - public void addListener(Consumer updateConsumer) { - this.callbacks.add(updateConsumer); - } - } - -} diff --git a/common/src/main/java/me/lucko/luckperms/common/storage/implementation/file/SeparatedConfigurateStorage.java b/common/src/main/java/me/lucko/luckperms/common/storage/implementation/file/SeparatedConfigurateStorage.java index d51de582c..c1f9dc8ca 100644 --- a/common/src/main/java/me/lucko/luckperms/common/storage/implementation/file/SeparatedConfigurateStorage.java +++ b/common/src/main/java/me/lucko/luckperms/common/storage/implementation/file/SeparatedConfigurateStorage.java @@ -31,6 +31,7 @@ import me.lucko.luckperms.common.model.User; import me.lucko.luckperms.common.node.model.HeldNodeImpl; import me.lucko.luckperms.common.plugin.LuckPermsPlugin; import me.lucko.luckperms.common.storage.implementation.file.loader.ConfigurateLoader; +import me.lucko.luckperms.common.storage.implementation.file.watcher.FileWatcher; import me.lucko.luckperms.common.util.Iterators; import me.lucko.luckperms.common.util.MoreFiles; import me.lucko.luckperms.common.util.Uuids; diff --git a/common/src/main/java/me/lucko/luckperms/common/storage/implementation/file/watcher/AbstractFileWatcher.java b/common/src/main/java/me/lucko/luckperms/common/storage/implementation/file/watcher/AbstractFileWatcher.java new file mode 100644 index 000000000..fee3363bc --- /dev/null +++ b/common/src/main/java/me/lucko/luckperms/common/storage/implementation/file/watcher/AbstractFileWatcher.java @@ -0,0 +1,189 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package me.lucko.luckperms.common.storage.implementation.file.watcher; + +import java.io.IOException; +import java.nio.file.ClosedWatchServiceException; +import java.nio.file.FileSystem; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Utility for "watching" for file changes using a {@link WatchService}. + */ +public abstract class AbstractFileWatcher implements AutoCloseable { + + /** + * Get a {@link WatchKey} from the given {@link WatchService} in the given {@link Path directory}. + * + * @param watchService the watch service + * @param directory the directory + * @return the watch key + * @throws IOException if unable to register + */ + private static WatchKey register(WatchService watchService, Path directory) throws IOException { + return directory.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE, StandardWatchEventKinds.ENTRY_MODIFY); + } + + /** The watch service */ + private final WatchService service; + + /** A map of all registered watch keys */ + private final Map keys = Collections.synchronizedMap(new HashMap<>()); + + /** If this file watcher should discover directories */ + private final boolean autoRegisterNewSubDirectories; + + /** The thread currently being used to wait for & process watch events */ + private AtomicReference processingThread = new AtomicReference<>(); + + public AbstractFileWatcher(FileSystem fileSystem, boolean autoRegisterNewSubDirectories) throws IOException { + this.service = fileSystem.newWatchService(); + this.autoRegisterNewSubDirectories = autoRegisterNewSubDirectories; + } + + /** + * Register a watch key in the given directory. + * + * @param directory the directory + * @throws IOException if unable to register a key + */ + public void register(Path directory) throws IOException { + final WatchKey key = register(this.service, directory); + this.keys.put(key, directory); + } + + /** + * Register a watch key recursively in the given directory. + * + * @param root the root directory + * @throws IOException if unable to register a key + */ + public void registerRecursively(Path root) throws IOException { + Files.walkFileTree(root, new SimpleFileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + register(dir); + return super.preVisitDirectory(dir, attrs); + } + }); + } + + /** + * Process an observed watch event. + * + * @param event the event + * @param path the resolved event context + */ + protected abstract void processEvent(WatchEvent event, Path path); + + /** + * Processes {@link WatchEvent}s from the watch service until it is closed, or until + * the thread is interrupted. + */ + public final void runEventProcessingLoop() { + if (!this.processingThread.compareAndSet(null, Thread.currentThread())) { + throw new IllegalStateException("A thread is already processing events for this watcher."); + } + + while (true) { + // poll for a key from the watch service + WatchKey key; + try { + key = this.service.take(); + } catch (InterruptedException e) { + e.printStackTrace(); + break; + } catch (ClosedWatchServiceException e) { + break; + } + + // find the directory the key is watching + Path directory = this.keys.get(key); + if (directory == null) { + key.cancel(); + continue; + } + + // process each watch event the key has + for (WatchEvent ev : key.pollEvents()) { + @SuppressWarnings("unchecked") + WatchEvent event = (WatchEvent) ev; + + Path context = event.context(); + + // ignore contexts with a name count of zero + if (context.getNameCount() == 0) { + continue; + } + + // resolve the context of the event against the directory being watched + Path file = directory.resolve(context); + + // if the file is a regular file, send the event on to be processed + if (Files.isRegularFile(file)) { + processEvent(event, file); + } + + // handle recursive directory creation + if (this.autoRegisterNewSubDirectories && event.kind() == StandardWatchEventKinds.ENTRY_CREATE) { + try { + if (Files.isDirectory(file, LinkOption.NOFOLLOW_LINKS)) { + registerRecursively(file); + } + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + // reset the key + boolean valid = key.reset(); + if (!valid) { + this.keys.remove(key); + } + } + + this.processingThread.compareAndSet(Thread.currentThread(), null); + } + + @Override + public void close() throws IOException { + this.service.close(); + } +} diff --git a/common/src/main/java/me/lucko/luckperms/common/storage/implementation/file/watcher/FileWatcher.java b/common/src/main/java/me/lucko/luckperms/common/storage/implementation/file/watcher/FileWatcher.java new file mode 100644 index 000000000..4a1ada46a --- /dev/null +++ b/common/src/main/java/me/lucko/luckperms/common/storage/implementation/file/watcher/FileWatcher.java @@ -0,0 +1,143 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package me.lucko.luckperms.common.storage.implementation.file.watcher; + +import me.lucko.luckperms.common.plugin.LuckPermsPlugin; +import me.lucko.luckperms.common.util.ExpiringSet; +import me.lucko.luckperms.common.util.Iterators; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.WatchEvent; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** + * Simple implementation of {@link AbstractFileWatcher} for LuckPerms data files. + */ +public class FileWatcher extends AbstractFileWatcher { + + /** The base watched path */ + private final Path basePath; + + /** A map of watched locations with corresponding listeners */ + private final Map watchedLocations; + + public FileWatcher(LuckPermsPlugin plugin, Path basePath) throws IOException { + super(basePath.getFileSystem(), true); + this.watchedLocations = Collections.synchronizedMap(new HashMap<>()); + this.basePath = basePath; + + super.registerRecursively(basePath); + plugin.getBootstrap().getScheduler().executeAsync(super::runEventProcessingLoop); + } + + /** + * Gets a {@link WatchedLocation} instance for a given path. + * + * @param path the path to get a watcher for + * @return the watched location + */ + public WatchedLocation getWatcher(Path path) { + if (path.isAbsolute()) { + path = this.basePath.relativize(path); + } + return this.watchedLocations.computeIfAbsent(path, WatchedLocation::new); + } + + @Override + protected void processEvent(WatchEvent event, Path path) { + // get the relative path of the event + Path relativePath = this.basePath.relativize(path); + if (relativePath.getNameCount() == 0) { + return; + } + + // pass the event onto all watched locations that match + for (Map.Entry entry : this.watchedLocations.entrySet()) { + if (relativePath.startsWith(entry.getKey())) { + entry.getValue().onEvent(event, relativePath); + } + } + } + + /** + * Encapsulates a "watcher" in a specific directory. + */ + public static final class WatchedLocation { + /** The directory being watched by this instance. */ + private final Path path; + + /** A set of files which have been modified recently */ + private final Set recentlyModifiedFiles = new ExpiringSet<>(4, TimeUnit.SECONDS); + + /** The listener callback functions */ + private final List> callbacks = new CopyOnWriteArrayList<>(); + + WatchedLocation(Path path) { + this.path = path; + } + + void onEvent(WatchEvent event, Path path) { + // get the relative path of the modified file + Path relativePath = this.path.relativize(path); + + // check if the file has been modified recently + String fileName = relativePath.toString(); + if (!this.recentlyModifiedFiles.add(fileName)) { + return; + } + + // pass the event onto registered listeners + Iterators.tryIterate(this.callbacks, cb -> cb.accept(relativePath)); + } + + /** + * Record that a file has been changed recently. + * + * @param fileName the name of the file + */ + public void recordChange(String fileName) { + this.recentlyModifiedFiles.add(fileName); + } + + /** + * Register a listener. + * + * @param listener the listener + */ + public void addListener(Consumer listener) { + this.callbacks.add(listener); + } + } + +} diff --git a/common/src/main/java/me/lucko/luckperms/common/util/ExpiringSet.java b/common/src/main/java/me/lucko/luckperms/common/util/ExpiringSet.java index 3ff953fc1..70df598ab 100644 --- a/common/src/main/java/me/lucko/luckperms/common/util/ExpiringSet.java +++ b/common/src/main/java/me/lucko/luckperms/common/util/ExpiringSet.java @@ -28,9 +28,7 @@ package me.lucko.luckperms.common.util; import com.github.benmanes.caffeine.cache.Cache; import com.google.common.collect.ForwardingSet; -import org.checkerframework.checker.nullness.qual.NonNull; - -import java.util.Collection; +import java.util.Collections; import java.util.Set; import java.util.concurrent.TimeUnit; @@ -40,46 +38,11 @@ import java.util.concurrent.TimeUnit; * @param element type */ public class ExpiringSet extends ForwardingSet { - private final Cache cache; private final Set setView; public ExpiringSet(long duration, TimeUnit unit) { - this.cache = CaffeineFactory.newBuilder().expireAfterAccess(duration, unit).build(); - this.setView = this.cache.asMap().keySet(); - } - - @Override - public boolean add(@NonNull E element) { - this.cache.put(element, Boolean.TRUE); - - // we don't care about the return value - return true; - } - - @Override - public boolean addAll(@NonNull Collection collection) { - for (E element : collection) { - add(element); - } - - // we don't care about the return value - return true; - } - - @Override - public boolean remove(@NonNull Object key) { - this.cache.invalidate(key); - - // we don't care about the return value - return true; - } - - @Override - public boolean removeAll(@NonNull Collection keys) { - this.cache.invalidateAll(keys); - - // we don't care about the return value - return true; + Cache cache = CaffeineFactory.newBuilder().expireAfterAccess(duration, unit).build(); + this.setView = Collections.newSetFromMap(cache.asMap()); } @Override