/*
* Copyright 2012 Jason Miller
*
* 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 jj.resource;
import static java.nio.file.StandardWatchEventKinds.*;
import java.io.IOException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.nio.file.WatchEvent.Kind;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import javax.inject.Inject;
import javax.inject.Singleton;
import jj.event.Publisher;
import jj.logging.Emergency;
import jj.logging.Warning;
/**
* Encapsulates the JDK watch service for mockability. this is also
* why this class has pretty much zero test coverage. specifically,
* on the mac, file watching is implemented with 1 second polling,
* so we only cover this class in an integration test to keep the
* unit suite quick. as such, this class should do nothing but translate
* to the JDK and publish errors
*
* @author jason
*
*/
@Singleton
class FileWatcher {
enum Action {
Unknown, Create, Modify, Delete;
static Action from(Kind<?> kind) {
if (kind == ENTRY_CREATE) {
return Create;
}
if (kind == ENTRY_MODIFY) {
return Modify;
}
if (kind == ENTRY_DELETE) {
return Delete;
}
return Unknown;
}
}
private static final boolean MAC_OS_X = "Mac OS X".equals(System.getProperty("os.name"));
private static final Kind<?>[] FILE_EVENTS = new Kind<?>[] { ENTRY_DELETE, ENTRY_MODIFY, ENTRY_CREATE };
private static final WatchEvent.Modifier FAST_POLLING_MODIFIER;
static {
// if we can find the modifier, we can speed up our polling quite a bit on OS X
// but this neatly avoids relying on the class
WatchEvent.Modifier candidate = null;
try {
@SuppressWarnings("unchecked")
Class<? extends Enum<?>> modifierClass =
(Class<? extends Enum<?>>)Class.forName("com.sun.nio.file.SensitivityWatchEventModifier");
for (Enum<?> value : modifierClass.getEnumConstants()) {
if (value.name().equals("HIGH")) {
candidate = (WatchEvent.Modifier)value;
break;
}
}
} catch (Exception ignored) {}
FAST_POLLING_MODIFIER = candidate;
}
private final AtomicReference<WatchService> watchServiceRef = new AtomicReference<>();
private final Publisher publisher;
@Inject
FileWatcher(Publisher publisher) {
this.publisher = publisher;
}
@SuppressWarnings("unchecked")
private WatchEvent<Path> cast(WatchEvent<?> event) {
return (WatchEvent<Path>)event;
}
void watch(Path directory) {
// this is kind of cheating
WatchService watchService = watchServiceRef.get();
assert watchService != null : "asked to watch a file but we aren't even running";
try {
if (MAC_OS_X && FAST_POLLING_MODIFIER != null) {
// this polls on MAC, but the high sensitivity makes it poll a lot, which seems fine
// for development purposes
directory.register(watchService, FILE_EVENTS, FAST_POLLING_MODIFIER);
} else {
directory.register(watchService, FILE_EVENTS);
}
} catch (IOException ioe) {
publisher.publish(new Emergency("could not watch a directory: " + directory, ioe));
}
}
boolean start() {
if (watchServiceRef.get() != null) {
return true; // already running? awesome
}
try {
WatchService watchService = FileSystems.getDefault().newWatchService();
if (!watchServiceRef.compareAndSet(null, watchService)) {
publisher.publish(
new Warning("started a watch service when one was already running! ignored", new Exception("Caller stacktrace"))
);
try { watchService.close(); } catch (Exception ignored) {}
}
return true;
} catch (IOException e) {
publisher.publish(new Emergency("creating a watcher failed", e));
}
return false;
}
void stop() {
WatchService watchService = watchServiceRef.getAndSet(null);
if (watchService != null) {
try {
watchService.close();
} catch (IOException e) {
publisher.publish(new Warning("closing a watcher threw", e));
}
}
}
Map<Path, Action> awaitChangedPaths() throws InterruptedException {
assert watchServiceRef.get() != null : "awaiting changes but never started!";
Map<Path, Action> result = new HashMap<>();
while (result.isEmpty()) {
WatchKey watchKey = watchServiceRef.get().take();
if (watchKey.isValid()) {
final Path directory = (Path)watchKey.watchable();
for (WatchEvent<?> watchEvent : watchKey.pollEvents()) {
final Kind<?> kind = watchEvent.kind();
if (kind == OVERFLOW) {
// not sure what to do about this. probably need to
// flush the whole cache in this scenario and reload
// the directories from the base
publisher.publish(new Warning("event overflow in the file watcher. changes were missed!"));
} else {
final WatchEvent<Path> event = cast(watchEvent);
final Path context = event.context();
final Path path = directory.resolve(context);
result.put(path, Action.from(kind));
}
}
// gotta clean up after ourselves?
// i'm not totally clear on if this is necessary but it seems to work
// cargo cult cancel!
if (!Files.exists(directory)) {
watchKey.cancel();
}
}
watchKey.reset();
}
return result;
}
}