5757import java .nio .file .Files ;
5858import java .nio .file .Path ;
5959import java .util .*;
60+ import java .util .concurrent .CopyOnWriteArrayList ;
61+ import java .util .concurrent .CountDownLatch ;
62+ import java .util .concurrent .TimeUnit ;
63+ import java .util .concurrent .atomic .AtomicReference ;
6064import java .util .stream .Collectors ;
6165
6266import static org .exist .util .ThreadUtils .newGlobalThread ;
6973 *
7074 * @author wolf
7175 */
72- public class JettyStart extends Observable implements LifeCycle .Listener {
76+ public class JettyStart implements LifeCycle .Listener {
7377
7478 public static final String JETTY_HOME_PROP = "jetty.home" ;
7579 public static final String JETTY_BASE_PROP = "jetty.base" ;
80+ public static final String STARTUP_TIMEOUT_MS_PROPERTY = "org.exist.jetty.startup.timeout.ms" ;
81+
82+ private static final String EXIST_CONTEXT_PATH = "/exist" ;
83+ private static final String PORTAL_CONTEXT_PATH = "/" ;
7684
7785 private static final String JETTY_PROPETIES_FILENAME = "jetty.properties" ;
7886 private static final Logger logger = LogManager .getLogger (JettyStart .class );
@@ -101,6 +109,8 @@ public class JettyStart extends Observable implements LifeCycle.Listener {
101109 @ GuardedBy ("this" ) private boolean webAppStartedSuccessfully = false ;
102110 @ GuardedBy ("this" ) private String webAppStartupFailureDetail = null ;
103111
112+ private final CopyOnWriteArrayList <JettyStartListener > jettyStartListeners = new CopyOnWriteArrayList <>();
113+
104114
105115 public static void main (final String [] args ) {
106116 try {
@@ -168,7 +178,25 @@ public synchronized void run(final boolean standalone) {
168178 run (new String [] { jettyConfig .toAbsolutePath ().toString () }, null );
169179 }
170180
171- public synchronized void run (final String [] args , final Observer observer ) {
181+ public void addJettyStartListener (final JettyStartListener listener ) {
182+ if (listener != null ) {
183+ jettyStartListeners .addIfAbsent (listener );
184+ }
185+ }
186+
187+ public void removeJettyStartListener (final JettyStartListener listener ) {
188+ if (listener != null ) {
189+ jettyStartListeners .remove (listener );
190+ }
191+ }
192+
193+ private void notifyJettyStartListeners (final String signal ) {
194+ for (final JettyStartListener listener : jettyStartListeners ) {
195+ listener .onJettyStartEvent (signal );
196+ }
197+ }
198+
199+ public synchronized void run (final String [] args , final JettyStartListener listener ) {
172200 if (args .length == 0 ) {
173201 logger .error ("No configuration file specified!" );
174202 return ;
@@ -209,8 +237,8 @@ public synchronized void run(final String[] args, final Observer observer) {
209237 configProperties .put (JETTY_BASE_PROP , jettyClasspathHome );
210238 }
211239
212- if (observer != null ) {
213- addObserver ( observer );
240+ if (listener != null ) {
241+ addJettyStartListener ( listener );
214242 }
215243
216244 logger .info ("Running with Java {} [{} ({}) in {}]" ,
@@ -249,7 +277,10 @@ public synchronized void run(final String[] args, final Observer observer) {
249277 .map (Path ::normalize ).map (Path ::toAbsolutePath ).map (Path ::toString )
250278 .orElse ("<UNKNOWN>" ));
251279
252- BrokerPool .configure (1 , 5 , config , Optional .ofNullable (observer ));
280+ final Optional <Observer > brokerPoolObserver = listener instanceof Observer observer
281+ ? Optional .of (observer )
282+ : Optional .empty ();
283+ BrokerPool .configure (1 , 5 , config , brokerPoolObserver );
253284
254285 // register the XMLDB driver
255286 final Database xmldb = new DatabaseImpl ();
@@ -362,13 +393,11 @@ public synchronized void run(final String[] args, final Observer observer) {
362393 webAppStartedSuccessfully = true ;
363394 webAppStartupFailureDetail = null ;
364395
365- setChanged ();
366- notifyObservers (SIGNAL_STARTED );
396+ notifyJettyStartListeners (SIGNAL_STARTED );
367397
368398 } catch (final SocketException e ) {
369399 recordStartupFailure ("Could not bind to port: " + e .getMessage (), e );
370- setChanged ();
371- notifyObservers (SIGNAL_ERROR );
400+ notifyJettyStartListeners (SIGNAL_ERROR );
372401
373402 } catch (final Exception e ) {
374403 if (webAppStartupFailureDetail == null ) {
@@ -377,8 +406,7 @@ public synchronized void run(final String[] args, final Observer observer) {
377406 } else {
378407 recordStartupFailure (webAppStartupFailureDetail , e );
379408 }
380- setChanged ();
381- notifyObservers (SIGNAL_ERROR );
409+ notifyJettyStartListeners (SIGNAL_ERROR );
382410 }
383411 }
384412
@@ -529,9 +557,9 @@ private Optional<Server> startJetty(final List<Object> configuredObjects) throws
529557 /**
530558 * Block until deployed webapps reach the readiness level required for tests.
531559 * <p>
532- * Every context except the distribution portal at {@code / } must be
560+ * Every context except the distribution portal at {@link #PORTAL_CONTEXT_PATH } must be
533561 * {@link org.eclipse.jetty.server.handler.ContextHandler#isAvailable()} — Jetty returns
534- * {@code 503} on all paths while unavailable. The portal coexists with {@code /exist } and is
562+ * {@code 503} on all paths while unavailable. The portal coexists with {@link #EXIST_CONTEXT_PATH } and is
535563 * non-gating.
536564 */
537565 private void awaitWebAppContextsStarted (final List <Handler > handlers ) throws InterruptedException {
@@ -547,29 +575,87 @@ private void awaitWebAppContextsStarted(final List<Handler> handlers) throws Int
547575
548576 final boolean distributionLayout = isDistributionLayout (webApps );
549577 final long timeoutMs = slowEnvironmentStartupDeadlineMs ();
550- final long deadline = System .currentTimeMillis () + timeoutMs ;
551- while (System .currentTimeMillis () < deadline ) {
552- boolean allReady = true ;
578+ final CountDownLatch readyLatch = new CountDownLatch (1 );
579+ final AtomicReference <IllegalStateException > failure = new AtomicReference <>();
580+
581+ final LifeCycle .Listener readinessListener = new LifeCycle .Listener () {
582+ @ Override
583+ public void lifeCycleStarted (final LifeCycle event ) {
584+ evaluateReadiness ();
585+ }
586+
587+ @ Override
588+ public void lifeCycleFailure (final LifeCycle event , final Throwable cause ) {
589+ if (event instanceof WebAppContext webApp ) {
590+ failure .compareAndSet (null , new IllegalStateException (
591+ "Web application failed to start: " + webApp .getContextPath (), cause ));
592+ } else {
593+ failure .compareAndSet (null , new IllegalStateException ("Web application failed to start" , cause ));
594+ }
595+ readyLatch .countDown ();
596+ }
597+
598+ private void evaluateReadiness () {
599+ for (final WebAppContext webApp : webApps ) {
600+ if (webApp .isFailed ()) {
601+ failure .compareAndSet (null , new IllegalStateException (
602+ "Web application failed to start: " + webApp .getContextPath ()));
603+ readyLatch .countDown ();
604+ return ;
605+ }
606+ }
607+ if (allWebAppsReady (webApps , distributionLayout )) {
608+ readyLatch .countDown ();
609+ }
610+ }
611+ };
612+
613+ for (final WebAppContext webApp : webApps ) {
614+ webApp .addEventListener (readinessListener );
615+ }
616+
617+ try {
618+ if (allWebAppsReady (webApps , distributionLayout )) {
619+ logger .info ("All required web application contexts are ready." );
620+ return ;
621+ }
553622 for (final WebAppContext webApp : webApps ) {
554623 if (webApp .isFailed ()) {
555624 throw new IllegalStateException (
556625 "Web application failed to start: " + webApp .getContextPath ());
557626 }
558- if (!isWebAppContextReady (webApp , distributionLayout )) {
559- allReady = false ;
560- break ;
561- }
562627 }
563- if (allReady ) {
564- logger .info ("All required web application contexts are ready." );
565- return ;
628+ if (!readyLatch .await (timeoutMs , TimeUnit .MILLISECONDS )) {
629+ throw new IllegalStateException (
630+ "Web application context did not become ready within " + timeoutMs + "ms: "
631+ + describePendingWebApps (webApps , distributionLayout ),
632+ firstUnavailableCause (webApps ));
633+ }
634+ final IllegalStateException startupFailure = failure .get ();
635+ if (startupFailure != null ) {
636+ throw startupFailure ;
637+ }
638+ if (!allWebAppsReady (webApps , distributionLayout )) {
639+ throw new IllegalStateException (
640+ "Web application context did not become ready: "
641+ + describePendingWebApps (webApps , distributionLayout ),
642+ firstUnavailableCause (webApps ));
643+ }
644+ logger .info ("All required web application contexts are ready." );
645+ } finally {
646+ for (final WebAppContext webApp : webApps ) {
647+ webApp .removeEventListener (readinessListener );
648+ }
649+ }
650+ }
651+
652+ private static boolean allWebAppsReady (final List <WebAppContext > webApps , final boolean distributionLayout ) {
653+ for (final WebAppContext webApp : webApps ) {
654+ if (!isWebAppContextReady (webApp , distributionLayout )) {
655+ return false ;
566656 }
567- Thread .sleep (200 );
568657 }
569- throw new IllegalStateException (
570- "Web application context did not become ready within " + timeoutMs + "ms: "
571- + describePendingWebApps (webApps , distributionLayout ),
572- firstUnavailableCause (webApps ));
658+ return true ;
573659 }
574660
575661 private static Throwable firstUnavailableCause (final List <WebAppContext > webApps ) {
@@ -583,18 +669,18 @@ private static Throwable firstUnavailableCause(final List<WebAppContext> webApps
583669 }
584670
585671 private static boolean isDistributionLayout (final List <WebAppContext > webApps ) {
586- return webApps .stream ().anyMatch (webApp -> "/exist" .equals (webApp .getContextPath ()));
672+ return webApps .stream ().anyMatch (webApp -> EXIST_CONTEXT_PATH .equals (webApp .getContextPath ()));
587673 }
588674
589675 /**
590- * Distribution portal {@code / } only needs {@code isStarted()}. Standalone {@code /} and
591- * {@code /exist } must be {@code isAvailable()} or HTTP clients see {@code 503}.
676+ * Distribution portal {@link #PORTAL_CONTEXT_PATH } only needs {@code isStarted()}. Standalone
677+ * {@link #PORTAL_CONTEXT_PATH} and {@link #EXIST_CONTEXT_PATH } must be {@code isAvailable()} or HTTP clients see {@code 503}.
592678 */
593679 private static boolean isWebAppContextReady (final WebAppContext webApp , final boolean distributionLayout ) {
594680 if (!webApp .isStarted ()) {
595681 return false ;
596682 }
597- if (distributionLayout && "/" .equals (webApp .getContextPath ())) {
683+ if (distributionLayout && PORTAL_CONTEXT_PATH .equals (webApp .getContextPath ())) {
598684 return true ;
599685 }
600686 return webApp .isAvailable ();
@@ -639,10 +725,14 @@ private static String describeWebAppWar(final WebAppContext webApp) {
639725 }
640726
641727 private static boolean requiresAvailability (final WebAppContext webApp , final boolean distributionLayout ) {
642- return !(distributionLayout && "/" .equals (webApp .getContextPath ()));
728+ return !(distributionLayout && PORTAL_CONTEXT_PATH .equals (webApp .getContextPath ()));
643729 }
644730
645731 private static long slowEnvironmentStartupDeadlineMs () {
732+ final String override = System .getProperty (STARTUP_TIMEOUT_MS_PROPERTY );
733+ if (override != null && !override .isBlank ()) {
734+ return Long .parseLong (override );
735+ }
646736 if (System .getenv ("CI" ) != null ) {
647737 return 180_000L ;
648738 }
@@ -799,17 +889,15 @@ public synchronized boolean isStarted() {
799889 @ Override
800890 public synchronized void lifeCycleStarting (final LifeCycle lifeCycle ) {
801891 logger .info ("Jetty server starting..." );
802- setChanged ();
803- notifyObservers (SIGNAL_STARTING );
892+ notifyJettyStartListeners (SIGNAL_STARTING );
804893 status = STATUS_STARTING ;
805894 notifyAll ();
806895 }
807896
808897 @ Override
809898 public synchronized void lifeCycleStarted (final LifeCycle lifeCycle ) {
810899 logger .info ("Jetty server started." );
811- setChanged ();
812- notifyObservers (SIGNAL_STARTED );
900+ notifyJettyStartListeners (SIGNAL_STARTED );
813901 status = STATUS_STARTED ;
814902 notifyAll ();
815903 }
0 commit comments