From 903a8ea9645cc0a9fde62ae9a91d9806cd2e0fba Mon Sep 17 00:00:00 2001 From: zengyufei Date: Mon, 30 Mar 2026 16:47:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(hotreload):=20=E6=B7=BB=E5=8A=A0=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E7=83=AD=E9=87=8D=E8=BD=BD=E6=94=AF=E6=8C=81=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 HotReloadLauncher 管理应用热重载生命周期和实例切换 - 实现 HotReloadManager 监控class文件变动自动触发重启 - SolonApp 增强活跃请求计数,用于优雅关闭和等待请求完成 - Solon.start 集成热重载启动逻辑,根据配置决定是否启用热重载 - 支持安全停止钩子和重启过程中类加载器资源释放 - 实现热重载期间请求透明切换,保证请求不中断 - 配置可调整监听目录、去抖动时间和热重载开启开关 - 优化日志记录和异常处理,增加重载相关信息输出 --- .../org/noear/solon/HotReloadLauncher.java | 296 ++++++++++++++++++ .../src/main/java/org/noear/solon/Solon.java | 32 ++ .../main/java/org/noear/solon/SolonApp.java | 112 ++++--- .../solon/hotreload/HotReloadManager.java | 293 +++++++++++++++++ 4 files changed, 695 insertions(+), 38 deletions(-) create mode 100644 solon/src/main/java/org/noear/solon/HotReloadLauncher.java create mode 100644 solon/src/main/java/org/noear/solon/hotreload/HotReloadManager.java 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 0000000000..9e1eeb8774 --- /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 bb14ea34e5..013541a37f 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 2f2e32d001..2a723ffc34 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 0000000000..7fc9fb700c --- /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"); + } +} -- Gitee