|
24 | 24 | import net.jcip.annotations.GuardedBy; |
25 | 25 | import org.apache.logging.log4j.LogManager; |
26 | 26 | import org.apache.logging.log4j.Logger; |
| 27 | +import org.eclipse.jetty.ee10.webapp.WebAppContext; |
27 | 28 | import org.eclipse.jetty.server.*; |
28 | 29 | import org.eclipse.jetty.server.handler.ContextHandler; |
29 | 30 | import org.eclipse.jetty.ee10.servlet.ServletContextHandler; |
@@ -97,6 +98,8 @@ public class JettyStart extends Observable implements LifeCycle.Listener { |
97 | 98 | @GuardedBy("this") private int status = STATUS_STOPPED; |
98 | 99 | @GuardedBy("this") private Optional<Thread> shutdownHookThread = Optional.empty(); |
99 | 100 | @GuardedBy("this") private int primaryPort = 8080; |
| 101 | + @GuardedBy("this") private boolean webAppStartedSuccessfully = false; |
| 102 | + @GuardedBy("this") private String webAppStartupFailureDetail = null; |
100 | 103 |
|
101 | 104 |
|
102 | 105 | public static void main(final String[] args) { |
@@ -132,6 +135,23 @@ private static void consoleOut(final String msg) { |
132 | 135 | System.out.println(msg); //NOSONAR this has to go to the console |
133 | 136 | } |
134 | 137 |
|
| 138 | + private static void consoleErr(final String msg) { |
| 139 | + System.err.println(msg); //NOSONAR surfaced in Surefire output when test log4j root is OFF |
| 140 | + } |
| 141 | + |
| 142 | + private synchronized void recordStartupFailure(final String detail, final Throwable cause) { |
| 143 | + webAppStartedSuccessfully = false; |
| 144 | + webAppStartupFailureDetail = detail; |
| 145 | + if (cause != null) { |
| 146 | + logger.fatal("Jetty startup failed: {}", detail, cause); |
| 147 | + consoleErr("Jetty startup failed: " + detail); |
| 148 | + cause.printStackTrace(System.err); //NOSONAR CI diagnostics when log4j is disabled in tests |
| 149 | + } else { |
| 150 | + logger.fatal("Jetty startup failed: {}", detail); |
| 151 | + consoleErr("Jetty startup failed: " + detail); |
| 152 | + } |
| 153 | + } |
| 154 | + |
135 | 155 | public synchronized void run() { |
136 | 156 | run(true); |
137 | 157 | } |
@@ -244,12 +264,13 @@ public synchronized void run(final String[] args, final Observer observer) { |
244 | 264 | DatabaseManager.registerDatabase(xmldb); |
245 | 265 |
|
246 | 266 | } catch (final Exception e) { |
247 | | - logger.error("configuration error: {}", e.getMessage(), e); |
248 | | - e.printStackTrace(); |
| 267 | + recordStartupFailure("configuration error: " + e.getMessage(), e); |
249 | 268 | return; |
250 | 269 | } |
251 | 270 |
|
252 | 271 | try { |
| 272 | + webAppStartupFailureDetail = null; |
| 273 | + webAppStartedSuccessfully = false; |
253 | 274 | // load jetty configurations |
254 | 275 | final List<Path> configFiles = getEnabledConfigFiles(jettyConfig); |
255 | 276 | final List<Object> configuredObjects = new ArrayList<>(); |
@@ -344,19 +365,25 @@ public synchronized void run(final String[] args, final Observer observer) { |
344 | 365 |
|
345 | 366 | logger.info("-----------------------------------------------------"); |
346 | 367 |
|
| 368 | + awaitWebAppContextsStarted(getAllHandlers(server.getHandler())); |
| 369 | + webAppStartedSuccessfully = true; |
| 370 | + webAppStartupFailureDetail = null; |
| 371 | + |
347 | 372 | setChanged(); |
348 | 373 | notifyObservers(SIGNAL_STARTED); |
349 | 374 |
|
350 | 375 | } catch (final SocketException e) { |
351 | | - logger.error("----------------------------------------------------------"); |
352 | | - logger.error("ERROR: Could not bind to port because {}", e.getMessage()); |
353 | | - logger.error(e.toString()); |
354 | | - logger.error("----------------------------------------------------------"); |
| 376 | + recordStartupFailure("Could not bind to port: " + e.getMessage(), e); |
355 | 377 | setChanged(); |
356 | 378 | notifyObservers(SIGNAL_ERROR); |
357 | 379 |
|
358 | 380 | } catch (final Exception e) { |
359 | | - logger.fatal("An unexpected error occurred, web server can not be started: {}", e.getMessage(), e); |
| 381 | + if (webAppStartupFailureDetail == null) { |
| 382 | + recordStartupFailure( |
| 383 | + e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(), e); |
| 384 | + } else { |
| 385 | + recordStartupFailure(webAppStartupFailureDetail, e); |
| 386 | + } |
360 | 387 | setChanged(); |
361 | 388 | notifyObservers(SIGNAL_ERROR); |
362 | 389 | } |
@@ -506,6 +533,129 @@ private Optional<Server> startJetty(final List<Object> configuredObjects) throws |
506 | 533 | return server; |
507 | 534 | } |
508 | 535 |
|
| 536 | + /** |
| 537 | + * Block until deployed webapps reach the readiness level required for tests. |
| 538 | + * <p> |
| 539 | + * Every context except the distribution portal at {@code /} must be |
| 540 | + * {@link org.eclipse.jetty.server.handler.ContextHandler#isAvailable()} — Jetty returns |
| 541 | + * {@code 503} on all paths while unavailable. The portal coexists with {@code /exist} and is |
| 542 | + * non-gating. |
| 543 | + */ |
| 544 | + private void awaitWebAppContextsStarted(final List<Handler> handlers) throws InterruptedException { |
| 545 | + final List<WebAppContext> webApps = new ArrayList<>(); |
| 546 | + for (final Handler handler : handlers) { |
| 547 | + if (handler instanceof WebAppContext webApp) { |
| 548 | + webApps.add(webApp); |
| 549 | + } |
| 550 | + } |
| 551 | + if (webApps.isEmpty()) { |
| 552 | + return; |
| 553 | + } |
| 554 | + |
| 555 | + final boolean distributionLayout = isDistributionLayout(webApps); |
| 556 | + final long timeoutMs = slowEnvironmentStartupDeadlineMs(); |
| 557 | + final long deadline = System.currentTimeMillis() + timeoutMs; |
| 558 | + while (System.currentTimeMillis() < deadline) { |
| 559 | + boolean allReady = true; |
| 560 | + for (final WebAppContext webApp : webApps) { |
| 561 | + if (webApp.isFailed()) { |
| 562 | + throw new IllegalStateException( |
| 563 | + "Web application failed to start: " + webApp.getContextPath()); |
| 564 | + } |
| 565 | + if (!isWebAppContextReady(webApp, distributionLayout)) { |
| 566 | + allReady = false; |
| 567 | + break; |
| 568 | + } |
| 569 | + } |
| 570 | + if (allReady) { |
| 571 | + logger.info("All required web application contexts are ready."); |
| 572 | + return; |
| 573 | + } |
| 574 | + Thread.sleep(200); |
| 575 | + } |
| 576 | + throw new IllegalStateException( |
| 577 | + "Web application context did not become ready within " + timeoutMs + "ms: " |
| 578 | + + describePendingWebApps(webApps, distributionLayout), |
| 579 | + firstUnavailableCause(webApps)); |
| 580 | + } |
| 581 | + |
| 582 | + private static Throwable firstUnavailableCause(final List<WebAppContext> webApps) { |
| 583 | + for (final WebAppContext webApp : webApps) { |
| 584 | + final Throwable unavailable = webApp.getUnavailableException(); |
| 585 | + if (unavailable != null) { |
| 586 | + return unavailable; |
| 587 | + } |
| 588 | + } |
| 589 | + return null; |
| 590 | + } |
| 591 | + |
| 592 | + private static boolean isDistributionLayout(final List<WebAppContext> webApps) { |
| 593 | + return webApps.stream().anyMatch(webApp -> "/exist".equals(webApp.getContextPath())); |
| 594 | + } |
| 595 | + |
| 596 | + /** |
| 597 | + * Distribution portal {@code /} only needs {@code isStarted()}. Standalone {@code /} and |
| 598 | + * {@code /exist} must be {@code isAvailable()} or HTTP clients see {@code 503}. |
| 599 | + */ |
| 600 | + private static boolean isWebAppContextReady(final WebAppContext webApp, final boolean distributionLayout) { |
| 601 | + if (!webApp.isStarted()) { |
| 602 | + return false; |
| 603 | + } |
| 604 | + if (distributionLayout && "/".equals(webApp.getContextPath())) { |
| 605 | + return true; |
| 606 | + } |
| 607 | + return webApp.isAvailable(); |
| 608 | + } |
| 609 | + |
| 610 | + private static String describePendingWebApps(final List<WebAppContext> webApps, final boolean distributionLayout) { |
| 611 | + final StringBuilder details = new StringBuilder(); |
| 612 | + for (final WebAppContext webApp : webApps) { |
| 613 | + if (webApp.isFailed()) { |
| 614 | + continue; |
| 615 | + } |
| 616 | + if (!isWebAppContextReady(webApp, distributionLayout)) { |
| 617 | + if (!details.isEmpty()) { |
| 618 | + details.append("; "); |
| 619 | + } |
| 620 | + details.append(webApp.getContextPath()) |
| 621 | + .append(" started=").append(webApp.isStarted()) |
| 622 | + .append(" available=").append(webApp.isAvailable()) |
| 623 | + .append(" requireAvailable=").append(requiresAvailability(webApp, distributionLayout)) |
| 624 | + .append(" war=").append(describeWebAppWar(webApp)); |
| 625 | + final Throwable unavailable = webApp.getUnavailableException(); |
| 626 | + if (unavailable != null) { |
| 627 | + details.append(" unavailableCause=").append(unavailable.getClass().getName()) |
| 628 | + .append(": ").append(unavailable.getMessage()); |
| 629 | + } |
| 630 | + } |
| 631 | + } |
| 632 | + return details.isEmpty() ? "unknown" : details.toString(); |
| 633 | + } |
| 634 | + |
| 635 | + private static String describeWebAppWar(final WebAppContext webApp) { |
| 636 | + final String war = webApp.getWar(); |
| 637 | + if (war != null && !war.isBlank()) { |
| 638 | + return war; |
| 639 | + } |
| 640 | + try { |
| 641 | + final Resource baseResource = webApp.getBaseResource(); |
| 642 | + return baseResource != null ? String.valueOf(baseResource) : "null"; |
| 643 | + } catch (final Exception e) { |
| 644 | + return "unresolved(" + e.getMessage() + ")"; |
| 645 | + } |
| 646 | + } |
| 647 | + |
| 648 | + private static boolean requiresAvailability(final WebAppContext webApp, final boolean distributionLayout) { |
| 649 | + return !(distributionLayout && "/".equals(webApp.getContextPath())); |
| 650 | + } |
| 651 | + |
| 652 | + private static long slowEnvironmentStartupDeadlineMs() { |
| 653 | + if (System.getenv("CI") != null) { |
| 654 | + return 180_000L; |
| 655 | + } |
| 656 | + return 60_000L; |
| 657 | + } |
| 658 | + |
509 | 659 | private Map<String, String> getConfigProperties(final Path configDir) throws IOException { |
510 | 660 | final Map<String, String> configProperties = new HashMap<>(); |
511 | 661 |
|
@@ -695,4 +845,21 @@ public synchronized void lifeCycleStopped(final LifeCycle lifeCycle) { |
695 | 845 | public synchronized int getPrimaryPort() { |
696 | 846 | return primaryPort; |
697 | 847 | } |
| 848 | + |
| 849 | + /** |
| 850 | + * {@code true} when all required {@link WebAppContext} instances finished startup. Used by |
| 851 | + * integration tests to detect swallowed startup failures. |
| 852 | + */ |
| 853 | + public synchronized boolean isWebAppStartedSuccessfully() { |
| 854 | + return webAppStartedSuccessfully; |
| 855 | + } |
| 856 | + |
| 857 | + /** |
| 858 | + * When {@link #isWebAppStartedSuccessfully()} is {@code false}, holds the last startup failure |
| 859 | + * message for test diagnostics (also printed to {@code System.err} because module test log4j |
| 860 | + * configs often set {@code Root level="OFF"}). |
| 861 | + */ |
| 862 | + public synchronized Optional<String> getWebAppStartupFailureDetail() { |
| 863 | + return Optional.ofNullable(webAppStartupFailureDetail); |
| 864 | + } |
698 | 865 | } |
0 commit comments