From 0a53f05ac59195e4b6b6bcf3f3dc3602a606672c Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 8 May 2026 19:55:09 -0300 Subject: [PATCH] joobyRun: optimize hot-reload latency with debouncer and smart compiler detection - fix #3943 --- .../jooby/internal/run/JoobyModuleFinder.java | 14 +- .../src/main/java/io/jooby/run/JoobyRun.java | 180 ++++++++++-------- .../java/io/jooby/run/JoobyRunOptions.java | 30 ++- 3 files changed, 128 insertions(+), 96 deletions(-) diff --git a/modules/jooby-run/src/main/java/io/jooby/internal/run/JoobyModuleFinder.java b/modules/jooby-run/src/main/java/io/jooby/internal/run/JoobyModuleFinder.java index c933b390f3..29d14902ee 100644 --- a/modules/jooby-run/src/main/java/io/jooby/internal/run/JoobyModuleFinder.java +++ b/modules/jooby-run/src/main/java/io/jooby/internal/run/JoobyModuleFinder.java @@ -22,6 +22,7 @@ import java.util.LinkedHashSet; import java.util.Set; import java.util.jar.JarFile; +import java.util.regex.Pattern; import org.jboss.modules.*; import org.jboss.modules.filter.PathFilters; @@ -29,6 +30,10 @@ import io.jooby.run.JoobyRun; public abstract class JoobyModuleFinder implements ModuleFinder { + // Matches logback, log4j, and application config files with optional environment suffixes + private static final Pattern EXCLUDE_CONFIG = + Pattern.compile( + "^(logback.*\\.xml|log4j.*\\.(xml|properties|yaml|yml|json)|application.*\\.(conf|properties|yaml|yml|json))$"); protected static final String JARS = "jars"; protected static final String RESOURCES = "resources"; protected final Set classes; @@ -100,14 +105,7 @@ private ModuleSpec.Builder newModule(String name, Set resources) { if (main.equals(name)) { resourceLoader = createFilteredResourceLoader( - not( - it -> - // remove duplicated log configuration - (it.startsWith("logback") || it.startsWith("log4j")) - && it.endsWith(".xml") - // remove duplicated configuration - || (it.startsWith("application") && it.endsWith(".conf"))), - resourceLoader); + not(it -> EXCLUDE_CONFIG.matcher(it).matches()), resourceLoader); } builder.addResourceRoot(ResourceLoaderSpec.createResourceLoaderSpec(resourceLoader)); } else { diff --git a/modules/jooby-run/src/main/java/io/jooby/run/JoobyRun.java b/modules/jooby-run/src/main/java/io/jooby/run/JoobyRun.java index b3f946fc2a..684205ef21 100644 --- a/modules/jooby-run/src/main/java/io/jooby/run/JoobyRun.java +++ b/modules/jooby-run/src/main/java/io/jooby/run/JoobyRun.java @@ -14,11 +14,13 @@ import java.nio.file.Path; import java.time.Clock; import java.util.*; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -51,19 +53,24 @@ public class JoobyRun { private record Event(Path path, long time, Supplier compileTask) {} private static class AppModule { + + private enum State { + CLOSED, + UNLOADING, + UNLOADED, + STARTING, + RESTART, + RUNNING, + FAILED + } + private final Logger logger; private final JoobyModuleLoader loader; private final JoobyRunOptions conf; private Module module; - private ClassLoader contextClassLoader; + private final ClassLoader contextClassLoader; private int counter; - private final AtomicInteger state = new AtomicInteger(CLOSED); - private static final int CLOSED = 1 << 0; - private static final int UNLOADING = 1 << 1; - private static final int UNLOADED = 1 << 2; - private static final int STARTING = 1 << 3; - private static final int RESTART = 1 << 4; - private static final int RUNNING = 1 << 5; + private final AtomicReference state = new AtomicReference<>(State.CLOSED); AppModule( Logger logger, @@ -77,10 +84,14 @@ private static class AppModule { } public Exception start() { - if (!(state.compareAndSet(CLOSED, STARTING) || state.compareAndSet(UNLOADED, STARTING))) { + if (!(state.compareAndSet(State.CLOSED, State.STARTING) + || state.compareAndSet(State.UNLOADED, State.STARTING))) { debugState("Jooby already starting."); return null; } + + boolean success = false; + try { module = loader.loadModule(conf.getProjectName()); ModuleClassLoader classLoader = module.getClassLoader(); @@ -103,6 +114,7 @@ public Exception start() { args.add("server.port=" + port); } module.run(conf.getMainClass(), args.toArray(new String[0])); + success = true; // Execution reached the end without throwing } catch (ClassNotFoundException x) { String message = x.getMessage(); if (message.trim().startsWith(conf.getMainClass())) { @@ -119,8 +131,13 @@ public Exception start() { } catch (Throwable x) { printErr(x); } finally { - if (state.compareAndSet(STARTING, RUNNING)) { - debugState("Jooby is now"); + if (success) { + if (state.compareAndSet(State.STARTING, State.RUNNING)) { + debugState("Jooby is now"); + } + } else { + state.set(State.FAILED); + debugState("Jooby start failed"); } Thread.currentThread().setContextClassLoader(contextClassLoader); } @@ -159,22 +176,28 @@ private boolean isFatal(Throwable cause) { } public boolean isStarting() { - long s = state.longValue(); - return s > CLOSED && s < RUNNING; + State s = state.get(); + return s == State.UNLOADING + || s == State.UNLOADED + || s == State.STARTING + || s == State.RESTART; } public void restart(boolean unload) { - if (state.compareAndSet(RUNNING, RESTART)) { - // Shutdown + // Allow restart if it's currently running OR if it previously failed to start + if (state.compareAndSet(State.RUNNING, State.RESTART) + || state.compareAndSet(State.FAILED, State.RESTART)) { + // Shutdown old state closeServer(); if (unload) { - // unload only when a class has changed unloadModule(); } - // Start + + // Start new state start(); - // Run gc - System.gc(); + + // Run gc asynchronously to clear discarded classloaders without blocking the thread + CompletableFuture.runAsync(System::gc); } else { debugState("Already restarting."); } @@ -195,7 +218,7 @@ private Throwable withoutReflection(Throwable cause) { } private void unloadModule() { - if (!state.compareAndSet(CLOSED, UNLOADING)) { + if (!state.compareAndSet(State.CLOSED, State.UNLOADING)) { debugState("Cannot unload as server isn't closed."); return; } @@ -206,7 +229,7 @@ private void unloadModule() { } catch (Exception x) { logger.debug("unload module resulted in exception", x); } finally { - state.compareAndSet(UNLOADING, UNLOADED); + state.compareAndSet(State.UNLOADING, State.UNLOADED); module = null; } } @@ -214,41 +237,20 @@ private void unloadModule() { private void closeServer() { try { debugState("Closing server."); - Class ref = module.getClassLoader().loadClass(SERVER_REF); - ref.getDeclaredMethod(SERVER_REF_STOP).invoke(null); + if (module != null) { + Class ref = module.getClassLoader().loadClass(SERVER_REF); + ref.getDeclaredMethod(SERVER_REF_STOP).invoke(null); + } } catch (Exception x) { logger.error("Application shutdown resulted in exception", withoutReflection(x)); } finally { - state.set(CLOSED); + state.set(State.CLOSED); } } private void debugState(String message) { if (logger.isDebugEnabled()) { - String name; - switch (state.get()) { - case CLOSED: - name = "CLOSED"; - break; - case UNLOADING: - name = "UNLOADING"; - break; - case UNLOADED: - name = "UNLOADED"; - break; - case STARTING: - name = "STARTING"; - break; - case RESTART: - name = "RESTART"; - break; - case RUNNING: - name = "RUNNING"; - break; - default: - throw new IllegalStateException("BUG"); - } - logger.debug("{} state: {}", message, name); + logger.debug("{} state: {}", message, state.get().name()); } } } @@ -275,13 +277,15 @@ private void debugState(String message) { private final ConcurrentLinkedQueue queue = new ConcurrentLinkedQueue<>(); + // Executor and task tracking for the sliding-window debouncer + private ScheduledExecutorService se; + private volatile ScheduledFuture scheduledRestart; + /* * How long we wait after the last change before restart */ private final long waitTimeBeforeRestartMillis; - private final long initialDelayBeforeFirstRestartMillis; - /** * Creates a new instances with the given options. * @@ -291,8 +295,6 @@ public JoobyRun(JoobyRunOptions options) { this.options = options; clock = Clock.systemUTC(); // Possibly change for unit test waitTimeBeforeRestartMillis = options.getWaitTimeBeforeRestart(); - // this might not need to be configurable - initialDelayBeforeFirstRestartMillis = JoobyRunOptions.INITIAL_DELAY_BEFORE_FIRST_RESTART; } /** @@ -389,19 +391,16 @@ public void start() throws Throwable { new JoobyModuleLoader(finder), Thread.currentThread().getContextClassLoader(), options); - ScheduledExecutorService se; + Exception error = module.start(); if (error == null) { se = Executors.newScheduledThreadPool(1); - se.scheduleAtFixedRate( - this::actualRestart, - initialDelayBeforeFirstRestartMillis, - waitTimeBeforeRestartMillis, - TimeUnit.MILLISECONDS); try { watcher.watch(); } finally { - se.shutdownNow(); + if (se != null) { + se.shutdownNow(); + } } } else { // exit @@ -419,35 +418,50 @@ public void restart(Path path) { } public void restart(Path path, Supplier compileTask) { + // 1. Queue the event queue.offer(new Event(path, clock.millis(), compileTask)); - } - private synchronized void actualRestart() { - if (module.isStarting()) { - return; // We don't empty the queue. This is the case a change was made while starting. + // 2. Cancel the pending restart if it hasn't executed yet (Sliding Window) + if (scheduledRestart != null && !scheduledRestart.isDone()) { + scheduledRestart.cancel(false); } - long t = clock.millis(); - Event e = queue.peek(); - if (e == null) { - return; // queue was empty + + // 3. Schedule a new restart after the wait period has elapsed since the LAST file change + if (se != null && !se.isShutdown()) { + scheduledRestart = + se.schedule( + this::processQueueAndRestart, waitTimeBeforeRestartMillis, TimeUnit.MILLISECONDS); + } + } + + private void processQueueAndRestart() { + if (module == null || module.isStarting()) { + return; } - var unload = false; + + Event e; + boolean unload = false; Supplier compileTask = null; - for (; e != null && (t - e.time) > waitTimeBeforeRestartMillis; e = queue.peek()) { - // unload on source code changes (.java, .kt) or binary changes (.class) + boolean hasEvents = false; + + // Drain the entire queue as a single batch + while ((e = queue.poll()) != null) { + hasEvents = true; unload = unload || options.isCompileExtension(e.path) || options.isClass(e.path); compileTask = Optional.ofNullable(compileTask).orElse(e.compileTask); - queue.poll(); } - // e will be null if the queue is empty which means all events were old enough - if (e == null) { - var restart = true; - if (compileTask != null) { - restart = compileTask.get(); - } - if (restart) { - module.restart(unload); - } + + if (!hasEvents) { + return; + } + + boolean restart = true; + if (compileTask != null) { + restart = compileTask.get(); + } + + if (restart) { + module.restart(unload); } } @@ -458,6 +472,10 @@ public void shutdown() { module = null; } + if (se != null) { + se.shutdownNow(); + } + if (watcher != null) { try { watcher.close(); diff --git a/modules/jooby-run/src/main/java/io/jooby/run/JoobyRunOptions.java b/modules/jooby-run/src/main/java/io/jooby/run/JoobyRunOptions.java index a48ff0273e..8be5e4a76f 100644 --- a/modules/jooby-run/src/main/java/io/jooby/run/JoobyRunOptions.java +++ b/modules/jooby-run/src/main/java/io/jooby/run/JoobyRunOptions.java @@ -5,6 +5,7 @@ */ package io.jooby.run; +import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; import java.util.List; @@ -29,14 +30,10 @@ public class JoobyRunOptions { private Integer port = null; private Path basedir; - private Long waitTimeBeforeRestart = DEFAULT_WAIT_TIME_BEFORE_RESTART; + private Long waitTimeBeforeRestart = null; private boolean useSingleClassLoader; - private static final long DEFAULT_WAIT_TIME_BEFORE_RESTART = 500L; - - static final long INITIAL_DELAY_BEFORE_FIRST_RESTART = 5000L; - /** * Project name. * @@ -100,11 +97,30 @@ public void setUseSingleClassLoader(boolean useSingleClassLoader) { } /** - * How long to wait after last file change to restart. Default is: 500 milliseconds. + * How long to wait after last file change to restart. Default is: 200 milliseconds, + * or 500 milliseconds for Eclipse projects. * * @return Wait time in milliseconds. */ public Long getWaitTimeBeforeRestart() { + // If the user explicitly configured a time, always honor it + if (waitTimeBeforeRestart != null) { + return waitTimeBeforeRestart; + } + + // Eclipse's aggressive compiler touches multiple files in a row, requiring a longer debounce + // window. + if (basedir != null) { + if (Files.exists(basedir.resolve(".classpath")) + || Files.exists(basedir.resolve(".project"))) { + waitTimeBeforeRestart = 500L; + } + } + if (waitTimeBeforeRestart == null) { + // Blazing fast default for IntelliJ / VSCode / CLI users + waitTimeBeforeRestart = 200L; + } + return waitTimeBeforeRestart; } @@ -172,7 +188,7 @@ public boolean isCompileExtension(Path path) { } public boolean isClass(Path path) { - return containsExtension(List.of("class"), path); + return containsExtension(Arrays.asList("class"), path); } /**