blob: 00d313ad4b9a303a06866f9fce6042ffcbd78588 [file] [log] [blame]
//
// ========================================================================
// Copyright (c) 1995-2017 Mort Bay Consulting Pty. Ltd.
// ------------------------------------------------------------------------
// All rights reserved. This program and the accompanying materials
// are made available under the terms of the Eclipse Public License v1.0
// and Apache License v2.0 which accompanies this distribution.
//
// The Eclipse Public License is available at
// http://www.eclipse.org/legal/epl-v10.html
//
// The Apache License v2.0 is available at
// http://www.opensource.org/licenses/apache2.0.php
//
// You may elect to redistribute this code under either of these licenses.
// ========================================================================
//
package org.eclipse.jetty.util;
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 java.io.File;
import java.io.IOException;
import java.lang.reflect.Field;
import java.nio.file.ClosedWatchServiceException;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.WatchEvent;
import java.nio.file.WatchEvent.Kind;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EventListener;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Scanner;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
import org.eclipse.jetty.util.component.AbstractLifeCycle;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;
/**
* Watch a Path (and sub directories) for Path changes.
* <p>
* Suitable replacement for the old {@link Scanner} implementation.
* <p>
* Allows for configured Excludes and Includes using {@link FileSystem#getPathMatcher(String)} syntax.
* <p>
* Reports activity via registered {@link Listener}s
*/
public class PathWatcher extends AbstractLifeCycle implements Runnable
{
public static class Config
{
public static final int UNLIMITED_DEPTH = -9999;
private static final String PATTERN_SEP;
static
{
String sep = File.separator;
if (File.separatorChar == '\\')
{
sep = "\\\\";
}
PATTERN_SEP = sep;
}
protected final Path dir;
protected int recurseDepth = 0; // 0 means no sub-directories are scanned
protected List<PathMatcher> includes;
protected List<PathMatcher> excludes;
protected boolean excludeHidden = false;
public Config(Path path)
{
this.dir = path;
includes = new ArrayList<>();
excludes = new ArrayList<>();
}
/**
* Add an exclude PathMatcher
*
* @param matcher
* the path matcher for this exclude
*/
public void addExclude(PathMatcher matcher)
{
this.excludes.add(matcher);
}
/**
* Add an exclude PathMatcher.
* <p>
* Note: this pattern is FileSystem specific (so use "/" for Linux and OSX, and "\\" for Windows)
*
* @param syntaxAndPattern
* the PathMatcher syntax and pattern to use
* @see FileSystem#getPathMatcher(String) for detail on syntax and pattern
*/
public void addExclude(final String syntaxAndPattern)
{
if (LOG.isDebugEnabled())
{
LOG.debug("Adding exclude: [{}]",syntaxAndPattern);
}
addExclude(dir.getFileSystem().getPathMatcher(syntaxAndPattern));
}
/**
* Add a <code>glob:</code> syntax pattern exclude reference in a directory relative, os neutral, pattern.
*
* <pre>
* On Linux:
* Config config = new Config(Path("/home/user/example"));
* config.addExcludeGlobRelative("*.war") =&gt; "glob:/home/user/example/*.war"
*
* On Windows
* Config config = new Config(Path("D:/code/examples"));
* config.addExcludeGlobRelative("*.war") =&gt; "glob:D:\\code\\examples\\*.war"
*
* </pre>
*
* @param pattern
* the pattern, in unixy format, relative to config.dir
*/
public void addExcludeGlobRelative(String pattern)
{
addExclude(toGlobPattern(dir,pattern));
}
/**
* Exclude hidden files and hidden directories
*/
public void addExcludeHidden()
{
if (!excludeHidden)
{
if (LOG.isDebugEnabled())
{
LOG.debug("Adding hidden files and directories to exclusions");
}
excludeHidden = true;
addExclude("regex:^.*" + PATTERN_SEP + "\\..*$"); // ignore hidden files
addExclude("regex:^.*" + PATTERN_SEP + "\\..*" + PATTERN_SEP + ".*$"); // ignore files in hidden directories
}
}
/**
* Add multiple exclude PathMatchers
*
* @param syntaxAndPatterns
* the list of PathMatcher syntax and patterns to use
* @see FileSystem#getPathMatcher(String) for detail on syntax and pattern
*/
public void addExcludes(List<String> syntaxAndPatterns)
{
for (String syntaxAndPattern : syntaxAndPatterns)
{
addExclude(syntaxAndPattern);
}
}
/**
* Add an include PathMatcher
*
* @param matcher
* the path matcher for this include
*/
public void addInclude(PathMatcher matcher)
{
this.includes.add(matcher);
}
/**
* Add an include PathMatcher
*
* @param syntaxAndPattern
* the PathMatcher syntax and pattern to use
* @see FileSystem#getPathMatcher(String) for detail on syntax and pattern
*/
public void addInclude(String syntaxAndPattern)
{
if (LOG.isDebugEnabled())
{
LOG.debug("Adding include: [{}]",syntaxAndPattern);
}
addInclude(dir.getFileSystem().getPathMatcher(syntaxAndPattern));
}
/**
* Add a <code>glob:</code> syntax pattern reference in a directory relative, os neutral, pattern.
*
* <pre>
* On Linux:
* Config config = new Config(Path("/home/user/example"));
* config.addIncludeGlobRelative("*.war") =&gt; "glob:/home/user/example/*.war"
*
* On Windows
* Config config = new Config(Path("D:/code/examples"));
* config.addIncludeGlobRelative("*.war") =&gt; "glob:D:\\code\\examples\\*.war"
*
* </pre>
*
* @param pattern
* the pattern, in unixy format, relative to config.dir
*/
public void addIncludeGlobRelative(String pattern)
{
addInclude(toGlobPattern(dir,pattern));
}
/**
* Add multiple include PathMatchers
*
* @param syntaxAndPatterns
* the list of PathMatcher syntax and patterns to use
* @see FileSystem#getPathMatcher(String) for detail on syntax and pattern
*/
public void addIncludes(List<String> syntaxAndPatterns)
{
for (String syntaxAndPattern : syntaxAndPatterns)
{
addInclude(syntaxAndPattern);
}
}
/**
* Build a new config from a this configuration.
* <p>
* Useful for working with sub-directories that also need to be watched.
*
* @param dir
* the directory to build new Config from (using this config as source of includes/excludes)
* @return the new Config
*/
public Config asSubConfig(Path dir)
{
Config subconfig = new Config(dir);
subconfig.includes = this.includes;
subconfig.excludes = this.excludes;
if (dir == this.dir)
subconfig.recurseDepth = this.recurseDepth; // TODO shouldn't really do a subconfig for this
else
{
if (this.recurseDepth == UNLIMITED_DEPTH)
subconfig.recurseDepth = UNLIMITED_DEPTH;
else
subconfig.recurseDepth = this.recurseDepth - (dir.getNameCount() - this.dir.getNameCount());
}
return subconfig;
}
public int getRecurseDepth()
{
return recurseDepth;
}
public boolean isRecurseDepthUnlimited ()
{
return this.recurseDepth == UNLIMITED_DEPTH;
}
public Path getPath ()
{
return this.dir;
}
private boolean hasMatch(Path path, List<PathMatcher> matchers)
{
for (PathMatcher matcher : matchers)
{
if (matcher.matches(path))
{
return true;
}
}
return false;
}
public boolean isExcluded(Path dir) throws IOException
{
if (excludeHidden)
{
if (Files.isHidden(dir))
{
if (NOISY_LOG.isDebugEnabled())
{
NOISY_LOG.debug("isExcluded [Hidden] on {}",dir);
}
return true;
}
}
if (excludes.isEmpty())
{
// no excludes == everything allowed
return false;
}
boolean matched = hasMatch(dir,excludes);
if (NOISY_LOG.isDebugEnabled())
{
NOISY_LOG.debug("isExcluded [{}] on {}",matched,dir);
}
return matched;
}
public boolean isIncluded(Path dir)
{
if (includes.isEmpty())
{
// no includes == everything allowed
if (NOISY_LOG.isDebugEnabled())
{
NOISY_LOG.debug("isIncluded [All] on {}",dir);
}
return true;
}
boolean matched = hasMatch(dir,includes);
if (NOISY_LOG.isDebugEnabled())
{
NOISY_LOG.debug("isIncluded [{}] on {}",matched,dir);
}
return matched;
}
public boolean matches(Path path)
{
try
{
return !isExcluded(path) && isIncluded(path);
}
catch (IOException e)
{
LOG.warn("Unable to match path: " + path,e);
return false;
}
}
/**
* Set the recurse depth for the directory scanning.
* <p>
* -999 indicates arbitrarily deep recursion, 0 indicates no recursion, 1 is only one directory deep, and so on.
*
* @param depth
* the number of directories deep to recurse
*/
public void setRecurseDepth(int depth)
{
this.recurseDepth = depth;
}
/**
* Determine if the provided child directory should be recursed into based on the configured {@link #setRecurseDepth(int)}
*
* @param child
* the child directory to test against
* @return true if recurse should occur, false otherwise
*/
public boolean shouldRecurseDirectory(Path child)
{
if (!child.startsWith(dir))
{
// not part of parent? don't recurse
return false;
}
//If not limiting depth, should recurse all
if (isRecurseDepthUnlimited())
return true;
//Depth limited, check it
int childDepth = dir.relativize(child).getNameCount();
return (childDepth <= recurseDepth);
}
private String toGlobPattern(Path path, String subPattern)
{
StringBuilder s = new StringBuilder();
s.append("glob:");
boolean needDelim = false;
// Add root (aka "C:\" for Windows)
Path root = path.getRoot();
if (root != null)
{
if (NOISY_LOG.isDebugEnabled())
{
NOISY_LOG.debug("Path: {} -> Root: {}",path,root);
}
for (char c : root.toString().toCharArray())
{
if (c == '\\')
{
s.append(PATTERN_SEP);
}
else
{
s.append(c);
}
}
}
else
{
needDelim = true;
}
// Add the individual path segments
for (Path segment : path)
{
if (needDelim)
{
s.append(PATTERN_SEP);
}
s.append(segment);
needDelim = true;
}
// Add the sub pattern (if specified)
if ((subPattern != null) && (subPattern.length() > 0))
{
if (needDelim)
{
s.append(PATTERN_SEP);
}
for (char c : subPattern.toCharArray())
{
if (c == '/')
{
s.append(PATTERN_SEP);
}
else
{
s.append(c);
}
}
}
return s.toString();
}
@Override
public String toString()
{
StringBuilder s = new StringBuilder();
s.append(dir);
if (recurseDepth > 0)
{
s.append(" [depth=").append(recurseDepth).append("]");
}
return s.toString();
}
}
public static class DepthLimitedFileVisitor extends SimpleFileVisitor<Path>
{
private Config base;
private PathWatcher watcher;
public DepthLimitedFileVisitor (PathWatcher watcher, Config base)
{
this.base = base;
this.watcher = watcher;
}
/*
* 2 situations:
*
* 1. a subtree exists at the time a dir to watch is added (eg watching /tmp/xxx and it contains aaa/)
* - will start with /tmp/xxx for which we want to register with the poller
* - want to visit each child
* - if child is file, gen add event
* - if child is dir, gen add event but ONLY register it if inside depth limit and ONLY continue visit of child if inside depth limit
* 2. a subtree is added inside a watched dir (eg watching /tmp/xxx, add aaa/ to xxx/)
* - will start with /tmp/xxx/aaa
* - gen add event but ONLY register it if inside depth limit and ONLY continue visit of children if inside depth limit
*
*/
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException
{
//In a directory:
// 1. the dir is the base directory
// - register it with the poll mechanism
// - generate pending add event (iff notifiable and matches patterns)
// - continue the visit (sibling dirs, sibling files)
// 2. the dir is a subdir at some depth in the basedir's tree
// - if the level of the subdir less or equal to base's limit
// - register it wih the poll mechanism
// - generate pending add event (iff notifiable and matches patterns)
// - else stop visiting this dir
if (!base.isExcluded(dir))
{
if (base.isIncluded(dir))
{
if (watcher.isNotifiable())
{
// Directory is specifically included in PathMatcher, then
// it should be notified as such to interested listeners
PathWatchEvent event = new PathWatchEvent(dir,PathWatchEventType.ADDED);
if (LOG.isDebugEnabled())
{
LOG.debug("Pending {}",event);
}
watcher.addToPendingList(dir, event);
}
}
//Register the dir with the watcher if it is:
// - the base dir and recursion is unlimited
// - the base dir and its depth is 0 (meaning we want to capture events from it, but not necessarily its children)
// - the base dir and we are recursing it and the depth is within the limit
// - a child dir and its depth is within the limits
if ((base.getPath().equals(dir) && (base.isRecurseDepthUnlimited() || base.getRecurseDepth() >= 0)) || base.shouldRecurseDirectory(dir))
watcher.register(dir,base);
}
//Continue walking the tree of this dir if it is:
// - the base dir and recursion is unlimited
// - the base dir and we're not recursing in it
// - the base dir and we are recursing it and the depth is within the limit
// - a child dir and its depth is within the limits
if ((base.getPath().equals(dir)&& (base.isRecurseDepthUnlimited() || base.getRecurseDepth() >= 0)) || base.shouldRecurseDirectory(dir))
return FileVisitResult.CONTINUE;
else
return FileVisitResult.SKIP_SUBTREE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException
{
// In a file:
// - register with poll mechanism
// - generate pending add event (iff notifiable and matches patterns)
if (base.matches(file) && watcher.isNotifiable())
{
PathWatchEvent event = new PathWatchEvent(file,PathWatchEventType.ADDED);
if (LOG.isDebugEnabled())
{
LOG.debug("Pending {}",event);
}
watcher.addToPendingList(file, event);
}
return FileVisitResult.CONTINUE;
}
}
/**
* Listener for path change events
*/
public static interface Listener extends EventListener
{
void onPathWatchEvent(PathWatchEvent event);
}
/**
* EventListListener
*
* Listener that reports accumulated events in one shot
*/
public static interface EventListListener extends EventListener
{
void onPathWatchEvents(List<PathWatchEvent> events);
}
/**
* PathWatchEvent
*
* Represents a file event. Reported to registered listeners.
*/
public static class PathWatchEvent
{
private final Path path;
private final PathWatchEventType type;
private int count = 0;
public PathWatchEvent(Path path, PathWatchEventType type)
{
this.path = path;
this.count = 1;
this.type = type;
}
public PathWatchEvent(Path path, WatchEvent<Path> event)
{
this.path = path;
this.count = event.count();
if (event.kind() == ENTRY_CREATE)
{
this.type = PathWatchEventType.ADDED;
}
else if (event.kind() == ENTRY_DELETE)
{
this.type = PathWatchEventType.DELETED;
}
else if (event.kind() == ENTRY_MODIFY)
{
this.type = PathWatchEventType.MODIFIED;
}
else
{
this.type = PathWatchEventType.UNKNOWN;
}
}
/**
* @see java.lang.Object#equals(java.lang.Object)
*/
@Override
public boolean equals(Object obj)
{
if (this == obj)
{
return true;
}
if (obj == null)
{
return false;
}
if (getClass() != obj.getClass())
{
return false;
}
PathWatchEvent other = (PathWatchEvent)obj;
if (path == null)
{
if (other.path != null)
{
return false;
}
}
else if (!path.equals(other.path))
{
return false;
}
if (type != other.type)
{
return false;
}
return true;
}
public Path getPath()
{
return path;
}
public PathWatchEventType getType()
{
return type;
}
public void incrementCount(int num)
{
count += num;
}
public int getCount()
{
return count;
}
/**
* @see java.lang.Object#hashCode()
*/
@Override
public int hashCode()
{
final int prime = 31;
int result = 1;
result = (prime * result) + ((path == null)?0:path.hashCode());
result = (prime * result) + ((type == null)?0:type.hashCode());
return result;
}
/**
* @see java.lang.Object#toString()
*/
@Override
public String toString()
{
return String.format("PathWatchEvent[%s|%s]",type,path);
}
}
/**
* PathPendingEvents
*
* For a given path, a list of events that are awaiting the
* quiet time. The list is in the order that the event were
* received from the WatchService
*/
public static class PathPendingEvents
{
private Path _path;
private List<PathWatchEvent> _events;
private long _timestamp;
private long _lastFileSize = -1;
public PathPendingEvents (Path path)
{
_path = path;
}
public PathPendingEvents (Path path, PathWatchEvent event)
{
this (path);
addEvent(event);
}
public void addEvent (PathWatchEvent event)
{
long now = System.currentTimeMillis();
_timestamp = now;
if (_events == null)
{
_events = new ArrayList<PathWatchEvent>();
_events.add(event);
}
else
{
//Check if the same type of event is already present, in which case we
//can increment its counter. Otherwise, add it
PathWatchEvent existingType = null;
for (PathWatchEvent e:_events)
{
if (e.getType() == event.getType())
{
existingType = e;
break;
}
}
if (existingType == null)
{
_events.add(event);
}
else
{
existingType.incrementCount(event.getCount());
}
}
}
public List<PathWatchEvent> getEvents()
{
return _events;
}
public long getTimestamp()
{
return _timestamp;
}
/**
* Check to see if the file referenced by this Event is quiet.
* <p>
* Will validate the timestamp to see if it is expired, as well as if the file size hasn't changed within the quiet period.
* <p>
* Always updates timestamp to 'now' on use of this method.
*
* @param now the time now
*
* @param expiredDuration
* the expired duration past the timestamp to be considered expired
* @param expiredUnit
* the unit of time for the expired check
* @return true if expired, false if not
*/
public boolean isQuiet(long now, long expiredDuration, TimeUnit expiredUnit)
{
long pastdue = _timestamp + expiredUnit.toMillis(expiredDuration);
_timestamp = now;
long fileSize = _path.toFile().length(); // File.length() returns 0 for non existant files
boolean fileSizeChanged = (_lastFileSize != fileSize);
_lastFileSize = fileSize;
if ((now > pastdue) && (!fileSizeChanged /*|| fileSize == 0*/))
{
// Quiet period timestamp has expired, and file size hasn't changed, or the file
// has been deleted.
// Consider this a quiet event now.
return true;
}
return false;
}
}
/**
* PathWatchEventType
*
* Type of an event
*/
public static enum PathWatchEventType
{
ADDED, DELETED, MODIFIED, UNKNOWN;
}
private static final boolean IS_WINDOWS;
static
{
String os = System.getProperty("os.name");
if (os == null)
{
IS_WINDOWS = false;
}
else
{
String osl = os.toLowerCase(Locale.ENGLISH);
IS_WINDOWS = osl.contains("windows");
}
}
private static final Logger LOG = Log.getLogger(PathWatcher.class);
/**
* super noisy debug logging
*/
private static final Logger NOISY_LOG = Log.getLogger(PathWatcher.class.getName() + ".Noisy");
@SuppressWarnings("unchecked")
protected static <T> WatchEvent<T> cast(WatchEvent<?> event)
{
return (WatchEvent<T>)event;
}
private static final WatchEvent.Kind<?> WATCH_EVENT_KINDS[] = { ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY };
private WatchService watchService;
private WatchEvent.Modifier watchModifiers[];
private boolean nativeWatchService;
private Map<WatchKey, Config> keys = new HashMap<>();
private List<EventListener> listeners = new CopyOnWriteArrayList<>(); //a listener may modify the listener list directly or by stopping the PathWatcher
private List<Config> configs = new ArrayList<>();
/**
* Update Quiet Time - set to 1000 ms as default (a lower value in Windows is not supported)
*/
private long updateQuietTimeDuration = 1000;
private TimeUnit updateQuietTimeUnit = TimeUnit.MILLISECONDS;
private Thread thread;
private boolean _notifyExistingOnStart = true;
private Map<Path, PathPendingEvents> pendingEvents = new LinkedHashMap<>();
/**
* Construct new PathWatcher
*/
public PathWatcher()
{
}
/**
* Request watch on a the given path (either file or dir)
* using all Config defaults. In the case of a dir,
* the default is not to recurse into subdirs for watching.
*
* @param file the path to watch
*/
public void watch (final Path file)
{
//Make a config for the dir above it and
//include a match only for the given path
//using all defaults for the configuration
Path abs = file;
if (!abs.isAbsolute())
{
abs = file.toAbsolutePath();
}
//Check we don't already have a config for the parent directory.
//If we do, add in this filename.
Config config = null;
Path parent = abs.getParent();
for (Config c:configs)
{
if (c.getPath().equals(parent))
{
config = c;
break;
}
}
//Make a new config
if (config == null)
{
config = new Config(abs.getParent());
// the include for the directory itself
config.addIncludeGlobRelative("");
//add the include for the file
config.addIncludeGlobRelative(file.getFileName().toString());
watch(config);
}
else
//add the include for the file
config.addIncludeGlobRelative(file.getFileName().toString());
}
/**
* Request watch on a path with custom Config
* provided.
*
* @param config the configuration to watch
*/
public void watch (final Config config)
{
//Add a custom config
configs.add(config);
}
/**
* Register path in the config with the file watch service,
* walking the tree if it happens to be a directory.
*
* @param baseDir the base directory configuration to watch
* @throws IOException if unable to walk the filesystem tree
*/
protected void prepareConfig (final Config baseDir) throws IOException
{
if (LOG.isDebugEnabled())
{
LOG.debug("Watching directory {}",baseDir);
}
Files.walkFileTree(baseDir.getPath(), new DepthLimitedFileVisitor(this, baseDir));
}
/**
* Add a listener for changes the watcher notices.
*
* @param listener change listener
*/
public void addListener(EventListener listener)
{
listeners.add(listener);
}
/**
* Append some info on the paths that we are watching.
*
* @param s
*/
private void appendConfigId(StringBuilder s)
{
List<Path> dirs = new ArrayList<>();
for (Config config : keys.values())
{
dirs.add(config.dir);
}
Collections.sort(dirs);
s.append("[");
if (dirs.size() > 0)
{
s.append(dirs.get(0));
if (dirs.size() > 1)
{
s.append(" (+").append(dirs.size() - 1).append(")");
}
}
else
{
s.append("<null>");
}
s.append("]");
}
/**
* @see org.eclipse.jetty.util.component.AbstractLifeCycle#doStart()
*/
@Override
protected void doStart() throws Exception
{
//create a new watchservice
createWatchService();
//ensure setting of quiet time is appropriate now we have a watcher
setUpdateQuietTime(getUpdateQuietTimeMillis(), TimeUnit.MILLISECONDS);
// Register all watched paths, walking dir hierarchies as needed, possibly generating
// fake add events if notifyExistingOnStart is true
for (Config c:configs)
prepareConfig(c);
// Start Thread for watcher take/pollKeys loop
StringBuilder threadId = new StringBuilder();
threadId.append("PathWatcher-Thread");
appendConfigId(threadId);
thread = new Thread(this,threadId.toString());
thread.setDaemon(true);
thread.start();
super.doStart();
}
/**
* @see org.eclipse.jetty.util.component.AbstractLifeCycle#doStop()
*/
@Override
protected void doStop() throws Exception
{
if (watchService != null)
watchService.close(); //will invalidate registered watch keys, interrupt thread in take or poll
watchService = null;
thread = null;
keys.clear();
pendingEvents.clear();
super.doStop();
}
/**
* Remove all current configs and listeners.
*/
public void reset ()
{
if (!isStopped())
throw new IllegalStateException("PathWatcher must be stopped before reset.");
configs.clear();
listeners.clear();
}
/**
* Create a fresh WatchService and determine if it is a
* native implementation or not.
*
* @throws IOException
*/
private void createWatchService () throws IOException
{
//create a watch service
this.watchService = FileSystems.getDefault().newWatchService();
WatchEvent.Modifier modifiers[] = null;
boolean nativeService = true;
// Try to determine native behavior
// See http://stackoverflow.com/questions/9588737/is-java-7-watchservice-slow-for-anyone-else
try
{
ClassLoader cl = Thread.currentThread().getContextClassLoader();
Class<?> pollingWatchServiceClass = Class.forName("sun.nio.fs.PollingWatchService",false,cl);
if (pollingWatchServiceClass.isAssignableFrom(this.watchService.getClass()))
{
nativeService = false;
LOG.info("Using Non-Native Java {}",pollingWatchServiceClass.getName());
Class<?> c = Class.forName("com.sun.nio.file.SensitivityWatchEventModifier");
Field f = c.getField("HIGH");
modifiers = new WatchEvent.Modifier[]
{
(WatchEvent.Modifier)f.get(c)
};
}
}
catch (Throwable t)
{
// Unknown JVM environment, assuming native.
LOG.ignore(t);
}
this.watchModifiers = modifiers;
this.nativeWatchService = nativeService;
}
/**
* Check to see if the watcher is in a state where it should generate
* watch events to the listeners. Used to determine if watcher should generate
* events for existing files and dirs on startup.
*
* @return true if the watcher should generate events to the listeners.
*/
protected boolean isNotifiable ()
{
return (isStarted() || (!isStarted() && isNotifyExistingOnStart()));
}
/**
* Get an iterator over the listeners.
*
* @return iterator over the listeners.
*/
public Iterator<EventListener> getListeners()
{
return listeners.iterator();
}
/**
* Change the quiet time.
*
* @return the quiet time in millis
*/
public long getUpdateQuietTimeMillis()
{
return TimeUnit.MILLISECONDS.convert(updateQuietTimeDuration,updateQuietTimeUnit);
}
/**
* Generate events to the listeners.
*
* @param events the events captured
*/
protected void notifyOnPathWatchEvents (List<PathWatchEvent> events)
{
if (events == null || events.isEmpty())
return;
for (EventListener listener : listeners)
{
if (listener instanceof EventListListener)
{
try
{
((EventListListener)listener).onPathWatchEvents(events);
}
catch (Throwable t)
{
LOG.warn(t);
}
}
else
{
Listener l = (Listener)listener;
for (PathWatchEvent event:events)
{
try
{
l.onPathWatchEvent(event);
}
catch (Throwable t)
{
LOG.warn(t);
}
}
}
}
}
/**
* Register a path (directory) with the WatchService.
*
* @param dir the directory to register
* @param root the configuration root
* @throws IOException if unable to register the path with the watch service.
*/
protected void register(Path dir, Config root) throws IOException
{
LOG.debug("Registering watch on {}",dir);
if(watchModifiers != null)
{
// Java Watcher
WatchKey key = dir.register(watchService,WATCH_EVENT_KINDS,watchModifiers);
keys.put(key,root.asSubConfig(dir));
} else
{
// Native Watcher
WatchKey key = dir.register(watchService,WATCH_EVENT_KINDS);
keys.put(key,root.asSubConfig(dir));
}
}
/**
* Delete a listener
* @param listener the listener to remove
* @return true if the listener existed and was removed
*/
public boolean removeListener(Listener listener)
{
return listeners.remove(listener);
}
/**
* Forever loop.
*
* Wait for the WatchService to report some filesystem events for the
* watched paths.
*
* When an event for a path first occurs, it is subjected to a quiet time.
* Subsequent events that arrive for the same path during this quiet time are
* accumulated and the timer reset. Only when the quiet time has expired are
* the accumulated events sent. MODIFY events are handled slightly differently -
* multiple MODIFY events arriving within a quiet time are coalesced into a
* single MODIFY event. Both the accumulation of events and coalescing of MODIFY
* events reduce the number and frequency of event reporting for "noisy" files (ie
* those that are undergoing rapid change).
*
* @see java.lang.Runnable#run()
*/
@Override
public void run()
{
List<PathWatchEvent> notifiableEvents = new ArrayList<PathWatchEvent>();
// Start the java.nio watching
if (LOG.isDebugEnabled())
{
LOG.debug("Starting java.nio file watching with {}",watchService);
}
while (watchService != null && thread == Thread.currentThread())
{
WatchKey key = null;
try
{
//If no pending events, wait forever for new events
if (pendingEvents.isEmpty())
{
if (NOISY_LOG.isDebugEnabled())
NOISY_LOG.debug("Waiting for take()");
key = watchService.take();
}
else
{
//There are existing events that might be ready to go,
//only wait as long as the quiet time for any new events
if (NOISY_LOG.isDebugEnabled())
NOISY_LOG.debug("Waiting for poll({}, {})",updateQuietTimeDuration,updateQuietTimeUnit);
key = watchService.poll(updateQuietTimeDuration,updateQuietTimeUnit);
//If no new events its safe to process the pendings
if (key == null)
{
long now = System.currentTimeMillis();
// no new event encountered.
for (Path path : new HashSet<Path>(pendingEvents.keySet()))
{
PathPendingEvents pending = pendingEvents.get(path);
if (pending.isQuiet(now, updateQuietTimeDuration,updateQuietTimeUnit))
{
//No fresh events received during quiet time for this path,
//so generate the events that were pent up
for (PathWatchEvent p:pending.getEvents())
{
notifiableEvents.add(p);
}
// remove from pending list
pendingEvents.remove(path);
}
}
}
}
}
catch (ClosedWatchServiceException e)
{
// Normal shutdown of watcher
return;
}
catch (InterruptedException e)
{
if (isRunning())
{
LOG.warn(e);
}
else
{
LOG.ignore(e);
}
return;
}
//If there was some new events to process
if (key != null)
{
Config config = keys.get(key);
if (config == null)
{
if (LOG.isDebugEnabled())
{
LOG.debug("WatchKey not recognized: {}",key);
}
continue;
}
for (WatchEvent<?> event : key.pollEvents())
{
@SuppressWarnings("unchecked")
WatchEvent.Kind<Path> kind = (Kind<Path>)event.kind();
WatchEvent<Path> ev = cast(event);
Path name = ev.context();
Path child = config.dir.resolve(name);
if (kind == ENTRY_CREATE)
{
// handle special case for registering new directories
// recursively
if (Files.isDirectory(child,LinkOption.NOFOLLOW_LINKS))
{
try
{
prepareConfig(config.asSubConfig(child));
}
catch (IOException e)
{
LOG.warn(e);
}
}
else if (config.matches(child))
{
addToPendingList(child, new PathWatchEvent(child,ev));
}
}
else if (config.matches(child))
{
addToPendingList(child, new PathWatchEvent(child,ev));
}
}
}
//Send any notifications generated this pass
notifyOnPathWatchEvents(notifiableEvents);
notifiableEvents.clear();
if (key != null && !key.reset())
{
keys.remove(key);
if (keys.isEmpty())
{
return; // all done, no longer monitoring anything
}
}
}
}
/**
* Add an event reported by the WatchService to list of pending events
* that will be sent after their quiet time has expired.
*
* @param path the path to add to the pending list
* @param event the pending event
*/
public void addToPendingList (Path path, PathWatchEvent event)
{
PathPendingEvents pending = pendingEvents.get(path);
//Are there already pending events for this path?
if (pending == null)
{
//No existing pending events, create pending list
pendingEvents.put(path,new PathPendingEvents(path, event));
}
else
{
//There are already some events pending for this path
pending.addEvent(event);
}
}
/**
* Whether or not to issue notifications for directories and files that
* already exist when the watcher starts.
*
* @param notify true if existing paths should be notified or not
*/
public void setNotifyExistingOnStart (boolean notify)
{
_notifyExistingOnStart = notify;
}
public boolean isNotifyExistingOnStart ()
{
return _notifyExistingOnStart;
}
/**
* Set the quiet time.
*
* @param duration the quiet time duration
* @param unit the quite time unit
*/
public void setUpdateQuietTime(long duration, TimeUnit unit)
{
long desiredMillis = unit.toMillis(duration);
if (watchService != null && !this.nativeWatchService && (desiredMillis < 5000))
{
LOG.warn("Quiet Time is too low for non-native WatchService [{}]: {} < 5000 ms (defaulting to 5000 ms)",watchService.getClass().getName(),desiredMillis);
this.updateQuietTimeDuration = 5000;
this.updateQuietTimeUnit = TimeUnit.MILLISECONDS;
return;
}
if (IS_WINDOWS && (desiredMillis < 1000))
{
LOG.warn("Quiet Time is too low for Microsoft Windows: {} < 1000 ms (defaulting to 1000 ms)",desiredMillis);
this.updateQuietTimeDuration = 1000;
this.updateQuietTimeUnit = TimeUnit.MILLISECONDS;
return;
}
// All other OS and watch service combinations can use desired setting
this.updateQuietTimeDuration = duration;
this.updateQuietTimeUnit = unit;
}
@Override
public String toString()
{
StringBuilder s = new StringBuilder(this.getClass().getName());
appendConfigId(s);
return s.toString();
}
}