diff --git a/annot/src/main/java/com/predic8/membrane/annot/generator/IncludeListClassGenerator.java b/annot/src/main/java/com/predic8/membrane/annot/generator/IncludeListClassGenerator.java
index 6e0602c6fb..d3281c6477 100644
--- a/annot/src/main/java/com/predic8/membrane/annot/generator/IncludeListClassGenerator.java
+++ b/annot/src/main/java/com/predic8/membrane/annot/generator/IncludeListClassGenerator.java
@@ -44,6 +44,8 @@ protected String getClassImpl() {
* Include entries are resolved in the order they are listed. If an entry points to a directory,
* all matching *.apis.yaml / *.apis.yml files in that directory are included
* in lexicographical order. Includes are resolved recursively and cyclic include chains are rejected.
+ * Directory includes are not watched for YAML hot deployment; adding or removing files there does not
+ * trigger a reload automatically.
*
* include:
@@ -62,7 +64,8 @@ public class IncludeList {
*
*
* Each string is a path to either a YAML file or a directory. Relative paths are resolved
- * against the directory of the including file. Absolute paths are also supported.
+ * against the directory of the including file. Absolute paths are also supported. Directory
+ * entries are loaded during parsing, but they are not watched for YAML hot deployment.
*
*/
@MCChildElement(allowForeign = true)
diff --git a/core/src/main/java/com/predic8/membrane/core/cli/RouterCLI.java b/core/src/main/java/com/predic8/membrane/core/cli/RouterCLI.java
index 5a9bf86d52..8ebab075a4 100644
--- a/core/src/main/java/com/predic8/membrane/core/cli/RouterCLI.java
+++ b/core/src/main/java/com/predic8/membrane/core/cli/RouterCLI.java
@@ -15,20 +15,17 @@
package com.predic8.membrane.core.cli;
import com.fasterxml.jackson.core.JsonProcessingException;
-import com.predic8.membrane.annot.beanregistry.BeanDefinitions;
-import com.predic8.membrane.annot.beanregistry.BeanRegistryImplementation;
import com.predic8.membrane.annot.yaml.ConfigurationParsingException;
-import com.predic8.membrane.core.config.spring.GrammarAutoGenerated;
import com.predic8.membrane.core.config.spring.TrackingFileSystemXmlApplicationContext;
import com.predic8.membrane.core.exceptions.SpringConfigurationErrorHandler;
import com.predic8.membrane.core.openapi.serviceproxy.APIProxy;
import com.predic8.membrane.core.openapi.serviceproxy.OpenAPISpec;
import com.predic8.membrane.core.resolver.ResolverMap;
import com.predic8.membrane.core.resolver.ResourceRetrievalException;
-import com.predic8.membrane.core.router.Configuration;
import com.predic8.membrane.core.router.DefaultRouter;
import com.predic8.membrane.core.router.Router;
import com.predic8.membrane.core.router.RouterXmlBootstrap;
+import com.predic8.membrane.core.router.hotdeploy.YamlRouterReloader;
import org.apache.commons.cli.ParseException;
import org.apache.commons.codec.binary.Hex;
import org.jetbrains.annotations.NotNull;
@@ -39,11 +36,7 @@
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
-import java.net.URI;
-import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
-import java.nio.file.InvalidPathException;
-import java.nio.file.Path;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.List;
@@ -58,9 +51,9 @@
import static com.predic8.membrane.core.openapi.serviceproxy.OpenAPISpec.YesNoOpenAPIOption.YES;
import static com.predic8.membrane.core.openapi.util.OpenAPIUtil.isOpenAPIMisplacedError;
import static com.predic8.membrane.core.proxies.ApiInfo.logInfosAboutStartedProxies;
+import static com.predic8.membrane.core.router.YamlRouterBootstrap.loadIntoRouter;
import static com.predic8.membrane.core.util.ExceptionUtil.concatMessageAndCauseMessages;
import static com.predic8.membrane.core.util.OSUtil.fixBackslashes;
-import static com.predic8.membrane.core.util.OSUtil.isWindowsAbsolutePath;
import static com.predic8.membrane.core.util.URIUtil.pathFromFileURI;
import static com.predic8.membrane.core.util.text.TerminalColors.*;
import static java.lang.Integer.parseInt;
@@ -236,46 +229,13 @@ private static Router initRouterByYAML(MembraneCommandLine commandLine, String o
static Router initRouterByYAML(String location) throws Exception {
var router = new DefaultRouter();
- var grammar = new GrammarAutoGenerated();
- var reg = new BeanRegistryImplementation(grammar);
-
- router.setRegistry(reg);
- reg.register("router", router);
-
- BeanDefinitions beanDefinitions = new BeanDefinitions(reg.parseYamlBeanDefinitions(router.getResolverMap().resolve(location), grammar, getRootSourceFile(location)));
- beanDefinitions.ensureSingleGlobalDefinition();
- beanDefinitions.getConfigDefinition()
- .ifPresent(configBd -> router.applyConfiguration((Configuration) reg.resolve(configBd.getName())));
-
- reg.finishStaticConfiguration();
- reg.start();
-
- // Must be set after reading the YAML. Otherwise, it will be overwritten
- router.getConfiguration().setBaseLocation(location);
-
+ router.setConfigurationReloader(new YamlRouterReloader(router, loadIntoRouter(router, location)));
router.start();
logInfosAboutStartedProxies(router.getRuleManager());
logStartupMessage();
return router;
}
- private static Path getRootSourceFile(String location) {
- if (!isWindowsAbsolutePath(location)) {
- try {
- URI uri = new URI(location);
- String scheme = uri.getScheme();
- if (scheme != null && !"file".equalsIgnoreCase(scheme))
- return null;
- } catch (URISyntaxException ignored) {}
- }
-
- try {
- return Path.of(pathFromFileURI(location));
- } catch (InvalidPathException ignored) {
- return null;
- }
- }
-
private static @NotNull APIProxy getApiProxy(MembraneCommandLine commandLine) throws IOException {
APIProxy api = new APIProxy();
api.setPort(commandLine.getCommand().isOptionSet("p") ?
diff --git a/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java b/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java
index a20de2ce1a..2f7a64c18b 100644
--- a/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java
+++ b/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java
@@ -36,9 +36,9 @@
import com.predic8.membrane.core.proxies.RuleManager.RuleDefinitionSource;
import com.predic8.membrane.core.proxies.SSLableProxy;
import com.predic8.membrane.core.resolver.ResolverMap;
+import com.predic8.membrane.core.router.hotdeploy.ConfigurationReloader;
import com.predic8.membrane.core.router.hotdeploy.DefaultHotDeployer;
import com.predic8.membrane.core.router.hotdeploy.HotDeployer;
-import com.predic8.membrane.core.router.hotdeploy.NullHotDeployer;
import com.predic8.membrane.core.transport.PortOccupiedException;
import com.predic8.membrane.core.transport.Transport;
import com.predic8.membrane.core.transport.http.HttpClient;
@@ -111,6 +111,9 @@ public class DefaultRouter extends AbstractRouter implements ApplicationContextA
@GuardedBy("lock")
private boolean initialized;
+ @GuardedBy("lock")
+ private boolean reloading;
+
/**
* HotDeployer for changes on the configuration file.
* Not synchronized, since only modified during initialization
@@ -299,12 +302,16 @@ public void stop() {
getRegistry().getBean(KubernetesWatcher.class).ifPresent(KubernetesWatcher::stop);
hotDeployer.stop();
- if (mainComponents.getTransport() != null)
+ if (reinitializer != null)
+ reinitializer.stop();
+ if (mainComponents.getTransport() != null)
mainComponents.getTransport().closeAll();
mainComponents.getTimerManager().shutdown();
+ closeRegistryIfSupported();
synchronized (lock) {
running = false;
+ reloading = false;
lock.notifyAll();
}
}
@@ -347,7 +354,7 @@ public String getId() {
*/
public void waitFor() {
synchronized (lock) {
- while (running) {
+ while (running || reloading) {
try {
lock.wait();
} catch (InterruptedException ignored) {
@@ -430,7 +437,7 @@ public BeanRegistry getRegistry() {
}
public void applyConfiguration(Configuration configuration) {
- hotDeployer = configuration.isHotDeploy() ? new DefaultHotDeployer() : new NullHotDeployer();
+ hotDeployer.setEnabled(configuration.isHotDeploy());
this.configuration = configuration;
}
@@ -454,6 +461,68 @@ public RuleReinitializer getReinitializer() {
return reinitializer;
}
+ public void setConfigurationReloader(ConfigurationReloader configurationReloader) {
+ hotDeployer.setConfigurationReloader(configurationReloader);
+ }
+
+ public boolean markReloading() {
+ synchronized (lock) {
+ if (reloading) {
+ return false;
+ }
+ reloading = true;
+ lock.notifyAll();
+ return true;
+ }
+ }
+
+ public void clearReloading() {
+ synchronized (lock) {
+ reloading = false;
+ lock.notifyAll();
+ }
+ }
+
+ public void stopRuntimeForReload() {
+ getRegistry().getBean(KubernetesWatcher.class).ifPresent(KubernetesWatcher::stop);
+
+ if (reinitializer != null)
+ reinitializer.stop();
+ if (mainComponents.getTransport() != null)
+ mainComponents.getTransport().closeAll(false);
+
+ synchronized (lock) {
+ running = false;
+ lock.notifyAll();
+ }
+ }
+
+ public void resetRuntime() {
+ synchronized (lock) {
+ mainComponents = new DefaultMainComponents(this);
+ configuration = new Configuration();
+ initialized = false;
+ reinitializer = null;
+ }
+ }
+
+ public void disposeRuntime() {
+ if (reinitializer != null)
+ reinitializer.stop();
+ mainComponents.getTimerManager().shutdown();
+ closeRegistryIfSupported();
+ }
+
+ private void closeRegistryIfSupported() {
+ closeRegistryIfSupported(getRegistry());
+ }
+
+ private void closeRegistryIfSupported(BeanRegistry registry) {
+ if (registry instanceof BeanRegistryImplementation beanRegistry) {
+ beanRegistry.close();
+ }
+ }
+
private static void handleOpenAPIParsingException(OpenAPIParsingException e) {
System.err.printf("""
================================================================================================
@@ -483,4 +552,4 @@ private static void handleDuplicateOpenAPIPaths(DuplicatePathException e) {
%n""", e.getPath());
throw new ExitException();
}
-}
\ No newline at end of file
+}
diff --git a/core/src/main/java/com/predic8/membrane/core/router/YamlConfigurationSource.java b/core/src/main/java/com/predic8/membrane/core/router/YamlConfigurationSource.java
new file mode 100644
index 0000000000..a650652483
--- /dev/null
+++ b/core/src/main/java/com/predic8/membrane/core/router/YamlConfigurationSource.java
@@ -0,0 +1,26 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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 com.predic8.membrane.core.router;
+
+import java.nio.file.Path;
+import java.util.List;
+
+/** Root YAML location and included local YAML files. */
+public record YamlConfigurationSource(String location, List trackedFiles) {
+
+ public YamlConfigurationSource {
+ trackedFiles = trackedFiles == null ? List.of() : List.copyOf(trackedFiles);
+ }
+}
diff --git a/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java b/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java
new file mode 100644
index 0000000000..201f35d12a
--- /dev/null
+++ b/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java
@@ -0,0 +1,97 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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 com.predic8.membrane.core.router;
+
+import com.predic8.membrane.annot.beanregistry.BeanDefinition;
+import com.predic8.membrane.annot.beanregistry.BeanDefinitions;
+import com.predic8.membrane.annot.beanregistry.BeanRegistryImplementation;
+import com.predic8.membrane.core.config.spring.GrammarAutoGenerated;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.file.InvalidPathException;
+import java.nio.file.Path;
+import java.util.LinkedHashSet;
+import java.util.List;
+
+import static com.predic8.membrane.core.util.OSUtil.isWindowsAbsolutePath;
+import static com.predic8.membrane.core.util.URIUtil.pathFromFileURI;
+
+/**
+ * Parses YAML into a router instance and returns the local files that can later be watched for hot reloads.
+ */
+public final class YamlRouterBootstrap {
+
+ private YamlRouterBootstrap() {
+ }
+
+ public static YamlConfigurationSource loadIntoRouter(DefaultRouter router, String location) throws Exception {
+ var grammar = new GrammarAutoGenerated();
+ var registry = new BeanRegistryImplementation(grammar);
+
+ router.setRegistry(registry);
+ registry.register("router", router);
+
+ List definitions = registry.parseYamlBeanDefinitions(
+ router.getResolverMap().resolve(location),
+ grammar,
+ getRootSourceFile(location)
+ );
+
+ BeanDefinitions beanDefinitions = new BeanDefinitions(definitions);
+ beanDefinitions.ensureSingleGlobalDefinition();
+ beanDefinitions.getConfigDefinition().ifPresent(configBd -> router.applyConfiguration((Configuration) registry.resolve(configBd.getName())));
+
+ registry.finishStaticConfiguration();
+ router.getConfiguration().setBaseLocation(location);
+ return new YamlConfigurationSource(location, collectTrackedFiles(location, definitions));
+ }
+
+ static List collectTrackedFiles(String location, List definitions) {
+ LinkedHashSet trackedFiles = new LinkedHashSet<>();
+
+ Path rootSourceFile = getRootSourceFile(location);
+ if (rootSourceFile != null) {
+ trackedFiles.add(rootSourceFile.toAbsolutePath().normalize());
+ }
+
+ for (BeanDefinition definition : definitions) {
+ if (definition.getSourceMetadata() == null || definition.getSourceMetadata().sourceFile() == null) {
+ continue;
+ }
+ trackedFiles.add(definition.getSourceMetadata().sourceFile().toAbsolutePath().normalize());
+ }
+
+ return List.copyOf(trackedFiles);
+ }
+
+ static Path getRootSourceFile(String location) {
+ if (!isWindowsAbsolutePath(location)) {
+ try {
+ URI uri = new URI(location);
+ String scheme = uri.getScheme();
+ if (scheme != null && !"file".equalsIgnoreCase(scheme))
+ return null;
+ } catch (URISyntaxException ignored) {
+ }
+ }
+
+ try {
+ return Path.of(pathFromFileURI(location));
+ } catch (InvalidPathException ignored) {
+ return null;
+ }
+ }
+}
diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/ConfigurationReloader.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/ConfigurationReloader.java
new file mode 100644
index 0000000000..daa7f541a2
--- /dev/null
+++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/ConfigurationReloader.java
@@ -0,0 +1,25 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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 com.predic8.membrane.core.router.hotdeploy;
+
+import java.nio.file.Path;
+import java.util.List;
+
+public interface ConfigurationReloader {
+
+ boolean reload();
+
+ List trackedFiles();
+}
diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java
index 591214c5ee..aaf2710fae 100644
--- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java
+++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java
@@ -21,16 +21,20 @@
import javax.annotation.concurrent.*;
+import static java.lang.Thread.currentThread;
+
public class DefaultHotDeployer implements HotDeployer {
private static final Logger log = LoggerFactory.getLogger(DefaultHotDeployer.class.getName());
@GuardedBy("lock")
- private HotDeploymentThread hdt;
+ private Thread hdt;
private DefaultRouter router;
+ private ConfigurationReloader configurationReloader;
private final Object lock = new Object();
+ private volatile boolean enabled = true;
@Override
public void start(@NotNull DefaultRouter defaultRouter) {
@@ -41,8 +45,19 @@ public void start(@NotNull DefaultRouter defaultRouter) {
private void startInternal() {
// Prevent multiple threads from starting hot deployment at the same time.
synchronized (lock) {
+ if (!enabled) {
+ return;
+ }
+
if (hdt != null) {
- log.warn("Hot deployment already started.");
+ if (!hdt.isAlive()) {
+ hdt = null;
+ } else {
+ return;
+ }
+ }
+
+ if (router == null) {
return;
}
@@ -54,38 +69,75 @@ private void startInternal() {
""");
return;
}
- hdt = new HotDeploymentThread(router.getRef());
- hdt.setFiles(tac.getFiles());
+ HotDeploymentThread hotDeploymentThread = new HotDeploymentThread(router.getRef());
+ hotDeploymentThread.setFiles(tac.getFiles());
+ hdt = hotDeploymentThread;
+ hotDeploymentThread.start();
+ return;
+ }
+
+ // Start from YAML
+ if (configurationReloader != null && !configurationReloader.trackedFiles().isEmpty()) {
+ hdt = new YamlHotDeploymentThread(configurationReloader, this::isEnabled);
hdt.start();
return;
}
- log.debug("Hot deployment is not yet supported for the YAML configuration.");
+ log.debug("Hot deployment skipped because no local YAML files are known.");
}
}
@Override
public void stop() {
+ Thread threadToStop;
synchronized (lock) {
if (hdt == null)
return;
-
- router.getReinitializer().stop();
- hdt.stopASAP();
+ threadToStop = hdt;
hdt = null;
}
+
+ // A watcher can stop itself after a failed reload; joining the current thread would deadlock here.
+ if (threadToStop == currentThread()) {
+ return;
+ }
+
+ if (threadToStop instanceof HotDeploymentThread hotDeploymentThread) {
+ hotDeploymentThread.stopASAP();
+ } else if (threadToStop instanceof YamlHotDeploymentThread yamlHotDeploymentThread) {
+ yamlHotDeploymentThread.stopASAP();
+ } else {
+ threadToStop.interrupt();
+ }
+
+ try {
+ threadToStop.join(2000);
+ } catch (InterruptedException e) {
+ currentThread().interrupt();
+ }
}
@Override
public void setEnabled(boolean enabled) {
- if (enabled && router != null)
- startInternal();
- else
+ this.enabled = enabled;
+
+ if (!enabled) {
stop();
+ return;
+ }
+
+ if (router != null) {
+ startInternal();
+ }
}
@Override
public boolean isEnabled() {
- return true;
+ return enabled;
+ }
+
+ @Override
+ public void setConfigurationReloader(ConfigurationReloader configurationReloader) {
+ this.configurationReloader = configurationReloader;
}
}
diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/FileWatchSnapshot.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/FileWatchSnapshot.java
new file mode 100644
index 0000000000..3141aa4a46
--- /dev/null
+++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/FileWatchSnapshot.java
@@ -0,0 +1,58 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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 com.predic8.membrane.core.router.hotdeploy;
+
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.List;
+
+/**
+ * Snapshot of the currently watched YAML files and their last-modified timestamps.
+ */
+final class FileWatchSnapshot {
+
+ private final List files;
+
+ private FileWatchSnapshot(List files) {
+ this.files = files;
+ }
+
+ static FileWatchSnapshot capture(Collection files) {
+ LinkedHashSet trackedPaths = new LinkedHashSet<>();
+ for (Path file : files) {
+ trackedPaths.add(file.toAbsolutePath().normalize());
+ }
+
+ return new FileWatchSnapshot(trackedPaths.stream()
+ .map(path -> new WatchedPath(path, lastModified(path)))
+ .toList());
+ }
+
+ boolean hasChanged() {
+ for (WatchedPath file : files) {
+ if (lastModified(file.path()) != file.lastModified()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static long lastModified(Path path) {
+ return path.toFile().lastModified();
+ }
+
+ private record WatchedPath(Path path, long lastModified) {}
+}
diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/HotDeployer.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/HotDeployer.java
index 71592e4143..8d5fd6030a 100644
--- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/HotDeployer.java
+++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/HotDeployer.java
@@ -24,9 +24,10 @@ public interface HotDeployer {
void setEnabled(boolean enabled);
+ default void setConfigurationReloader(ConfigurationReloader configurationReloader) {}
+
default boolean isEnabled() {
return false;
}
}
-
diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java
new file mode 100644
index 0000000000..702f79e4f9
--- /dev/null
+++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java
@@ -0,0 +1,87 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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 com.predic8.membrane.core.router.hotdeploy;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.function.BooleanSupplier;
+
+import static com.predic8.membrane.core.router.hotdeploy.FileWatchSnapshot.capture;
+
+/**
+ * Polling watcher for the tracked YAML files; it only detects changes and delegates reload decisions.
+ */
+public class YamlHotDeploymentThread extends Thread {
+
+ private static final Logger log = LoggerFactory.getLogger(YamlHotDeploymentThread.class.getName());
+
+ private final ConfigurationReloader reloader;
+ private final BooleanSupplier enabled;
+ private FileWatchSnapshot snapshot;
+
+ public YamlHotDeploymentThread(ConfigurationReloader reloader, BooleanSupplier enabled) {
+ super("yaml-hotdeploy");
+ this.reloader = reloader;
+ this.enabled = enabled;
+ refreshTrackedFiles();
+ }
+
+ private void refreshTrackedFiles() {
+ snapshot = capture(reloader.trackedFiles());
+ }
+
+ @Override
+ public void run() {
+ log.debug("YAML Hot Deployment Thread started.");
+
+ while (!isInterrupted() && enabled.getAsBoolean()) {
+ try {
+ while (!snapshot.hasChanged()) {
+ if (isInterrupted() || !enabled.getAsBoolean()) {
+ log.debug("YAML Hot Deployment Thread interrupted.");
+ return;
+ }
+
+ //noinspection BusyWait
+ sleep(1000);
+ }
+
+ log.info("Configuration Changed.");
+
+ if (!reloader.reload()) {
+ break;
+ }
+
+ if (!enabled.getAsBoolean()) {
+ break;
+ }
+
+ refreshTrackedFiles();
+ } catch (InterruptedException e) {
+ interrupt();
+ } catch (Exception e) {
+ log.error("Could not redeploy YAML configuration.", e);
+ break;
+ }
+ }
+
+ log.debug("YAML Hot Deployment Thread interrupted.");
+ }
+
+ public void stopASAP() {
+ interrupt();
+ }
+}
diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java
new file mode 100644
index 0000000000..c5e45f6f2b
--- /dev/null
+++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java
@@ -0,0 +1,121 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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 com.predic8.membrane.core.router.hotdeploy;
+
+import com.predic8.membrane.annot.yaml.ConfigurationParsingException;
+import com.predic8.membrane.core.router.DefaultRouter;
+import com.predic8.membrane.core.router.YamlConfigurationSource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.concurrent.GuardedBy;
+import java.nio.file.Path;
+import java.util.List;
+
+import static com.predic8.membrane.core.router.YamlRouterBootstrap.loadIntoRouter;
+
+/**
+ * Performs YAML validate-and-reload orchestration around the router lifecycle.
+ */
+public final class YamlRouterReloader implements ConfigurationReloader {
+
+ private static final Logger log = LoggerFactory.getLogger(YamlRouterReloader.class);
+
+ private final DefaultRouter router;
+
+ @GuardedBy("this")
+ private YamlConfigurationSource source;
+
+ public YamlRouterReloader(DefaultRouter router, YamlConfigurationSource source) {
+ this.router = router;
+ this.source = source;
+ }
+
+ @Override
+ public synchronized List trackedFiles() {
+ return source == null ? List.of() : source.trackedFiles();
+ }
+
+ @Override
+ public boolean reload() {
+ YamlConfigurationSource currentSource = getSource();
+ boolean runtimeStopped = false;
+
+ if (!router.markReloading()) {
+ return false;
+ }
+
+ try {
+ if (currentSource == null || currentSource.location() == null || currentSource.location().isBlank()) {
+ throw new IllegalStateException("No YAML configuration location is known.");
+ }
+
+ log.debug("Reloading YAML configuration from {}.", currentSource.location());
+ // Validation only bootstraps a fresh router; it does not call start() or bind ports.
+ validate(currentSource.location());
+
+ router.stopRuntimeForReload();
+ runtimeStopped = true;
+ router.resetRuntime();
+
+ setSource(loadIntoRouter(router, currentSource.location()));
+ router.start();
+ log.info("Configuration Reloaded.");
+ return true;
+ } catch (Exception e) {
+ logReloadFailure(e);
+ if (!runtimeStopped) {
+ log.info("Keeping the previous YAML runtime because reload validation failed before shutdown.");
+ return true;
+ }
+ try {
+ router.stopRuntimeForReload();
+ router.disposeRuntime();
+ } catch (Exception cleanupError) {
+ cleanupError.addSuppressed(e);
+ log.error("Could not clean up the failed YAML runtime after reload failure.", cleanupError);
+ }
+ log.info("YAML runtime remains stopped after reload failure.");
+ return false;
+ } finally {
+ router.clearReloading();
+ }
+ }
+
+ private void logReloadFailure(Exception e) {
+ if (e instanceof ConfigurationParsingException) {
+ log.error("Could not reload YAML configuration: {}", e.getMessage());
+ return;
+ }
+ log.error("Could not reload YAML configuration.", e);
+ }
+
+ private void validate(String location) throws Exception {
+ DefaultRouter candidate = new DefaultRouter();
+ try {
+ loadIntoRouter(candidate, location);
+ } finally {
+ candidate.disposeRuntime();
+ }
+ }
+
+ private synchronized YamlConfigurationSource getSource() {
+ return source;
+ }
+
+ private synchronized void setSource(YamlConfigurationSource source) {
+ this.source = source;
+ }
+}
diff --git a/core/src/test/java/com/predic8/membrane/core/router/YamlHotDeploymentTest.java b/core/src/test/java/com/predic8/membrane/core/router/YamlHotDeploymentTest.java
new file mode 100644
index 0000000000..6429c8f4a7
--- /dev/null
+++ b/core/src/test/java/com/predic8/membrane/core/router/YamlHotDeploymentTest.java
@@ -0,0 +1,120 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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 com.predic8.membrane.core.router;
+
+import com.predic8.membrane.core.proxies.Proxy;
+import com.predic8.membrane.core.router.hotdeploy.YamlRouterReloader;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static com.predic8.membrane.core.router.YamlRouterBootstrap.loadIntoRouter;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class YamlHotDeploymentTest {
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void shouldKeepPreviousRuntimeWhenValidationFails() throws Exception {
+ Path config = tempDir.resolve("apis.yaml");
+ Files.writeString(config, configWithInternal("first", "https://example.com"));
+
+ DefaultRouter router = new DefaultRouter();
+ try {
+ YamlRouterReloader reloader = new YamlRouterReloader(router, loadIntoRouter(router, config.toString()));
+ router.setConfigurationReloader(reloader);
+ router.start();
+
+ Files.writeString(config, """
+ configuration:
+ hotDeploy: false
+ ---
+ internal:
+ name: broken
+ target:
+ url: "https://example.org
+ """);
+
+ assertTrue(reloader.reload());
+ assertTrue(router.isRunning());
+ assertEquals(List.of("first"), getRuleNames(router));
+ } finally {
+ router.stop();
+ }
+ }
+
+ @Test
+ void shouldLeaveRouterStoppedWhenReloadFailsAfterShutdown() throws Exception {
+ Path config = tempDir.resolve("apis.yaml");
+ Files.writeString(config, configWithInternal("first", "https://example.com"));
+
+ FailingReloadStartRouter router = new FailingReloadStartRouter();
+ try {
+ YamlRouterReloader reloader = new YamlRouterReloader(router, loadIntoRouter(router, config.toString()));
+ router.setConfigurationReloader(reloader);
+ router.start();
+
+ Files.writeString(config, configWithInternal("second", "https://example.org"));
+ router.failNextStart();
+
+ assertFalse(reloader.reload());
+ assertFalse(router.isRunning());
+ } finally {
+ router.stop();
+ }
+ }
+
+ private static List getRuleNames(DefaultRouter router) {
+ return router.getRuleManager().getRules().stream()
+ .map(Proxy::getName)
+ .toList();
+ }
+
+ private static String configWithInternal(String name, String url) {
+ return """
+ configuration:
+ hotDeploy: false
+ ---
+ internal:
+ name: %s
+ target:
+ url: %s
+ """.formatted(name, url);
+ }
+
+ private static class FailingReloadStartRouter extends DefaultRouter {
+ private boolean failNextStart;
+
+ void failNextStart() {
+ failNextStart = true;
+ }
+
+ @Override
+ public void start() {
+ if (failNextStart) {
+ failNextStart = false;
+ throw new RuntimeException("simulated reload start failure");
+ }
+ super.start();
+ }
+ }
+}