Skip to content

Commit

Permalink
Improve config file watcher, avoid creating excess threads on world r…
Browse files Browse the repository at this point in the history
…eload
  • Loading branch information
mezz committed Feb 21, 2023
1 parent e70a56e commit 59ef1d2
Show file tree
Hide file tree
Showing 10 changed files with 256 additions and 153 deletions.
12 changes: 2 additions & 10 deletions Common/src/main/java/mezz/jei/common/config/file/ConfigSchema.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.atomic.AtomicBoolean;
Expand Down Expand Up @@ -56,19 +55,12 @@ private void onFileChanged() {
}

@Override
public void register() {
public void register(FileWatcher fileWatcher) {
if (!Files.exists(path)) {
save();
}

try {
Map<Path, Runnable> callbacks = Map.of(path, this::onFileChanged);
FileWatcher fileWatcher = new FileWatcher(callbacks);
Thread thread = new Thread(fileWatcher::run, "JEI Config file watcher");
thread.start();
} catch (IOException e) {
LOGGER.error("Failed to create FileWatcher Thread for config file: '{}'", path, e);
}
fileWatcher.addCallback(path, this::onFileChanged);
}

private void save() {
Expand Down
171 changes: 40 additions & 131 deletions Common/src/main/java/mezz/jei/common/config/file/FileWatcher.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,161 +2,70 @@

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.Unmodifiable;
import org.jetbrains.annotations.Nullable;

import java.io.IOException;
import java.nio.file.FileSystem;
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.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

public class FileWatcher {
private static final Logger LOGGER = LogManager.getLogger();

/**
* To avoid calling the callbacks many times while a file is being edited,
* wait a little while for there to be no more changes before we call them.
*/
private static final int quietTimeMs = 1_000;
/**
* If a directory we want to watch does not exist, we should periodically check for it.
*/
private static final int recheckDirectoriesMs = 60_000;

private final WatchService watchService;
@Unmodifiable
private final Map<Path, Runnable> callbacks;
@Unmodifiable
private final Set<Path> directoriesToWatch;

private final Map<WatchKey, Path> watchedDirectories = new HashMap<>();
private final Set<Path> changedPaths = new HashSet<>();
private long lastRecheckTime = 0;
private @Nullable FileWatcherThread thread;

/**
* @param callbacks a map of files to watch and callbacks to call when a file changes
*/
public FileWatcher(Map<Path, Runnable> callbacks) throws IOException {
this.callbacks = Map.copyOf(callbacks);
this.directoriesToWatch = callbacks.keySet().stream()
.map(Path::getParent)
.collect(Collectors.toUnmodifiableSet());
FileSystem fileSystem = FileSystems.getDefault();
this.watchService = fileSystem.newWatchService();
public FileWatcher(String threadName) {
this.thread = createThread(threadName);
}

@SuppressWarnings("InfiniteLoopStatement")
public void run() {
try (watchService) {
while (true) {
runIteration();
}
} catch (InterruptedException e) {
LOGGER.error("FileWatcher was interrupted, stopping.", e);
} catch (IOException e) {
LOGGER.error("FileWatcher encountered an unhandled IOException, stopping.", e);
@Nullable
private static FileWatcherThread createThread(String threadName) {
try {
return new FileWatcherThread(threadName);
} catch (UnsupportedOperationException | IOException e) {
LOGGER.error("Unable to create file watcher: ", e);
return null;
}
notifyChanges();
}

private void runIteration() throws InterruptedException {
long time = System.currentTimeMillis();
if (time > lastRecheckTime + recheckDirectoriesMs) {
lastRecheckTime = time;
watchDirectories();
/**
* @param path a config file to watch
* @param callback a callbacks to call when the file changes.
* Callbacks must be thread-safe, they will be called from this thread.
*/
public void addCallback(Path path, Runnable callback) {
if (thread != null) {
this.thread.addCallback(path, callback);
}
}

if (changedPaths.isEmpty()) {
// There are no changes yet.
// Just block and wait for some changes.
WatchKey watchKey = watchService.take();
if (watchKey != null) {
pollWatchKey(watchKey);
}
} else {
// We have some detected some changes already.
// Collect more changes, or notify the callbacks if there are no new changes.
WatchKey watchKey = watchService.poll(quietTimeMs, TimeUnit.MILLISECONDS);
if (watchKey != null) {
pollWatchKey(watchKey);
} else {
notifyChanges();
}
/**
* Start the file watcher thread
*/
public void start() {
if (thread != null) {
this.thread.start();
}
}

private void pollWatchKey(WatchKey watchKey) {
Path watchedDirectory = watchedDirectories.get(watchKey);
if (watchedDirectory == null) {
/**
* Stop the file watcher thread and clear all callbacks.
*/
public void reset() {
if (this.thread == null) {
return;
}

List<WatchEvent<?>> events = watchKey.pollEvents();
for (WatchEvent<?> event : events) {
if (event.kind() == StandardWatchEventKinds.OVERFLOW) {
// we missed some events,
// so we must assume every watched file in the directory has changed
callbacks.keySet().stream()
.filter(path -> path.getParent().equals(watchedDirectory))
.forEach(changedPaths::add);
break;
} else if (event.context() instanceof Path eventPath) {
Path fullPath = watchedDirectory.resolve(eventPath);
if (callbacks.containsKey(fullPath)) {
changedPaths.add(fullPath);
}
}
String threadName = this.thread.getName();
this.thread.interrupt();
try {
this.thread.join(1000);
} catch (InterruptedException consumed) {
Thread.currentThread().interrupt();
}

if (!watchKey.reset()) {
LOGGER.info("Failed to re-watch directory {}. It may have been deleted.", watchedDirectory);
watchedDirectories.remove(watchKey);
if (this.thread.isAlive()) {
LOGGER.error("File Watcher thread could not be stopped and will be abandoned.");
}
}

private void notifyChanges() {
if (changedPaths.isEmpty()) {
return;
}
LOGGER.info("Detected changes in files:\n{}", changedPaths.stream().map(Path::toString).collect(Collectors.joining("\n")));
for (Path changedPath : changedPaths) {
Runnable runnable = callbacks.get(changedPath);
if (runnable != null) {
runnable.run();
}
}
changedPaths.clear();
}

private void watchDirectories() {
for (Path directory : directoriesToWatch) {
if (!watchedDirectories.containsValue(directory) &&
Files.isDirectory(directory)
) {
try {
WatchKey key = directory.register(
watchService,
StandardWatchEventKinds.ENTRY_DELETE,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.OVERFLOW
);
watchedDirectories.put(key, directory);
} catch (IOException e) {
LOGGER.error("Failed to watch directory: {}", directory, e);
}
}
}
this.thread = createThread(threadName);
}
}
Loading

0 comments on commit 59ef1d2

Please sign in to comment.