/* * Copyright 2015 MovingBlocks * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.terasology.assets.module; import com.google.common.base.Preconditions; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.LinkedHashMultimap; import com.google.common.collect.ListMultimap; import com.google.common.collect.Lists; import com.google.common.collect.MapMaker; import com.google.common.collect.Multimaps; import com.google.common.collect.Queues; import com.google.common.collect.SetMultimap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.terasology.assets.AssetType; import org.terasology.assets.ResourceUrn; import org.terasology.module.Module; import org.terasology.module.ModuleEnvironment; import org.terasology.naming.Name; import java.io.IOException; import java.nio.file.DirectoryStream; import java.nio.file.FileSystems; import java.nio.file.Files; 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.Arrays; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.BlockingDeque; /** * Centralised detector of changes to asset files (creation, modification, deletion), to inform ModuleAssetDataProducers of the changes * and trigger the reload of those assets. * <p> * One small note: Sometimes it is possible to get file change notifications before the change has actually happened. I observed this under windows * when using the New->Folder/New->File optional in explorer - the path will report as not existing unless you wait a short period of time. To address * this those events are added into a unreadyEvents queue and processed next check. * </p> * * @author Immortius */ class ModuleWatcher { private static final Logger logger = LoggerFactory.getLogger(ModuleWatcher.class); private final WatchService service; private final Map<WatchKey, PathWatcher> pathWatchers = new MapMaker().concurrencyLevel(1).makeMap(); private final Map<Path, WatchKey> watchKeys = new MapMaker().concurrencyLevel(1).makeMap(); private final ListMultimap<String, SubscriberInfo> subscribers = Multimaps.synchronizedListMultimap(ArrayListMultimap.create()); private final BlockingDeque<DelayedEvent> unreadyEvents = Queues.newLinkedBlockingDeque(); private boolean closed; /** * Creates a ModuleWatcher for the given module environment * * @param environment The environment to watch. * @throws IOException If there is an issue establishing the watch service */ public ModuleWatcher(ModuleEnvironment environment) throws IOException { this.service = environment.getFileSystem().newWatchService(); for (Path rootPath : environment.getFileSystem().getRootDirectories()) { try { Module module = environment.get(new Name(rootPath.getName(0).toString())); boolean canWatch = module.getLocations().stream().anyMatch(location -> FileSystems.getDefault().equals(location.getFileSystem())); if (canWatch) { PathWatcher watcher = new RootPathWatcher(rootPath, module.getId(), service); watcher.onRegistered(); } } catch (IOException e) { logger.warn("Failed to establish change watch service for path '{}'", rootPath, e); } } } /** * Registers an subscriber to a specific asset folder, for a given asset type. * * @param folderName The name of the folder to subscribe to - under which assets of interest lie (e.g. "textures") * @param subscriber The subscriber to notify of changes * @param assetType The asset type whom changes urns belong to */ public synchronized void register(String folderName, AssetFileChangeSubscriber subscriber, AssetType<?, ?> assetType) { Preconditions.checkState(!closed, "Cannot register folder into closed ModuleWatcher"); subscribers.put(folderName, new SubscriberInfo(assetType, subscriber)); } public synchronized boolean isClosed() { return closed; } /** * Shuts down the ModuleWatcher. * * @throws IOException If there is an error shutting down the service */ public synchronized void shutdown() throws IOException { if (!closed) { pathWatchers.clear(); watchKeys.clear(); service.close(); closed = true; } } /** * Checks the file system for any changes that affects assets. * * @return A set of ResourceUrns of changed assets. */ public synchronized SetMultimap<AssetType<?, ?>, ResourceUrn> checkForChanges() { if (closed) { return LinkedHashMultimap.create(); } SetMultimap<AssetType<?, ?>, ResourceUrn> changed = LinkedHashMultimap.create(); List<DelayedEvent> events = Lists.newArrayList(); unreadyEvents.drainTo(events); for (DelayedEvent event : events) { changed.putAll(event.replay()); } WatchKey key = service.poll(); while (key != null) { PathWatcher pathWatcher = pathWatchers.get(key); changed.putAll(pathWatcher.update(key.pollEvents(), Optional.of(unreadyEvents))); key.reset(); key = service.poll(); } return changed; } /** * Notifies subscribers of an asset file change * * @param folderName The asset folder in which the changed asset resides * @param target The path of the file * @param module The module the file contributes to * @param providingModule The module that provides the file * @param method The subscription method to call to notify * @param outChanged A map of asset types and their changed urns to add any modified resource urns to. */ private void notifySubscribers(String folderName, Path target, Name module, Name providingModule, SubscriptionMethod method, SetMultimap<AssetType<?, ?>, ResourceUrn> outChanged) { for (SubscriberInfo subscriber : subscribers.get(folderName)) { Optional<ResourceUrn> urn = method.notify(subscriber.subscriber, target, module, providingModule); if (urn.isPresent()) { outChanged.put(subscriber.type, urn.get()); } } } /** * Information on a subscriber. */ private static class SubscriberInfo { public final AssetType<?, ?> type; public final AssetFileChangeSubscriber subscriber; public SubscriberInfo(AssetType<?, ?> type, AssetFileChangeSubscriber subscriber) { this.type = type; this.subscriber = subscriber; } } /** * The form of a method to call to notify a subscriber of changes */ private interface SubscriptionMethod { Optional<ResourceUrn> notify(AssetFileChangeSubscriber subscriber, Path path, Name module, Name providingModule); } /** * A PathWatcher watches a path for changes, and reacts to those changes. */ private abstract class PathWatcher { private Path watchedPath; private WatchService watchService; public PathWatcher(Path path, WatchService watchService) throws IOException { this.watchedPath = path; this.watchService = watchService; WatchKey key = path.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE); if (key.isValid()) { pathWatchers.put(key, this); watchKeys.put(path, key); } } public Path getWatchedPath() { return watchedPath; } public WatchService getWatchService() { return watchService; } @SuppressWarnings("unchecked") public final SetMultimap<AssetType<?, ?>, ResourceUrn> update(List<WatchEvent<?>> watchEvents, Optional<Collection<DelayedEvent>> outDelayedEvents) { final SetMultimap<AssetType<?, ?>, ResourceUrn> changedAssets = LinkedHashMultimap.create(); for (WatchEvent<?> event : watchEvents) { WatchEvent.Kind kind = event.kind(); if (kind == StandardWatchEventKinds.OVERFLOW) { logger.warn("File event overflow - lost change events"); continue; } WatchEvent<Path> pathEvent = (WatchEvent<Path>) event; Path target = watchedPath.resolve(pathEvent.context()); if (event.kind() == StandardWatchEventKinds.ENTRY_CREATE) { if (Files.isDirectory(target)) { logger.debug("New directory registered: {}", target); onDirectoryCreated(target, changedAssets); } else if (Files.isRegularFile(target)) { onFileCreated(target, changedAssets); } else if (outDelayedEvents.isPresent()) { outDelayedEvents.get().add(new DelayedEvent(event, this)); } } else if (event.kind() == StandardWatchEventKinds.ENTRY_MODIFY) { if (Files.isRegularFile(target)) { onFileModified(target, changedAssets); } } else if (event.kind() == StandardWatchEventKinds.ENTRY_DELETE) { WatchKey key = watchKeys.remove(target); if (key != null) { pathWatchers.remove(key); } else { onFileDeleted(target, changedAssets); } } } return changedAssets; } private void onDirectoryCreated(Path target, SetMultimap<AssetType<?, ?>, ResourceUrn> outChanged) { try { Optional<? extends PathWatcher> pathWatcher = processPath(target); if (pathWatcher.isPresent()) { pathWatcher.get().onCreated(outChanged); } } catch (IOException e) { logger.error("Error registering path for change watching '{}'", getWatchedPath(), e); } } /** * Called when the path watcher is registered for an existing path */ public final void onRegistered() { try (DirectoryStream<Path> contents = Files.newDirectoryStream(getWatchedPath())) { for (Path path : contents) { if (Files.isDirectory(path)) { Optional<? extends PathWatcher> pathWatcher = processPath(path); if (pathWatcher.isPresent()) { pathWatcher.get().onRegistered(); } } } } catch (IOException e) { logger.error("Error registering path for change watching '{}'", getWatchedPath(), e); } } /** * Called when the path watcher is for a newly created path * * @param outChanged The ResourceUrns of any assets affected by the creation of this path */ public final void onCreated(SetMultimap<AssetType<?, ?>, ResourceUrn> outChanged) { try (DirectoryStream<Path> contents = Files.newDirectoryStream(getWatchedPath())) { for (Path path : contents) { if (Files.isDirectory(path)) { onDirectoryCreated(path, outChanged); } else { onFileCreated(path, outChanged); } } } catch (IOException e) { logger.error("Error registering path for change watching '{}'", getWatchedPath(), e); } } /** * Processes a path within this path watcher * * @param target The path to process * @return A new path watcher for the path * @throws IOException If there was any issue processing the path */ protected abstract Optional<? extends PathWatcher> processPath(Path target) throws IOException; /** * Called when a file is created * * @param target The created file * @param outChanged The ResourceUrns of any assets affected */ protected void onFileCreated(Path target, final SetMultimap<AssetType<?, ?>, ResourceUrn> outChanged) { } /** * Called when a file is modified * * @param target The modified file * @param outChanged The ResourceUrns of any assets affected */ protected void onFileModified(Path target, final SetMultimap<AssetType<?, ?>, ResourceUrn> outChanged) { } /** * Called when a file is deleted * * @param target The deleted file * @param outChanged The ResourceUrns of any assets affected */ protected void onFileDeleted(Path target, final SetMultimap<AssetType<?, ?>, ResourceUrn> outChanged) { } } private class RootPathWatcher extends PathWatcher { private Name module; public RootPathWatcher(Path path, Name module, WatchService watchService) throws IOException { super(path, watchService); this.module = module; } @Override protected Optional<? extends PathWatcher> processPath(Path target) throws IOException { if (target.getNameCount() == 2) { switch (target.getName(1).toString()) { case ModuleAssetDataProducer.ASSET_FOLDER: { return Optional.of(new AssetRootPathWatcher(target, module, getWatchService())); } case ModuleAssetDataProducer.DELTA_FOLDER: { return Optional.of(new DeltaRootPathWatcher(target, module, getWatchService())); } case ModuleAssetDataProducer.OVERRIDE_FOLDER: { return Optional.of(new OverrideRootPathWatcher(target, module, getWatchService())); } } } return Optional.empty(); } } private class AssetRootPathWatcher extends PathWatcher { private Name module; public AssetRootPathWatcher(Path path, Name module, WatchService watchService) throws IOException { super(path, watchService); this.module = module; } @Override protected Optional<? extends PathWatcher> processPath(Path target) throws IOException { if (target.getNameCount() == 3) { return Optional.of(new AssetPathWatcher(target, target.getName(2).toString(), module, module, getWatchService())); } return Optional.empty(); } } private class OverrideRootPathWatcher extends PathWatcher { private Name module; public OverrideRootPathWatcher(Path path, Name module, WatchService watchService) throws IOException { super(path, watchService); this.module = module; } @Override protected Optional<? extends PathWatcher> processPath(Path target) throws IOException { if (target.getNameCount() == 3) { return Optional.of(new OverrideRootPathWatcher(target, module, getWatchService())); } else if (target.getNameCount() == 4) { return Optional.of(new AssetPathWatcher(target, target.getName(3).toString(), new Name(target.getName(2).toString()), module, getWatchService())); } return Optional.empty(); } } private class DeltaRootPathWatcher extends PathWatcher { private Name module; public DeltaRootPathWatcher(Path path, Name module, WatchService watchService) throws IOException { super(path, watchService); this.module = module; } @Override protected Optional<? extends PathWatcher> processPath(Path target) throws IOException { if (target.getNameCount() == 3) { return Optional.of(new DeltaRootPathWatcher(target, module, getWatchService())); } else if (target.getNameCount() == 4) { return Optional.of(new DeltaPathWatcher(target, new Name(target.getName(2).toString()), module, getWatchService())); } return Optional.empty(); } } private class DeltaPathWatcher extends PathWatcher { private final Name providingModule; private final Name module; public DeltaPathWatcher(Path path, Name module, Name providingModule, WatchService watchService) throws IOException { super(path, watchService); this.module = module; this.providingModule = providingModule; } @Override protected Optional<? extends PathWatcher> processPath(Path target) throws IOException { return Optional.of(new DeltaPathWatcher(target, module, providingModule, getWatchService())); } @Override protected void onFileCreated(Path target, SetMultimap<AssetType<?, ?>, ResourceUrn> outChanged) { logger.debug("Delta added: {}", target); String folderName = target.getName(3).toString(); notifySubscribers(folderName, target, module, providingModule, AssetFileChangeSubscriber::deltaFileAdded, outChanged); } @Override protected void onFileModified(Path target, SetMultimap<AssetType<?, ?>, ResourceUrn> outChanged) { logger.debug("Delta modified: {}", target); String folderName = target.getName(3).toString(); notifySubscribers(folderName, target, module, providingModule, AssetFileChangeSubscriber::deltaFileModified, outChanged); } @Override protected void onFileDeleted(Path target, SetMultimap<AssetType<?, ?>, ResourceUrn> outChanged) { logger.debug("Delta deleted: {}", target); String folderName = target.getName(3).toString(); notifySubscribers(folderName, target, module, providingModule, AssetFileChangeSubscriber::deltaFileDeleted, outChanged); } } private class AssetPathWatcher extends PathWatcher { private String folderName; private Name module; private Name providingModule; public AssetPathWatcher(Path path, String folderName, Name module, Name providingModule, WatchService watchService) throws IOException { super(path, watchService); this.folderName = folderName; this.module = module; this.providingModule = providingModule; } @Override protected Optional<? extends PathWatcher> processPath(Path target) throws IOException { return Optional.of(new AssetPathWatcher(target, folderName, module, providingModule, getWatchService())); } @Override protected void onFileCreated(Path target, SetMultimap<AssetType<?, ?>, ResourceUrn> outChanged) { logger.debug("Asset added: {}", target); notifySubscribers(folderName, target, module, providingModule, AssetFileChangeSubscriber::assetFileAdded, outChanged); } @Override protected void onFileModified(Path target, SetMultimap<AssetType<?, ?>, ResourceUrn> outChanged) { logger.debug("Asset modified: {}", target); notifySubscribers(folderName, target, module, providingModule, AssetFileChangeSubscriber::assetFileModified, outChanged); } @Override protected void onFileDeleted(Path target, SetMultimap<AssetType<?, ?>, ResourceUrn> outChanged) { logger.debug("Asset deleted: {}", target); notifySubscribers(folderName, target, module, providingModule, AssetFileChangeSubscriber::assetFileDeleted, outChanged); } } private static class DelayedEvent { private WatchEvent<?> event; private PathWatcher watcher; public DelayedEvent(WatchEvent<?> event, PathWatcher watcher) { this.event = event; this.watcher = watcher; } public SetMultimap<AssetType<?, ?>, ResourceUrn> replay() { return watcher.update(Arrays.asList(event), Optional.empty()); } } }