1414import java .nio .file .Path ;
1515import java .time .Clock ;
1616import java .util .*;
17+ import java .util .concurrent .CompletableFuture ;
1718import java .util .concurrent .ConcurrentLinkedQueue ;
1819import java .util .concurrent .Executors ;
1920import java .util .concurrent .ScheduledExecutorService ;
21+ import java .util .concurrent .ScheduledFuture ;
2022import java .util .concurrent .TimeUnit ;
21- import java .util .concurrent .atomic .AtomicInteger ;
23+ import java .util .concurrent .atomic .AtomicReference ;
2224import java .util .function .BiConsumer ;
2325import java .util .function .Supplier ;
2426import java .util .stream .Collectors ;
@@ -51,19 +53,24 @@ public class JoobyRun {
5153 private record Event (Path path , long time , Supplier <Boolean > compileTask ) {}
5254
5355 private static class AppModule {
56+
57+ private enum State {
58+ CLOSED ,
59+ UNLOADING ,
60+ UNLOADED ,
61+ STARTING ,
62+ RESTART ,
63+ RUNNING ,
64+ FAILED
65+ }
66+
5467 private final Logger logger ;
5568 private final JoobyModuleLoader loader ;
5669 private final JoobyRunOptions conf ;
5770 private Module module ;
58- private ClassLoader contextClassLoader ;
71+ private final ClassLoader contextClassLoader ;
5972 private int counter ;
60- private final AtomicInteger state = new AtomicInteger (CLOSED );
61- private static final int CLOSED = 1 << 0 ;
62- private static final int UNLOADING = 1 << 1 ;
63- private static final int UNLOADED = 1 << 2 ;
64- private static final int STARTING = 1 << 3 ;
65- private static final int RESTART = 1 << 4 ;
66- private static final int RUNNING = 1 << 5 ;
73+ private final AtomicReference <State > state = new AtomicReference <>(State .CLOSED );
6774
6875 AppModule (
6976 Logger logger ,
@@ -77,10 +84,14 @@ private static class AppModule {
7784 }
7885
7986 public Exception start () {
80- if (!(state .compareAndSet (CLOSED , STARTING ) || state .compareAndSet (UNLOADED , STARTING ))) {
87+ if (!(state .compareAndSet (State .CLOSED , State .STARTING )
88+ || state .compareAndSet (State .UNLOADED , State .STARTING ))) {
8189 debugState ("Jooby already starting." );
8290 return null ;
8391 }
92+
93+ boolean success = false ;
94+
8495 try {
8596 module = loader .loadModule (conf .getProjectName ());
8697 ModuleClassLoader classLoader = module .getClassLoader ();
@@ -103,6 +114,7 @@ public Exception start() {
103114 args .add ("server.port=" + port );
104115 }
105116 module .run (conf .getMainClass (), args .toArray (new String [0 ]));
117+ success = true ; // Execution reached the end without throwing
106118 } catch (ClassNotFoundException x ) {
107119 String message = x .getMessage ();
108120 if (message .trim ().startsWith (conf .getMainClass ())) {
@@ -119,8 +131,13 @@ public Exception start() {
119131 } catch (Throwable x ) {
120132 printErr (x );
121133 } finally {
122- if (state .compareAndSet (STARTING , RUNNING )) {
123- debugState ("Jooby is now" );
134+ if (success ) {
135+ if (state .compareAndSet (State .STARTING , State .RUNNING )) {
136+ debugState ("Jooby is now" );
137+ }
138+ } else {
139+ state .set (State .FAILED );
140+ debugState ("Jooby start failed" );
124141 }
125142 Thread .currentThread ().setContextClassLoader (contextClassLoader );
126143 }
@@ -159,22 +176,28 @@ private boolean isFatal(Throwable cause) {
159176 }
160177
161178 public boolean isStarting () {
162- long s = state .longValue ();
163- return s > CLOSED && s < RUNNING ;
179+ State s = state .get ();
180+ return s == State .UNLOADING
181+ || s == State .UNLOADED
182+ || s == State .STARTING
183+ || s == State .RESTART ;
164184 }
165185
166186 public void restart (boolean unload ) {
167- if (state .compareAndSet (RUNNING , RESTART )) {
168- // Shutdown
187+ // Allow restart if it's currently running OR if it previously failed to start
188+ if (state .compareAndSet (State .RUNNING , State .RESTART )
189+ || state .compareAndSet (State .FAILED , State .RESTART )) {
190+ // Shutdown old state
169191 closeServer ();
170192 if (unload ) {
171- // unload only when a class has changed
172193 unloadModule ();
173194 }
174- // Start
195+
196+ // Start new state
175197 start ();
176- // Run gc
177- System .gc ();
198+
199+ // Run gc asynchronously to clear discarded classloaders without blocking the thread
200+ CompletableFuture .runAsync (System ::gc );
178201 } else {
179202 debugState ("Already restarting." );
180203 }
@@ -195,7 +218,7 @@ private Throwable withoutReflection(Throwable cause) {
195218 }
196219
197220 private void unloadModule () {
198- if (!state .compareAndSet (CLOSED , UNLOADING )) {
221+ if (!state .compareAndSet (State . CLOSED , State . UNLOADING )) {
199222 debugState ("Cannot unload as server isn't closed." );
200223 return ;
201224 }
@@ -206,49 +229,28 @@ private void unloadModule() {
206229 } catch (Exception x ) {
207230 logger .debug ("unload module resulted in exception" , x );
208231 } finally {
209- state .compareAndSet (UNLOADING , UNLOADED );
232+ state .compareAndSet (State . UNLOADING , State . UNLOADED );
210233 module = null ;
211234 }
212235 }
213236
214237 private void closeServer () {
215238 try {
216239 debugState ("Closing server." );
217- Class <?> ref = module .getClassLoader ().loadClass (SERVER_REF );
218- ref .getDeclaredMethod (SERVER_REF_STOP ).invoke (null );
240+ if (module != null ) {
241+ Class <?> ref = module .getClassLoader ().loadClass (SERVER_REF );
242+ ref .getDeclaredMethod (SERVER_REF_STOP ).invoke (null );
243+ }
219244 } catch (Exception x ) {
220245 logger .error ("Application shutdown resulted in exception" , withoutReflection (x ));
221246 } finally {
222- state .set (CLOSED );
247+ state .set (State . CLOSED );
223248 }
224249 }
225250
226251 private void debugState (String message ) {
227252 if (logger .isDebugEnabled ()) {
228- String name ;
229- switch (state .get ()) {
230- case CLOSED :
231- name = "CLOSED" ;
232- break ;
233- case UNLOADING :
234- name = "UNLOADING" ;
235- break ;
236- case UNLOADED :
237- name = "UNLOADED" ;
238- break ;
239- case STARTING :
240- name = "STARTING" ;
241- break ;
242- case RESTART :
243- name = "RESTART" ;
244- break ;
245- case RUNNING :
246- name = "RUNNING" ;
247- break ;
248- default :
249- throw new IllegalStateException ("BUG" );
250- }
251- logger .debug ("{} state: {}" , message , name );
253+ logger .debug ("{} state: {}" , message , state .get ().name ());
252254 }
253255 }
254256 }
@@ -275,13 +277,15 @@ private void debugState(String message) {
275277
276278 private final ConcurrentLinkedQueue <Event > queue = new ConcurrentLinkedQueue <>();
277279
280+ // Executor and task tracking for the sliding-window debouncer
281+ private ScheduledExecutorService se ;
282+ private volatile ScheduledFuture <?> scheduledRestart ;
283+
278284 /*
279285 * How long we wait after the last change before restart
280286 */
281287 private final long waitTimeBeforeRestartMillis ;
282288
283- private final long initialDelayBeforeFirstRestartMillis ;
284-
285289 /**
286290 * Creates a new instances with the given options.
287291 *
@@ -291,8 +295,6 @@ public JoobyRun(JoobyRunOptions options) {
291295 this .options = options ;
292296 clock = Clock .systemUTC (); // Possibly change for unit test
293297 waitTimeBeforeRestartMillis = options .getWaitTimeBeforeRestart ();
294- // this might not need to be configurable
295- initialDelayBeforeFirstRestartMillis = JoobyRunOptions .INITIAL_DELAY_BEFORE_FIRST_RESTART ;
296298 }
297299
298300 /**
@@ -389,19 +391,16 @@ public void start() throws Throwable {
389391 new JoobyModuleLoader (finder ),
390392 Thread .currentThread ().getContextClassLoader (),
391393 options );
392- ScheduledExecutorService se ;
394+
393395 Exception error = module .start ();
394396 if (error == null ) {
395397 se = Executors .newScheduledThreadPool (1 );
396- se .scheduleAtFixedRate (
397- this ::actualRestart ,
398- initialDelayBeforeFirstRestartMillis ,
399- waitTimeBeforeRestartMillis ,
400- TimeUnit .MILLISECONDS );
401398 try {
402399 watcher .watch ();
403400 } finally {
404- se .shutdownNow ();
401+ if (se != null ) {
402+ se .shutdownNow ();
403+ }
405404 }
406405 } else {
407406 // exit
@@ -419,35 +418,50 @@ public void restart(Path path) {
419418 }
420419
421420 public void restart (Path path , Supplier <Boolean > compileTask ) {
421+ // 1. Queue the event
422422 queue .offer (new Event (path , clock .millis (), compileTask ));
423- }
424423
425- private synchronized void actualRestart () {
426- if (module . isStarting ()) {
427- return ; // We don't empty the queue. This is the case a change was made while starting.
424+ // 2. Cancel the pending restart if it hasn't executed yet (Sliding Window)
425+ if (scheduledRestart != null && ! scheduledRestart . isDone ()) {
426+ scheduledRestart . cancel ( false );
428427 }
429- long t = clock .millis ();
430- Event e = queue .peek ();
431- if (e == null ) {
432- return ; // queue was empty
428+
429+ // 3. Schedule a new restart after the wait period has elapsed since the LAST file change
430+ if (se != null && !se .isShutdown ()) {
431+ scheduledRestart =
432+ se .schedule (
433+ this ::processQueueAndRestart , waitTimeBeforeRestartMillis , TimeUnit .MILLISECONDS );
434+ }
435+ }
436+
437+ private void processQueueAndRestart () {
438+ if (module == null || module .isStarting ()) {
439+ return ;
433440 }
434- var unload = false ;
441+
442+ Event e ;
443+ boolean unload = false ;
435444 Supplier <Boolean > compileTask = null ;
436- for (; e != null && (t - e .time ) > waitTimeBeforeRestartMillis ; e = queue .peek ()) {
437- // unload on source code changes (.java, .kt) or binary changes (.class)
445+ boolean hasEvents = false ;
446+
447+ // Drain the entire queue as a single batch
448+ while ((e = queue .poll ()) != null ) {
449+ hasEvents = true ;
438450 unload = unload || options .isCompileExtension (e .path ) || options .isClass (e .path );
439451 compileTask = Optional .ofNullable (compileTask ).orElse (e .compileTask );
440- queue .poll ();
441452 }
442- // e will be null if the queue is empty which means all events were old enough
443- if (e == null ) {
444- var restart = true ;
445- if (compileTask != null ) {
446- restart = compileTask .get ();
447- }
448- if (restart ) {
449- module .restart (unload );
450- }
453+
454+ if (!hasEvents ) {
455+ return ;
456+ }
457+
458+ boolean restart = true ;
459+ if (compileTask != null ) {
460+ restart = compileTask .get ();
461+ }
462+
463+ if (restart ) {
464+ module .restart (unload );
451465 }
452466 }
453467
@@ -458,6 +472,10 @@ public void shutdown() {
458472 module = null ;
459473 }
460474
475+ if (se != null ) {
476+ se .shutdownNow ();
477+ }
478+
461479 if (watcher != null ) {
462480 try {
463481 watcher .close ();
0 commit comments