From 36242d851488171d1b65d3f1bb2fb7ebf560f154 Mon Sep 17 00:00:00 2001 From: Claus Ibsen Date: Mon, 21 Sep 2026 09:02:02 +0200 Subject: [PATCH 1/2] CAMEL-24862: the recursive file watcher watches directories created while it runs, and ignores the compile work directory Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Bp3538HRBPMQkb5ta9xRaj --- ...FileWatcherResourceReloadStrategyTest.java | 89 +++++++++++++++++++ .../FileWatcherResourceReloadStrategy.java | 70 ++++++++++++++- .../pages/camel-4x-upgrade-guide-4_23.adoc | 9 ++ 3 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 core/camel-core/src/test/java/org/apache/camel/support/FileWatcherResourceReloadStrategyTest.java diff --git a/core/camel-core/src/test/java/org/apache/camel/support/FileWatcherResourceReloadStrategyTest.java b/core/camel-core/src/test/java/org/apache/camel/support/FileWatcherResourceReloadStrategyTest.java new file mode 100644 index 0000000000000..c0fb33cc3dcf5 --- /dev/null +++ b/core/camel-core/src/test/java/org/apache/camel/support/FileWatcherResourceReloadStrategyTest.java @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.camel.support; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.apache.camel.ContextTestSupport; +import org.apache.camel.impl.engine.DefaultCompileStrategy; +import org.apache.camel.spi.CompileStrategy; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfSystemProperty; +import org.junit.jupiter.api.io.TempDir; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * CAMEL-24862: the file watcher, when recursive, watches a directory created while it runs, and it ignores the compile + * work directory where the runtime writes the class files it compiles. + */ +@DisabledIfSystemProperty(named = "ci.env.name", matches = ".*", + disabledReason = "Runs only local: the JDK watch service on a CI file system is too slow to time") +public class FileWatcherResourceReloadStrategyTest extends ContextTestSupport { + + @TempDir + Path dir; + + @Override + public boolean isUseRouteBuilder() { + return false; + } + + @Test + public void testNewDirectoryIsWatchedAndCompileWorkDirIsNot() throws Exception { + Path compile = dir.resolve(".camel-jbang/compile"); + Files.createDirectories(compile); + CompileStrategy cs = new DefaultCompileStrategy(); + cs.setWorkDir(compile.toString()); + context.getCamelContextExtension().addContextPlugin(CompileStrategy.class, cs); + + List reloaded = new CopyOnWriteArrayList<>(); + FileWatcherResourceReloadStrategy strategy = new FileWatcherResourceReloadStrategy(dir.toString(), true); + strategy.setCamelContext(context); + strategy.setResourceReload((name, resource) -> reloaded.add(name)); + strategy.start(); + try { + // a tree created after the start, with its first file in it + Path tree = dir.resolve("src/main/java/camel/example"); + Files.createDirectories(tree); + Files.writeString(tree.resolve("OrderNumber.java"), "package camel.example; public class OrderNumber {}"); + await().atMost(java.time.Duration.ofSeconds(30)) + .until(() -> reloaded.stream().anyMatch(n -> n.endsWith("OrderNumber.java"))); + + // a file written later into the new tree is seen as well: the directory is watched now + Files.writeString(tree.resolve("Other.java"), "package camel.example; public class Other {}"); + await().atMost(java.time.Duration.ofSeconds(30)) + .until(() -> reloaded.stream().anyMatch(n -> n.endsWith("Other.java"))); + + // a class file the runtime writes into the compile work dir is not a change + Files.createDirectories(compile.resolve("camel/example")); + Files.writeString(compile.resolve("camel/example/OrderNumber.class"), "bytecode"); + Files.writeString(dir.resolve("marker.txt"), "after the class file"); + await().atMost(java.time.Duration.ofSeconds(30)) + .until(() -> reloaded.stream().anyMatch(n -> n.endsWith("marker.txt"))); + assertFalse(reloaded.stream().anyMatch(n -> n.endsWith(".class")), "class files reloaded: " + reloaded); + assertTrue(reloaded.stream().noneMatch(n -> n.contains(".camel-jbang")), reloaded.toString()); + } finally { + strategy.stop(); + } + } +} diff --git a/core/camel-support/src/main/java/org/apache/camel/support/FileWatcherResourceReloadStrategy.java b/core/camel-support/src/main/java/org/apache/camel/support/FileWatcherResourceReloadStrategy.java index c0f3e9da8c4a2..3deddc39066c6 100644 --- a/core/camel-support/src/main/java/org/apache/camel/support/FileWatcherResourceReloadStrategy.java +++ b/core/camel-support/src/main/java/org/apache/camel/support/FileWatcherResourceReloadStrategy.java @@ -22,12 +22,15 @@ import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.nio.file.SimpleFileVisitor; import java.nio.file.WatchEvent; import java.nio.file.WatchKey; import java.nio.file.WatchService; import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.concurrent.ExecutorService; @@ -36,6 +39,7 @@ import org.apache.camel.RuntimeCamelException; import org.apache.camel.api.management.ManagedAttribute; import org.apache.camel.api.management.ManagedResource; +import org.apache.camel.spi.CompileStrategy; import org.apache.camel.spi.Resource; import org.apache.camel.util.FileUtil; import org.apache.camel.util.IOHelper; @@ -64,6 +68,7 @@ public class FileWatcherResourceReloadStrategy extends ResourceReloadStrategySup ExecutorService executorService; WatchFileChangesTask task; Map folderKeys; + WatchEvent.Modifier watchModifier; FileFilter fileFilter; String folder; boolean isRecursive; @@ -188,6 +193,7 @@ protected void doStart() throws Exception { Path path = dir.toPath(); watcher = path.getFileSystem().newWatchService(); // we cannot support deleting files as we don't know which routes that would be + this.watchModifier = modifier; if (isRecursive) { this.folderKeys = new HashMap<>(); registerRecursive(watcher, path, modifier); @@ -225,6 +231,9 @@ private void registerRecursive(final WatchService watcher, final Path root, fina Files.walkFileTree(root, new SimpleFileVisitor<>() { @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + if (isCompileWorkDir(dir)) { + return FileVisitResult.SKIP_SUBTREE; + } WatchKey key = registerPathToWatcher(modifier, dir, watcher); folderKeys.put(key, dir); return FileVisitResult.CONTINUE; @@ -232,6 +241,52 @@ public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) th }); } + /** + * Registers a directory created while watching recursively, and its subdirectories, and collects the files already + * in them as changes: a tree such as src/main/java/com/acme is usually created with its first file in it, before + * the watcher can see the directory. + */ + private void registerNewDirectory(Path dir, List changed) { + try { + Files.walkFileTree(dir, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path d, BasicFileAttributes attrs) throws IOException { + if (isCompileWorkDir(d) || folderKeys.containsValue(d)) { + return FileVisitResult.SKIP_SUBTREE; + } + WatchKey k = registerPathToWatcher(watchModifier, d, watcher); + folderKeys.put(k, d); + LOG.debug("Watching new directory: {}", d); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path f, BasicFileAttributes attrs) { + changed.add(f.toFile()); + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + LOG.warn("Cannot watch new directory: {} due to: {}. This exception is ignored.", dir, e.getMessage(), e); + } + } + + /** + * Whether the directory is the compile work directory (or inside it): the class files the runtime writes there when + * it compiles a Java source are not changes to watch, and would otherwise trigger a reload of their own, which + * compiles again, which writes again (CAMEL-24862). + */ + protected boolean isCompileWorkDir(Path dir) { + CompileStrategy cs = getCamelContext() != null + ? getCamelContext().getCamelContextExtension().getContextPlugin(CompileStrategy.class) : null; + String workDir = cs != null ? cs.getWorkDir() : null; + if (workDir == null) { + return false; + } + Path work = Paths.get(workDir).toAbsolutePath().normalize(); + return dir.toAbsolutePath().normalize().startsWith(work); + } + @Override protected void doStop() throws Exception { super.doStop(); @@ -290,15 +345,28 @@ public void run() { pathToReload = folder; } + // the files of the events, plus the files of a directory created under a watched one + // when recursive (registered here, since the watch service only reports what is registered + // at the time; a class under src/main/java added while running was never seen, CAMEL-24862) + List changed = new ArrayList<>(); for (WatchEvent event : key.pollEvents()) { WatchEvent we = (WatchEvent) event; Path path = we.context(); File file = pathToReload.resolve(path).toFile(); LOG.trace("File watch-event: {} on file: {}", we, file); if (file.isDirectory()) { + if (isRecursive && we.kind() == ENTRY_CREATE && !isCompileWorkDir(file.toPath())) { + registerNewDirectory(file.toPath(), changed); + } continue; } - + if (isCompileWorkDir(file.toPath().getParent())) { + // a class file the runtime wrote while compiling: not a change of ours + continue; + } + changed.add(file); + } + for (File file : changed) { String name = FileUtil.compactPath(file.getPath()); LOG.debug("Detected Modified/Created file: {}", name); boolean accept = fileFilter == null || fileFilter.accept(file); diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc index 4bef77ec50044..f38bf2b012d73 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc @@ -473,6 +473,15 @@ set here, because that would reject input that parses today. Routes that genuine an external DTD or parameter entity through this converter must supply their own `SAXParserFactory`. +=== camel-core - the recursive file watcher watches directories created while it runs + +The file watcher reload strategy (`camel run --dev`, the route watcher reload strategy with a recursive directory) +registered the directory tree once at start: a directory created afterwards, such as `src/main/java/com/acme` for a +Java class added to a running project, was never watched and its files never reloaded. A directory created under a +watched one is now registered as it appears, with the files already in it reloaded. The watcher also ignores the +compile work directory (`camel.main.compileWorkDir`, `.camel-jbang/compile` for the CLI), where the runtime writes the +class files it compiles, so a compiled source no longer triggers a reload of its own. + === camel-core - the type of a bean created by a script or a builder is optional The `type` (class name) of a bean definition — `bean` under `beans`, `templateBean` of a route From 249c76d10ffb435fdc92afcf5cc6f6b6933d20b8 Mon Sep 17 00:00:00 2001 From: Claus Ibsen Date: Mon, 21 Sep 2026 09:40:07 +0200 Subject: [PATCH 2/2] CAMEL-24862: Address review feedback: the compile work dir is resolved once at start, Path.of, and a set of the watched directories Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Bp3538HRBPMQkb5ta9xRaj --- .../FileWatcherResourceReloadStrategy.java | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/core/camel-support/src/main/java/org/apache/camel/support/FileWatcherResourceReloadStrategy.java b/core/camel-support/src/main/java/org/apache/camel/support/FileWatcherResourceReloadStrategy.java index 3deddc39066c6..422340d2a48f4 100644 --- a/core/camel-support/src/main/java/org/apache/camel/support/FileWatcherResourceReloadStrategy.java +++ b/core/camel-support/src/main/java/org/apache/camel/support/FileWatcherResourceReloadStrategy.java @@ -22,7 +22,6 @@ import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.nio.file.SimpleFileVisitor; import java.nio.file.WatchEvent; import java.nio.file.WatchKey; @@ -30,9 +29,11 @@ import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; @@ -68,7 +69,10 @@ public class FileWatcherResourceReloadStrategy extends ResourceReloadStrategySup ExecutorService executorService; WatchFileChangesTask task; Map folderKeys; + Set watchedFolders; WatchEvent.Modifier watchModifier; + /** The compile work directory, resolved once at start (null when there is none). */ + Path compileWorkDir; FileFilter fileFilter; String folder; boolean isRecursive; @@ -194,8 +198,10 @@ protected void doStart() throws Exception { watcher = path.getFileSystem().newWatchService(); // we cannot support deleting files as we don't know which routes that would be this.watchModifier = modifier; + this.compileWorkDir = resolveCompileWorkDir(); if (isRecursive) { this.folderKeys = new HashMap<>(); + this.watchedFolders = new HashSet<>(); registerRecursive(watcher, path, modifier); } else { registerPathToWatcher(modifier, path, watcher); @@ -236,6 +242,7 @@ public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) th } WatchKey key = registerPathToWatcher(modifier, dir, watcher); folderKeys.put(key, dir); + watchedFolders.add(dir); return FileVisitResult.CONTINUE; } }); @@ -251,11 +258,12 @@ private void registerNewDirectory(Path dir, List changed) { Files.walkFileTree(dir, new SimpleFileVisitor<>() { @Override public FileVisitResult preVisitDirectory(Path d, BasicFileAttributes attrs) throws IOException { - if (isCompileWorkDir(d) || folderKeys.containsValue(d)) { + if (isCompileWorkDir(d) || watchedFolders.contains(d)) { return FileVisitResult.SKIP_SUBTREE; } WatchKey k = registerPathToWatcher(watchModifier, d, watcher); folderKeys.put(k, d); + watchedFolders.add(d); LOG.debug("Watching new directory: {}", d); return FileVisitResult.CONTINUE; } @@ -277,14 +285,14 @@ public FileVisitResult visitFile(Path f, BasicFileAttributes attrs) { * compiles again, which writes again (CAMEL-24862). */ protected boolean isCompileWorkDir(Path dir) { + return compileWorkDir != null && dir.toAbsolutePath().normalize().startsWith(compileWorkDir); + } + + private Path resolveCompileWorkDir() { CompileStrategy cs = getCamelContext() != null ? getCamelContext().getCamelContextExtension().getContextPlugin(CompileStrategy.class) : null; String workDir = cs != null ? cs.getWorkDir() : null; - if (workDir == null) { - return false; - } - Path work = Paths.get(workDir).toAbsolutePath().normalize(); - return dir.toAbsolutePath().normalize().startsWith(work); + return workDir != null ? Path.of(workDir).toAbsolutePath().normalize() : null; } @Override