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}