package org.netbeans.gradle.project.util; import java.io.IOException; import java.nio.file.ClosedWatchServiceException; import java.nio.file.FileSystem; import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.NoSuchFileException; 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.nio.file.Watchable; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.logging.Level; import java.util.logging.Logger; import org.jtrim.cancel.Cancellation; import org.jtrim.cancel.CancellationToken; import org.jtrim.collections.RefLinkedList; import org.jtrim.collections.RefList; import org.jtrim.concurrent.CancelableTask; import org.jtrim.concurrent.CleanupTask; import org.jtrim.concurrent.GenericUpdateTaskExecutor; import org.jtrim.concurrent.MonitorableTaskExecutorService; import org.jtrim.concurrent.TaskExecutor; import org.jtrim.concurrent.UpdateTaskExecutor; import org.jtrim.event.ListenerRef; import org.jtrim.event.UnregisteredListenerRef; import org.jtrim.swing.concurrent.SwingTaskExecutor; import org.jtrim.utils.ExceptionHelper; import org.netbeans.gradle.project.api.event.NbListenerRefs; public final class FileSystemWatcher { private static final Logger LOGGER = Logger.getLogger(FileSystemWatcher.class.getName()); private static final WatchEvent.Kind<?>[] EVENTS = new WatchEvent.Kind<?>[]{ StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE }; private final FileSystem fileSystem; private final MonitorableTaskExecutorService pollExecutor; private final TaskExecutor eventExecutor; private WatchService activeWatchService; private final Map<Path, Listeners> checkedPaths; private final Map<WatchKey, WatchKeyListeners> watchKeys; private final Lock mainLock; public FileSystemWatcher(FileSystem fileSystem, TaskExecutor eventExecutor) { ExceptionHelper.checkNotNullArgument(fileSystem, "fileSystem"); ExceptionHelper.checkNotNullArgument(eventExecutor, "eventExecutor"); this.fileSystem = fileSystem; this.eventExecutor = eventExecutor; this.mainLock = new ReentrantLock(); this.pollExecutor = NbTaskExecutors.newStoppableExecutor("FileSystem-watcher-poll", 1); this.checkedPaths = new HashMap<>(); this.watchKeys = new HashMap<>(); this.activeWatchService = null; } public static FileSystemWatcher getDefault() { return DefaultHolder.DEFAULT; } /** * Waits until there are no more watches registered and no more polling is done. * Fails if that state cannot be reached. * <P> * This method is only for testing purposes to verify that this filesystem watcher cleans up * properly. */ void waitFor(long timeout, TimeUnit unit) { pollExecutor.shutdown(); if (!pollExecutor.tryAwaitTermination(Cancellation.UNCANCELABLE_TOKEN, timeout, unit)) { throw new IllegalStateException("Failed to wait for polling executor."); } mainLock.lock(); try { if (!checkedPaths.isEmpty()) { throw new IllegalStateException("There are checked paths: " + checkedPaths.keySet()); } if (!watchKeys.isEmpty()) { throw new IllegalStateException("There are watched keys: " + toString(watchKeys.keySet())); } } finally { mainLock.unlock(); } } private static String toString(Collection<WatchKey> keys) { List<Object> watchables = new ArrayList<>(keys.size()); for (WatchKey key: keys) { watchables.add(key.watchable()); } return watchables.toString(); } private Path tryResolve(Path keyContext, Object context) { if (context instanceof Path) { Path relPath = (Path)context; if (keyContext != null) { return keyContext.resolve(relPath); } else { return relPath; } } return null; } private void notifyPath(Path keyContext, WatchEvent<?> event) { Path path = tryResolve(keyContext, event.context()); if (path != null) { notifyPath(path); } } private void notifyPath(Path path) { Listeners listenerRefs; mainLock.lock(); try { listenerRefs = checkedPaths.get(path); } finally { mainLock.unlock(); } if (listenerRefs != null) { listenerRefs.notifyIfChanged(); } } private static void cancelWatchService(WatchService watchService) { try { watchService.close(); } catch (IOException ex) { LOGGER.log(Level.WARNING, "Failed to close watch service.", ex); } } private void stopPolling(WatchService watchService) throws IOException { WatchService newWatchService = null; mainLock.lock(); try { if (watchService == activeWatchService) { if (checkedPaths.isEmpty()) { activeWatchService = null; } else { newWatchService = fileSystem.newWatchService(); activeWatchService = newWatchService; } } } finally { mainLock.unlock(); } if (newWatchService != null) { startPolling(newWatchService); } } private boolean hasCheckedPaths() { mainLock.lock(); try { return !checkedPaths.isEmpty(); } finally { mainLock.unlock(); } } private static Path keyContext(WatchKey key) { Watchable result = key.watchable(); return result instanceof Path ? (Path)result : null; } private void startPolling(final WatchService watchService) { pollExecutor.execute(Cancellation.UNCANCELABLE_TOKEN, new CancelableTask() { @Override public void execute(CancellationToken cancelToken) throws Exception { ListenerRef cancelRef = null; try { cancelRef = cancelToken.addCancellationListener(new Runnable() { @Override public void run() { cancelWatchService(watchService); } }); while (!cancelToken.isCanceled() && hasCheckedPaths()) { WatchKey key = watchService.take(); Path keyContext = keyContext(key); List<WatchEvent<?>> events = key.pollEvents(); for (WatchEvent<?> event : events) { notifyPath(keyContext, event); } resetWatchKey(watchService, key, events); } } catch (ClosedWatchServiceException ex) { // Canceled } finally { if (cancelRef != null) { cancelRef.unregister(); } stopPolling(watchService); } } }, new CleanupTask() { @Override public void cleanup(boolean canceled, Throwable error) throws Exception { NbTaskExecutors.defaultCleanup(canceled, error); watchService.close(); } }); } public ListenerRef watchPath(final Path path, Runnable listener) { ExceptionHelper.checkNotNullArgument(path, "path"); ExceptionHelper.checkNotNullArgument(listener, "listener"); if (path.getFileSystem() != fileSystem) { return UnregisteredListenerRef.INSTANCE; } try { return watchPathUnsafe(path, listener); } catch (IOException ex) { LOGGER.log(Level.INFO, "Failed to watch for path: " + path, ex); return UnregisteredListenerRef.INSTANCE; } } private ListenerRef watchPathUnsafe(final Path path, Runnable listener) throws IOException { final ElementRemover listenerRemover; WatchService newWatchService = null; WatchService currentWatchService; Listeners listeners; mainLock.lock(); try { listeners = checkedPaths.get(path); if (listeners == null) { listeners = new Listeners(path, eventExecutor); checkedPaths.put(path, listeners); } listenerRemover = listeners.addListener(listener); if (activeWatchService == null) { newWatchService = fileSystem.newWatchService(); activeWatchService = newWatchService; } currentWatchService = activeWatchService; } finally { mainLock.unlock(); } if (newWatchService != null) { startPolling(newWatchService); } startWatching(currentWatchService, path, listeners); return NbListenerRefs.fromRunnable(new Runnable() { @Override public void run() { unregisterPath(path, listenerRemover); } }); } private void unregisterPath(Path path, ElementRemover listenerRemover) { WatchService watchServiceToClose = null; WatchKey watchKey; mainLock.lock(); try { int remaining = listenerRemover.removeAndGetRemainingCount(); if (remaining > 0) { return; } watchServiceToClose = activeWatchService; activeWatchService = null; Listeners listeners = checkedPaths.remove(path); if (listeners == null) { return; } watchKey = listeners.getWatchKey(); if (!removeWathKeyOfPath(path, watchKey)) { return; } } finally { mainLock.unlock(); tryClose(watchServiceToClose); } watchKey.cancel(); } private static void tryClose(WatchService watchService) { if (watchService == null) { return; } try { watchService.close(); } catch (IOException ex) { LOGGER.log(Level.INFO, "Failed to close watch service.", ex); } } private boolean removeWathKeyOfPath(Path path, WatchKey watchKey) { if (watchKey == null) { return false; } WatchKeyListeners watchKeyListeners = watchKeys.get(watchKey); if (watchKeyListeners == null) { return false; } if (watchKeyListeners.removeAndGetRemainingCount(path) > 0) { return false; } watchKeys.remove(watchKey); return true; } private static WatchKey tryRegister(WatchService watchService, Path path) throws IOException { try { if (!Files.exists(path)) { return null; } return path.register(watchService, EVENTS); } catch (NoSuchFileException ex) { return null; } } private void startWatching(WatchService watchService, Path path, Listeners listeners) throws IOException { Path parent = path.getParent(); while (parent != null) { WatchKey watchKey = tryRegister(watchService, parent); if (watchKey != null && watchKey.reset()) { WatchKey prevWatchKey; mainLock.lock(); try { WatchKeyListeners watchKeyListeners = watchKeys.get(watchKey); if (watchKeyListeners == null) { watchKeyListeners = new WatchKeyListeners(); watchKeys.put(watchKey, watchKeyListeners); } watchKeyListeners.setListeners(path, listeners); prevWatchKey = listeners.setWatchKey(watchKey); if (!removeWathKeyOfPath(path, prevWatchKey)) { return; } } finally { mainLock.unlock(); } prevWatchKey.cancel(); } parent = parent.getParent(); } } private boolean mayCreatedChild(List<WatchEvent<?>> events) { for (WatchEvent<?> event: events) { WatchEvent.Kind<?> kind = event.kind(); if (kind == StandardWatchEventKinds.ENTRY_CREATE || kind == StandardWatchEventKinds.OVERFLOW) { return true; } } return false; } private void resetWatchKey(WatchService watchService, WatchKey watchKey, List<WatchEvent<?>> events) throws IOException { if (mayCreatedChild(events)) { watchKey.cancel(); rebuildWatchKeys(watchService, watchKey); } else { if (!watchKey.reset()) { rebuildWatchKeys(watchService, watchKey); } } } private void rebuildWatchKeys(WatchService watchService, WatchKey watchKey) throws IOException { WatchKeyListeners watchKeyListeners = null; mainLock.lock(); try { watchKeyListeners = watchKeys.remove(watchKey); } finally { mainLock.unlock(); } if (watchKeyListeners != null) { List<Listeners> listenersSnapshot = new ArrayList<>(watchKeyListeners.listeners.size()); for (Map.Entry<Path, Listeners> entry: watchKeyListeners.listeners.entrySet()) { Listeners listeners = entry.getValue(); listenersSnapshot.add(listeners); startWatching(watchService, entry.getKey(), listeners); } for (Listeners listeners: listenersSnapshot) { listeners.notifyIfChanged(); } } } private static final class WatchKeyListeners { private final Map<Path, Listeners> listeners; public WatchKeyListeners() { this.listeners = new HashMap<>(); } public int removeAndGetRemainingCount(Path path) { listeners.remove(path); return listeners.size(); } public void setListeners(Path path, Listeners pathListeners) { listeners.put(path, pathListeners); } } private static final class Listeners { private final Path path; private final Lock listenersLock; private final UpdateTaskExecutor executor; private final RefList<Runnable> listeners; private volatile boolean notifiedOnce; private final AtomicBoolean lastState; private WatchKey watchKey; public Listeners(Path path, TaskExecutor eventExecutor) { this.path = path; this.executor = new GenericUpdateTaskExecutor(eventExecutor); this.listenersLock = new ReentrantLock(); this.listeners = new RefLinkedList<>(); this.watchKey = null; this.notifiedOnce = false; this.lastState = new AtomicBoolean(); } public WatchKey getWatchKey() { return watchKey; } public WatchKey setWatchKey(WatchKey watchKey) { WatchKey prevWatchKey = this.watchKey; if (Objects.equals(prevWatchKey, watchKey)) { prevWatchKey = null; } this.watchKey = watchKey; return prevWatchKey; } public ElementRemover addListener(Runnable listener) { final RefList.ElementRef<Runnable> elementRef; listenersLock.lock(); try { elementRef = listeners.addLastGetReference(listener); } finally { listenersLock.unlock(); } return new ElementRemover() { @Override public int removeAndGetRemainingCount() { listenersLock.lock(); try { elementRef.remove(); return listeners.size(); } finally { listenersLock.unlock(); } } }; } private boolean getState() { return Files.exists(path); } private boolean needNotify() { boolean currentState = getState(); if (!notifiedOnce) { notifiedOnce = true; lastState.set(currentState); return true; } boolean prevState = lastState.getAndSet(currentState); return prevState != currentState; } public void notifyIfChanged() { if (!needNotify()) { return; } executor.execute(new Runnable() { @Override public void run() { for (Runnable listener: getListenersSnapshot()) { try { listener.run(); } catch (Throwable ex) { LOGGER.log(Level.WARNING, "Path change listener has thrown an unexpected exception.", ex); } } } }); } private List<Runnable> getListenersSnapshot() { listenersLock.lock(); try { return new ArrayList<>(listeners); } finally { listenersLock.unlock(); } } } private interface ElementRemover { public int removeAndGetRemainingCount(); } private static final class DefaultHolder { private static final FileSystemWatcher DEFAULT = new FileSystemWatcher(FileSystems.getDefault(), SwingTaskExecutor.getStrictExecutor(true)); } }