001/* 002 * Configurate 003 * Copyright (C) zml and Configurate contributors 004 * 005 * Licensed under the Apache License, Version 2.0 (the "License"); 006 * you may not use this file except in compliance with the License. 007 * You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.spongepowered.configurate.reference; 018 019import static java.util.Objects.requireNonNull; 020 021import org.checkerframework.checker.nullness.qual.Nullable; 022import org.spongepowered.configurate.ConfigurateException; 023import org.spongepowered.configurate.ScopedConfigurationNode; 024import org.spongepowered.configurate.loader.ConfigurationLoader; 025import org.spongepowered.configurate.reactive.Disposable; 026import org.spongepowered.configurate.reactive.Subscriber; 027 028import java.io.IOException; 029import java.nio.file.ClosedWatchServiceException; 030import java.nio.file.FileSystem; 031import java.nio.file.FileSystems; 032import java.nio.file.Files; 033import java.nio.file.Path; 034import java.nio.file.StandardWatchEventKinds; 035import java.nio.file.WatchEvent; 036import java.nio.file.WatchKey; 037import java.nio.file.WatchService; 038import java.util.HashSet; 039import java.util.Set; 040import java.util.concurrent.ConcurrentHashMap; 041import java.util.concurrent.Executor; 042import java.util.concurrent.ForkJoinPool; 043import java.util.concurrent.ThreadFactory; 044import java.util.function.Function; 045 046/** 047 * A wrapper around NIO's {@link WatchService} that uses the provided watch key 048 * to poll for changes, and calls listeners once an event occurs. 049 * 050 * <p>Some deduplication is performed because Windows can be fairly spammy with 051 * its events, so one callback may receive multiple events at one time.</p> 052 * 053 * <p>Callback functions are {@link Subscriber Subscribers} that take the 054 * {@link WatchEvent} as their parameter.</p> 055 * 056 * <p>Listening to a directory provides updates on the directory's immediate 057 * children, but does not listen recursively.</p> 058 * 059 * @since 4.0.0 060 */ 061public final class WatchServiceListener implements AutoCloseable { 062 063 @SuppressWarnings("rawtypes") // IntelliJ says it's unnecessary, but the compiler shows warnings 064 private static final WatchEvent.Kind<?>[] DEFAULT_WATCH_EVENTS = new WatchEvent.Kind[]{StandardWatchEventKinds.OVERFLOW, 065 StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE, StandardWatchEventKinds.ENTRY_MODIFY}; 066 private static final int PARALLEL_THRESHOLD = 100; 067 private static final ThreadFactory DEFAULT_THREAD_FACTORY = new PrefixedNameThreadFactory("Configurate-WatchService", true); 068 069 private final WatchService watchService; 070 private volatile boolean open = true; 071 private final Thread executor; 072 final Executor taskExecutor; 073 @SuppressWarnings("PMD.LooseCoupling") // we use implementation-specific API 074 private final ConcurrentHashMap<Path, DirectoryListenerRegistration> activeListeners = new ConcurrentHashMap<>(); 075 private static final ThreadLocal<IOException> exceptionHolder = new ThreadLocal<>(); 076 077 /** 078 * Returns a new builder for a WatchServiceListener to create a 079 * customized listener. 080 * 081 * @return a new builder 082 * @since 4.0.0 083 */ 084 public static Builder builder() { 085 return new Builder(); 086 } 087 088 /** 089 * Create a new {@link WatchServiceListener} using a new cached thread pool 090 * executor and the default filesystem. 091 * 092 * @return a new instance with default values 093 * @throws IOException if a watch service cannot be created 094 * @see #builder() for customization 095 * @since 4.0.0 096 */ 097 public static WatchServiceListener create() throws IOException { 098 return new WatchServiceListener(DEFAULT_THREAD_FACTORY, FileSystems.getDefault(), ForkJoinPool.commonPool()); 099 } 100 101 private WatchServiceListener(final ThreadFactory factory, final FileSystem fileSystem, final Executor taskExecutor) throws IOException { 102 this.watchService = fileSystem.newWatchService(); 103 this.executor = factory.newThread(() -> { 104 while (this.open) { 105 final WatchKey key; 106 try { 107 key = this.watchService.take(); 108 } catch (final InterruptedException e) { 109 this.open = false; 110 Thread.currentThread().interrupt(); 111 break; 112 } catch (final ClosedWatchServiceException e) { 113 break; 114 } 115 final Path watched = (Path) key.watchable(); 116 final DirectoryListenerRegistration registration = this.activeListeners.get(watched); 117 if (registration != null) { 118 final Set<Object> seenContexts = new HashSet<>(); 119 for (WatchEvent<?> event : key.pollEvents()) { 120 if (!key.isValid()) { 121 break; 122 } 123 124 if (!seenContexts.add(event.context())) { 125 continue; 126 } 127 128 // Process listeners 129 registration.submit(event); 130 if (registration.closeIfEmpty()) { 131 key.cancel(); 132 break; 133 } 134 } 135 136 // If the watch key is no longer valid, send all listeners a close event 137 if (!key.reset()) { 138 final DirectoryListenerRegistration oldListeners = this.activeListeners.remove(watched); 139 oldListeners.onClose(); 140 } 141 } 142 try { 143 Thread.sleep(20); 144 } catch (final InterruptedException e) { 145 Thread.currentThread().interrupt(); 146 break; 147 } 148 } 149 }); 150 this.taskExecutor = taskExecutor; 151 this.executor.start(); 152 } 153 154 /** 155 * Gets or creates a registration holder for a specific directory. This 156 * handles registering with the watch service if necessary. 157 * 158 * @param directory the directory to listen to 159 * @return a registration, created new if necessary. 160 * @throws ConfigurateException if produced while registering the path with 161 * our WatchService 162 */ 163 private DirectoryListenerRegistration registration(final Path directory) throws ConfigurateException { 164 final @Nullable DirectoryListenerRegistration reg = this.activeListeners.computeIfAbsent(directory, dir -> { 165 try { 166 return new DirectoryListenerRegistration(dir.register(this.watchService, DEFAULT_WATCH_EVENTS), this.taskExecutor); 167 } catch (final IOException ex) { 168 exceptionHolder.set(ex); 169 return null; 170 } 171 }); 172 173 if (reg == null) { 174 throw new ConfigurateException("While adding listener for " + directory, exceptionHolder.get()); 175 } 176 return reg; 177 } 178 179 /** 180 * Listen for changes to a specific file or directory. 181 * 182 * @param file the path of the file or directory to listen for changes on. 183 * @param callback a subscriber that will be notified when changes occur. 184 * @return a {@link Disposable} that can be used to cancel this subscription 185 * @throws ConfigurateException if a filesystem error occurs. 186 * @throws IllegalArgumentException if the provided path is a directory. 187 * @since 4.0.0 188 */ 189 public Disposable listenToFile(Path file, final Subscriber<WatchEvent<?>> callback) throws ConfigurateException, IllegalArgumentException { 190 file = file.toAbsolutePath(); 191 if (Files.isDirectory(file)) { 192 throw new IllegalArgumentException("Path " + file + " must be a file"); 193 } 194 195 final Path fileName = file.getFileName(); 196 return registration(file.getParent()).subscribe(fileName, callback); 197 } 198 199 /** 200 * Listen to a directory. Callbacks will receive events both for the 201 * directory and for its contents. 202 * 203 * @param directory the directory to listen to 204 * @param callback a subscriber that will be notified when changes occur. 205 * @return a {@link Disposable} that can be used to cancel this subscription 206 * @throws ConfigurateException when an error occurs registering with the 207 * underlying watch service. 208 * @throws IllegalArgumentException if the provided path is not a directory 209 * @since 4.0.0 210 */ 211 public Disposable listenToDirectory(Path directory, final Subscriber<WatchEvent<?>> callback) 212 throws ConfigurateException, IllegalArgumentException { 213 directory = directory.toAbsolutePath(); 214 if (!(Files.isDirectory(directory) || !Files.exists(directory))) { 215 throw new IllegalArgumentException("Path " + directory + " must be a directory"); 216 } 217 218 return registration(directory).subscribe(callback); 219 } 220 221 /** 222 * Create a new {@link ConfigurationReference} subscribed to FS updates. 223 * 224 * @param loaderFunc function that will create a new loader 225 * @param path path to to for changes 226 * @param <N> node type 227 * @return new reference 228 * @throws ConfigurateException if unable to complete an initial load of 229 * the configuration. 230 * @since 4.0.0 231 */ 232 public <N extends ScopedConfigurationNode<N>> ConfigurationReference<N> 233 listenToConfiguration(final Function<Path, ConfigurationLoader<? extends N>> loaderFunc, final Path path) throws ConfigurateException { 234 return ConfigurationReference.watching(loaderFunc, path, this); 235 } 236 237 @Override 238 public void close() throws IOException { 239 this.open = false; 240 this.watchService.close(); 241 this.activeListeners.forEachValue(PARALLEL_THRESHOLD, DirectoryListenerRegistration::onClose); 242 this.activeListeners.clear(); 243 try { 244 this.executor.interrupt(); 245 this.executor.join(); 246 } catch (final InterruptedException e) { 247 throw new IOException("Failed to await termination of executor thread!"); 248 } 249 } 250 251 /** 252 * Set the parameters needed to create a {@link WatchServiceListener}. All params are optional and defaults will be 253 * used if no values are specified. 254 * 255 * @since 4.0.0 256 */ 257 public static final class Builder { 258 259 private @Nullable ThreadFactory threadFactory; 260 private @Nullable FileSystem fileSystem; 261 private @Nullable Executor taskExecutor; 262 263 private Builder() { } 264 265 /** 266 * Set the thread factory that will be used to create the polling thread 267 * for the returned watch service. 268 * 269 * @param factory the thread factory to use to create the deamon thread 270 * @return this builder 271 * @since 4.0.0 272 */ 273 public Builder threadFactory(final ThreadFactory factory) { 274 this.threadFactory = requireNonNull(factory, "factory"); 275 return this; 276 } 277 278 /** 279 * Set the executor that will be used to execute tasks queued based on 280 * received events. By default, the 281 * {@link ForkJoinPool#commonPool() common pool} is used. 282 * 283 * @param executor the executor to use 284 * @return this builder 285 * @since 4.0.0 286 */ 287 public Builder taskExecutor(final Executor executor) { 288 this.taskExecutor = requireNonNull(executor, "executor"); 289 return this; 290 } 291 292 /** 293 * Set the filesystem expected to be used for paths. A separate 294 * {@link WatchServiceListener} should be created to listen to events on 295 * each different file system. 296 * 297 * @param system the file system to use. 298 * @return this builder 299 * @since 4.0.0 300 */ 301 public Builder fileSystem(final FileSystem system) { 302 this.fileSystem = system; 303 return this; 304 } 305 306 /** 307 * Create a new listener, using default values for any unset parameters. 308 * 309 * @return a newly created executor 310 * @throws IOException if thrown by {@link WatchServiceListener}'s constructor 311 * @since 4.0.0 312 */ 313 public WatchServiceListener build() throws IOException { 314 if (this.threadFactory == null) { 315 this.threadFactory = DEFAULT_THREAD_FACTORY; 316 } 317 318 if (this.fileSystem == null) { 319 this.fileSystem = FileSystems.getDefault(); 320 } 321 322 if (this.taskExecutor == null) { 323 this.taskExecutor = ForkJoinPool.commonPool(); 324 } 325 326 return new WatchServiceListener(this.threadFactory, this.fileSystem, this.taskExecutor); 327 } 328 329 } 330 331}