/* * Copyright (c) 2013-2014 the original author or authors * * 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 io.werval.devshell; import java.io.File; import java.io.IOException; import java.nio.file.FileSystems; import java.nio.file.FileVisitResult; import java.nio.file.Path; import java.nio.file.SimpleFileVisitor; import java.nio.file.WatchEvent; import java.nio.file.WatchKey; import java.nio.file.WatchService; import java.nio.file.attribute.BasicFileAttributes; import java.util.List; import java.util.Objects; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import io.werval.util.LinkedMultiValueMap; import io.werval.util.MultiValueMap; import io.werval.spi.dev.DevShellSPI.SourceChangeListener; import io.werval.spi.dev.DevShellSPI.SourceWatch; import io.werval.spi.dev.DevShellSPI.SourceWatcher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import static java.nio.file.Files.exists; import static java.nio.file.Files.isDirectory; import static java.nio.file.Files.isRegularFile; import static java.nio.file.Files.isSymbolicLink; import static java.nio.file.Files.walkFileTree; import static java.nio.file.LinkOption.NOFOLLOW_LINKS; import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE; import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE; import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY; import static java.nio.file.StandardWatchEventKinds.OVERFLOW; import static io.werval.util.IllegalArguments.ensureNotNull; import static io.werval.util.Iterables.first; /** * Java WatchService based SourceWatcher. * <p> * Based on the <a href="http://docs.oracle.com/javase/tutorial/essential/io/notification.html">Watching a Directory * for Changes</a> Java Tutorial. * <p> * Adapted for Werval needs and extended to support watching individual files and deletion of watched directories. */ // Note that thanks to the akward Java WatchService API, this code is pretty fragile. // This implementation is greedy and may leak. Not critical tough as it is used in dev mode only. // ===================================================================================================================== // TODO JavaWatcher> Handle OVERFLOW> reprocess watched directory // Could lead to missed events if it prevents new watches registration. // Not critical as this would be after stating that the source changed. // TODO JavaWatcher> Refactor to move WatchedDirectory deletion handling into the main logic public class JavaWatcher implements SourceWatcher { private abstract static class Watched { protected final Path path; Watched( Path path ) { this.path = path; } final Path path() { return path; } boolean satisfiedBy( WatchEvent<?> event ) { return true; } @Override public int hashCode() { int hash = 5; hash = 41 * hash + Objects.hashCode( this.path ); return hash; } @Override public boolean equals( Object obj ) { if( this == obj ) { return true; } if( obj == null ) { return false; } if( getClass() != obj.getClass() ) { return false; } final Watched other = (Watched) obj; return Objects.equals( this.path, other.path ); } @Override public String toString() { return getClass().getSimpleName() + "{" + "path=" + path + '}'; } } private static final class WatchedDirectory extends Watched { WatchedDirectory( Path path ) { super( path ); ensureNotNull( "Watched Directory Path", path ); } } private static final class WatchedSingleFile extends Watched { WatchedSingleFile( Path path ) { super( path ); ensureNotNull( "Watched Single File Path", path ); } @Override boolean satisfiedBy( WatchEvent<?> event ) { if( event.context() != null && event.context() instanceof Path ) { Path eventPath = (Path) event.context(); if( path.getFileName().equals( eventPath ) ) { return true; } } return false; } } private static final class WatchedAbsent extends Watched { private final Path upstream; WatchedAbsent( Path path, Path upstream ) { super( path ); ensureNotNull( "Watched Absent Path", path ); ensureNotNull( "Watched Absent Upstream Path", upstream ); this.upstream = upstream; } public Path upstream() { return upstream; } @Override boolean satisfiedBy( WatchEvent<?> event ) { if( event.context() != null && event.context() instanceof Path ) { Path eventPath = (Path) event.context(); if( path.startsWith( upstream.resolve( eventPath ) ) ) { return true; } } return false; } @Override public int hashCode() { int hash = super.hashCode(); hash = 17 * hash + Objects.hashCode( this.upstream ); return hash; } @Override public boolean equals( Object obj ) { if( this == obj ) { return true; } if( obj == null ) { return false; } if( getClass() != obj.getClass() ) { return false; } final WatchedAbsent other = (WatchedAbsent) obj; return !( !Objects.equals( this.upstream, other.upstream ) || !Objects.equals( path(), other.path() ) ); } } private static final class ArtificialWatchedDirectoryDeletedEvent implements WatchEvent<Path> { private final Path context; private ArtificialWatchedDirectoryDeletedEvent( Path context ) { this.context = context; } @Override public Kind<Path> kind() { return ENTRY_DELETE; } @Override public int count() { return 1; } @Override public Path context() { return context; } } private static final class SourceChangeWatcher implements Runnable { private final WatchService watchService; private final MultiValueMap<WatchKey, Watched> keys; private final SourceChangeListener listener; private boolean run = true; private SourceChangeWatcher( WatchService watchService, MultiValueMap<WatchKey, Watched> keys, SourceChangeListener listener ) throws IOException { this.watchService = watchService; this.keys = keys; this.listener = listener; } @Override public void run() { for( ;; ) { if( !run ) { LOG.trace( "Source Change Watcher Thread stopped" ); return; } // Wait for a key to be signalled WatchKey key; try { key = watchService.take(); } catch( InterruptedException ex ) { Thread.interrupted(); LOG.trace( "Source Change Watcher Thread interrupted" ); return; } List<Watched> watcheds = keys.get( key ); if( watcheds == null || watcheds.isEmpty() ) { LOG.warn( "WatchKey not recognized!!" ); continue; } // Retrieve events for the signalled key List<WatchEvent<?>> watchEvents = key.pollEvents(); LOG.trace( ">> {} total events for {}", watchEvents.size(), watcheds ); // Has source changed? boolean sourceChanged = false; MultiValueMap<Watched, WatchEvent<Path>> matchedEvents = new LinkedMultiValueMap<>(); // Collect WatchEvents for each matching Watched for( Watched watched : watcheds ) { for( WatchEvent<?> watchEvent : watchEvents ) { if( watched.satisfiedBy( watchEvent ) ) { matchedEvents.add( watched, (WatchEvent<Path>) watchEvent ); } } } // WatchedDirectory special handling for( final Watched watched : watcheds ) { if( watched instanceof WatchedDirectory && !matchedEvents.containsKey( watched ) && watchEvents.isEmpty() && !exists( watched.path() ) ) { LOG.trace( "{} deleted! Inserting an artificial event.", watched ); matchedEvents.add( watched, new ArtificialWatchedDirectoryDeletedEvent( watched.path() ) ); } } // Handle watch matches for( Watched watched : matchedEvents.keySet() ) { LOG.trace( ">>>> {} matching events for {}", matchedEvents.get( watched ).size(), watched ); if( watched instanceof WatchedDirectory ) { LOG.trace( "Directory changed {}", watched.path().toAbsolutePath() ); sourceChanged = true; if( exists( watched.path() ) ) { // Recursively watch newly created sub-directories for( WatchEvent<Path> event : matchedEvents.get( watched ) ) { WatchEvent.Kind<?> kind = event.kind(); if( kind == OVERFLOW ) { LOG.trace( "{} events may have been lost or discarded.", event.count() ); continue; } // Context for directory entry event is the file name of entry WatchEvent<Path> ev = cast( event ); Path name = ev.context(); Path child = watched.path().resolve( name ); LOG.trace( "{}: {}", event.kind().name(), child ); // if directory is created then register it and its sub-directories if( kind == ENTRY_CREATE && isDirectory( child, NOFOLLOW_LINKS ) ) { try { registerDirectory( child, watchService, keys ); LOG.trace( "Watching newly created directory {}", child.toAbsolutePath() ); } catch( IOException ex ) { LOG.warn( "Unable to watch newly created directory: {}", ex.getMessage(), ex ); } } } } else { try { registerAbsent( watched.path(), watchService, keys ); } catch( IOException ex ) { LOG.warn( "Unable to watch absent '{}' on watched directory deletion: {}", watched.path(), ex.getMessage(), ex ); } finally { // key.cancel(); } } } else if( watched instanceof WatchedSingleFile ) { LOG.trace( "Single File changed {}", watched.path().toAbsolutePath() ); sourceChanged = true; if( !exists( watched.path().getParent() ) ) { try { registerAbsent( watched.path(), watchService, keys ); } catch( IOException ex ) { LOG.warn( "Unable to watch absent '{}' on single-file watch parent deletion: {}", watched.path(), ex.getMessage(), ex ); } finally { // key.cancel(); } } } else if( watched instanceof WatchedAbsent ) { LOG.trace( "Absent File upstream changed {}", watched.path().toAbsolutePath() ); if( exists( watched.path() ) ) { // Created sourceChanged = true; if( isRegularFile( watched.path(), NOFOLLOW_LINKS ) ) { try { registerSingleFile( watched.path(), watchService, keys ); LOG.trace( "Watching newly created (was absent) single file {}", watched.path() ); // key.cancel(); } catch( IOException ex ) { LOG.warn( "Unable to watch newly created (was absent) single file: {}", ex.getMessage(), ex ); } } else if( isDirectory( watched.path(), NOFOLLOW_LINKS ) ) { try { registerDirectory( watched.path(), watchService, keys ); LOG.trace( "Watching newly created (was absent) directory {}", watched.path() ); // key.cancel(); } catch( IOException ex ) { LOG.warn( "Unable to watch newly created (was absent) directory: {}", ex.getMessage(), ex ); } } else { LOG.warn( "Absent File '" + watched.path() + "' created but it is neither a directory nor a regular file, ignoring." ); } } else { for( WatchEvent<Path> event : matchedEvents.get( watched ) ) { WatchEvent.Kind<?> kind = event.kind(); if( kind == OVERFLOW ) { LOG.trace( "{} events may have been lost or discarded.", event.count() ); continue; } // Context for directory entry event is the file name of entry WatchEvent<Path> ev = cast( event ); Path name = ev.context(); Path child = ( (WatchedAbsent) watched ).upstream().resolve( name ); if( !watched.path().startsWith( child ) ) { // Ignore non relevant event, not in the target path continue; } if( kind == ENTRY_CREATE && isDirectory( child, NOFOLLOW_LINKS ) ) { try { registerAbsent( watched.path(), watchService, keys ); LOG.trace( "Watching newly created upstream path of absent {}", watched.path() ); // key.cancel(); } catch( IOException ex ) { LOG.warn( "Unable to update absent watch after path component creation: {}", ex.getMessage(), ex ); } } } } } else { throw new InternalError( "Something is wrong with " + JavaWatcher.class.getName() + " codebase, please report!" ); } } // Notify source change if( sourceChanged ) { listener.onChange(); } // reset key and remove from set if invalid boolean valid = key.reset(); if( !valid ) { keys.remove( key ); // for( Watched watched : watcheds ) // { // if( !exists( watched.path() ) ) // { // try // { // registerAbsent( watched.path(), watchService, keys ); // LOG.trace( "Deleted {}, now watching as absent", watched.path() ); // } // catch( IOException ex ) // { // LOG.warn( // "Unable to watch '{}' as absent, just got deleted: {}", // watched.path(), ex.getMessage(), ex // ); // } // sourceChanged = true; // } // } } // all watched are gone if( keys.isEmpty() ) { LOG.warn( "Nothing left to watch, stopping source watcher thread!" ); break; } } } private void stop() { run = false; } private <T> WatchEvent<T> cast( WatchEvent<?> event ) { return (WatchEvent<T>) event; } } private static final Logger LOG = LoggerFactory.getLogger( JavaWatcher.class ); private static final AtomicInteger THREAD_NUMBER = new AtomicInteger(); private static final WatchEvent.Kind<?>[] WATCHED_EVENT_KINDS; private static final WatchEvent.Modifier[] WATCHED_MODIFIERS; static { WATCHED_EVENT_KINDS = new WatchEvent.Kind<?>[] { ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY }; WatchEvent.Modifier[] modifiers; try { // com.sun based tuning, **really** faster on OSX Class<?> sensitivityEnumClass = Class.forName( "com.sun.nio.file.SensitivityWatchEventModifier" ); modifiers = new WatchEvent.Modifier[] { (WatchEvent.Modifier) sensitivityEnumClass.getEnumConstants()[0] }; } catch( ClassNotFoundException ex ) { // Sensitivity modifier not available, falling back to no modifiers modifiers = new WatchEvent.Modifier[ 0 ]; } WATCHED_MODIFIERS = modifiers; } @Override public synchronized SourceWatch watch( Set<File> filesAndDirectories, SourceChangeListener listener ) { try { final WatchService watchService = FileSystems.getDefault().newWatchService(); final MultiValueMap<WatchKey, Watched> keys = new LinkedMultiValueMap<>(); for( File fileOrDirectory : filesAndDirectories ) { Path start = fileOrDirectory.toPath(); if( isSymbolicLink( start ) ) { throw new DevShellStartException( "Cannot watch '" + start + "', it is a symbolic link. If you need this feature, please report!" ); } else if( isRegularFile( start, NOFOLLOW_LINKS ) ) { registerSingleFile( start, watchService, keys ); } else if( isDirectory( start, NOFOLLOW_LINKS ) ) { // register directory and sub-directories walkFileTree( start, new SimpleFileVisitor<Path>() { @Override public FileVisitResult preVisitDirectory( Path dir, BasicFileAttributes attrs ) throws IOException { registerDirectory( dir, watchService, keys ); return FileVisitResult.CONTINUE; } } ); } else if( exists( start ) ) { throw new DevShellStartException( "Cannot watch '" + start + "', it is neither a file nor a directory." ); } else { registerAbsent( start, watchService, keys ); } } String watchThreadName = "werval-devshell-watcher-" + THREAD_NUMBER.getAndIncrement(); final SourceChangeWatcher sourceChangeWatcher = new SourceChangeWatcher( watchService, keys, listener ); final Thread watchThread = new Thread( sourceChangeWatcher, watchThreadName ); watchThread.start(); return new SourceWatch() { @Override public void unwatch() { Set<WatchKey> keySet = keys.keySet(); while( !keySet.isEmpty() ) { WatchKey key = first( keySet ); key.cancel(); keys.remove( key ); } sourceChangeWatcher.stop(); watchThread.interrupt(); } }; } catch( IOException ex ) { throw new DevShellStartException( "Unable to watch sources for changes", ex ); } } private static void registerSingleFile( final Path file, WatchService watchService, MultiValueMap<WatchKey, Watched> keys ) throws IOException { WatchKey key = file.getParent().register( watchService, WATCHED_EVENT_KINDS, WATCHED_MODIFIERS ); keys.add( key, new WatchedSingleFile( file ) ); LOG.trace( "Registered watch on single file {}", file ); } private static void registerDirectory( Path directory, WatchService watchService, MultiValueMap<WatchKey, Watched> keys ) throws IOException { WatchKey key = directory.register( watchService, WATCHED_EVENT_KINDS, WATCHED_MODIFIERS ); keys.add( key, new WatchedDirectory( directory ) ); LOG.trace( "Registered watch on directory {}", directory ); } private static void registerAbsent( Path absent, WatchService watchService, MultiValueMap<WatchKey, Watched> keys ) throws IOException { Path upstream = findUpstream( absent ); WatchKey key = upstream.register( watchService, WATCHED_EVENT_KINDS, WATCHED_MODIFIERS ); keys.add( key, new WatchedAbsent( absent, upstream ) ); LOG.trace( "Registered a watch on absent {} starting at upstream {}", absent, upstream ); } private static Path findUpstream( Path path ) { Path upstream = path; while( ( upstream = upstream.getParent() ) != null ) { if( exists( upstream ) ) { if( isDirectory( upstream, NOFOLLOW_LINKS ) ) { return upstream; } throw new DevShellStartException( "Cannot watch absent '" + path + "' for changes, it has an existing non-directory parent!" ); } } throw new InternalError( "Cannot watch '" + path + "' for changes, is has no root!" ); } }