/*
* Copyright (c) 2014, 2016 J. Lewis Muir <jlmuir@imca-cat.org>
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS
* BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
package org.imca_cat.pollingwatchservice;
import java.io.IOException;
import java.nio.file.ClosedWatchServiceException;
import java.nio.file.DirectoryIteratorException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.NotDirectoryException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardWatchEventKinds;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Callable;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
/**
* A {@link PathWatchService} that polls for changes.
* <p>
* Example:
*
* <pre>
* PathWatchService s = new PollingWatchService(4, 15, TimeUnit.SECONDS);
* s.register(FileSystems.getDefault().getPath("/home/luke"),
* StandardWatchEventKinds.ENTRY_CREATE,
* StandardWatchEventKinds.ENTRY_DELETE,
* StandardWatchEventKinds.ENTRY_MODIFY);
* s.start();
* for (;;) {
* WatchKey k = s.take();
* ...
* }
* </pre>
* <p>
* Note: polling is only safe when operating on secure directories (i.e. ones
* in which there is no risk of a file system attack by a malicious user). If
* operating on an insecure directory, an attacker may be able to trick this
* service into reporting file system changes of their choosing (e.g. an
* ENTRY_CREATE event for a file with a name and path of the attacker's
* choosing).
* <p>
* According to the CERT Secure Coding website, rule <a
* href="https://www.securecoding.cert.org/confluence/x/ioHWAw">FIO00-J. Do not
* operate on files in shared directories</a> (accessed on July 1, 2014):
* <q>A directory is secure with respect to a particular user if only the user
* and the system administrator are allowed to create, move, or delete files
* inside the directory. Furthermore, each parent directory must itself be a
* secure directory up to and including the root directory. On most systems,
* home or user directories are secure by default and only shared directories
* are insecure.</q>
*/
public class PollingWatchService implements PathWatchService {
private final long period;
private final TimeUnit periodUnit;
private boolean useFileMetadata;
private boolean started;
private final BlockingQueue<PollingWatchKey> queue;
private final ConcurrentHashMap<Path, PollingWatchKey> keys;
private final ExecutorService executor;
private final ScheduledExecutorService timer;
private final PollingWatchServiceExceptionHandler exceptionHandler;
private final PollingWatchServiceInfoHandler infoHandler;
private final Object registerLock;
private final PollingWatchKey closedSentinel;
private final Object closeLock;
private volatile boolean closed;
private volatile boolean registerMethodEverInvoked;
/**
* Constructs a new service with the specified thread pool size, period, and
* time unit, and with a default exception handler that invokes the
* exception's {@code printStackTrace()} method and a default information
* handler that does nothing.
*
* @param threadPoolSize size of thread pool used for polling file system
* for
* changes
* @param period interval of time between each poll
* @param unit time unit of {@code period}
*/
public PollingWatchService(int threadPoolSize, long period, TimeUnit unit) {
this(threadPoolSize, period, unit, null, null);
}
/**
* Constructs a new service with the specified thread pool size, period,
* time
* unit, exception handler, and information handler.
*
* @param threadPoolSize size of thread pool used for polling file system
* for
* changes
* @param period interval of time between each poll
* @param unit time unit of {@code period}
* @param handler exception handler to handle exceptions thrown in this
* service; if {@code null}, a default handler will be used that
* invokes the exception's {@code printStackTrace()} method
* @param infoHandler information handler to handle information published by
* this service; if {@code null}, a default handler will be used
* that does nothing
*/
public PollingWatchService(int threadPoolSize, long period, TimeUnit unit,
PollingWatchServiceExceptionHandler handler,
PollingWatchServiceInfoHandler infoHandler) {
super();
this.period = period;
this.periodUnit = unit;
useFileMetadata = true;
started = false;
queue = new LinkedBlockingQueue<>();
keys = new ConcurrentHashMap<>(16, 0.75f, threadPoolSize);
executor = Executors.newFixedThreadPool(threadPoolSize,
new ThreadFactoryBuilder()
.setNameFormat("polling-watch-service-executor")
.setDaemon(true).build());
timer = Executors
.newSingleThreadScheduledExecutor(new ThreadFactoryBuilder()
.setNameFormat("polling-watch-service-timer")
.setDaemon(true).build());
this.exceptionHandler = (handler != null) ? handler
: newDefaultExceptionHandler();
this.infoHandler = (infoHandler != null) ? infoHandler
: newDefaultInfoHandler();
registerLock = new Object();
closedSentinel = new PollingWatchKey(this, Paths.get(""));
closeLock = new Object();
closed = false;
registerMethodEverInvoked = false;
}
/**
* Answers whether this service is configured to use file metadata. The
* default is {@code true}.
*
* @return {@code true} if this service is configured to use file metadata;
* {@code false} otherwise
*
* @see #useFileMetadata(boolean)
*/
public boolean useFileMetadata() {
return useFileMetadata;
}
/**
* Configures this service to use file metadata or not.
* <p>
* When using file metadata, this service will read metadata (i.e.
* {@link BasicFileAttributes}) for all files in directories being monitored
* when polling for changes, and it will use the metadata to determine which
* events need to be signaled. When not using file metadata, the files in
* directories being monitored will be listed when polling for changes, but
* the metadata for those files will not be read. The {@code ENTRY_CREATE},
* {@code ENTRY_DELETE}, and {@code OVERFLOW} watch event kinds may be
* signaled, but the {@code ENTRY_MODIFY} event kind will never be signaled
* because the metadata that would enable the detection of a modification is
* not read.
* <p>
* On some file systems, reading file metadata may be expensive. Configuring
* this service to not use file metadata provides a way to not incur the
* cost
* of reading file metadata when polling for changes, but gives up the
* {@code ENTRY_MODIFY} event kind. A consumer of the events produced by
* this
* service when configured to not use file metadata would be able to detect
* file creation and deletion, but would not, for example, be able to detect
* that a file was modified.
* <p>
* If this method is to be invoked, it must be invoked before any paths are
* registered via the {@code register} methods of this service and before
* this service is started.
*
* @param useFileMetadata {@code true} to use file metadata when determining
* which events to signal; {@code false} to not
*
* @throws IllegalStateException if a {@code register} method has already
* been invoked or this service has already been started
*
* @see #useFileMetadata()
*/
public void useFileMetadata(boolean useFileMetadata) {
if(registerMethodEverInvoked) {
throw new IllegalStateException("Register method already invoked");
}
synchronized (closeLock) {
if(started)
throw new IllegalStateException("Service already started");
}
this.useFileMetadata = useFileMetadata;
}
/**
* @throws ClosedWatchServiceException {@inheritDoc}
* @throws IllegalStateException {@inheritDoc}
*/
@Override
public void start() {
synchronized (closeLock) {
if(closed)
throw new ClosedWatchServiceException();
if(started)
throw new IllegalStateException("Already started");
started = true;
timer.scheduleAtFixedRate(newPollForChangesRunnable(this), 0L,
period, periodUnit);
}
}
/**
* Note: runs in a single separate thread.
*/
private void pollForChanges() throws InterruptedException {
try {
long startTime = System.nanoTime();
List<Future<?>> f = new ArrayList<>(keys.size());
for (PollingWatchKey each : keys.values()) {
f.add(executor
.submit(newPollForChangesOnKeyCallable(this, each)));
}
for (Future<?> each : f) {
try {
each.get();
}
catch (CancellationException e) {
/* IGNORED */
}
catch (ExecutionException e) {
exceptionHandler.exception(e);
}
}
long endTime = System.nanoTime();
infoHandler.pollTime(endTime - startTime);
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw e;
}
catch (ThreadDeath td) {
throw td;
}
catch (Throwable t) {
exceptionHandler.exception(t);
}
}
/**
* Note: runs in multiple separate threads.
*/
private void pollForChanges(PollingWatchKey key) throws IOException {
Path dir = key.watchablePath();
if(key.isInvalid()) {
keys.remove(dir);
return;
}
if(!Files.isDirectory(dir, LinkOption.NOFOLLOW_LINKS)) {
key.cancel();
keys.remove(dir);
key.enqueue();
return;
}
Map<Path, BasicFileAttributes> oldEntries = new HashMap<>(
key.entries());
Map<Path, BasicFileAttributes> newEntries = useFileMetadata
? readDirectoryEntries(dir)
: readDirectoryEntriesWithoutMetadata(dir, oldEntries);
for (Path each : newEntries.keySet()) {
BasicFileAttributes oldEntryAttributes = oldEntries.remove(each);
if(oldEntryAttributes == null) {
if(key.hasKind(StandardWatchEventKinds.ENTRY_CREATE)) {
key.addEvent(PollingWatchEvent.create(each));
}
}
else {
if(key.hasKind(StandardWatchEventKinds.ENTRY_MODIFY)) {
BasicFileAttributes newEntryAttributes = newEntries
.get(each);
if(!equals(oldEntryAttributes, newEntryAttributes)) {
key.addEvent(PollingWatchEvent.modify(each));
}
}
}
}
if(key.hasKind(StandardWatchEventKinds.ENTRY_DELETE)) {
for (Path each : oldEntries.keySet()) {
key.addEvent(PollingWatchEvent.delete(each));
}
}
key.entries(newEntries);
key.enqueueIfReadyAndHasEvents();
}
@Override
public void close() {
synchronized (closeLock) {
if(closed)
return;
closed = true;
Throwable t = null;
boolean interrupted = Thread.interrupted();
for (ExecutorService each : Arrays.asList(timer, executor)) {
try {
each.shutdownNow();
for (;;) {
try {
each.awaitTermination(Long.MAX_VALUE,
TimeUnit.DAYS);
break;
}
catch (InterruptedException e) {
interrupted = true;
}
}
}
catch (ThreadDeath td) {
t = td;
}
catch (Throwable th) {
if(t == null) {
t = th;
}
else {
t.addSuppressed(th);
}
}
}
for (PollingWatchKey each : keys.values()) {
try {
each.cancel();
}
catch (ThreadDeath td) {
t = td;
}
catch (Throwable th) {
if(t == null) {
t = th;
}
else {
t.addSuppressed(th);
}
}
}
try {
keys.clear();
}
catch (ThreadDeath td) {
t = td;
}
catch (Throwable th) {
if(t == null) {
t = th;
}
else {
t.addSuppressed(th);
}
}
try {
queue.clear();
queue.add(closedSentinel);
}
catch (ThreadDeath td) {
t = td;
}
catch (Throwable th) {
if(t == null) {
t = th;
}
else {
t.addSuppressed(th);
}
}
if(interrupted)
Thread.currentThread().interrupt();
if(t != null)
throwUncheckedException(t);
}
}
/**
* @throws ClosedWatchServiceException {@inheritDoc}
*/
@Override
public WatchKey poll() {
if(closed)
throw new ClosedWatchServiceException();
PollingWatchKey result = queue.poll();
if(result == closedSentinel)
throw new ClosedWatchServiceException();
return result;
}
/**
* @throws ClosedWatchServiceException {@inheritDoc}
*/
@Override
public WatchKey poll(long timeout, TimeUnit unit)
throws InterruptedException {
if(closed)
throw new ClosedWatchServiceException();
PollingWatchKey result = queue.poll(timeout, unit);
if(result == closedSentinel)
throw new ClosedWatchServiceException();
return result;
}
/**
* @throws ClosedWatchServiceException {@inheritDoc}
*/
@Override
public WatchKey take() throws InterruptedException {
if(closed)
throw new ClosedWatchServiceException();
PollingWatchKey result = queue.take();
if(result == closedSentinel)
throw new ClosedWatchServiceException();
return result;
}
/**
* @throws ClosedWatchServiceException {@inheritDoc}
*/
@Override
public WatchKey register(Path dir, WatchEvent.Kind<?>... kinds)
throws IOException {
return register(dir, kinds, new WatchEvent.Modifier[0]);
}
/**
* @throws ClosedWatchServiceException {@inheritDoc}
*/
@Override
public WatchKey register(Path dir, WatchEvent.Kind<?>[] kinds,
WatchEvent.Modifier... modifiers) throws IOException {
registerMethodEverInvoked = true;
/*
* Perform some sanity checks and read the directory entries before
* acquiring registerLock so that we aren't holding the lock while
* performing the potentially time consuming directory entries read.
* This
* means that for the common case of a new directory registration, we
* get
* good concurrency. For the uncommon case of registering a directory
* that
* is already registered, we will perform a directory entries read
* unnecessarily since it will not be used (if the key is still valid).
*/
if(closed)
throw new ClosedWatchServiceException();
if(!Files.isDirectory(dir)) {
throw new NotDirectoryException(dir.toString());
}
Map<Path, BasicFileAttributes> dirEntries = useFileMetadata
? readDirectoryEntries(dir)
: readDirectoryEntriesWithoutMetadata(dir);
/*
* It is possible for an invalid key to be returned, but this is by
* design
* and does not violate the contract specified by Path.register nor
* Watchable.register.
*/
synchronized (registerLock) {
if(closed)
throw new ClosedWatchServiceException();
if(!Files.isDirectory(dir)) {
throw new NotDirectoryException(dir.toString());
}
PollingWatchKey k = keys.get(dir);
if(k != null && k.isInvalid()) {
keys.remove(dir);
k = null;
}
if(k == null) {
k = new PollingWatchKey(this, dir, kinds, modifiers,
dirEntries);
synchronized (closeLock) {
if(closed) {
throw new ClosedWatchServiceException();
}
else {
keys.put(dir, k);
}
}
return k;
}
else {
k.kindsAndModifiers(kinds, modifiers);
return k;
}
}
}
void enqueue(PollingWatchKey k) {
queue.add(k);
}
private static PollingWatchServiceExceptionHandler newDefaultExceptionHandler() {
return new PollingWatchServiceExceptionHandler() {
@Override
public void exception(Throwable t) {
t.printStackTrace();
}
};
}
private static PollingWatchServiceInfoHandler newDefaultInfoHandler() {
return new PollingWatchServiceInfoHandler() {
@Override
public void pollTime(long t) {
/* IGNORED */
}
};
}
private static Runnable newPollForChangesRunnable(
final PollingWatchService s) {
return new Runnable() {
@Override
public void run() {
try {
s.pollForChanges();
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
};
}
private static Callable<?> newPollForChangesOnKeyCallable(
final PollingWatchService s, final PollingWatchKey k) {
return new Callable<Void>() {
@Override
public Void call() throws IOException {
s.pollForChanges(k);
return null;
}
};
}
private static void throwUncheckedException(Throwable t) {
if(t instanceof RuntimeException)
throw (RuntimeException) t;
if(t instanceof Error)
throw (Error) t;
throw new RuntimeException(t);
}
private static boolean equals(BasicFileAttributes a,
BasicFileAttributes b) {
return JavaUtilities.equalsIgnoreLastAccessTime(a, b);
}
private static Map<Path, BasicFileAttributes> readDirectoryEntries(
Path dir) {
Map<Path, BasicFileAttributes> result = new HashMap<>();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir)) {
for (Path each : stream) {
try {
result.put(each.getFileName(), readAttributes(each));
}
catch (IOException e) {
e.printStackTrace();
/*
* IGNORED: The entry may have been deleted, or a number of
* other
* problems may have occurred. Just read what we can.
*/
}
}
}
catch (DirectoryIteratorException | IOException e) {
e.printStackTrace();
/*
* IGNORED: Various problems could occur while creating a new
* directory
* stream or reading the directory. Just return what we have read so
* far.
*/
return result;
}
return result;
}
private static BasicFileAttributes readAttributes(Path p)
throws IOException {
return Files.readAttributes(p, BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS);
}
private static Map<Path, BasicFileAttributes> readDirectoryEntriesWithoutMetadata(
Path dir) {
return readDirectoryEntriesWithoutMetadata(dir,
Collections.<Path, BasicFileAttributes> emptyMap());
}
private static Map<Path, BasicFileAttributes> readDirectoryEntriesWithoutMetadata(
Path dir, Map<Path, BasicFileAttributes> preexistingMetadata) {
Map<Path, BasicFileAttributes> result = new HashMap<>();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir)) {
BasicFileAttributes noMetadataAttributes = NoMetadataFileAttributes
.instance();
for (Path each : stream) {
Path eachFileName = each.getFileName();
BasicFileAttributes eachAttributes = preexistingMetadata
.get(eachFileName);
result.put(eachFileName, eachAttributes != null ? eachAttributes
: noMetadataAttributes);
}
}
catch (DirectoryIteratorException | IOException e) {
/*
* IGNORED: Various problems could occur while creating a new
* directory
* stream or reading the directory. Just return what we have read so
* far.
*/
return result;
}
return result;
}
private static final class NoMetadataFileAttributes
implements BasicFileAttributes {
private static final NoMetadataFileAttributes INSTANCE = new NoMetadataFileAttributes();
private static final FileTime EPOCH = FileTime.fromMillis(0L);
private NoMetadataFileAttributes() {}
@Override
public FileTime lastModifiedTime() {
return EPOCH;
}
@Override
public FileTime lastAccessTime() {
return EPOCH;
}
@Override
public FileTime creationTime() {
return EPOCH;
}
@Override
public boolean isRegularFile() {
return false;
}
@Override
public boolean isDirectory() {
return false;
}
@Override
public boolean isSymbolicLink() {
return false;
}
@Override
public boolean isOther() {
return false;
}
@Override
public long size() {
return 0;
}
@Override
public Object fileKey() {
return null;
}
public static NoMetadataFileAttributes instance() {
return INSTANCE;
}
}
}