From 9ba4adfe40653d7ebccce0d30bc016af9b3df8df Mon Sep 17 00:00:00 2001 From: Guillaume Smet Date: Wed, 3 Jul 2024 19:11:53 +0200 Subject: [PATCH] Modernize WatchServiceFileSystemWatcher - Switch to java.nio consistently - Allow to monitor a directory for specific files - Monitor .env file --- .../dev/RuntimeUpdatesProcessor.java | 14 +- .../dev/filesystem/watch/FileChangeEvent.java | 8 +- .../watch/WatchServiceFileSystemWatcher.java | 157 +++++++++++++----- .../dev/FileSystemWatcherTestCase.java | 84 +++++----- 4 files changed, 167 insertions(+), 96 deletions(-) diff --git a/core/deployment/src/main/java/io/quarkus/deployment/dev/RuntimeUpdatesProcessor.java b/core/deployment/src/main/java/io/quarkus/deployment/dev/RuntimeUpdatesProcessor.java index f32d052a25aec4..5d6b4370582261 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/dev/RuntimeUpdatesProcessor.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/dev/RuntimeUpdatesProcessor.java @@ -246,13 +246,17 @@ public void handleChanges(Collection changes) { periodicTestCompile(); } }; + // monitor .env as it can impact test execution + testClassChangeWatcher.watchFiles(Path.of(context.getApplicationRoot().getProjectDirectory()), + List.of(Path.of(".env")), + callback); Set nonExistent = new HashSet<>(); for (DevModeContext.ModuleInfo module : context.getAllModules()) { for (Path path : module.getMain().getSourcePaths()) { - testClassChangeWatcher.watchPath(path.toFile(), callback); + testClassChangeWatcher.watchDirectoryRecursively(path, callback); } for (Path path : module.getMain().getResourcePaths()) { - testClassChangeWatcher.watchPath(path.toFile(), callback); + testClassChangeWatcher.watchDirectoryRecursively(path, callback); } } for (DevModeContext.ModuleInfo module : context.getAllModules()) { @@ -261,14 +265,14 @@ public void handleChanges(Collection changes) { if (!Files.isDirectory(path)) { nonExistent.add(path); } else { - testClassChangeWatcher.watchPath(path.toFile(), callback); + testClassChangeWatcher.watchDirectoryRecursively(path, callback); } } for (Path path : module.getTest().get().getResourcePaths()) { if (!Files.isDirectory(path)) { nonExistent.add(path); } else { - testClassChangeWatcher.watchPath(path.toFile(), callback); + testClassChangeWatcher.watchDirectoryRecursively(path, callback); } } } @@ -284,7 +288,7 @@ public void run() { Path i = iterator.next(); if (Files.isDirectory(i)) { iterator.remove(); - testClassChangeWatcher.watchPath(i.toFile(), callback); + testClassChangeWatcher.watchDirectoryRecursively(i, callback); added = true; } diff --git a/core/deployment/src/main/java/io/quarkus/deployment/dev/filesystem/watch/FileChangeEvent.java b/core/deployment/src/main/java/io/quarkus/deployment/dev/filesystem/watch/FileChangeEvent.java index 33468a891627c1..a8085072917a8d 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/dev/filesystem/watch/FileChangeEvent.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/dev/filesystem/watch/FileChangeEvent.java @@ -1,6 +1,6 @@ package io.quarkus.deployment.dev.filesystem.watch; -import java.io.File; +import java.nio.file.Path; /** * The event object that is fired when a file system change is detected. @@ -10,7 +10,7 @@ */ public class FileChangeEvent { - private final File file; + private final Path file; private final Type type; /** @@ -19,7 +19,7 @@ public class FileChangeEvent { * @param file the file which is being watched * @param type the type of event that was encountered */ - public FileChangeEvent(File file, Type type) { + public FileChangeEvent(Path file, Type type) { this.file = file; this.type = type; } @@ -29,7 +29,7 @@ public FileChangeEvent(File file, Type type) { * * @return the file which was being watched */ - public File getFile() { + public Path getFile() { return file; } diff --git a/core/deployment/src/main/java/io/quarkus/deployment/dev/filesystem/watch/WatchServiceFileSystemWatcher.java b/core/deployment/src/main/java/io/quarkus/deployment/dev/filesystem/watch/WatchServiceFileSystemWatcher.java index cfd21f6f1940e5..de2459b4f75997 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/dev/filesystem/watch/WatchServiceFileSystemWatcher.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/dev/filesystem/watch/WatchServiceFileSystemWatcher.java @@ -4,12 +4,12 @@ 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.io.UncheckedIOException; import java.nio.file.ClosedWatchServiceException; import java.nio.file.FileSystems; +import java.nio.file.Files; 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; @@ -26,6 +26,8 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.jboss.logging.Logger; @@ -43,9 +45,9 @@ public class WatchServiceFileSystemWatcher implements Runnable { private static final AtomicInteger threadIdCounter = new AtomicInteger(0); private WatchService watchService; - private final Map files = Collections.synchronizedMap(new HashMap()); + private final Map monitoredDirectories = Collections.synchronizedMap(new HashMap<>()); private final Map pathDataByKey = Collections - .synchronizedMap(new IdentityHashMap()); + .synchronizedMap(new IdentityHashMap<>()); private volatile boolean stopped = false; private final Thread watchThread; @@ -70,19 +72,19 @@ public void run() { try { PathData pathData = pathDataByKey.get(key); if (pathData != null) { - final List results = new ArrayList(); + final List results = new ArrayList<>(); List> events = key.pollEvents(); - final Set addedFiles = new HashSet(); - final Set deletedFiles = new HashSet(); + final Set addedFiles = new HashSet<>(); + final Set deletedFiles = new HashSet<>(); for (WatchEvent event : events) { Path eventPath = (Path) event.context(); - File targetFile = ((Path) key.watchable()).resolve(eventPath).toFile(); + Path targetFile = ((Path) key.watchable()).resolve(eventPath).toAbsolutePath(); FileChangeEvent.Type type; if (event.kind() == StandardWatchEventKinds.ENTRY_CREATE) { type = FileChangeEvent.Type.ADDED; addedFiles.add(targetFile); - if (targetFile.isDirectory()) { + if (Files.isDirectory(targetFile)) { try { addWatchedDirectory(pathData, targetFile); } catch (IOException e) { @@ -107,6 +109,12 @@ public void run() { Iterator it = results.iterator(); while (it.hasNext()) { FileChangeEvent event = it.next(); + + if (!pathData.isMonitored(event.getFile())) { + it.remove(); + continue; + } + if (event.getType() == FileChangeEvent.Type.MODIFIED) { if (addedFiles.contains(event.getFile()) && deletedFiles.contains(event.getFile())) { @@ -134,7 +142,7 @@ public void run() { } if (!results.isEmpty()) { - for (FileChangeCallback callback : pathData.callbacks) { + for (FileChangeCallback callback : pathData.getCallbacks()) { invokeCallback(callback, results); } } @@ -142,7 +150,7 @@ public void run() { } finally { //if the key is no longer valid remove it from the files list if (!key.reset()) { - files.remove(key.watchable()); + monitoredDirectories.remove(key.watchable()); } } } @@ -156,39 +164,59 @@ public void run() { } } - public synchronized void watchPath(File file, FileChangeCallback callback) { + public synchronized void watchDirectoryRecursively(Path directory, FileChangeCallback callback) { try { - PathData data = files.get(file); + Path absoluteDirectory = directory.toAbsolutePath(); + PathData data = monitoredDirectories.get(absoluteDirectory); if (data == null) { - Set allDirectories = doScan(file).keySet(); - Path path = Paths.get(file.toURI()); - data = new PathData(path); - for (File dir : allDirectories) { + Set allDirectories = doScan(absoluteDirectory).keySet(); + data = new PathData(absoluteDirectory, List.of()); + for (Path dir : allDirectories) { addWatchedDirectory(data, dir); } - files.put(file, data); + monitoredDirectories.put(absoluteDirectory, data); + } + data.addCallback(callback); + + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * @param directory a directory that will be watched + * @param monitoredFiles list of monitored files relative to directory. An empty list will monitor all files. + * @param callback callback called when a file is changed + */ + public synchronized void watchFiles(Path directory, List monitoredFiles, FileChangeCallback callback) { + try { + Path absoluteDirectory = directory.toAbsolutePath(); + PathData data = monitoredDirectories.get(absoluteDirectory); + if (data == null) { + data = new PathData(absoluteDirectory, monitoredFiles); + addWatchedDirectory(data, absoluteDirectory); + monitoredDirectories.put(absoluteDirectory, data); } - data.callbacks.add(callback); + data.addCallback(callback); } catch (IOException e) { throw new RuntimeException(e); } } - private void addWatchedDirectory(PathData data, File dir) throws IOException { - Path path = Paths.get(dir.toURI()); - WatchKey key = path.register(watchService, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY); + private void addWatchedDirectory(PathData data, Path dir) throws IOException { + WatchKey key = dir.register(watchService, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY); pathDataByKey.put(key, data); - data.keys.add(key); + data.addWatchKey(key); } - public synchronized void unwatchPath(File file, final FileChangeCallback callback) { - PathData data = files.get(file); + public synchronized void unwatchPath(Path directory, final FileChangeCallback callback) { + PathData data = monitoredDirectories.get(directory); if (data != null) { - data.callbacks.remove(callback); - if (data.callbacks.isEmpty()) { - files.remove(file); - for (WatchKey key : data.keys) { + data.removeCallback(callback); + if (data.getCallbacks().isEmpty()) { + monitoredDirectories.remove(directory); + for (WatchKey key : data.getWatchKeys()) { key.cancel(); pathDataByKey.remove(key); } @@ -205,20 +233,21 @@ public void close() throws IOException { } } - private static Map doScan(File file) { - final Map results = new HashMap(); + private static Map doScan(Path directory) { + final Map results = new HashMap<>(); - final Deque toScan = new ArrayDeque(); - toScan.add(file); + final Deque toScan = new ArrayDeque<>(); + toScan.add(directory); while (!toScan.isEmpty()) { - File next = toScan.pop(); - if (next.isDirectory()) { - results.put(next, next.lastModified()); - File[] list = next.listFiles(); - if (list != null) { - for (File f : list) { - toScan.push(new File(f.getAbsolutePath())); + Path next = toScan.pop(); + if (Files.isDirectory(next)) { + try { + results.put(next, Files.getLastModifiedTime(directory).toMillis()); + try (Stream list = Files.list(next)) { + list.forEach(p -> toScan.push(p.toAbsolutePath())); } + } catch (IOException e) { + throw new UncheckedIOException("Unable to scan: " + next, e); } } } @@ -234,12 +263,52 @@ private static void invokeCallback(FileChangeCallback callback, List callbacks = new ArrayList(); - final List keys = new ArrayList(); - private PathData(Path path) { + private final Path path; + private final List callbacks = new ArrayList<>(); + private final List watchKeys = new ArrayList<>(); + private final List monitoredFiles; + + private PathData(Path path, List monitoredFiles) { this.path = path; + this.monitoredFiles = monitoredFiles.stream().map(p -> path.resolve(p).toAbsolutePath()) + .collect(Collectors.toList()); + } + + private void addWatchKey(WatchKey key) { + this.watchKeys.add(key); + } + + private void addCallback(FileChangeCallback callback) { + this.callbacks.add(callback); + } + + private void removeCallback(FileChangeCallback callback) { + this.callbacks.remove(callback); + } + + private List getCallbacks() { + return callbacks; + } + + private List getWatchKeys() { + return watchKeys; + } + + private boolean isMonitored(Path file) { + if (monitoredFiles.isEmpty()) { + return true; + } + + Path absolutePath = file.isAbsolute() ? file : file.toAbsolutePath(); + + for (Path monitoredFile : monitoredFiles) { + if (monitoredFile.equals(absolutePath)) { + return true; + } + } + + return false; } } diff --git a/core/deployment/src/test/java/io/quarkus/deployment/dev/FileSystemWatcherTestCase.java b/core/deployment/src/test/java/io/quarkus/deployment/dev/FileSystemWatcherTestCase.java index 8ea252fa07e54c..d605cb883cdd16 100644 --- a/core/deployment/src/test/java/io/quarkus/deployment/dev/FileSystemWatcherTestCase.java +++ b/core/deployment/src/test/java/io/quarkus/deployment/dev/FileSystemWatcherTestCase.java @@ -5,9 +5,12 @@ import static io.quarkus.deployment.dev.filesystem.watch.FileChangeEvent.Type.REMOVED; import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; import java.util.Collection; +import java.util.Comparator; import java.util.concurrent.BlockingDeque; import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.TimeUnit; @@ -33,8 +36,8 @@ public class FileSystemWatcherTestCase { private final BlockingDeque> results = new LinkedBlockingDeque<>(); private final BlockingDeque> secondResults = new LinkedBlockingDeque<>(); - File rootDir; - File existingSubDir; + Path rootDir; + Path existingSubDir; @BeforeEach public void setup() throws Exception { @@ -42,30 +45,24 @@ public void setup() throws Exception { //as it just relies on polling Assumptions.assumeTrue(RuntimeUpdatesProcessor.IS_LINUX); - rootDir = new File(System.getProperty("java.io.tmpdir") + DIR_NAME); + rootDir = Path.of(System.getProperty("java.io.tmpdir"), DIR_NAME); deleteRecursive(rootDir); - rootDir.mkdirs(); - File existing = new File(rootDir, EXISTING_FILE_NAME); + Files.createDirectories(rootDir); + Path existing = rootDir.resolve(EXISTING_FILE_NAME); touchFile(existing); - existingSubDir = new File(rootDir, EXISTING_DIR); - existingSubDir.mkdir(); - existing = new File(existingSubDir, EXISTING_FILE_NAME); + existingSubDir = rootDir.resolve(EXISTING_DIR); + Files.createDirectory(existingSubDir); + existing = existingSubDir.resolve(EXISTING_FILE_NAME); touchFile(existing); } - private static void touchFile(File existing) throws IOException { - FileOutputStream out = new FileOutputStream(existing); - try { - out.write(("data" + System.currentTimeMillis()).getBytes()); - out.flush(); - } finally { - out.close(); - } + private static void touchFile(Path existing) throws IOException { + Files.writeString(existing, "data" + System.currentTimeMillis()); } @AfterEach - public void after() { + public void after() throws IOException { if (rootDir != null) { deleteRecursive(rootDir); } @@ -75,48 +72,48 @@ public void after() { public void testFileSystemWatcher() throws Exception { WatchServiceFileSystemWatcher watcher = new WatchServiceFileSystemWatcher("test", true); try { - watcher.watchPath(rootDir, new FileChangeCallback() { + watcher.watchDirectoryRecursively(rootDir, new FileChangeCallback() { @Override public void handleChanges(Collection changes) { results.add(changes); } }); - watcher.watchPath(rootDir, new FileChangeCallback() { + watcher.watchDirectoryRecursively(rootDir, new FileChangeCallback() { @Override public void handleChanges(Collection changes) { secondResults.add(changes); } }); //first add a file - File added = new File(rootDir, "newlyAddedFile.txt").getAbsoluteFile(); + Path added = rootDir.resolve("newlyAddedFile.txt").toAbsolutePath(); touchFile(added); checkResult(added, ADDED); - added.setLastModified(500); + Files.setLastModifiedTime(added, FileTime.fromMillis(500)); checkResult(added, MODIFIED); - added.delete(); + Files.delete(added); Thread.sleep(1); checkResult(added, REMOVED); - added = new File(existingSubDir, "newSubDirFile.txt"); + added = existingSubDir.resolve("newSubDirFile.txt"); touchFile(added); checkResult(added, ADDED); - added.setLastModified(500); + Files.setLastModifiedTime(added, FileTime.fromMillis(500)); checkResult(added, MODIFIED); - added.delete(); + Files.delete(added); Thread.sleep(1); checkResult(added, REMOVED); - File existing = new File(rootDir, EXISTING_FILE_NAME); - existing.delete(); + Path existing = rootDir.resolve(EXISTING_FILE_NAME); + Files.delete(existing); Thread.sleep(1); checkResult(existing, REMOVED); - File newDir = new File(rootDir, "newlyCreatedDirectory"); - newDir.mkdir(); + Path newDir = rootDir.resolve("newlyCreatedDirectory"); + Files.createDirectory(newDir); checkResult(newDir, ADDED); - added = new File(newDir, "newlyAddedFileInNewlyAddedDirectory.txt").getAbsoluteFile(); + added = newDir.resolve("newlyAddedFileInNewlyAddedDirectory.txt").toAbsolutePath(); touchFile(added); checkResult(added, ADDED); - added.setLastModified(500); + Files.setLastModifiedTime(added, FileTime.fromMillis(500)); checkResult(added, MODIFIED); - added.delete(); + Files.delete(added); Thread.sleep(1); checkResult(added, REMOVED); @@ -126,7 +123,7 @@ public void handleChanges(Collection changes) { } - private void checkResult(File file, FileChangeEvent.Type type) throws InterruptedException { + private void checkResult(Path file, FileChangeEvent.Type type) throws InterruptedException { Collection results = this.results.poll(20, TimeUnit.SECONDS); Collection secondResults = this.secondResults.poll(20, TimeUnit.SECONDS); Assertions.assertNotNull(results); @@ -151,8 +148,8 @@ private void checkResult(File file, FileChangeEvent.Type type) throws Interrupte endTime = System.currentTimeMillis() + 10000; while (type == ADDED && (res.getType() == MODIFIED || res2.getType() == MODIFIED) - && (res.getFile().equals(file.getParentFile()) || res2.getFile().equals(file.getParentFile())) - && !file.isDirectory() + && (res.getFile().equals(file.getParent()) || res2.getFile().equals(file.getParent())) + && !Files.isDirectory(file) && System.currentTimeMillis() < endTime) { FileChangeEvent[] nextEvents = consumeEvents(); res = nextEvents[0]; @@ -179,14 +176,15 @@ private FileChangeEvent[] consumeEvents() throws InterruptedException { return nextEvents; } - public static void deleteRecursive(final File file) { - File[] files = file.listFiles(); - if (files != null) { - for (File f : files) { - deleteRecursive(f); - } + public static void deleteRecursive(final Path path) throws IOException { + if (!Files.exists(path)) { + return; } - file.delete(); + + Files.walk(path) + .sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); } }