diff --git a/solon/src/main/java/org/noear/solon/HotReloadLauncher.java b/solon/src/main/java/org/noear/solon/HotReloadLauncher.java
new file mode 100644
index 0000000000000000000000000000000000000000..9e1eeb87740f5abdb009b9647434dd32b57b1d1d
--- /dev/null
+++ b/solon/src/main/java/org/noear/solon/HotReloadLauncher.java
@@ -0,0 +1,296 @@
+package org.noear.solon;
+
+import org.noear.solon.core.AppClassLoader;
+import org.noear.solon.core.runtime.NativeDetector;
+import org.noear.solon.core.util.ClassUtil;
+import org.noear.solon.core.util.MultiMap;
+import org.noear.solon.hotreload.HotReloadManager;
+import org.noear.solon.logging.LogIncubator;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.lang.management.ManagementFactory;
+import java.lang.management.RuntimeMXBean;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.ServiceLoader;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Hot reload launcher – manages the lifecycle of the reloadable application.
+ *
+ *
When hot reload is active, {@link HotReloadLauncher} holds the current
+ * {@link SolonApp} instance (the user application) in a volatile/atomic reference.
+ * New requests obtain the latest instance; in-flight requests continue using the old one.
+ *
+ * This class is intended to be used by {@link HotReloadManager} (file watcher)
+ * and by the Solon startup bootstrap when hot reload is enabled.
+ *
+ * @author noear
+ * @since 3.10
+ */
+public class HotReloadLauncher {
+ private static final Logger log = LoggerFactory.getLogger(HotReloadLauncher.class);
+
+ private static final AtomicReference CURRENT = new AtomicReference<>();
+ private static volatile boolean active = false;
+ private static HotReloadManager hotReloadManager;
+ // Flags to prevent overlapping restarts
+ private static volatile boolean restartInProgress = false;
+ private static volatile boolean needsRestart = false;
+
+ /**
+ * Check if hot reload is enabled.
+ */
+ public static boolean isActive() {
+ return active;
+ }
+
+ /**
+ * Get the current application instance (the one from the reloadable AppClassLoader).
+ * This replaces {@link org.noear.solon.Solon#app()} when hot reload is active.
+ */
+ public static SolonApp app() {
+ return CURRENT.get();
+ }
+
+ public static boolean isRestartInProgress() {
+ return restartInProgress;
+ }
+
+ public static boolean isNeedsRestart() {
+ return needsRestart;
+ }
+
+ public static void setNeedsRestart(boolean needsRestart) {
+ HotReloadLauncher.needsRestart = needsRestart;
+ }
+
+ /**
+ * Start hot reload mode. This should be called instead of the normal Solon.start().
+ *
+ * @param source the main application class
+ * @param args startup arguments
+ * @return the started SolonApp
+ * @throws Exception if startup fails
+ */
+ public static SolonApp start(Class> source, MultiMap args) throws Throwable {
+ active = true;
+ log.info("Hot reload mode enabled. Starting application...");
+
+ // Initialize system properties (mirroring Solon.start)
+ String encoding = Solon.encoding();
+ if (encoding != null && !encoding.isEmpty()) {
+ System.setProperty("file.encoding", encoding);
+ }
+ System.getProperties().putIfAbsent("java.awt.headless", "true");
+
+ // Set Solon.location
+ URL locationUrl = source.getProtectionDomain().getCodeSource().getLocation();
+ Solon.locationSet(locationUrl);
+
+ // Create the application with a fresh AppClassLoader
+ SolonApp app = createApp(source, args);
+ CURRENT.set(app);
+ Solon.appSet(app);
+
+ // Start the file watcher (now that app exists, Solon.cfg() works)
+ if (hotReloadManager == null) {
+ hotReloadManager = new HotReloadManager();
+ hotReloadManager.start();
+ }
+
+ // Bind the new AppClassLoader to the current thread
+ AppClassLoader.globalSet(app.classLoader());
+ AppClassLoader.bindingThread();
+
+ // Load logging incubator (same as Solon.logIncubate)
+ ServiceLoader internetServices = ServiceLoader.load(LogIncubator.class);
+ for (LogIncubator logIncubator : internetServices) {
+ logIncubator.incubate();
+ }
+
+ log.info("App: Start loading");
+
+ // Start the application (initialize beans, start servers, etc.)
+ app.startDo(null);
+
+ // Register shutdown hook for graceful termination
+ if (NativeDetector.isNotAotRuntime()) {
+ if (app.cfg().stopSafe()) {
+ int stopDelay = app.cfg().stopDelay();
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> Solon.stopBlock(false, stopDelay)));
+ } else {
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> Solon.stopBlock(false, 0)));
+ }
+ }
+
+ RuntimeMXBean runtimeMXBean = ManagementFactory.getRuntimeMXBean();
+ // runtimeMXBean.getName() 的返回格式通常为 "pid@hostname"
+ String jvmName = runtimeMXBean.getName();
+ long pid = Long.parseLong(jvmName.split("@")[0]);
+ log.info("Application started with hot reload. PID: " + pid);
+ return app;
+ }
+
+ /**
+ * Restart the application with a new ClassLoader. This is called when class files change.
+ */
+ public static void restart() {
+ if (restartInProgress) {
+ log.debug("Restart already in progress, marking for later");
+ needsRestart = true;
+ return;
+ }
+ restartInProgress = true;
+ SolonApp oldApp = CURRENT.get();
+ if (oldApp == null) {
+ log.warn("No application to restart");
+ restartInProgress = false;
+ return;
+ }
+
+ log.info("Hot reload: restarting application...");
+ try {
+ // 1. Pre-stop the old application (graceful shutdown signal)
+ oldApp.preStopDo();
+
+ // 2. Mark as stopping to reject new requests
+ oldApp.stoppingDo();
+
+ // 3. Wait for in-flight requests to finish (with timeout)
+ int waitMs = 30000; // default 30 seconds
+ long deadline = System.currentTimeMillis() + waitMs;
+ while (oldApp.getActiveRequestCount() > 0 && System.currentTimeMillis() < deadline) {
+ try {
+ Thread.sleep(100);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+ if (oldApp.getActiveRequestCount() > 0) {
+ log.warn("Hot reload: {} in-flight requests still active after waiting {}ms, proceeding with stop",
+ oldApp.getActiveRequestCount(), waitMs);
+ }
+
+ // 4. Stop the old application completely
+ oldApp.stopDo();
+
+ // 4.1. Close the old AppClassLoader to release resources (if Closeable)
+ try {
+ if (oldApp.classLoader() instanceof java.io.Closeable) {
+ ((java.io.Closeable) oldApp.classLoader()).close();
+ }
+ } catch (IOException e) {
+ log.warn("Failed to close old AppClassLoader", e);
+ }
+
+ // 4.2. Small delay to ensure server socket is fully released by OS
+ try {
+ Thread.sleep(1000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+
+ // 5. Build new application with fresh ClassLoader
+ SolonApp newApp = createApp(oldApp._source(), oldApp._args());
+
+ // Prepare classloader and logging for the new app
+ AppClassLoader.globalSet(newApp.classLoader());
+ AppClassLoader.bindingThread();
+
+ // Load logging incubator for the new app
+ ServiceLoader internetServices = ServiceLoader.load(LogIncubator.class);
+ for (LogIncubator logIncubator : internetServices) {
+ logIncubator.incubate();
+ }
+
+ // 5. Atomically swap the reference
+ if (CURRENT.compareAndSet(oldApp, newApp)) {
+ Solon.appSet(newApp);
+ newApp.startDo(null);
+ log.info("Hot reload: restart completed successfully");
+ } else {
+ // Lost race – another restart already occurred
+ log.warn("Hot reload: concurrent restart detected, skipping");
+ newApp.stopDo();
+ }
+ } catch (Throwable e) {
+ log.error("Hot reload: restart failed", e);
+ } finally {
+ restartInProgress = false;
+ }
+ }
+
+ /**
+ * Create a new SolonApp instance using a fresh AppClassLoader.
+ *
+ * The new AppClassLoader is created with a copy of the original classpath URLs,
+ * excluding framework modules that should stay in the base classloader.
+ */
+ private static SolonApp createApp(Class> source, MultiMap args) throws Exception {
+ // The base classloader (which loaded this HotReloadLauncher) is the parent
+ ClassLoader baseCl = HotReloadLauncher.class.getClassLoader();
+
+ // Determine classpath URLs
+ URL[] urls;
+ SolonApp oldApp = CURRENT.get();
+ if (oldApp != null) {
+ // Reuse the old app's classpath (URLs from its AppClassLoader)
+ AppClassLoader oldAppCl = oldApp.classLoader();
+ if (oldAppCl instanceof java.net.URLClassLoader) {
+ urls = ((java.net.URLClassLoader) oldAppCl).getURLs();
+ } else {
+ urls = computeAppClasspath();
+ }
+ } else {
+ // Initial start: use only the watch directories (reloadable app classes)
+ urls = computeAppClasspath();
+ }
+
+ // Create a new AppClassLoader with base as parent
+ AppClassLoader newAppCl = new AppClassLoader(urls, baseCl);
+
+ // Load the source class using the new classloader
+ Class> sourceClazz = ClassUtil.loadClass(newAppCl, source.getName());
+ if (sourceClazz == null) {
+ throw new ClassNotFoundException("Failed to load source class: " + source.getName() + " with new AppClassLoader");
+ }
+
+ // Use the constructor that accepts a custom ClassLoader
+ return new SolonApp(sourceClazz, args, newAppCl);
+ }
+
+ /**
+ * Compute the reloadable application classpath: only the watch directories (where compiled classes reside).
+ * Framework and library jars are loaded by the base classloader, so they should not be included here.
+ */
+ private static URL[] computeAppClasspath() {
+ // Use the same property as HotReloadManager, default "target/classes"
+ String watchDirs = System.getProperty("solon.hotreload.watch-dirs", "target/classes");
+ String[] parts = watchDirs.split(",");
+ List urlList = new ArrayList<>();
+ for (String part : parts) {
+ String trimmed = part.trim();
+ if (!trimmed.isEmpty()) {
+ try {
+ Path p = Paths.get(trimmed).toAbsolutePath();
+ if (Files.isDirectory(p)) {
+ urlList.add(p.toUri().toURL());
+ } else {
+ log.warn("Watch directory not found, skipping: " + p);
+ }
+ } catch (Exception e) {
+ log.warn("Invalid watch directory: " + trimmed, e);
+ }
+ }
+ }
+ return urlList.toArray(new URL[0]);
+ }
+}
diff --git a/solon/src/main/java/org/noear/solon/Solon.java b/solon/src/main/java/org/noear/solon/Solon.java
index bb14ea34e5ddcddc96e314f0732bc78f4a944eae..013541a37f33f90a34a2ad2c36733451a250f222 100644
--- a/solon/src/main/java/org/noear/solon/Solon.java
+++ b/solon/src/main/java/org/noear/solon/Solon.java
@@ -68,6 +68,9 @@ public class Solon {
* 全局实例
*/
public static SolonApp app() {
+ if (HotReloadLauncher.isActive()) {
+ return HotReloadLauncher.app();
+ }
return app;
}
@@ -116,6 +119,13 @@ public class Solon {
return location;
}
+ /**
+ * 设置应用源码位置(内部使用)
+ */
+ static void locationSet(URL url) {
+ location = url;
+ }
+
/**
* 全局默认编码
*/
@@ -203,6 +213,16 @@ public class Solon {
* @param initialize 实始化函数
*/
public static SolonApp start(Class> source, MultiMap argx, ConsumerEx initialize) {
+ // Check if hot reload is enabled (via system property or args)
+ if (isHotReloadEnabled(argx)) {
+ try {
+ return HotReloadLauncher.start(source, argx);
+ } catch (Throwable e) {
+ log.error("Hot reload start failed", e);
+ throw new IllegalStateException(e);
+ }
+ }
+
if (appMain != null) {
app = appMain; //有可能被测试给切走了
return appMain;
@@ -270,6 +290,18 @@ public class Solon {
return app;
}
+ private static boolean isHotReloadEnabled(MultiMap argx) {
+ String sys = System.getProperty("solon.hotreload.enabled");
+ if ("true".equalsIgnoreCase(sys)) {
+ return true;
+ }
+ String val = argx.get("solon.hotreload.enabled");
+ if ("true".equalsIgnoreCase(val)) {
+ return true;
+ }
+ return "true".equalsIgnoreCase(argx.get("hotreload"));
+ }
+
/**
* 日志孵化(加载配置到日志器)
*
diff --git a/solon/src/main/java/org/noear/solon/SolonApp.java b/solon/src/main/java/org/noear/solon/SolonApp.java
index 2f2e32d0013ad99c08b07d3bc59dd0c8e98789ec..2a723ffc34f29e54c05845ebd9f0d8c7855f3280 100644
--- a/solon/src/main/java/org/noear/solon/SolonApp.java
+++ b/solon/src/main/java/org/noear/solon/SolonApp.java
@@ -34,6 +34,7 @@ import org.noear.solon.core.util.RunUtil;
import java.lang.annotation.Annotation;
import java.net.URL;
import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
@@ -69,8 +70,10 @@ public class SolonApp extends RouterWrapper {
private final Class> _source; //应用加载源
private final URL _sourceLocation;
private final long _startupTime;
+ private final MultiMap _args; //启动参数
private boolean stopping = false;
+ private final AtomicInteger activeRequests = new AtomicInteger(0);
/**
* 应用上下文
@@ -208,6 +211,13 @@ public class SolonApp extends RouterWrapper {
}
protected SolonApp(Class> source, MultiMap args) throws Exception {
+ this(source, args, new AppClassLoader(AppClassLoader.global()));
+ }
+
+ /**
+ * Construct with custom ClassLoader (for hot reload).
+ */
+ protected SolonApp(Class> source, MultiMap args, AppClassLoader classLoader) throws Exception {
//添加启动类检测
if (source == null) {
throw new IllegalArgumentException("The startup class parameter('source') cannot be null");
@@ -220,7 +230,8 @@ public class SolonApp extends RouterWrapper {
_startupTime = System.currentTimeMillis();
_source = source;
- _classLoader = new AppClassLoader(AppClassLoader.global());
+ _args = args;
+ _classLoader = classLoader;
_sourceLocation = source.getProtectionDomain().getCodeSource().getLocation();
_converterManager = new ConverterManager();
_serializerManager = new SerializerManager();
@@ -615,52 +626,56 @@ public class SolonApp extends RouterWrapper {
* 应用请求处理入口(异常时,自动500处理)
*/
public void tryHandle(Context x) {
- //使用当前线程上下文
- Context.currentWith(x, () -> {
- try {
- if (stopping) {
- x.status(503);
- } else {
- chains().doFilter(x, _handler);
- }
+ activeRequests.incrementAndGet();
+ try {
+ Context.currentWith(x, () -> {
+ try {
+ if (stopping) {
+ x.status(503);
+ } else {
+ chains().doFilter(x, _handler);
+ }
- //40x,50x...
- doStatus(x);
- } catch (Throwable ex) {
- ex = Utils.throwableUnwrap(ex);
+ //40x,50x...
+ doStatus(x);
+ } catch (Throwable ex) {
+ ex = Utils.throwableUnwrap(ex);
- //如果未处理,尝试处理
- if (ex instanceof StatusException) {
- StatusException se = (StatusException) ex;
- x.status(se.getCode());
+ //如果未处理,尝试处理
+ if (ex instanceof StatusException) {
+ StatusException se = (StatusException) ex;
+ x.status(se.getCode());
- if (se.getCode() != 404 && se.getCode() != 405) {
- log.warn("SolonApp tryHandle failed, code=" + se.getCode(), ex);
- }
- } else {
- //推送异常事件 //todo: Action -> Gateway? -> RouterHandler -> Filter -> SolonApp!
- log.warn("SolonApp tryHandle failed!", ex);
+ if (se.getCode() != 404 && se.getCode() != 405) {
+ log.warn("SolonApp tryHandle failed, code=" + se.getCode(), ex);
+ }
+ } else {
+ //推送异常事件 //todo: Action -> Gateway? -> RouterHandler -> Filter -> SolonApp!
+ log.warn("SolonApp tryHandle failed!", ex);
- x.status(500);
- }
+ x.status(500);
+ }
- //如果未渲染,尝试渲染
- if (x.getRendered() == false) {
- //40x,50x...
- try {
- if (doStatus(x) == false) {
- if (this.cfg().isDebugMode()) {
- x.output(ex);
+ //如果未渲染,尝试渲染
+ if (x.getRendered() == false) {
+ //40x,50x...
+ try {
+ if (doStatus(x) == false) {
+ if (this.cfg().isDebugMode()) {
+ x.output(ex);
+ }
}
+ } catch (RuntimeException e) {
+ throw e;
+ } catch (Throwable e) {
+ throw new RuntimeException(e);
}
- } catch (RuntimeException e) {
- throw e;
- } catch (Throwable e) {
- throw new RuntimeException(e);
}
}
- }
- });
+ });
+ } finally {
+ activeRequests.decrementAndGet();
+ }
}
protected boolean doStatus(Context x) throws Throwable {
@@ -862,4 +877,25 @@ public class SolonApp extends RouterWrapper {
_enableSessionState = enable;
return this;
}
+
+ /**
+ * 获取应用加载源类(受保护,供热重载使用)
+ */
+ protected Class> _source() {
+ return _source;
+ }
+
+ /**
+ * 获取启动参数(受保护,供热重载使用)
+ */
+ protected MultiMap _args() {
+ return _args;
+ }
+
+ /**
+ * 获取当前活跃请求数(用于热重载优雅关闭)
+ */
+ public int getActiveRequestCount() {
+ return activeRequests.get();
+ }
}
\ No newline at end of file
diff --git a/solon/src/main/java/org/noear/solon/hotreload/HotReloadManager.java b/solon/src/main/java/org/noear/solon/hotreload/HotReloadManager.java
new file mode 100644
index 0000000000000000000000000000000000000000..7fc9fb700cb9f03c8aaecc7ec619403d20a04ae2
--- /dev/null
+++ b/solon/src/main/java/org/noear/solon/hotreload/HotReloadManager.java
@@ -0,0 +1,293 @@
+package org.noear.solon.hotreload;
+
+import org.noear.solon.HotReloadLauncher;
+import org.noear.solon.Solon;
+import org.noear.solon.core.Props;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.file.*;
+import java.util.List;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+
+/**
+ * Hot reload manager – watches configured directories for class file changes.
+ *
+ * This is a {@link org.noear.solon.core.Lifecycle} bean. It should be auto-discovered
+ * via ServiceLoader so that it starts automatically when the hotreload module is on the classpath.
+ *
+ * Configuration properties (all under {@code solon.hotreload}):
+ *
+ * - {@code enabled} – whether hot reload is enabled (default: false)
+ * - {@code watch-dirs} – comma-separated list of directories to watch (default: target/classes)
+ * - {@code debounce} – debounce time in milliseconds (default: 500)
+ *
+ *
+ * @author noear
+ * @since 3.10
+ */
+public class HotReloadManager implements org.noear.solon.core.Lifecycle {
+ private static final Logger log = LoggerFactory.getLogger(HotReloadManager.class);
+
+ private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+ private final ScheduledExecutorService restartScheduler = Executors.newSingleThreadScheduledExecutor();
+ private WatchService watchService;
+ private final List watchDirs;
+ private final long debounceMs;
+ private volatile boolean running = false;
+ private ScheduledFuture> restartTask;
+ private volatile boolean needsWatchReinit = false;
+ // After a restart completes, ignore changes for this many ms to avoid duplicate triggers on Windows
+ private volatile long restartCooldownUntil = 0L;
+ private static final long COOLDOWN_MS = 1000L;
+
+ public HotReloadManager() {
+ this.watchService = createWatchService();
+ this.watchDirs = getWatchDirs();
+ this.debounceMs = getDebounceMs();
+ }
+
+ private WatchService createWatchService() {
+ try {
+ return FileSystems.getDefault().newWatchService();
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to create WatchService", e);
+ }
+ }
+
+ private List getWatchDirs() {
+ Props cfg = Solon.cfg();
+ String dirs = cfg.get("solon.hotreload.watch-dirs", "target/classes");
+ String[] parts = dirs.split(",");
+ java.util.ArrayList list = new java.util.ArrayList<>();
+ for (String part : parts) {
+ String trimmed = part.trim();
+ if (!trimmed.isEmpty()) {
+ Path p = Paths.get(trimmed).toAbsolutePath();
+ list.add(p);
+ }
+ }
+ return list;
+ }
+
+ private long getDebounceMs() {
+ Props cfg = Solon.cfg();
+ String val = cfg.get("solon.hotreload.debounce", "1000");
+ try {
+ return Long.parseLong(val);
+ } catch (NumberFormatException e) {
+ return 1000L;
+ }
+ }
+
+ @Override
+ public void start() throws Exception {
+ // Register watch events for each directory (including subdirectories)
+ for (Path dir : watchDirs) {
+ try {
+ if (Files.exists(dir) && Files.isDirectory(dir)) {
+ registerRecursive(dir);
+ log.info("HotReloadManager watching: " + dir);
+ }
+ else {
+ log.warn("HotReloadManager: watch directory does not exist: " + dir);
+ }
+ } catch (IOException e) {
+ log.warn("Failed to register watch for: " + dir, e);
+ }
+ }
+
+ running = true;
+ scheduler.scheduleAtFixedRate(this::pollEvents, 0, 200, TimeUnit.MILLISECONDS);
+ log.info("HotReloadManager started (debounce: {}ms)", debounceMs);
+ }
+
+ /**
+ * Register watch events for a directory and all its subdirectories.
+ */
+ private void registerRecursive(Path dir) throws IOException {
+ // Register all existing subdirectories recursively (including the root)
+ try (Stream stream = Files.walk(dir)) {
+ stream.filter(Files::isDirectory)
+ .forEach(sub -> {
+ try {
+ sub.register(watchService,
+ StandardWatchEventKinds.ENTRY_CREATE,
+ StandardWatchEventKinds.ENTRY_MODIFY,
+ StandardWatchEventKinds.ENTRY_DELETE);
+ } catch (IOException e) {
+ log.warn("Failed to register watch for subdirectory: " + sub, e);
+ }
+ });
+ }
+ }
+
+ /**
+ * Recreate the WatchService and re-register all watch directories.
+ * This handles cases where the watched directory is deleted (e.g., mvn clean)
+ * and later recreated.
+ */
+ private void reinitializeWatchService() {
+ boolean anyExists = false;
+ try {
+ // Close old watchService if open
+ if (watchService != null) {
+ try {
+ watchService.close();
+ } catch (IOException e) { /* ignore */ }
+ }
+ this.watchService = createWatchService();
+ for (Path dir : watchDirs) {
+ try {
+ if (Files.exists(dir) && Files.isDirectory(dir)) {
+ registerRecursive(dir);
+ log.info("HotReloadManager re-watching: " + dir);
+ anyExists = true;
+ }
+ else {
+ log.warn("HotReloadManager: watch directory still missing: " + dir);
+ }
+ } catch (IOException e) {
+ log.warn("Failed to re-register watch for: " + dir, e);
+ }
+ }
+ if (anyExists) {
+ log.info("Watch service reinitialized; scheduling restart to load changes");
+ scheduleRestart();
+ }
+ } finally {
+ // Only clear the reinit flag if we successfully (re)registered at least one directory.
+ // If none exist, keep the flag true so we keep trying on subsequent polls.
+ if (anyExists) {
+ needsWatchReinit = false;
+ }
+ }
+ }
+
+ private void pollEvents() {
+ if (!running) {
+ return;
+ }
+
+ WatchKey key;
+ try {
+ // poll with timeout to avoid blocking scheduler
+ key = watchService.poll(100, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+
+ if (key == null) {
+ // If we need to reinitialize (invalid key) or any watch dir is missing, try to reinit
+ if (needsWatchReinit || anyWatchDirMissing()) {
+ reinitializeWatchService();
+ }
+ return;
+ }
+
+ boolean changed = false;
+ for (WatchEvent> event : key.pollEvents()) {
+ WatchEvent.Kind> kind = event.kind();
+
+ // Overflow indicates events may have been lost
+ if (kind == StandardWatchEventKinds.OVERFLOW) {
+ changed = true;
+ continue;
+ }
+
+ // Context is the relative path (relative to watched dir)
+ @SuppressWarnings("unchecked")
+ WatchEvent ev = (WatchEvent) event;
+ Path changedFile = ev.context();
+
+ // Only interested in .class files
+ if (changedFile.toString().endsWith(".class")) {
+ changed = true;
+ }
+ }
+
+ boolean valid = key.reset();
+ if (!valid) {
+ log.warn("WatchKey no longer valid – some directories may be inaccessible");
+ needsWatchReinit = true;
+ }
+
+ // After processing, if reinitialization needed, do it now
+ if (needsWatchReinit) {
+ reinitializeWatchService();
+ }
+
+ if (changed) {
+ long now = System.currentTimeMillis();
+ if (now < restartCooldownUntil) {
+ log.debug("Change detected during cooldown ({}ms remaining), ignoring", restartCooldownUntil - now);
+ return;
+ }
+ scheduleRestart();
+ }
+ }
+
+ private boolean anyWatchDirMissing() {
+ for (Path dir : watchDirs) {
+ if (!Files.isDirectory(dir)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void scheduleRestart() {
+ // If a restart is already in progress, mark that another restart is needed after it finishes
+ if (HotReloadLauncher.isRestartInProgress()) {
+ HotReloadLauncher.setNeedsRestart(true);
+ log.debug("Restart in progress, will restart again after completion");
+ return;
+ }
+
+ if (restartTask != null) {
+ if (!restartTask.isDone()) {
+ restartTask.cancel(false);
+ }
+ else {
+ restartTask = null;
+ }
+ }
+ restartTask = restartScheduler.schedule(() -> {
+ try {
+ log.info("Class changes detected – triggering reload");
+ HotReloadLauncher.restart();
+ } catch (Throwable e) {
+ log.error("Hot reload failed", e);
+ } finally {
+ // Set cooldown to avoid immediate duplicate triggers (e.g., Windows duplicate events)
+ restartCooldownUntil = System.currentTimeMillis() + COOLDOWN_MS;
+ // After restart, check if another restart was requested during it
+ if (HotReloadLauncher.isNeedsRestart()) {
+ HotReloadLauncher.setNeedsRestart(false);
+ log.debug("Scheduling another restart due to changes during restart");
+ scheduleRestart();
+ }
+ }
+ }, debounceMs, TimeUnit.MILLISECONDS);
+ }
+
+ @Override
+ public void preStop() throws Exception {
+ // No special pre-stop needed
+ }
+
+ @Override
+ public void stop() throws Throwable {
+ running = false;
+ scheduler.shutdownNow();
+ restartScheduler.shutdownNow();
+ watchService.close();
+ log.info("HotReloadManager stopped");
+ }
+}