From 067ffe732fd688e0718ee026c7a1a068debcfa35 Mon Sep 17 00:00:00 2001 From: duncdrum Date: Fri, 22 May 2026 10:23:10 +0200 Subject: [PATCH 01/11] Bump the jetty group with 7 updates --- exist-parent/pom.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/exist-parent/pom.xml b/exist-parent/pom.xml index 8a9e707cdb5..6c5b9f9e5b6 100644 --- a/exist-parent/pom.xml +++ b/exist-parent/pom.xml @@ -128,7 +128,7 @@ 4.0.5 4.0.2 2.0.3 - 12.0.35 + 12.1.9 2.26.0 10.4.0 1.8.1.3 @@ -151,7 +151,7 @@ . - + eXist-db_exist exist-db @@ -1242,7 +1242,7 @@ FDB-backport-LGPL-21-ONLY-license.template.txt FDB-backport-LGPL-21-ONLY-license.xml.template.txt - LGPL-21-license.template.txt + LGPL-21-license.template.txt LGPL-21-license.txt LGPL-21-license.template.txt **/README.md From 27e901fb742bbf623b0b83f35bcab7c7b378601b Mon Sep 17 00:00:00 2001 From: duncdrum Date: Fri, 22 May 2026 10:23:46 +0200 Subject: [PATCH 02/11] [bugfix] work around Jetty 12.1 PathResource Windows resolve regression Jetty 12.1 resolves sub-paths with path.resolve(uri.getPath()), which throws InvalidPathException for Windows drive URIs (/D:/...). Exploded test webapps fail in WebAppContext.getWebInf() with available=false and 503 on every path. WindowsPathResource wraps PathResource on Windows and restores Jetty 12.0 resolve behaviour via Paths.get(resolvedUri). Enable throwUnavailableOnStartupException so startup failures surface immediately in CI. --- .../java/org/exist/jetty/WebAppContext.java | 29 ++-- .../org/exist/jetty/WindowsPathResource.java | 132 ++++++++++++++++++ .../org/exist/jetty/etc/jetty-deploy.xml | 1 + .../jetty/etc/standalone-jetty-deploy.xml | 1 + 4 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 exist-core/src/main/java/org/exist/jetty/WindowsPathResource.java diff --git a/exist-core/src/main/java/org/exist/jetty/WebAppContext.java b/exist-core/src/main/java/org/exist/jetty/WebAppContext.java index 660bd0cb545..3774527addb 100644 --- a/exist-core/src/main/java/org/exist/jetty/WebAppContext.java +++ b/exist-core/src/main/java/org/exist/jetty/WebAppContext.java @@ -21,24 +21,37 @@ */ package org.exist.jetty; +import org.eclipse.jetty.util.resource.Resource; +import org.eclipse.jetty.util.resource.ResourceFactory; import org.exist.storage.BrokerPool; /** - * @author Dmitriy Shabanov + * eXist {@link org.eclipse.jetty.ee10.webapp.WebAppContext} with Windows path handling for + * Jetty 12.1 ({@link WindowsPathResource}). * + * @author Dmitriy Shabanov */ public class WebAppContext extends org.eclipse.jetty.ee10.webapp.WebAppContext { @Override - public String toString() { - return "eXist-db Open Source Native XML Database"; - } + public String toString() { + return "eXist-db Open Source Native XML Database"; + } @Override - protected void doStop() throws Exception { - super.doStop(); + public void setBaseResource(final Resource baseResource) { + super.setBaseResource(WindowsPathResource.wrapIfNeeded(baseResource, ResourceFactory.of(this))); + } - BrokerPool.stopAll(true); - } + @Override + public Resource newResource(final String urlOrPath) { + return WindowsPathResource.wrapIfNeeded(super.newResource(urlOrPath), ResourceFactory.of(this)); + } + + @Override + protected void doStop() throws Exception { + super.doStop(); + BrokerPool.stopAll(true); + } } diff --git a/exist-core/src/main/java/org/exist/jetty/WindowsPathResource.java b/exist-core/src/main/java/org/exist/jetty/WindowsPathResource.java new file mode 100644 index 00000000000..f65a763c04b --- /dev/null +++ b/exist-core/src/main/java/org/exist/jetty/WindowsPathResource.java @@ -0,0 +1,132 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.jetty; + +import org.eclipse.jetty.util.URIUtil; +import org.eclipse.jetty.util.resource.PathResource; +import org.eclipse.jetty.util.resource.Resource; +import org.eclipse.jetty.util.resource.ResourceFactory; + +import java.io.File; +import java.net.URI; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Iterator; +import java.util.Objects; + +/** + * Workaround for Jetty 12.1 {@link PathResource#resolve(String)} on Windows. + *

+ * Jetty 12.0 resolves sub-paths with {@code Paths.get(resolvedUri)}. Jetty 12.1 uses + * {@code path.resolve(uri.getPath())}, which throws {@link java.nio.file.InvalidPathException} + * when the URI path is {@code /D:/...}. Exploded test webapps on Windows CI hit this in + * {@code WebAppContext.getWebInf()}. Remove when upstream Jetty restores safe Windows resolve. + */ +public final class WindowsPathResource extends Resource { + + private final Resource delegate; + private final ResourceFactory resourceFactory; + + private WindowsPathResource(final Resource delegate, final ResourceFactory resourceFactory) { + this.delegate = Objects.requireNonNull(delegate); + this.resourceFactory = Objects.requireNonNull(resourceFactory); + } + + public static Resource wrapIfNeeded(final Resource resource, final ResourceFactory resourceFactory) { + if (resource == null || File.separatorChar != '\\' || !(resource instanceof PathResource)) { + return resource; + } + if (resource instanceof WindowsPathResource) { + return resource; + } + return new WindowsPathResource(resource, resourceFactory); + } + + @Override + public Path getPath() { + return delegate.getPath(); + } + + @Override + public boolean isDirectory() { + return delegate.isDirectory(); + } + + @Override + public boolean isReadable() { + return delegate.isReadable(); + } + + @Override + public URI getURI() { + return delegate.getURI(); + } + + @Override + public String getName() { + return delegate.getName(); + } + + @Override + public String getFileName() { + return delegate.getFileName(); + } + + @Override + public Resource resolve(final String subUriPath) { + if (URIUtil.isNotNormalWithinSelf(subUriPath)) { + throw new IllegalArgumentException(subUriPath); + } + if ("/".equals(subUriPath)) { + return this; + } + final URI resolvedUri = URIUtil.addPath(getURI(), subUriPath); + final Path path = Paths.get(resolvedUri); + return wrapIfNeeded(resourceFactory.newResource(path), resourceFactory); + } + + @Override + public Iterator iterator() { + return delegate.iterator(); + } + + @Override + public boolean equals(final Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof WindowsPathResource other)) { + return false; + } + return delegate.equals(other.delegate); + } + + @Override + public int hashCode() { + return delegate.hashCode(); + } + + @Override + public String toString() { + return delegate.toString(); + } +} diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-deploy.xml b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-deploy.xml index 8e2edeb9596..b26d5157228 100644 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-deploy.xml +++ b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-deploy.xml @@ -14,6 +14,7 @@ /exist + true /../../../webapp/ /etc/webdefault.xml diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone-jetty-deploy.xml b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone-jetty-deploy.xml index 5c6439e3b2d..c9506c6f2dd 100644 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone-jetty-deploy.xml +++ b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone-jetty-deploy.xml @@ -12,6 +12,7 @@ / + true /../../../standalone-webapp/ /etc/webdefault.xml From 741c83e54382e9022c710e7d5ba42e79ec94ae89 Mon Sep 17 00:00:00 2001 From: duncdrum Date: Fri, 22 May 2026 10:24:16 +0200 Subject: [PATCH 03/11] [bugfix] stabilize IndexController index worker chain order HashMap iteration order is undefined; Lucene could run before structural indexing during store/flush. Use explicit ordering: structural, statistics, Lucene, then others. Unrelated to the Jetty 12.1 upgrade; bundled for review convenience. --- .../org/exist/indexing/IndexController.java | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/exist-core/src/main/java/org/exist/indexing/IndexController.java b/exist-core/src/main/java/org/exist/indexing/IndexController.java index 488446c4405..4e42cd43fea 100644 --- a/exist-core/src/main/java/org/exist/indexing/IndexController.java +++ b/exist-core/src/main/java/org/exist/indexing/IndexController.java @@ -40,9 +40,11 @@ import org.w3c.dom.NodeList; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.TreeMap; import org.exist.security.PermissionDeniedException; /** @@ -56,7 +58,16 @@ public enum CollectionIndexRemovalMode { CONFIG_ONLY_REINDEX } - private final Map indexWorkers = new HashMap<>(); + /** + * Stable iteration order for listener chains and {@link #flush()}. + * Alphabetical {@link TreeMap} put Lucene first and broke store-time indexing + * (see {@code LuceneTests} facet/field-type tests). Core indexes run before Lucene. + */ + private static final Comparator INDEX_WORKER_ORDER = Comparator + .comparingInt(IndexController::indexWorkerChainRank) + .thenComparing(Comparator.naturalOrder()); + + private final Map indexWorkers = new TreeMap<>(INDEX_WORKER_ORDER); private final DBBroker broker; private StreamListener listener = null; @@ -73,6 +84,23 @@ public IndexController(final DBBroker broker) { } } + /** + * Preferred {@link StreamListener} chain order: structural → statistics → Lucene → others. + * Uses index-id substrings so exist-core does not depend on extension modules. + */ + private static int indexWorkerChainRank(final String indexId) { + if (indexId.contains("NativeStructuralIndex")) { + return 0; + } + if (indexId.contains("IndexStatistics")) { + return 1; + } + if (indexId.contains("lucene.LuceneIndex")) { + return 2; + } + return 3; + } + /** * Configures all index workers registered with the db instance. * From 6defbce781f5d3beb2ea00ae8a28092d2c22d6f5 Mon Sep 17 00:00:00 2001 From: duncdrum Date: Fri, 22 May 2026 10:24:39 +0200 Subject: [PATCH 04/11] [bugfix] wait for Jetty webapp availability in integration tests Block in JettyStart until required contexts reach isAvailable(); fail fast from ExistWebServer with embedded startup detail. Set exist.jetty.portal.dir for distribution-mode tests. Remove LoginModuleIT TCP probe now redundant. --- exist-core/pom.xml | 2 + .../main/java/org/exist/jetty/JettyStart.java | 181 +++++++++++++++++- .../java/org/exist/test/ExistWebServer.java | 129 +++++++++---- .../persistentlogin/LoginModuleIT.java | 31 ++- 4 files changed, 282 insertions(+), 61 deletions(-) diff --git a/exist-core/pom.xml b/exist-core/pom.xml index 09d016548b3..766bc57b350 100644 --- a/exist-core/pom.xml +++ b/exist-core/pom.xml @@ -1203,6 +1203,7 @@ The BaseX Team. The original license statement is also included below.]]>${project.basedir}/../exist-jetty-config/target/classes/org/exist/jetty ${project.build.testOutputDirectory}/conf.xml ${project.build.testOutputDirectory}/standalone-webapp + ${project.basedir}/../exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/portal ${project.build.testOutputDirectory}/log4j2.xml @@ -1229,6 +1230,7 @@ The BaseX Team. The original license statement is also included below.]]>${project.basedir}/../exist-jetty-config/target/classes/org/exist/jetty ${project.build.testOutputDirectory}/conf.xml ${project.build.testOutputDirectory}/standalone-webapp + ${project.basedir}/../exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/portal ${project.build.testOutputDirectory}/log4j2.xml diff --git a/exist-core/src/main/java/org/exist/jetty/JettyStart.java b/exist-core/src/main/java/org/exist/jetty/JettyStart.java index a7cad260f66..802e6f5215c 100644 --- a/exist-core/src/main/java/org/exist/jetty/JettyStart.java +++ b/exist-core/src/main/java/org/exist/jetty/JettyStart.java @@ -24,6 +24,7 @@ import net.jcip.annotations.GuardedBy; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.eclipse.jetty.ee10.webapp.WebAppContext; import org.eclipse.jetty.server.*; import org.eclipse.jetty.server.handler.ContextHandler; import org.eclipse.jetty.ee10.servlet.ServletContextHandler; @@ -97,6 +98,8 @@ public class JettyStart extends Observable implements LifeCycle.Listener { @GuardedBy("this") private int status = STATUS_STOPPED; @GuardedBy("this") private Optional shutdownHookThread = Optional.empty(); @GuardedBy("this") private int primaryPort = 8080; + @GuardedBy("this") private boolean webAppStartedSuccessfully = false; + @GuardedBy("this") private String webAppStartupFailureDetail = null; public static void main(final String[] args) { @@ -132,6 +135,23 @@ private static void consoleOut(final String msg) { System.out.println(msg); //NOSONAR this has to go to the console } + private static void consoleErr(final String msg) { + System.err.println(msg); //NOSONAR surfaced in Surefire output when test log4j root is OFF + } + + private synchronized void recordStartupFailure(final String detail, final Throwable cause) { + webAppStartedSuccessfully = false; + webAppStartupFailureDetail = detail; + if (cause != null) { + logger.fatal("Jetty startup failed: {}", detail, cause); + consoleErr("Jetty startup failed: " + detail); + cause.printStackTrace(System.err); //NOSONAR CI diagnostics when log4j is disabled in tests + } else { + logger.fatal("Jetty startup failed: {}", detail); + consoleErr("Jetty startup failed: " + detail); + } + } + public synchronized void run() { run(true); } @@ -244,12 +264,13 @@ public synchronized void run(final String[] args, final Observer observer) { DatabaseManager.registerDatabase(xmldb); } catch (final Exception e) { - logger.error("configuration error: {}", e.getMessage(), e); - e.printStackTrace(); + recordStartupFailure("configuration error: " + e.getMessage(), e); return; } try { + webAppStartupFailureDetail = null; + webAppStartedSuccessfully = false; // load jetty configurations final List configFiles = getEnabledConfigFiles(jettyConfig); final List configuredObjects = new ArrayList<>(); @@ -344,19 +365,25 @@ public synchronized void run(final String[] args, final Observer observer) { logger.info("-----------------------------------------------------"); + awaitWebAppContextsStarted(getAllHandlers(server.getHandler())); + webAppStartedSuccessfully = true; + webAppStartupFailureDetail = null; + setChanged(); notifyObservers(SIGNAL_STARTED); } catch (final SocketException e) { - logger.error("----------------------------------------------------------"); - logger.error("ERROR: Could not bind to port because {}", e.getMessage()); - logger.error(e.toString()); - logger.error("----------------------------------------------------------"); + recordStartupFailure("Could not bind to port: " + e.getMessage(), e); setChanged(); notifyObservers(SIGNAL_ERROR); } catch (final Exception e) { - logger.fatal("An unexpected error occurred, web server can not be started: {}", e.getMessage(), e); + if (webAppStartupFailureDetail == null) { + recordStartupFailure( + e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(), e); + } else { + recordStartupFailure(webAppStartupFailureDetail, e); + } setChanged(); notifyObservers(SIGNAL_ERROR); } @@ -506,6 +533,129 @@ private Optional startJetty(final List configuredObjects) throws return server; } + /** + * Block until deployed webapps reach the readiness level required for tests. + *

+ * Every context except the distribution portal at {@code /} must be + * {@link org.eclipse.jetty.server.handler.ContextHandler#isAvailable()} — Jetty returns + * {@code 503} on all paths while unavailable. The portal coexists with {@code /exist} and is + * non-gating. + */ + private void awaitWebAppContextsStarted(final List handlers) throws InterruptedException { + final List webApps = new ArrayList<>(); + for (final Handler handler : handlers) { + if (handler instanceof WebAppContext webApp) { + webApps.add(webApp); + } + } + if (webApps.isEmpty()) { + return; + } + + final boolean distributionLayout = isDistributionLayout(webApps); + final long timeoutMs = slowEnvironmentStartupDeadlineMs(); + final long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) { + boolean allReady = true; + for (final WebAppContext webApp : webApps) { + if (webApp.isFailed()) { + throw new IllegalStateException( + "Web application failed to start: " + webApp.getContextPath()); + } + if (!isWebAppContextReady(webApp, distributionLayout)) { + allReady = false; + break; + } + } + if (allReady) { + logger.info("All required web application contexts are ready."); + return; + } + Thread.sleep(200); + } + throw new IllegalStateException( + "Web application context did not become ready within " + timeoutMs + "ms: " + + describePendingWebApps(webApps, distributionLayout), + firstUnavailableCause(webApps)); + } + + private static Throwable firstUnavailableCause(final List webApps) { + for (final WebAppContext webApp : webApps) { + final Throwable unavailable = webApp.getUnavailableException(); + if (unavailable != null) { + return unavailable; + } + } + return null; + } + + private static boolean isDistributionLayout(final List webApps) { + return webApps.stream().anyMatch(webApp -> "/exist".equals(webApp.getContextPath())); + } + + /** + * Distribution portal {@code /} only needs {@code isStarted()}. Standalone {@code /} and + * {@code /exist} must be {@code isAvailable()} or HTTP clients see {@code 503}. + */ + private static boolean isWebAppContextReady(final WebAppContext webApp, final boolean distributionLayout) { + if (!webApp.isStarted()) { + return false; + } + if (distributionLayout && "/".equals(webApp.getContextPath())) { + return true; + } + return webApp.isAvailable(); + } + + private static String describePendingWebApps(final List webApps, final boolean distributionLayout) { + final StringBuilder details = new StringBuilder(); + for (final WebAppContext webApp : webApps) { + if (webApp.isFailed()) { + continue; + } + if (!isWebAppContextReady(webApp, distributionLayout)) { + if (!details.isEmpty()) { + details.append("; "); + } + details.append(webApp.getContextPath()) + .append(" started=").append(webApp.isStarted()) + .append(" available=").append(webApp.isAvailable()) + .append(" requireAvailable=").append(requiresAvailability(webApp, distributionLayout)) + .append(" war=").append(describeWebAppWar(webApp)); + final Throwable unavailable = webApp.getUnavailableException(); + if (unavailable != null) { + details.append(" unavailableCause=").append(unavailable.getClass().getName()) + .append(": ").append(unavailable.getMessage()); + } + } + } + return details.isEmpty() ? "unknown" : details.toString(); + } + + private static String describeWebAppWar(final WebAppContext webApp) { + final String war = webApp.getWar(); + if (war != null && !war.isBlank()) { + return war; + } + try { + final Resource baseResource = webApp.getBaseResource(); + return baseResource != null ? String.valueOf(baseResource) : "null"; + } catch (final Exception e) { + return "unresolved(" + e.getMessage() + ")"; + } + } + + private static boolean requiresAvailability(final WebAppContext webApp, final boolean distributionLayout) { + return !(distributionLayout && "/".equals(webApp.getContextPath())); + } + + private static long slowEnvironmentStartupDeadlineMs() { + if (System.getenv("CI") != null) { + return 180_000L; + } + return 60_000L; + } + private Map getConfigProperties(final Path configDir) throws IOException { final Map configProperties = new HashMap<>(); @@ -695,4 +845,21 @@ public synchronized void lifeCycleStopped(final LifeCycle lifeCycle) { public synchronized int getPrimaryPort() { return primaryPort; } + + /** + * {@code true} when all required {@link WebAppContext} instances finished startup. Used by + * integration tests to detect swallowed startup failures. + */ + public synchronized boolean isWebAppStartedSuccessfully() { + return webAppStartedSuccessfully; + } + + /** + * When {@link #isWebAppStartedSuccessfully()} is {@code false}, holds the last startup failure + * message for test diagnostics (also printed to {@code System.err} because module test log4j + * configs often set {@code Root level="OFF"}). + */ + public synchronized Optional getWebAppStartupFailureDetail() { + return Optional.ofNullable(webAppStartupFailureDetail); + } } diff --git a/exist-core/src/main/java/org/exist/test/ExistWebServer.java b/exist-core/src/main/java/org/exist/test/ExistWebServer.java index da058038fc0..53f7a3ab8af 100644 --- a/exist-core/src/main/java/org/exist/test/ExistWebServer.java +++ b/exist-core/src/main/java/org/exist/test/ExistWebServer.java @@ -42,7 +42,32 @@ import static org.exist.repo.AutoDeploymentTrigger.AUTODEPLOY_PROPERTY; /** - * Exist Jetty Web Server Rule for JUnit + * JUnit {@link org.junit.rules.ExternalResource} that starts an embedded eXist Jetty server for tests. + *

+ * Prefer {@link org.junit.ClassRule} over {@link org.junit.Rule} when every test method in the class + * can share one server instance (for example {@code org.exist.http.urlrewrite.ControllerTest}). + *

+ * Jetty layout ({@code jettyStandaloneMode}) + *

    + *
  • {@code true} (default): standalone deploy — single webapp at {@code /} via + * {@code exist.jetty.standalone.webapp.dir}. The context must reach + * {@link org.eclipse.jetty.server.handler.ContextHandler#isAvailable()} before HTTP clients + * are used; {@link org.exist.jetty.JettyStart} blocks until it is ready.
  • + *
  • {@code false}: distribution layout — {@code /exist} (main app) plus portal {@code /}. + * {@code /exist} must be available; the portal may only need {@code isStarted()}. + * Requires {@code exist.jetty.portal.dir} in Maven test configuration (see {@code exist-core/pom.xml}).
  • + *
+ *

+ * Common system properties (usually set in module {@code pom.xml} Surefire config): + *

    + *
  • {@code exist.jetty.standalone.webapp.dir} — exploded standalone test webapp root
  • + *
  • {@code exist.jetty.portal.dir} — portal webapp for distribution-mode tests
  • + *
  • {@code jetty.port}, {@code jetty.secure.port}, {@code jetty.ssl.port} — set automatically when + * {@code useRandomPort} is {@code true}
  • + *
  • {@code jetty.home} — Jetty configuration directory ({@code exist-jetty-config/target/classes/...})
  • + *
+ * Startup failures throw {@link IllegalStateException} with detail from {@link org.exist.jetty.JettyStart} + * (also printed to {@code System.err} when test log4j root is OFF). */ public class ExistWebServer extends ExternalResource { @@ -123,16 +148,10 @@ protected void before() throws Throwable { if(useRandomPort) { synchronized(ExistWebServer.class) { - System.setProperty(PROP_JETTY_PORT, Integer.toString(nextFreePort(MIN_RANDOM_PORT, MAX_RANDOM_PORT, MAX_RANDOM_PORT_ATTEMPTS))); - System.setProperty(PROP_JETTY_SECURE_PORT, Integer.toString(nextFreePort(MIN_RANDOM_PORT, MAX_RANDOM_PORT, MAX_RANDOM_PORT_ATTEMPTS))); - System.setProperty(PROP_JETTY_SSL_PORT, Integer.toString(nextFreePort(MIN_RANDOM_PORT, MAX_RANDOM_PORT, MAX_RANDOM_PORT_ATTEMPTS))); - - server = new JettyStart(); - server.run(jettyStandaloneMode); + startJettyServer(); } } else { - server = new JettyStart(); - server.run(); + startJettyServer(); } } else { throw new IllegalStateException("ExistWebServer already running"); @@ -143,10 +162,19 @@ protected void before() throws Throwable { public void restart() { if(server != null) { try { - server.shutdown(); - server.run(); - } catch (final Throwable t) { - throw new RuntimeException(t); + if (useRandomPort) { + synchronized (ExistWebServer.class) { + server.shutdown(); + server.run(jettyStandaloneMode); + awaitJettyReadyAfterRun(); + } + } else { + server.shutdown(); + server.run(jettyStandaloneMode); + awaitJettyReadyAfterRun(); + } + } catch (final Exception e) { + throw new IllegalStateException("Failed to restart ExistWebServer", e); } } else { throw new IllegalStateException("ExistWebServer already stopped"); @@ -156,29 +184,14 @@ public void restart() { @Override protected void after() { if(server != null) { - if(cleanupDbOnShutdown) { - try { - TestUtils.cleanupDB(); - } catch (final EXistException | PermissionDeniedException | LockException | IOException | TriggerException e) { - fail(e.getMessage()); - } - } - server.shutdown(); - server = null; - - if(useTemporaryStorage && temporaryStorage.isPresent()) { - FileUtils.deleteQuietly(temporaryStorage.get()); - temporaryStorage = Optional.empty(); - System.clearProperty(CONFIG_PROP_JOURNAL_DIR); - System.clearProperty(CONFIG_PROP_FILES); - } - - if(useRandomPort) { + if (useRandomPort) { synchronized (ExistWebServer.class) { - System.clearProperty(PROP_JETTY_SSL_PORT); - System.clearProperty(PROP_JETTY_SECURE_PORT); - System.clearProperty(PROP_JETTY_PORT); + shutdownJettyServer(); + disposeTemporaryStorage(); } + } else { + shutdownJettyServer(); + disposeTemporaryStorage(); } } else { throw new IllegalStateException("ExistWebServer already stopped"); @@ -191,4 +204,52 @@ protected void after() { super.after(); } + + private void startJettyServer() { + if (useRandomPort) { + System.setProperty(PROP_JETTY_PORT, Integer.toString(nextFreePort(MIN_RANDOM_PORT, MAX_RANDOM_PORT, MAX_RANDOM_PORT_ATTEMPTS))); + System.setProperty(PROP_JETTY_SECURE_PORT, Integer.toString(nextFreePort(MIN_RANDOM_PORT, MAX_RANDOM_PORT, MAX_RANDOM_PORT_ATTEMPTS))); + System.setProperty(PROP_JETTY_SSL_PORT, Integer.toString(nextFreePort(MIN_RANDOM_PORT, MAX_RANDOM_PORT, MAX_RANDOM_PORT_ATTEMPTS))); + } + server = new JettyStart(); + server.run(jettyStandaloneMode); + awaitJettyReadyAfterRun(); + } + + private void awaitJettyReadyAfterRun() { + if (!server.isWebAppStartedSuccessfully()) { + final String detail = server.getWebAppStartupFailureDetail() + .filter(s -> !s.isBlank()) + .orElse("no startup detail recorded"); + throw new IllegalStateException( + "Jetty web application context did not start successfully: " + detail); + } + } + + private void shutdownJettyServer() { + if (cleanupDbOnShutdown) { + try { + TestUtils.cleanupDB(); + } catch (final EXistException | PermissionDeniedException | LockException | IOException | TriggerException e) { + fail(e.getMessage()); + } + } + server.shutdown(); + server = null; + + if(useRandomPort) { + System.clearProperty(PROP_JETTY_SSL_PORT); + System.clearProperty(PROP_JETTY_SECURE_PORT); + System.clearProperty(PROP_JETTY_PORT); + } + } + + private void disposeTemporaryStorage() { + if (useTemporaryStorage && temporaryStorage.isPresent()) { + FileUtils.deleteQuietly(temporaryStorage.get()); + temporaryStorage = Optional.empty(); + System.clearProperty(CONFIG_PROP_JOURNAL_DIR); + System.clearProperty(CONFIG_PROP_FILES); + } + } } diff --git a/extensions/modules/persistentlogin/src/test/java/org/exist/xquery/modules/persistentlogin/LoginModuleIT.java b/extensions/modules/persistentlogin/src/test/java/org/exist/xquery/modules/persistentlogin/LoginModuleIT.java index 2a47297b45a..bbb789882b0 100644 --- a/extensions/modules/persistentlogin/src/test/java/org/exist/xquery/modules/persistentlogin/LoginModuleIT.java +++ b/extensions/modules/persistentlogin/src/test/java/org/exist/xquery/modules/persistentlogin/LoginModuleIT.java @@ -67,40 +67,31 @@ public class LoginModuleIT { private static Collection root; private static HttpClient client; - /** Wait for server port to accept connections before XML-RPC. Windows CI can be slower to bind. */ - private static void waitForServerReady(int port, int timeoutMs) throws InterruptedException { - final long deadline = System.currentTimeMillis() + timeoutMs; - while (System.currentTimeMillis() < deadline) { - try (java.net.Socket s = new java.net.Socket()) { - s.connect(new java.net.InetSocketAddress("localhost", port), 1000); - return; - } catch (IOException e) { - Thread.sleep(500); - } - } - } - @BeforeClass - public static void beforeClass() throws XMLDBException, InterruptedException { + public static void beforeClass() throws XMLDBException { final int port = existWebServer.getPort(); - final boolean isWindows = System.getProperty("os.name", "").toLowerCase().contains("win"); - waitForServerReady(port, isWindows ? 60_000 : 30_000); - final String uri = "xmldb:exist://localhost:" + port + "/xmlrpc" + XmldbURI.ROOT_COLLECTION; XMLDBException lastException = null; for (int i = 0; i < 20; i++) { try { root = DatabaseManager.getCollection(uri, TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD); + lastException = null; break; - } catch (XMLDBException e) { + } catch (final XMLDBException e) { lastException = e; if (i < 19) { - Thread.sleep(500); + try { + Thread.sleep(500); + } catch (final InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted while waiting for XML-RPC", ie); + } } } } if (root == null) { - throw new AssertionError("Failed to connect to XML-RPC after 20 retries: " + (lastException != null ? lastException.getMessage() : "")); + throw new AssertionError("Failed to connect to XML-RPC: " + + (lastException != null ? lastException.getMessage() : "unknown")); } final BinaryResource res = root.createResource(XQUERY_FILENAME, BinaryResource.class); ((EXistResource) res).setMimeType("application/xquery"); From 11f333960a149f745407bd7b1f691c84ff7e3119 Mon Sep 17 00:00:00 2001 From: duncdrum Date: Fri, 22 May 2026 10:25:04 +0200 Subject: [PATCH 05/11] [test] Jetty test follow-ups: Windows IT, logging, ClassRule - WindowsPathResourceIT in exist-webdav (Windows CI integration job) - jetty-util test dependency for dependency analyze - org.exist.jetty WARN logging in integration test log4j2 configs - ControllerTest @ClassRule (one server per class) --- .../exist/http/urlrewrite/ControllerTest.java | 6 +- exist-core/src/test/resources/log4j2.xml | 1 + .../restxq/src/test/resources/log4j2.xml | 1 + .../file/src/test/resources/log4j2.xml | 1 + extensions/webdav/pom.xml | 5 ++ .../exist/jetty/WindowsPathResourceIT.java | 77 +++++++++++++++++++ .../webdav/src/test/resources/log4j2.xml | 1 + 7 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 extensions/webdav/src/test/java/org/exist/jetty/WindowsPathResourceIT.java diff --git a/exist-core/src/test/java/org/exist/http/urlrewrite/ControllerTest.java b/exist-core/src/test/java/org/exist/http/urlrewrite/ControllerTest.java index a8089d3df52..6a3945323c1 100644 --- a/exist-core/src/test/java/org/exist/http/urlrewrite/ControllerTest.java +++ b/exist-core/src/test/java/org/exist/http/urlrewrite/ControllerTest.java @@ -30,7 +30,7 @@ import org.apache.http.entity.ContentType; import org.exist.http.AbstractHttpTest; import org.exist.test.ExistWebServer; -import org.junit.Rule; +import org.junit.ClassRule; import org.junit.Test; import java.io.IOException; @@ -50,8 +50,8 @@ public class ControllerTest extends AbstractHttpTest { private static final String LEGACY_CONTROLLER_XQUERY = "xql"; private static final String TEST_DOCUMENT_NAME = "test.xml"; - @Rule - public final ExistWebServer existWebServer = new ExistWebServer(true, false, true, true, false); + @ClassRule + public static final ExistWebServer existWebServer = new ExistWebServer(true, false, true, true, false); @Test public void findsLegacyController() throws IOException { diff --git a/exist-core/src/test/resources/log4j2.xml b/exist-core/src/test/resources/log4j2.xml index b355862720a..67207929752 100644 --- a/exist-core/src/test/resources/log4j2.xml +++ b/exist-core/src/test/resources/log4j2.xml @@ -29,6 +29,7 @@ + diff --git a/extensions/exquery/restxq/src/test/resources/log4j2.xml b/extensions/exquery/restxq/src/test/resources/log4j2.xml index 17ef1f00033..79962f977d3 100644 --- a/extensions/exquery/restxq/src/test/resources/log4j2.xml +++ b/extensions/exquery/restxq/src/test/resources/log4j2.xml @@ -6,6 +6,7 @@ + diff --git a/extensions/modules/file/src/test/resources/log4j2.xml b/extensions/modules/file/src/test/resources/log4j2.xml index b355862720a..67207929752 100644 --- a/extensions/modules/file/src/test/resources/log4j2.xml +++ b/extensions/modules/file/src/test/resources/log4j2.xml @@ -29,6 +29,7 @@ + diff --git a/extensions/webdav/pom.xml b/extensions/webdav/pom.xml index 68c6352b655..3f902141300 100644 --- a/extensions/webdav/pom.xml +++ b/extensions/webdav/pom.xml @@ -123,6 +123,11 @@ + + org.eclipse.jetty + jetty-util + test + org.eclipse.jetty jetty-deploy diff --git a/extensions/webdav/src/test/java/org/exist/jetty/WindowsPathResourceIT.java b/extensions/webdav/src/test/java/org/exist/jetty/WindowsPathResourceIT.java new file mode 100644 index 00000000000..267e50a3b9d --- /dev/null +++ b/extensions/webdav/src/test/java/org/exist/jetty/WindowsPathResourceIT.java @@ -0,0 +1,77 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.jetty; + +import org.eclipse.jetty.util.resource.PathResource; +import org.eclipse.jetty.util.resource.Resource; +import org.eclipse.jetty.util.resource.ResourceFactory; +import org.junit.Test; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; + +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +/** + * Windows integration regression test for Jetty 12.1 {@link PathResource#resolve(String)} on + * drive URIs ({@code /D:/...}). Lives in {@code exist-webdav} so it runs on Windows CI + * ({@code verify -DskipUnitTests=true}). + */ +public class WindowsPathResourceIT { + + @Test + public void resolveWebInfOnWindowsDriveUri() throws Exception { + assumeTrue("Windows-only PathResource URI regression", File.separatorChar == '\\'); + + final ResourceFactory resourceFactory = ResourceFactory.root(); + final Path webapp = Files.createTempDirectory("webapp"); + Files.createDirectory(webapp.resolve("WEB-INF")); + try { + final Resource resource = resourceFactory.newResource(webapp); + assumeTrue("Expected PathResource for local webapp directory", resource instanceof PathResource); + final String uriPath = resource.getURI().getPath(); + assumeTrue("Expected absolute Windows drive URI path, got: " + uriPath, + uriPath != null && uriPath.matches("/[A-Za-z]:/.*")); + + final PathResource pathResource = (PathResource) resource; + try { + pathResource.resolve("WEB-INF/"); + // Jetty version may already fix resolve; wrapped path must still work. + } catch (final InvalidPathException e) { + // Expected on Jetty 12.1.x with /X:/... URI paths. + } + + final Resource wrapped = WindowsPathResource.wrapIfNeeded(pathResource, resourceFactory); + assertNotSame(pathResource, wrapped); + + final Resource webInf = wrapped.resolve("WEB-INF/"); + assertTrue("WEB-INF should resolve to a directory", webInf.isDirectory()); + } finally { + Files.deleteIfExists(webapp.resolve("WEB-INF")); + Files.deleteIfExists(webapp); + } + } +} diff --git a/extensions/webdav/src/test/resources/log4j2.xml b/extensions/webdav/src/test/resources/log4j2.xml index b355862720a..67207929752 100644 --- a/extensions/webdav/src/test/resources/log4j2.xml +++ b/extensions/webdav/src/test/resources/log4j2.xml @@ -29,6 +29,7 @@ + From bb9b6ada2529bda571dced19b9de01c2a2d65305 Mon Sep 17 00:00:00 2001 From: duncdrum Date: Fri, 22 May 2026 11:08:51 +0200 Subject: [PATCH 06/11] [bugfix] harden WindowsPathResource wrapper and Jetty startup diagnostics - Symmetric equals via delegate unwrapping - Reorder wrapIfNeeded guards to prevent double-wrap on Windows - Use OSUtil.isWindows() consistently - Remove System.err from recordStartupFailure; rely on logger + test exception detail - Document instanceof PathResource limitation in WindowsPathResource javadoc --- .../main/java/org/exist/jetty/JettyStart.java | 11 ++--------- .../org/exist/jetty/WindowsPathResource.java | 18 ++++++++++++++---- .../java/org/exist/test/ExistWebServer.java | 2 +- .../org/exist/jetty/WindowsPathResourceIT.java | 4 ++-- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/exist-core/src/main/java/org/exist/jetty/JettyStart.java b/exist-core/src/main/java/org/exist/jetty/JettyStart.java index 802e6f5215c..bb0c06f77aa 100644 --- a/exist-core/src/main/java/org/exist/jetty/JettyStart.java +++ b/exist-core/src/main/java/org/exist/jetty/JettyStart.java @@ -135,20 +135,13 @@ private static void consoleOut(final String msg) { System.out.println(msg); //NOSONAR this has to go to the console } - private static void consoleErr(final String msg) { - System.err.println(msg); //NOSONAR surfaced in Surefire output when test log4j root is OFF - } - private synchronized void recordStartupFailure(final String detail, final Throwable cause) { webAppStartedSuccessfully = false; webAppStartupFailureDetail = detail; if (cause != null) { logger.fatal("Jetty startup failed: {}", detail, cause); - consoleErr("Jetty startup failed: " + detail); - cause.printStackTrace(System.err); //NOSONAR CI diagnostics when log4j is disabled in tests } else { logger.fatal("Jetty startup failed: {}", detail); - consoleErr("Jetty startup failed: " + detail); } } @@ -856,8 +849,8 @@ public synchronized boolean isWebAppStartedSuccessfully() { /** * When {@link #isWebAppStartedSuccessfully()} is {@code false}, holds the last startup failure - * message for test diagnostics (also printed to {@code System.err} because module test log4j - * configs often set {@code Root level="OFF"}). + * message for test diagnostics (surfaced by {@link org.exist.test.ExistWebServer} in thrown + * {@link IllegalStateException}s). */ public synchronized Optional getWebAppStartupFailureDetail() { return Optional.ofNullable(webAppStartupFailureDetail); diff --git a/exist-core/src/main/java/org/exist/jetty/WindowsPathResource.java b/exist-core/src/main/java/org/exist/jetty/WindowsPathResource.java index f65a763c04b..20c88b6855c 100644 --- a/exist-core/src/main/java/org/exist/jetty/WindowsPathResource.java +++ b/exist-core/src/main/java/org/exist/jetty/WindowsPathResource.java @@ -25,8 +25,8 @@ import org.eclipse.jetty.util.resource.PathResource; import org.eclipse.jetty.util.resource.Resource; import org.eclipse.jetty.util.resource.ResourceFactory; +import org.exist.util.OSUtil; -import java.io.File; import java.net.URI; import java.nio.file.Path; import java.nio.file.Paths; @@ -40,6 +40,10 @@ * {@code path.resolve(uri.getPath())}, which throws {@link java.nio.file.InvalidPathException} * when the URI path is {@code /D:/...}. Exploded test webapps on Windows CI hit this in * {@code WebAppContext.getWebInf()}. Remove when upstream Jetty restores safe Windows resolve. + *

+ * This class extends {@link Resource}, not {@link PathResource}. Jetty internals that use + * {@code instanceof PathResource} will not treat wrapped resources as path resources; CI and + * integration tests validate that the exploded-webapp startup path does not depend on that. */ public final class WindowsPathResource extends Resource { @@ -52,12 +56,15 @@ private WindowsPathResource(final Resource delegate, final ResourceFactory resou } public static Resource wrapIfNeeded(final Resource resource, final ResourceFactory resourceFactory) { - if (resource == null || File.separatorChar != '\\' || !(resource instanceof PathResource)) { + if (resource == null || !OSUtil.isWindows()) { return resource; } if (resource instanceof WindowsPathResource) { return resource; } + if (!(resource instanceof PathResource)) { + return resource; + } return new WindowsPathResource(resource, resourceFactory); } @@ -114,10 +121,13 @@ public boolean equals(final Object obj) { if (this == obj) { return true; } - if (!(obj instanceof WindowsPathResource other)) { + if (!(obj instanceof Resource other)) { return false; } - return delegate.equals(other.delegate); + if (other instanceof WindowsPathResource wrapped) { + other = wrapped.delegate; + } + return delegate.equals(other); } @Override diff --git a/exist-core/src/main/java/org/exist/test/ExistWebServer.java b/exist-core/src/main/java/org/exist/test/ExistWebServer.java index 53f7a3ab8af..d8553e3480c 100644 --- a/exist-core/src/main/java/org/exist/test/ExistWebServer.java +++ b/exist-core/src/main/java/org/exist/test/ExistWebServer.java @@ -67,7 +67,7 @@ *

  • {@code jetty.home} — Jetty configuration directory ({@code exist-jetty-config/target/classes/...})
  • * * Startup failures throw {@link IllegalStateException} with detail from {@link org.exist.jetty.JettyStart} - * (also printed to {@code System.err} when test log4j root is OFF). + * ({@code webAppStartupFailureDetail} is included in the exception message). */ public class ExistWebServer extends ExternalResource { diff --git a/extensions/webdav/src/test/java/org/exist/jetty/WindowsPathResourceIT.java b/extensions/webdav/src/test/java/org/exist/jetty/WindowsPathResourceIT.java index 267e50a3b9d..3fc307aed44 100644 --- a/extensions/webdav/src/test/java/org/exist/jetty/WindowsPathResourceIT.java +++ b/extensions/webdav/src/test/java/org/exist/jetty/WindowsPathResourceIT.java @@ -24,9 +24,9 @@ import org.eclipse.jetty.util.resource.PathResource; import org.eclipse.jetty.util.resource.Resource; import org.eclipse.jetty.util.resource.ResourceFactory; +import org.exist.util.OSUtil; import org.junit.Test; -import java.io.File; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; @@ -44,7 +44,7 @@ public class WindowsPathResourceIT { @Test public void resolveWebInfOnWindowsDriveUri() throws Exception { - assumeTrue("Windows-only PathResource URI regression", File.separatorChar == '\\'); + assumeTrue("Windows-only PathResource URI regression", OSUtil.isWindows()); final ResourceFactory resourceFactory = ResourceFactory.root(); final Path webapp = Files.createTempDirectory("webapp"); From 4c1793433e865cfbf23e558b836f11ad0009aa1d Mon Sep 17 00:00:00 2001 From: duncdrum Date: Fri, 22 May 2026 11:44:54 +0200 Subject: [PATCH 07/11] [refactor] index worker priorities, Jetty listener API, test server builder - IndexWorker.getChainPriority() replaces IndexController substring ranking - JettyStartListener replaces deprecated Observable for Jetty lifecycle signals - Event-driven webapp readiness wait with CountDownLatch - org.exist.jetty.startup.timeout.ms override; EXIST/PORTAL context constants - ExistWebServer.builder() for readable test configuration --- .../org/exist/indexing/IndexController.java | 35 +--- .../java/org/exist/indexing/IndexWorker.java | 15 ++ .../main/java/org/exist/jetty/JettyStart.java | 162 ++++++++++++++---- .../org/exist/jetty/JettyStartListener.java | 31 ++++ .../org/exist/jetty/WindowsPathResource.java | 4 + .../java/org/exist/launcher/Launcher.java | 26 ++- .../java/org/exist/launcher/SplashScreen.java | 61 ++++--- .../java/org/exist/launcher/UtilityPanel.java | 19 +- .../statistics/IndexStatisticsWorker.java | 5 + .../NativeStructuralIndexWorker.java | 5 + .../java/org/exist/test/ExistWebServer.java | 90 ++++++++-- .../xquery/update/IndexIntegrationTest.java | 22 ++- .../indexing/lucene/LuceneIndexWorker.java | 5 + 13 files changed, 365 insertions(+), 115 deletions(-) create mode 100644 exist-core/src/main/java/org/exist/jetty/JettyStartListener.java diff --git a/exist-core/src/main/java/org/exist/indexing/IndexController.java b/exist-core/src/main/java/org/exist/indexing/IndexController.java index 4e42cd43fea..7eab1310d64 100644 --- a/exist-core/src/main/java/org/exist/indexing/IndexController.java +++ b/exist-core/src/main/java/org/exist/indexing/IndexController.java @@ -42,9 +42,9 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.TreeMap; import org.exist.security.PermissionDeniedException; /** @@ -60,14 +60,12 @@ public enum CollectionIndexRemovalMode { /** * Stable iteration order for listener chains and {@link #flush()}. - * Alphabetical {@link TreeMap} put Lucene first and broke store-time indexing - * (see {@code LuceneTests} facet/field-type tests). Core indexes run before Lucene. */ - private static final Comparator INDEX_WORKER_ORDER = Comparator - .comparingInt(IndexController::indexWorkerChainRank) - .thenComparing(Comparator.naturalOrder()); + private static final Comparator INDEX_WORKER_ORDER = Comparator + .comparingInt(IndexWorker::getChainPriority) + .thenComparing(IndexWorker::getIndexId); - private final Map indexWorkers = new TreeMap<>(INDEX_WORKER_ORDER); + private final Map indexWorkers = new LinkedHashMap<>(); private final DBBroker broker; private StreamListener listener = null; @@ -79,26 +77,9 @@ public enum CollectionIndexRemovalMode { public IndexController(final DBBroker broker) { this.broker = broker; final List workers = broker.getBrokerPool().getIndexManager().getWorkers(broker); - for (final IndexWorker worker : workers) { - indexWorkers.put(worker.getIndexId(), worker); - } - } - - /** - * Preferred {@link StreamListener} chain order: structural → statistics → Lucene → others. - * Uses index-id substrings so exist-core does not depend on extension modules. - */ - private static int indexWorkerChainRank(final String indexId) { - if (indexId.contains("NativeStructuralIndex")) { - return 0; - } - if (indexId.contains("IndexStatistics")) { - return 1; - } - if (indexId.contains("lucene.LuceneIndex")) { - return 2; - } - return 3; + workers.stream() + .sorted(INDEX_WORKER_ORDER) + .forEach(worker -> indexWorkers.put(worker.getIndexId(), worker)); } /** diff --git a/exist-core/src/main/java/org/exist/indexing/IndexWorker.java b/exist-core/src/main/java/org/exist/indexing/IndexWorker.java index 5e820993f5b..43674147b88 100644 --- a/exist-core/src/main/java/org/exist/indexing/IndexWorker.java +++ b/exist-core/src/main/java/org/exist/indexing/IndexWorker.java @@ -53,6 +53,21 @@ public interface IndexWorker { */ public static final String VALUE_COUNT = "value_count"; + /** + * Lower values run earlier in {@link IndexController} listener chains and {@link #flush()}. + */ + int CHAIN_PRIORITY_STRUCTURAL = 0; + int CHAIN_PRIORITY_STATISTICS = 100; + int CHAIN_PRIORITY_LUCENE = 1000; + + /** + * Chain-order priority for {@link IndexController}. Default {@link Integer#MAX_VALUE} preserves + * legacy ordering among workers that do not override. + */ + default int getChainPriority() { + return Integer.MAX_VALUE; + } + /** * Returns an ID which uniquely identifies this worker's index. * @return a unique name identifying this worker's index. diff --git a/exist-core/src/main/java/org/exist/jetty/JettyStart.java b/exist-core/src/main/java/org/exist/jetty/JettyStart.java index bb0c06f77aa..7ac61e86805 100644 --- a/exist-core/src/main/java/org/exist/jetty/JettyStart.java +++ b/exist-core/src/main/java/org/exist/jetty/JettyStart.java @@ -57,6 +57,10 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.*; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import static org.exist.util.ThreadUtils.newGlobalThread; @@ -69,10 +73,14 @@ * * @author wolf */ -public class JettyStart extends Observable implements LifeCycle.Listener { +public class JettyStart implements LifeCycle.Listener { public static final String JETTY_HOME_PROP = "jetty.home"; public static final String JETTY_BASE_PROP = "jetty.base"; + public static final String STARTUP_TIMEOUT_MS_PROPERTY = "org.exist.jetty.startup.timeout.ms"; + + private static final String EXIST_CONTEXT_PATH = "/exist"; + private static final String PORTAL_CONTEXT_PATH = "/"; private static final String JETTY_PROPETIES_FILENAME = "jetty.properties"; private static final Logger logger = LogManager.getLogger(JettyStart.class); @@ -101,6 +109,8 @@ public class JettyStart extends Observable implements LifeCycle.Listener { @GuardedBy("this") private boolean webAppStartedSuccessfully = false; @GuardedBy("this") private String webAppStartupFailureDetail = null; + private final CopyOnWriteArrayList jettyStartListeners = new CopyOnWriteArrayList<>(); + public static void main(final String[] args) { try { @@ -168,7 +178,25 @@ public synchronized void run(final boolean standalone) { run(new String[] { jettyConfig.toAbsolutePath().toString() }, null); } - public synchronized void run(final String[] args, final Observer observer) { + public void addJettyStartListener(final JettyStartListener listener) { + if (listener != null) { + jettyStartListeners.addIfAbsent(listener); + } + } + + public void removeJettyStartListener(final JettyStartListener listener) { + if (listener != null) { + jettyStartListeners.remove(listener); + } + } + + private void notifyJettyStartListeners(final String signal) { + for (final JettyStartListener listener : jettyStartListeners) { + listener.onJettyStartEvent(signal); + } + } + + public synchronized void run(final String[] args, final JettyStartListener listener) { if (args.length == 0) { logger.error("No configuration file specified!"); return; @@ -209,8 +237,8 @@ public synchronized void run(final String[] args, final Observer observer) { configProperties.put(JETTY_BASE_PROP, jettyClasspathHome); } - if (observer != null) { - addObserver(observer); + if (listener != null) { + addJettyStartListener(listener); } logger.info("Running with Java {} [{} ({}) in {}]", @@ -249,7 +277,10 @@ public synchronized void run(final String[] args, final Observer observer) { .map(Path::normalize).map(Path::toAbsolutePath).map(Path::toString) .orElse("")); - BrokerPool.configure(1, 5, config, Optional.ofNullable(observer)); + final Optional brokerPoolObserver = listener instanceof Observer observer + ? Optional.of(observer) + : Optional.empty(); + BrokerPool.configure(1, 5, config, brokerPoolObserver); // register the XMLDB driver final Database xmldb = new DatabaseImpl(); @@ -362,13 +393,11 @@ public synchronized void run(final String[] args, final Observer observer) { webAppStartedSuccessfully = true; webAppStartupFailureDetail = null; - setChanged(); - notifyObservers(SIGNAL_STARTED); + notifyJettyStartListeners(SIGNAL_STARTED); } catch (final SocketException e) { recordStartupFailure("Could not bind to port: " + e.getMessage(), e); - setChanged(); - notifyObservers(SIGNAL_ERROR); + notifyJettyStartListeners(SIGNAL_ERROR); } catch (final Exception e) { if (webAppStartupFailureDetail == null) { @@ -377,8 +406,7 @@ public synchronized void run(final String[] args, final Observer observer) { } else { recordStartupFailure(webAppStartupFailureDetail, e); } - setChanged(); - notifyObservers(SIGNAL_ERROR); + notifyJettyStartListeners(SIGNAL_ERROR); } } @@ -529,9 +557,9 @@ private Optional startJetty(final List configuredObjects) throws /** * Block until deployed webapps reach the readiness level required for tests. *

    - * Every context except the distribution portal at {@code /} must be + * Every context except the distribution portal at {@link #PORTAL_CONTEXT_PATH} must be * {@link org.eclipse.jetty.server.handler.ContextHandler#isAvailable()} — Jetty returns - * {@code 503} on all paths while unavailable. The portal coexists with {@code /exist} and is + * {@code 503} on all paths while unavailable. The portal coexists with {@link #EXIST_CONTEXT_PATH} and is * non-gating. */ private void awaitWebAppContextsStarted(final List handlers) throws InterruptedException { @@ -547,29 +575,87 @@ private void awaitWebAppContextsStarted(final List handlers) throws Int final boolean distributionLayout = isDistributionLayout(webApps); final long timeoutMs = slowEnvironmentStartupDeadlineMs(); - final long deadline = System.currentTimeMillis() + timeoutMs; - while (System.currentTimeMillis() < deadline) { - boolean allReady = true; + final CountDownLatch readyLatch = new CountDownLatch(1); + final AtomicReference failure = new AtomicReference<>(); + + final LifeCycle.Listener readinessListener = new LifeCycle.Listener() { + @Override + public void lifeCycleStarted(final LifeCycle event) { + evaluateReadiness(); + } + + @Override + public void lifeCycleFailure(final LifeCycle event, final Throwable cause) { + if (event instanceof WebAppContext webApp) { + failure.compareAndSet(null, new IllegalStateException( + "Web application failed to start: " + webApp.getContextPath(), cause)); + } else { + failure.compareAndSet(null, new IllegalStateException("Web application failed to start", cause)); + } + readyLatch.countDown(); + } + + private void evaluateReadiness() { + for (final WebAppContext webApp : webApps) { + if (webApp.isFailed()) { + failure.compareAndSet(null, new IllegalStateException( + "Web application failed to start: " + webApp.getContextPath())); + readyLatch.countDown(); + return; + } + } + if (allWebAppsReady(webApps, distributionLayout)) { + readyLatch.countDown(); + } + } + }; + + for (final WebAppContext webApp : webApps) { + webApp.addEventListener(readinessListener); + } + + try { + if (allWebAppsReady(webApps, distributionLayout)) { + logger.info("All required web application contexts are ready."); + return; + } for (final WebAppContext webApp : webApps) { if (webApp.isFailed()) { throw new IllegalStateException( "Web application failed to start: " + webApp.getContextPath()); } - if (!isWebAppContextReady(webApp, distributionLayout)) { - allReady = false; - break; - } } - if (allReady) { - logger.info("All required web application contexts are ready."); - return; + if (!readyLatch.await(timeoutMs, TimeUnit.MILLISECONDS)) { + throw new IllegalStateException( + "Web application context did not become ready within " + timeoutMs + "ms: " + + describePendingWebApps(webApps, distributionLayout), + firstUnavailableCause(webApps)); + } + final IllegalStateException startupFailure = failure.get(); + if (startupFailure != null) { + throw startupFailure; + } + if (!allWebAppsReady(webApps, distributionLayout)) { + throw new IllegalStateException( + "Web application context did not become ready: " + + describePendingWebApps(webApps, distributionLayout), + firstUnavailableCause(webApps)); + } + logger.info("All required web application contexts are ready."); + } finally { + for (final WebAppContext webApp : webApps) { + webApp.removeEventListener(readinessListener); + } + } + } + + private static boolean allWebAppsReady(final List webApps, final boolean distributionLayout) { + for (final WebAppContext webApp : webApps) { + if (!isWebAppContextReady(webApp, distributionLayout)) { + return false; } - Thread.sleep(200); } - throw new IllegalStateException( - "Web application context did not become ready within " + timeoutMs + "ms: " - + describePendingWebApps(webApps, distributionLayout), - firstUnavailableCause(webApps)); + return true; } private static Throwable firstUnavailableCause(final List webApps) { @@ -583,18 +669,18 @@ private static Throwable firstUnavailableCause(final List webApps } private static boolean isDistributionLayout(final List webApps) { - return webApps.stream().anyMatch(webApp -> "/exist".equals(webApp.getContextPath())); + return webApps.stream().anyMatch(webApp -> EXIST_CONTEXT_PATH.equals(webApp.getContextPath())); } /** - * Distribution portal {@code /} only needs {@code isStarted()}. Standalone {@code /} and - * {@code /exist} must be {@code isAvailable()} or HTTP clients see {@code 503}. + * Distribution portal {@link #PORTAL_CONTEXT_PATH} only needs {@code isStarted()}. Standalone + * {@link #PORTAL_CONTEXT_PATH} and {@link #EXIST_CONTEXT_PATH} must be {@code isAvailable()} or HTTP clients see {@code 503}. */ private static boolean isWebAppContextReady(final WebAppContext webApp, final boolean distributionLayout) { if (!webApp.isStarted()) { return false; } - if (distributionLayout && "/".equals(webApp.getContextPath())) { + if (distributionLayout && PORTAL_CONTEXT_PATH.equals(webApp.getContextPath())) { return true; } return webApp.isAvailable(); @@ -639,10 +725,14 @@ private static String describeWebAppWar(final WebAppContext webApp) { } private static boolean requiresAvailability(final WebAppContext webApp, final boolean distributionLayout) { - return !(distributionLayout && "/".equals(webApp.getContextPath())); + return !(distributionLayout && PORTAL_CONTEXT_PATH.equals(webApp.getContextPath())); } private static long slowEnvironmentStartupDeadlineMs() { + final String override = System.getProperty(STARTUP_TIMEOUT_MS_PROPERTY); + if (override != null && !override.isBlank()) { + return Long.parseLong(override); + } if (System.getenv("CI") != null) { return 180_000L; } @@ -799,8 +889,7 @@ public synchronized boolean isStarted() { @Override public synchronized void lifeCycleStarting(final LifeCycle lifeCycle) { logger.info("Jetty server starting..."); - setChanged(); - notifyObservers(SIGNAL_STARTING); + notifyJettyStartListeners(SIGNAL_STARTING); status = STATUS_STARTING; notifyAll(); } @@ -808,8 +897,7 @@ public synchronized void lifeCycleStarting(final LifeCycle lifeCycle) { @Override public synchronized void lifeCycleStarted(final LifeCycle lifeCycle) { logger.info("Jetty server started."); - setChanged(); - notifyObservers(SIGNAL_STARTED); + notifyJettyStartListeners(SIGNAL_STARTED); status = STATUS_STARTED; notifyAll(); } diff --git a/exist-core/src/main/java/org/exist/jetty/JettyStartListener.java b/exist-core/src/main/java/org/exist/jetty/JettyStartListener.java new file mode 100644 index 00000000000..e6ccbf2517c --- /dev/null +++ b/exist-core/src/main/java/org/exist/jetty/JettyStartListener.java @@ -0,0 +1,31 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.jetty; + +/** + * Receives lifecycle signals from {@link JettyStart} (replacing deprecated {@code java.util.Observer}). + */ +@FunctionalInterface +public interface JettyStartListener { + + void onJettyStartEvent(String signal); +} diff --git a/exist-core/src/main/java/org/exist/jetty/WindowsPathResource.java b/exist-core/src/main/java/org/exist/jetty/WindowsPathResource.java index 20c88b6855c..19ab4dc1884 100644 --- a/exist-core/src/main/java/org/exist/jetty/WindowsPathResource.java +++ b/exist-core/src/main/java/org/exist/jetty/WindowsPathResource.java @@ -44,6 +44,10 @@ * This class extends {@link Resource}, not {@link PathResource}. Jetty internals that use * {@code instanceof PathResource} will not treat wrapped resources as path resources; CI and * integration tests validate that the exploded-webapp startup path does not depend on that. + *

    + * Only {@link #resolve(String)} differs from Jetty 12.1 {@link PathResource} behaviour. Other + * {@link Resource} methods delegate to the wrapped resource or rely on inherited defaults that + * route through {@link #getPath()}, {@link #getURI()}, {@link #iterator()}, and {@link #resolve(String)}. */ public final class WindowsPathResource extends Resource { diff --git a/exist-core/src/main/java/org/exist/launcher/Launcher.java b/exist-core/src/main/java/org/exist/launcher/Launcher.java index fe523449d75..6c074f42b26 100644 --- a/exist-core/src/main/java/org/exist/launcher/Launcher.java +++ b/exist-core/src/main/java/org/exist/launcher/Launcher.java @@ -721,14 +721,24 @@ private void registerObserver() { public void update(final Observable observable, final Object o) { final ExistRepository.Notification notification = (ExistRepository.Notification) o; - if (notification.getPackageURI().equals(PACKAGE_DASHBOARD) && dashboardItem != null) { - dashboardItem.setEnabled(notification.getAction() == ExistRepository.Action.INSTALL); - - } else if (notification.getPackageURI().equals(PACKAGE_EXIDE) && eXideItem != null) { - eXideItem.setEnabled(notification.getAction() == ExistRepository.Action.INSTALL); - - } else if (notification.getPackageURI().equals(PACKAGE_MONEX) && monexItem != null) { - monexItem.setEnabled(notification.getAction() == ExistRepository.Action.INSTALL); + switch (notification.getPackageURI()) { + case PACKAGE_DASHBOARD -> { + if (dashboardItem != null) { + dashboardItem.setEnabled(notification.getAction() == ExistRepository.Action.INSTALL); + } + } + case PACKAGE_EXIDE -> { + if (eXideItem != null) { + eXideItem.setEnabled(notification.getAction() == ExistRepository.Action.INSTALL); + } + } + case PACKAGE_MONEX -> { + if (monexItem != null) { + monexItem.setEnabled(notification.getAction() == ExistRepository.Action.INSTALL); + } + } + default -> { + } } } diff --git a/exist-core/src/main/java/org/exist/launcher/SplashScreen.java b/exist-core/src/main/java/org/exist/launcher/SplashScreen.java index 8661766bff2..5597496e26e 100644 --- a/exist-core/src/main/java/org/exist/launcher/SplashScreen.java +++ b/exist-core/src/main/java/org/exist/launcher/SplashScreen.java @@ -22,6 +22,7 @@ package org.exist.launcher; import org.exist.jetty.JettyStart; +import org.exist.jetty.JettyStartListener; import org.exist.storage.BrokerPool; import javax.swing.*; @@ -39,7 +40,7 @@ * * @author Wolfgang Meier */ -public class SplashScreen extends JFrame implements Observer, Comparable { +public class SplashScreen extends JFrame implements JettyStartListener, Observer, Comparable { @Serial private static final long serialVersionUID = -8449133653386075548L; @@ -111,29 +112,41 @@ public void setStatus(final String status) { SwingUtilities.invokeLater(() -> statusLabel.setText(status)); } - public void update(Observable o, Object arg) { - if (JettyStart.SIGNAL_STARTED.equals(arg)) { - setStatus("Server started!"); - setVisible(false); - launcher.signalStarted(); - } else if (BrokerPool.SIGNAL_STARTUP.equals(arg)) { - setStatus("Starting eXist-db ..."); - } else if (BrokerPool.SIGNAL_ABORTED.equals(arg)) { - setVisible(false); - launcher.showMessageAndExit("Startup aborted", - "eXist-db detected an error during recovery. This may not be fatal, " + - "but to avoid possible damage, the db will now stop. Please consider " + - "running a consistency check via the export tool and create " + - "a backup if problems are reported. The db should come up again if you restart " + - "it.", true); - } else if (BrokerPool.SIGNAL_WRITABLE.equals(arg)) { - setStatus("eXist-db is up. Waiting for web server ..."); - } else if (JettyStart.SIGNAL_ERROR.equals(arg)) { - setVisible(false); - launcher.showMessageAndExit("Error Occurred", - "An error occurred during startup. Please check the logs.", true); - } else if (BrokerPool.SIGNAL_SHUTDOWN.equals(arg)) { - launcher.signalShutdown(); + @Override + public void onJettyStartEvent(final String signal) { + switch (signal) { + case JettyStart.SIGNAL_STARTED -> { + setStatus("Server started!"); + setVisible(false); + launcher.signalStarted(); + } + case JettyStart.SIGNAL_ERROR -> { + setVisible(false); + launcher.showMessageAndExit("Error Occurred", + "An error occurred during startup. Please check the logs.", true); + } + case JettyStart.SIGNAL_STARTING -> setStatus("Starting Jetty ..."); + default -> setStatus(signal); + } + } + + public void update(final Observable o, final Object arg) { + if (arg instanceof String signal) { + switch (signal) { + case BrokerPool.SIGNAL_STARTUP -> setStatus("Starting eXist-db ..."); + case BrokerPool.SIGNAL_ABORTED -> { + setVisible(false); + launcher.showMessageAndExit("Startup aborted", + "eXist-db detected an error during recovery. This may not be fatal, " + + "but to avoid possible damage, the db will now stop. Please consider " + + "running a consistency check via the export tool and create " + + "a backup if problems are reported. The db should come up again if you restart " + + "it.", true); + } + case BrokerPool.SIGNAL_WRITABLE -> setStatus("eXist-db is up. Waiting for web server ..."); + case BrokerPool.SIGNAL_SHUTDOWN -> launcher.signalShutdown(); + default -> setStatus(signal); + } } else { setStatus(arg.toString()); } diff --git a/exist-core/src/main/java/org/exist/launcher/UtilityPanel.java b/exist-core/src/main/java/org/exist/launcher/UtilityPanel.java index a80b628e42f..ce469c27bcd 100644 --- a/exist-core/src/main/java/org/exist/launcher/UtilityPanel.java +++ b/exist-core/src/main/java/org/exist/launcher/UtilityPanel.java @@ -176,12 +176,21 @@ protected void setStatus(final String message) { } @Override - public void update(Observable observable, final Object o) { + public void update(final Observable observable, final Object o) { if (o instanceof ExistRepository.Notification notification) { - if (notification.getPackageURI().equals(Launcher.PACKAGE_DASHBOARD) && dashboardButton != null) { - dashboardButton.setEnabled(notification.getAction() == ExistRepository.Action.INSTALL); - } else if (notification.getPackageURI().equals(Launcher.PACKAGE_EXIDE) && eXideButton != null) { - eXideButton.setEnabled(notification.getAction() == ExistRepository.Action.INSTALL); + switch (notification.getPackageURI()) { + case Launcher.PACKAGE_DASHBOARD -> { + if (dashboardButton != null) { + dashboardButton.setEnabled(notification.getAction() == ExistRepository.Action.INSTALL); + } + } + case Launcher.PACKAGE_EXIDE -> { + if (eXideButton != null) { + eXideButton.setEnabled(notification.getAction() == ExistRepository.Action.INSTALL); + } + } + default -> { + } } } else { SwingUtilities.invokeLater(() -> messages.append(o.toString())); diff --git a/exist-core/src/main/java/org/exist/storage/statistics/IndexStatisticsWorker.java b/exist-core/src/main/java/org/exist/storage/statistics/IndexStatisticsWorker.java index 1bacb2a7cc1..4b836a5829b 100644 --- a/exist-core/src/main/java/org/exist/storage/statistics/IndexStatisticsWorker.java +++ b/exist-core/src/main/java/org/exist/storage/statistics/IndexStatisticsWorker.java @@ -77,6 +77,11 @@ public IndexStatisticsWorker(final IndexStatistics index) { this.index = index; } + @Override + public int getChainPriority() { + return IndexWorker.CHAIN_PRIORITY_STATISTICS; + } + @Override public String getIndexId() { return index.getIndexId(); diff --git a/exist-core/src/main/java/org/exist/storage/structural/NativeStructuralIndexWorker.java b/exist-core/src/main/java/org/exist/storage/structural/NativeStructuralIndexWorker.java index a998e5b8035..9bb46ba8480 100644 --- a/exist-core/src/main/java/org/exist/storage/structural/NativeStructuralIndexWorker.java +++ b/exist-core/src/main/java/org/exist/storage/structural/NativeStructuralIndexWorker.java @@ -415,6 +415,11 @@ public boolean indexInfo(Value value, long pointer) throws TerminatedException { } } + @Override + public int getChainPriority() { + return IndexWorker.CHAIN_PRIORITY_STRUCTURAL; + } + public String getIndexId() { return NativeStructuralIndex.ID; } diff --git a/exist-core/src/main/java/org/exist/test/ExistWebServer.java b/exist-core/src/main/java/org/exist/test/ExistWebServer.java index d8553e3480c..747ba276385 100644 --- a/exist-core/src/main/java/org/exist/test/ExistWebServer.java +++ b/exist-core/src/main/java/org/exist/test/ExistWebServer.java @@ -68,6 +68,8 @@ * * Startup failures throw {@link IllegalStateException} with detail from {@link org.exist.jetty.JettyStart} * ({@code webAppStartupFailureDetail} is included in the exception message). + *

    + * Prefer {@link #builder()} over the boolean constructor chain for readable test setup. */ public class ExistWebServer extends ExternalResource { @@ -94,32 +96,100 @@ public class ExistWebServer extends ExternalResource { private Optional temporaryStorage = Optional.empty(); private final boolean jettyStandaloneMode; + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private boolean useRandomPort; + private boolean cleanupDbOnShutdown; + private boolean disableAutoDeploy; + private boolean useTemporaryStorage; + private boolean jettyStandaloneMode = true; + + public Builder useRandomPort() { + return useRandomPort(true); + } + + public Builder useRandomPort(final boolean useRandomPort) { + this.useRandomPort = useRandomPort; + return this; + } + + public Builder cleanupDbOnShutdown() { + return cleanupDbOnShutdown(true); + } + + public Builder cleanupDbOnShutdown(final boolean cleanupDbOnShutdown) { + this.cleanupDbOnShutdown = cleanupDbOnShutdown; + return this; + } + + public Builder disableAutoDeploy() { + return disableAutoDeploy(true); + } + + public Builder disableAutoDeploy(final boolean disableAutoDeploy) { + this.disableAutoDeploy = disableAutoDeploy; + return this; + } + + public Builder useTemporaryStorage() { + return useTemporaryStorage(true); + } + + public Builder useTemporaryStorage(final boolean useTemporaryStorage) { + this.useTemporaryStorage = useTemporaryStorage; + return this; + } + + public Builder jettyStandaloneMode(final boolean jettyStandaloneMode) { + this.jettyStandaloneMode = jettyStandaloneMode; + return this; + } + + public Builder distributionMode() { + return jettyStandaloneMode(false); + } + + public ExistWebServer build() { + return new ExistWebServer(this); + } + } + + private ExistWebServer(final Builder builder) { + this.useRandomPort = builder.useRandomPort; + this.cleanupDbOnShutdown = builder.cleanupDbOnShutdown; + this.disableAutoDeploy = builder.disableAutoDeploy; + this.useTemporaryStorage = builder.useTemporaryStorage; + this.jettyStandaloneMode = builder.jettyStandaloneMode; + } + public ExistWebServer() { - this(false); + this(builder()); } public ExistWebServer(final boolean useRandomPort) { - this(useRandomPort, false); + this(builder().useRandomPort(useRandomPort)); } public ExistWebServer(final boolean useRandomPort, final boolean cleanupDbOnShutdown) { - this(useRandomPort, cleanupDbOnShutdown, false); + this(builder().useRandomPort(useRandomPort).cleanupDbOnShutdown(cleanupDbOnShutdown)); } public ExistWebServer(final boolean useRandomPort, final boolean cleanupDbOnShutdown, final boolean disableAutoDeploy) { - this(useRandomPort, cleanupDbOnShutdown, disableAutoDeploy, false); + this(builder().useRandomPort(useRandomPort).cleanupDbOnShutdown(cleanupDbOnShutdown).disableAutoDeploy(disableAutoDeploy)); } public ExistWebServer(final boolean useRandomPort, final boolean cleanupDbOnShutdown, final boolean disableAutoDeploy, final boolean useTemporaryStorage) { - this(useRandomPort, cleanupDbOnShutdown, disableAutoDeploy, useTemporaryStorage, true); + this(builder().useRandomPort(useRandomPort).cleanupDbOnShutdown(cleanupDbOnShutdown) + .disableAutoDeploy(disableAutoDeploy).useTemporaryStorage(useTemporaryStorage)); } public ExistWebServer(final boolean useRandomPort, final boolean cleanupDbOnShutdown, final boolean disableAutoDeploy, final boolean useTemporaryStorage, final boolean jettyStandaloneMode) { - this.useRandomPort = useRandomPort; - this.cleanupDbOnShutdown = cleanupDbOnShutdown; - this.disableAutoDeploy = disableAutoDeploy; - this.useTemporaryStorage = useTemporaryStorage; - this.jettyStandaloneMode = jettyStandaloneMode; + this(builder().useRandomPort(useRandomPort).cleanupDbOnShutdown(cleanupDbOnShutdown) + .disableAutoDeploy(disableAutoDeploy).useTemporaryStorage(useTemporaryStorage) + .jettyStandaloneMode(jettyStandaloneMode)); } public final int getPort() { diff --git a/exist-core/src/test/java/org/exist/xquery/update/IndexIntegrationTest.java b/exist-core/src/test/java/org/exist/xquery/update/IndexIntegrationTest.java index fba8ed4c72e..0fe4d8f567b 100644 --- a/exist-core/src/test/java/org/exist/xquery/update/IndexIntegrationTest.java +++ b/exist-core/src/test/java/org/exist/xquery/update/IndexIntegrationTest.java @@ -54,14 +54,19 @@ private void run(final XmldbURI docUri, final String data, final BiConsumer happy. + expect(worker.getIndexId()).andStubReturn("TestIndex"); + expect(worker.getChainPriority()).andStubReturn(Integer.MAX_VALUE); expect(worker.getQueryRewriter(anyObject(XQueryContext.class))).andStubReturn(null); expect(worker.getIndexName()).andStubReturn("TestIndex"); expect(worker.getListener()).andStubReturn(stream); @@ -90,8 +100,12 @@ private void run(final XmldbURI docUri, final String data, final BiConsumer against that stale mock — corrupting + // BrokerPool and cascading NPEs into hundreds of unrelated tests. + pool.getIndexManager().unregisterIndex(index); control.resetToStrict(); } diff --git a/extensions/indexes/lucene/src/main/java/org/exist/indexing/lucene/LuceneIndexWorker.java b/extensions/indexes/lucene/src/main/java/org/exist/indexing/lucene/LuceneIndexWorker.java index a600307b68f..3dcae8e3da3 100644 --- a/extensions/indexes/lucene/src/main/java/org/exist/indexing/lucene/LuceneIndexWorker.java +++ b/extensions/indexes/lucene/src/main/java/org/exist/indexing/lucene/LuceneIndexWorker.java @@ -143,6 +143,11 @@ public LuceneIndexWorker(LuceneIndex parent, DBBroker broker) { this.queryTranslator = new XMLToQuery(index); } + @Override + public int getChainPriority() { + return IndexWorker.CHAIN_PRIORITY_LUCENE; + } + public String getIndexId() { return LuceneIndex.ID; } From 37d28b75857a60ba68ff994776300a8a6325ecdc Mon Sep 17 00:00:00 2001 From: duncdrum Date: Fri, 22 May 2026 13:18:50 +0200 Subject: [PATCH 08/11] [bugfix] make MimeTable.load() throw on missing or invalid mime-types.xml Replace silent LOG.error + empty maps with IllegalStateException so a bad or missing mime-types.xml fails fast instead of breaking MIME handling across REST, WebDAV, and XML-RPC. --- .../main/java/org/exist/util/MimeTable.java | 81 +++++++++---------- .../java/org/exist/util/MimeTableTest.java | 28 +++++++ 2 files changed, 68 insertions(+), 41 deletions(-) diff --git a/exist-core/src/main/java/org/exist/util/MimeTable.java b/exist-core/src/main/java/org/exist/util/MimeTable.java index 8bb9240eb98..09d303e9ae9 100644 --- a/exist-core/src/main/java/org/exist/util/MimeTable.java +++ b/exist-core/src/main/java/org/exist/util/MimeTable.java @@ -70,7 +70,7 @@ public class MimeTable { private static final String MIME_TYPES_XML = "mime-types.xml"; private static final String MIME_TYPES_XML_DEFAULT = "org/exist/util/" + MIME_TYPES_XML; - private static MimeTable instance = null; + private static volatile MimeTable instance = null; /** From where the mime table is loaded for message purpose */ private String src; @@ -80,8 +80,12 @@ public class MimeTable { * @return the mimetable */ public static MimeTable getInstance() { - if(instance == null) { - instance = new MimeTable(); + if (instance == null) { + synchronized (MimeTable.class) { + if (instance == null) { + instance = new MimeTable(); + } + } } return instance; } @@ -95,7 +99,11 @@ public static MimeTable getInstance() { */ public static MimeTable getInstance(final Path path) { if (instance == null) { - instance = new MimeTable(path); + synchronized (MimeTable.class) { + if (instance == null) { + instance = new MimeTable(path); + } + } } return instance; } @@ -111,7 +119,11 @@ public static MimeTable getInstance(final Path path) { */ public static MimeTable getInstance(final InputStream stream, final String src) { if (instance == null) { - instance = new MimeTable(stream, src); + synchronized (MimeTable.class) { + if (instance == null) { + instance = new MimeTable(stream, src); + } + } } return instance; } @@ -126,16 +138,15 @@ public MimeTable() { } public MimeTable(final Path path) { - if (Files.isReadable(path)) { - try { - LOG.info("Loading mime table from file: {}", path.toAbsolutePath().toString()); - try(final InputStream is = Files.newInputStream(path)) { - loadMimeTypes(is); - } - this.src = path.toUri().toString(); - } catch (final ParserConfigurationException | SAXException | IOException e) { - LOG.error(FILE_LOAD_FAILED_ERR + "{}", path.toAbsolutePath().toString(), e); - } + if (!Files.isReadable(path)) { + throw new IllegalStateException(FILE_LOAD_FAILED_ERR + path.toAbsolutePath() + ": file not readable"); + } + try (final InputStream is = Files.newInputStream(path)) { + LOG.info("Loading mime table from file: {}", path.toAbsolutePath()); + loadMimeTypes(is); + this.src = path.toUri().toString(); + } catch (final ParserConfigurationException | SAXException | IOException e) { + throw new IllegalStateException(FILE_LOAD_FAILED_ERR + path.toAbsolutePath(), e); } } @@ -236,39 +247,27 @@ private void load() { final ClassLoader cl = MimeTable.class.getClassLoader(); final InputStream is = cl.getResourceAsStream(MIME_TYPES_XML_DEFAULT); if (is == null) { - LOG.error(LOAD_FAILED_ERR); - } - - try { - loadMimeTypes(is); - this.src = "resource://" + MIME_TYPES_XML_DEFAULT; - } catch (final ParserConfigurationException | SAXException | IOException e) { - LOG.error(LOAD_FAILED_ERR, e); + throw new IllegalStateException(LOAD_FAILED_ERR + ": classpath resource not found: " + MIME_TYPES_XML_DEFAULT); } + loadFromStream(is, "resource://" + MIME_TYPES_XML_DEFAULT); } private void load(final InputStream stream, final String src) { - boolean loaded = false; LOG.info("Loading mime table from stream: {}", src); try { - loadMimeTypes(stream); - this.src=src; - } catch (final ParserConfigurationException | SAXException | IOException e) { - LOG.error(LOAD_FAILED_ERR, e); + loadFromStream(stream, src); + } catch (final IllegalStateException e) { + LOG.warn("Failed to load mime table from {}, falling back to classpath default", src, e); + load(); } - - if (!loaded) { - final ClassLoader cl = MimeTable.class.getClassLoader(); - final InputStream is = cl.getResourceAsStream(MIME_TYPES_XML_DEFAULT); - if (is == null) { - LOG.error(LOAD_FAILED_ERR); - } - try { - loadMimeTypes(is); - this.src="resource://"+MIME_TYPES_XML_DEFAULT; - } catch (final ParserConfigurationException | SAXException | IOException e) { - LOG.error(LOAD_FAILED_ERR, e); - } + } + + private void loadFromStream(final InputStream stream, final String sourceDescription) { + try (stream) { + loadMimeTypes(stream); + this.src = sourceDescription; + } catch (final ParserConfigurationException | SAXException | IOException e) { + throw new IllegalStateException("Failed to load mime-type table from " + sourceDescription, e); } } diff --git a/exist-core/src/test/java/org/exist/util/MimeTableTest.java b/exist-core/src/test/java/org/exist/util/MimeTableTest.java index a85ab2b4716..4476ef2da2e 100644 --- a/exist-core/src/test/java/org/exist/util/MimeTableTest.java +++ b/exist-core/src/test/java/org/exist/util/MimeTableTest.java @@ -22,6 +22,7 @@ package org.exist.util; import java.net.URISyntaxException; +import java.nio.file.Files; import java.nio.file.Path; import static org.junit.Assert.*; @@ -154,4 +155,31 @@ public void testWithDefaultMimeTypeFeature() throws URISyntaxException { assertEquals("Incorrect mime type", "foo/bar", mt.getName()); assertEquals("Incorrect resource type", MimeType.BINARY, mt.getType()); } + + @Test + public void testClasspathDefaultIncludesApplicationXquery() { + final MimeTable mimeTable = new MimeTable(); + final MimeType xquery = mimeTable.getContentType("application/xquery"); + assertNotNull("application/xquery must be registered in the default mime-types.xml", xquery); + assertEquals("application/xquery", xquery.getName()); + } + + @Test + public void testUnreadablePathThrows() { + final Path missing = Path.of("/nonexistent/mime-types-does-not-exist.xml"); + final IllegalStateException ex = assertThrows(IllegalStateException.class, () -> new MimeTable(missing)); + assertTrue(ex.getMessage().contains("not readable")); + } + + @Test + public void testInvalidXmlThrows() throws Exception { + final Path broken = Files.createTempFile("mime-types-broken", ".xml"); + try { + Files.writeString(broken, " new MimeTable(broken)); + assertTrue(ex.getMessage().contains("Failed to load mime-type table")); + } finally { + Files.deleteIfExists(broken); + } + } } From 89bbd8694e341eaa92040fe01fcc33b5ac275c7e Mon Sep 17 00:00:00 2001 From: duncdrum Date: Fri, 22 May 2026 13:21:41 +0200 Subject: [PATCH 09/11] [bugfix] fix persistent-login cookies on standalone Jetty "/" context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On root context deployments getContextPath() returns "", which produced Set-Cookie Path="" and caused clients to ignore org.exist.login. Map empty context paths to "/" in login.xql and omit blank paths in HttpResponseWrapper. Use "|" as the token separator so strict Set-Cookie parsers accept the value. LoginModuleIT failed at step 3 (admin→guest) because HttpClient 4.x DEFAULT (NetscapeDraftSpec) rejects Jetty 12 RFC6265 Expires dates. Use CookieSpecs.STANDARD with a shared cookie store instead of manually parsing Set-Cookie headers; apply the same spec in AbstractHttpTest for future ITs. Refs jetty/jetty.project#12771 --- .../http/servlets/HttpResponseWrapper.java | 18 ++++- .../java/org/exist/http/AbstractHttpTest.java | 5 ++ .../persistentlogin/PersistentLogin.java | 49 +++++++++++--- .../xquery/modules/persistentlogin/login.xql | 16 ++++- .../persistentlogin/LoginModuleIT.java | 29 ++++++-- .../persistentlogin/PersistentLoginTest.java | 67 +++++++++++++++++++ .../exist/jetty/WindowsPathResourceIT.java | 1 + 7 files changed, 164 insertions(+), 21 deletions(-) create mode 100644 extensions/modules/persistentlogin/src/test/java/org/exist/xquery/modules/persistentlogin/PersistentLoginTest.java diff --git a/exist-core/src/main/java/org/exist/http/servlets/HttpResponseWrapper.java b/exist-core/src/main/java/org/exist/http/servlets/HttpResponseWrapper.java index d9b9a0199aa..99442875b77 100644 --- a/exist-core/src/main/java/org/exist/http/servlets/HttpResponseWrapper.java +++ b/exist-core/src/main/java/org/exist/http/servlets/HttpResponseWrapper.java @@ -78,13 +78,25 @@ public void addCookie(final String name, final String value, final int maxAge, b final Cookie cookie = new Cookie(name, encode(value)); cookie.setMaxAge(maxAge); cookie.setSecure( secure ); - if (domain != null) { + if (domain != null && !domain.isEmpty()) { cookie.setDomain(domain); } - if (path != null) { + setCookiePath(cookie, path); + response.addCookie(cookie); + } + + /** + * Apply a cookie path only when it is a non-empty string. + *

    + * Standalone Jetty deployments use a root context ({@code getContextPath()} returns {@code ""}). + * Passing that empty string as an explicit Path makes many HTTP clients (including Apache HttpClient + * used in integration tests) reject the cookie entirely. Omitting Path lets the container apply + * the RFC 6265 default for the request URI. + */ + private static void setCookiePath(final Cookie cookie, final String path) { + if (path != null && !path.isEmpty()) { cookie.setPath(path); } - response.addCookie(cookie); } @Override diff --git a/exist-core/src/test/java/org/exist/http/AbstractHttpTest.java b/exist-core/src/test/java/org/exist/http/AbstractHttpTest.java index b7d69f5f4bd..f54157e241e 100644 --- a/exist-core/src/test/java/org/exist/http/AbstractHttpTest.java +++ b/exist-core/src/test/java/org/exist/http/AbstractHttpTest.java @@ -25,6 +25,8 @@ import com.evolvedbinary.j8fu.function.FunctionE; import org.apache.http.HttpHost; import org.apache.http.client.HttpClient; +import org.apache.http.client.config.CookieSpecs; +import org.apache.http.client.config.RequestConfig; import org.apache.http.client.fluent.Executor; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; @@ -85,6 +87,9 @@ protected static T withHttpClient(final FunctionE seriesMap = Collections.synchronizedMap(new HashMap<>()); private SecureRandom random; + public static PersistentLogin getInstance() { + return instance; + } + public PersistentLogin() { random = new SecureRandom(); } @@ -100,7 +111,11 @@ public LoginDetails register(String user, String password, DurationValue timeToL * or an out-of-sequence request. */ public LoginDetails lookup(String token) throws XPathException { - String[] tokens = token.split(":"); + final String[] tokens = splitTokenValue(token); + if (tokens.length < 2) { + LOG.debug("Malformed persistent login token"); + return null; + } LoginDetails data = seriesMap.get(tokens[0]); if (data == null) { @@ -135,8 +150,22 @@ public LoginDetails lookup(String token) throws XPathException { * @param token token string provided by the user */ public void invalidate(String token) { - String[] tokens = token.split(":"); - seriesMap.remove(tokens[0]); + final String[] tokens = splitTokenValue(token); + if (tokens.length > 0) { + seriesMap.remove(tokens[0]); + } + } + + /** + * Split a cookie value into series and token. New cookies use {@link #TOKEN_SEPARATOR}; + * {@link #LEGACY_TOKEN_SEPARATOR} is still accepted for in-flight sessions created before the switch. + */ + private static String[] splitTokenValue(final String token) { + final String[] pipeParts = token.split("\\" + TOKEN_SEPARATOR, 2); + if (pipeParts.length == 2) { + return pipeParts; + } + return token.split(LEGACY_TOKEN_SEPARATOR, 2); } private String generateSeriesToken() { @@ -229,7 +258,7 @@ private void timeoutCheck() { @Override public String toString() { - return this.series + ":" + this.token; + return this.series + TOKEN_SEPARATOR + this.token; } } } diff --git a/extensions/modules/persistentlogin/src/main/resources/org/exist/xquery/modules/persistentlogin/login.xql b/extensions/modules/persistentlogin/src/main/resources/org/exist/xquery/modules/persistentlogin/login.xql index 5c366e3b4d0..562c2fd449d 100644 --- a/extensions/modules/persistentlogin/src/main/resources/org/exist/xquery/modules/persistentlogin/login.xql +++ b/extensions/modules/persistentlogin/src/main/resources/org/exist/xquery/modules/persistentlogin/login.xql @@ -98,6 +98,18 @@ declare function login:set-user($domain as xs:string, $maxAge as xs:dayTimeDurat login:set-user($domain, (), $maxAge, $asDba) }; +declare %private function login:cookie-path($path as xs:string?) as xs:string { + if (exists($path) and $path != "") then + $path + else + let $ctx := request:get-context-path() + return + if ($ctx = "") then + "/" + else + $ctx +}; + declare %private function login:callback($newToken as xs:string?, $user as xs:string, $password as xs:string, $expiration as xs:duration, $domain as xs:string, $path as xs:string?, $asDba as xs:boolean) { if (not($asDba) or sm:is-dba($user)) then ( @@ -106,7 +118,7 @@ declare %private function login:callback($newToken as xs:string?, $user as xs:st request:set-attribute("xquery.password", $password), if ($newToken) then response:set-cookie($domain, $newToken, $expiration, false(), (), - if (exists($path)) then $path else request:get-context-path()) + login:cookie-path($path)) else () ) else @@ -128,7 +140,7 @@ declare %private function login:create-login-session($domain as xs:string, $path declare %private function login:clear-credentials($token as xs:string?, $domain as xs:string, $path as xs:string?) as empty-sequence() { response:set-cookie($domain, "deleted", xs:dayTimeDuration("-P1D"), false(), (), - if (exists($path)) then $path else request:get-context-path()), + login:cookie-path($path)), if ($token and $token != "deleted") then plogin:invalidate($token) else diff --git a/extensions/modules/persistentlogin/src/test/java/org/exist/xquery/modules/persistentlogin/LoginModuleIT.java b/extensions/modules/persistentlogin/src/test/java/org/exist/xquery/modules/persistentlogin/LoginModuleIT.java index bbb789882b0..c6b9dac9a64 100644 --- a/extensions/modules/persistentlogin/src/test/java/org/exist/xquery/modules/persistentlogin/LoginModuleIT.java +++ b/extensions/modules/persistentlogin/src/test/java/org/exist/xquery/modules/persistentlogin/LoginModuleIT.java @@ -23,9 +23,12 @@ import org.apache.http.HttpEntity; import org.apache.http.HttpResponse; -import org.apache.http.client.HttpClient; +import org.apache.http.client.config.CookieSpecs; +import org.apache.http.client.config.RequestConfig; import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.protocol.HttpClientContext; import org.apache.http.impl.client.BasicCookieStore; +import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.util.EntityUtils; import org.exist.TestUtils; @@ -65,7 +68,9 @@ public class LoginModuleIT { private final static String XQUERY_FILENAME = "test-login.xql"; private static Collection root; - private static HttpClient client; + private static CloseableHttpClient client; + private static BasicCookieStore cookieStore; + private static HttpClientContext httpContext; @BeforeClass public static void beforeClass() throws XMLDBException { @@ -100,12 +105,23 @@ public static void beforeClass() throws XMLDBException { final UserManagementService ums = root.getService(UserManagementService.class); ums.chmod(res, 0777); - final BasicCookieStore store = new BasicCookieStore(); - client = HttpClientBuilder.create().setDefaultCookieStore(store).build(); + cookieStore = new BasicCookieStore(); + httpContext = HttpClientContext.create(); + httpContext.setCookieStore(cookieStore); + // Jetty 12 emits RFC 6265 Set-Cookie (RFC1123 Expires). HttpClient 4.x DEFAULT (NetscapeDraftSpec) + // rejects that format; STANDARD is required for automatic cookie storage. See jetty/jetty.project#12771. + client = HttpClientBuilder.create() + .setDefaultRequestConfig(RequestConfig.custom() + .setCookieSpec(CookieSpecs.STANDARD) + .build()) + .build(); } @AfterClass - public static void afterClass() throws XMLDBException { + public static void afterClass() throws Exception { + if (client != null) { + client.close(); + } if (root != null) { final org.xmldb.api.base.Resource res = root.getResource(XQUERY_FILENAME); if (res != null) { @@ -132,10 +148,11 @@ public void loginAndLogout() throws IOException { private void doGet(@Nullable String params, String expected) throws IOException { final HttpGet httpGet = new HttpGet("http://localhost:" + existWebServer.getPort() + "/rest" + XmldbURI.ROOT_COLLECTION + '/' + XQUERY_FILENAME + (params == null ? "" : "?" + params)); - HttpResponse response = client.execute(httpGet); + HttpResponse response = client.execute(httpGet, httpContext); HttpEntity entity = response.getEntity(); final String responseBody = EntityUtils.toString(entity); assertEquals(responseBody, SC_OK, response.getStatusLine().getStatusCode()); assertEquals(expected, responseBody); } + } diff --git a/extensions/modules/persistentlogin/src/test/java/org/exist/xquery/modules/persistentlogin/PersistentLoginTest.java b/extensions/modules/persistentlogin/src/test/java/org/exist/xquery/modules/persistentlogin/PersistentLoginTest.java new file mode 100644 index 00000000000..3d43ab35244 --- /dev/null +++ b/extensions/modules/persistentlogin/src/test/java/org/exist/xquery/modules/persistentlogin/PersistentLoginTest.java @@ -0,0 +1,67 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.modules.persistentlogin; + +import org.exist.xquery.XPathException; +import org.exist.xquery.value.DayTimeDurationValue; +import org.exist.xquery.value.DurationValue; +import org.junit.BeforeClass; +import org.junit.Test; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class PersistentLoginTest { + + private static DurationValue oneDay; + + @BeforeClass + public static void initDuration() throws XPathException { + oneDay = new DayTimeDurationValue("P1D"); + } + + @Test + public void newTokensUsePipeSeparator() throws XPathException { + final PersistentLogin login = new PersistentLogin(); + final PersistentLogin.LoginDetails details = login.register("admin", "admin", oneDay); + assertTrue(details.toString().contains("|")); + assertNotNull(login.lookup(details.toString())); + } + + @Test + public void lookupAcceptsLegacyColonSeparator() throws XPathException { + final PersistentLogin login = new PersistentLogin(); + final PersistentLogin.LoginDetails details = login.register("admin", "admin", oneDay); + final String legacyToken = details.getSeries() + ":" + details.getToken(); + assertNotNull(login.lookup(legacyToken)); + } + + @Test + public void invalidateAcceptsLegacyColonSeparator() throws XPathException { + final PersistentLogin login = new PersistentLogin(); + final PersistentLogin.LoginDetails details = login.register("admin", "admin", oneDay); + final String legacyToken = details.getSeries() + ":" + details.getToken(); + login.invalidate(legacyToken); + assertNull(login.lookup(legacyToken)); + } +} diff --git a/extensions/webdav/src/test/java/org/exist/jetty/WindowsPathResourceIT.java b/extensions/webdav/src/test/java/org/exist/jetty/WindowsPathResourceIT.java index 3fc307aed44..824e654f5c0 100644 --- a/extensions/webdav/src/test/java/org/exist/jetty/WindowsPathResourceIT.java +++ b/extensions/webdav/src/test/java/org/exist/jetty/WindowsPathResourceIT.java @@ -40,6 +40,7 @@ * drive URIs ({@code /D:/...}). Lives in {@code exist-webdav} so it runs on Windows CI * ({@code verify -DskipUnitTests=true}). */ +@SuppressWarnings("PMD.ClassNamingConventions") // Failsafe *IT suffix; not a JUnit *Test class public class WindowsPathResourceIT { @Test From b26b50bd32c488cfc517007e3e4dd4f33a6e6db5 Mon Sep 17 00:00:00 2001 From: duncdrum Date: Fri, 22 May 2026 16:20:03 +0200 Subject: [PATCH 10/11] [refactor] Reduce JettyStart NPath complexity for Codacy Extract run() startup phases and WebAppReadinessAwaiter to bring NPath scores under the PMD threshold without changing behaviour. --- .../main/java/org/exist/jetty/JettyStart.java | 491 ++++++++++-------- 1 file changed, 272 insertions(+), 219 deletions(-) diff --git a/exist-core/src/main/java/org/exist/jetty/JettyStart.java b/exist-core/src/main/java/org/exist/jetty/JettyStart.java index 7ac61e86805..0e337cbf734 100644 --- a/exist-core/src/main/java/org/exist/jetty/JettyStart.java +++ b/exist-core/src/main/java/org/exist/jetty/JettyStart.java @@ -202,211 +202,219 @@ public synchronized void run(final String[] args, final JettyStartListener liste return; } - Path jettyConfig = Path.of(args[0]).normalize(); - boolean configFromClasspath = false; - if (Files.notExists(jettyConfig)) { - logger.warn("Configuration file: {} does not exist!", jettyConfig.toAbsolutePath().toString()); - - final String jettyConfigFileName = FileUtils.fileName(jettyConfig.getFileName()); - logger.warn("Fallback... searching for configuration file on classpath: {}!etc/{}", getClass().getPackage().getName(), jettyConfigFileName); - - final URL jettyConfigUrl = getClass().getResource("etc/" + jettyConfigFileName); - if (jettyConfigUrl != null) { - try { - jettyConfig = Path.of(jettyConfigUrl.toURI()).normalize(); - configFromClasspath = true; - } catch (final URISyntaxException e) { - logger.error("Unable to retrieve configuration file from classpath: {}", e.getMessage(), e); - return; - } - } else { - logger.error("Unable to find configuration file on classpath!"); - return; - } + final Optional resolvedConfig = resolveJettyConfigPath(args[0]); + if (resolvedConfig.isEmpty()) { + return; } final Map configProperties; try { - configProperties = getConfigProperties(jettyConfig.getParent()); + configProperties = bootstrapExistDb(args, listener, resolvedConfig.get()); + } catch (final Exception e) { + recordStartupFailure("configuration error: " + e.getMessage(), e); + return; + } - // modify JETTY_HOME and JETTY_BASE properties when running with classpath config - if (configFromClasspath) { - final String jettyClasspathHome = jettyConfig.getParent().getParent().toAbsolutePath().toString(); - System.setProperty(JETTY_HOME_PROP, jettyClasspathHome); - configProperties.put(JETTY_HOME_PROP, jettyClasspathHome); - configProperties.put(JETTY_BASE_PROP, jettyClasspathHome); + try { + launchJettyServer(resolvedConfig.get().path(), configProperties); + webAppStartedSuccessfully = true; + webAppStartupFailureDetail = null; + notifyJettyStartListeners(SIGNAL_STARTED); + } catch (final SocketException e) { + recordStartupFailure("Could not bind to port: " + e.getMessage(), e); + notifyJettyStartListeners(SIGNAL_ERROR); + } catch (final Exception e) { + if (webAppStartupFailureDetail == null) { + recordStartupFailure( + e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(), e); + } else { + recordStartupFailure(webAppStartupFailureDetail, e); } + notifyJettyStartListeners(SIGNAL_ERROR); + } + } - if (listener != null) { - addJettyStartListener(listener); - } + private record ResolvedJettyConfig(Path path, boolean fromClasspath) {} - logger.info("Running with Java {} [{} ({}) in {}]", - System.getProperty("java.version", "(unknown java.version)"), - System.getProperty("java.vendor", "(unknown java.vendor)"), - System.getProperty("java.vm.name", "(unknown java.vm.name)"), - System.getProperty("java.home", "(unknown java.home)") - ); - - logger.info("Approximate maximum amount of memory for JVM: {}", FileUtils.humanSize(Runtime.getRuntime().maxMemory())); - logger.info("Number of processors available to JVM: {}", Runtime.getRuntime().availableProcessors()); - - logger.info("Running as user '{}'", System.getProperty("user.name", "(unknown user.name)")); - logger.info("[eXist Home : {}]", System.getProperty("exist.home", "unknown")); - logger.info("[eXist Version : {}]", SystemProperties.getInstance().getSystemProperty("product-version", "unknown")); - logger.info("[eXist Build : {}]", SystemProperties.getInstance().getSystemProperty("product-build", "unknown")); - logger.info("[Git commit : {}]", SystemProperties.getInstance().getSystemProperty("git-commit", "unknown")); - logger.info("[Git commit timestamp : {}]", SystemProperties.getInstance().getSystemProperty("git-commit-timestamp", "unknown")); - - logger.info("[Operating System : {} {} {}]", System.getProperty("os.name"), System.getProperty("os.version"), System.getProperty("os.arch")); - logger.info("[log4j.configurationFile : {}]", System.getProperty("log4j.configurationFile")); - logger.info("[jetty Version: {}]", Jetty.VERSION); - logger.info("[{} : {}]", JETTY_HOME_PROP, configProperties.get(JETTY_HOME_PROP)); - logger.info("[{} : {}]", JETTY_BASE_PROP, configProperties.get(JETTY_BASE_PROP)); - logger.info("[jetty configuration : {}]", jettyConfig.toAbsolutePath().toString()); - - // configure the database instance - SingleInstanceConfiguration config; - if (args.length == 2) { - config = new SingleInstanceConfiguration(args[1]); - } else { - config = new SingleInstanceConfiguration(); - } - logger.info("Configuring eXist from {}", - config.getConfigFilePath() - .map(Path::normalize).map(Path::toAbsolutePath).map(Path::toString) - .orElse("")); + private Optional resolveJettyConfigPath(final String configArg) { + Path jettyConfig = Path.of(configArg).normalize(); + if (Files.exists(jettyConfig)) { + return Optional.of(new ResolvedJettyConfig(jettyConfig, false)); + } - final Optional brokerPoolObserver = listener instanceof Observer observer - ? Optional.of(observer) - : Optional.empty(); - BrokerPool.configure(1, 5, config, brokerPoolObserver); + logger.warn("Configuration file: {} does not exist!", jettyConfig.toAbsolutePath().toString()); - // register the XMLDB driver - final Database xmldb = new DatabaseImpl(); - xmldb.setProperty("create-database", "false"); - DatabaseManager.registerDatabase(xmldb); + final String jettyConfigFileName = FileUtils.fileName(jettyConfig.getFileName()); + logger.warn("Fallback... searching for configuration file on classpath: {}!etc/{}", + getClass().getPackage().getName(), jettyConfigFileName); - } catch (final Exception e) { - recordStartupFailure("configuration error: " + e.getMessage(), e); - return; + final URL jettyConfigUrl = getClass().getResource("etc/" + jettyConfigFileName); + if (jettyConfigUrl == null) { + logger.error("Unable to find configuration file on classpath!"); + return Optional.empty(); } try { - webAppStartupFailureDetail = null; - webAppStartedSuccessfully = false; - // load jetty configurations - final List configFiles = getEnabledConfigFiles(jettyConfig); - final List configuredObjects = new ArrayList<>(); - XmlConfiguration last = null; - for(final Path confFile : configFiles) { - logger.info("[loading jetty configuration : {}]", confFile.toString()); - final Resource resource = ResourceFactory.root().newResource(confFile); - final XmlConfiguration configuration = new XmlConfiguration(resource); - if (last != null) { - configuration.getIdMap().putAll(last.getIdMap()); - } - configuration.getProperties().putAll(configProperties); - configuredObjects.add(configuration.configure()); - last = configuration; - } + jettyConfig = Path.of(jettyConfigUrl.toURI()).normalize(); + return Optional.of(new ResolvedJettyConfig(jettyConfig, true)); + } catch (final URISyntaxException e) { + logger.error("Unable to retrieve configuration file from classpath: {}", e.getMessage(), e); + return Optional.empty(); + } + } - // configure WebSocket on any ServletContextHandler - configureWebSocket(configuredObjects); + private Map bootstrapExistDb( + final String[] args, + final JettyStartListener listener, + final ResolvedJettyConfig resolvedConfig) throws Exception { + final Path jettyConfig = resolvedConfig.path(); + final Map configProperties = getConfigProperties(jettyConfig.getParent()); - // start Jetty - final Optional maybeServer = startJetty(configuredObjects); - if(maybeServer.isEmpty()) { - logger.error("Unable to find a server to start in jetty configurations"); - throw new IllegalStateException(); - } + if (resolvedConfig.fromClasspath()) { + final String jettyClasspathHome = jettyConfig.getParent().getParent().toAbsolutePath().toString(); + System.setProperty(JETTY_HOME_PROP, jettyClasspathHome); + configProperties.put(JETTY_HOME_PROP, jettyClasspathHome); + configProperties.put(JETTY_BASE_PROP, jettyClasspathHome); + } - final Server server = maybeServer.get(); + if (listener != null) { + addJettyStartListener(listener); + } - final Connector[] connectors = server.getConnectors(); + logStartupEnvironment(configProperties, jettyConfig); - // Construct description of all ports opened. - final StringBuilder allPorts = new StringBuilder(); + final SingleInstanceConfiguration config = args.length == 2 + ? new SingleInstanceConfiguration(args[1]) + : new SingleInstanceConfiguration(); + logger.info("Configuring eXist from {}", + config.getConfigFilePath() + .map(Path::normalize).map(Path::toAbsolutePath).map(Path::toString) + .orElse("")); - if (connectors.length > 1) { - // plural s - allPorts.append("s"); - } + final Optional brokerPoolObserver = listener instanceof Observer observer + ? Optional.of(observer) + : Optional.empty(); + BrokerPool.configure(1, 5, config, brokerPoolObserver); - boolean establishedPrimaryPort = false; - for(final Connector connector : connectors) { - if(connector instanceof NetworkConnector networkConnector) { + final Database xmldb = new DatabaseImpl(); + xmldb.setProperty("create-database", "false"); + DatabaseManager.registerDatabase(xmldb); - if(!establishedPrimaryPort) { - this.primaryPort = networkConnector.getLocalPort(); - establishedPrimaryPort = true; - } + return configProperties; + } - allPorts.append(" "); - allPorts.append(networkConnector.getLocalPort()); - } - } + private void logStartupEnvironment(final Map configProperties, final Path jettyConfig) { + logger.info("Running with Java {} [{} ({}) in {}]", + System.getProperty("java.version", "(unknown java.version)"), + System.getProperty("java.vendor", "(unknown java.vendor)"), + System.getProperty("java.vm.name", "(unknown java.vm.name)"), + System.getProperty("java.home", "(unknown java.home)")); - //************************************************************* - final List serverUris = getSeverURIs(server); - if(!serverUris.isEmpty()) { - this.primaryPort = serverUris.getFirst().getPort(); + logger.info("Approximate maximum amount of memory for JVM: {}", FileUtils.humanSize(Runtime.getRuntime().maxMemory())); + logger.info("Number of processors available to JVM: {}", Runtime.getRuntime().availableProcessors()); - } - logger.info("-----------------------------------------------------"); - logger.info("Server has started, listening on:"); - for(final URI serverUri : serverUris) { - logger.info("{}", serverUri.resolve("/")); - } + logger.info("Running as user '{}'", System.getProperty("user.name", "(unknown user.name)")); + logger.info("[eXist Home : {}]", System.getProperty("exist.home", "unknown")); + logger.info("[eXist Version : {}]", SystemProperties.getInstance().getSystemProperty("product-version", "unknown")); + logger.info("[eXist Build : {}]", SystemProperties.getInstance().getSystemProperty("product-build", "unknown")); + logger.info("[Git commit : {}]", SystemProperties.getInstance().getSystemProperty("git-commit", "unknown")); + logger.info("[Git commit timestamp : {}]", SystemProperties.getInstance().getSystemProperty("git-commit-timestamp", "unknown")); - logger.info("Configured contexts:"); - final List handlers = getAllHandlers(server.getHandler()); - for (final Handler handler: handlers) { + logger.info("[Operating System : {} {} {}]", System.getProperty("os.name"), System.getProperty("os.version"), System.getProperty("os.arch")); + logger.info("[log4j.configurationFile : {}]", System.getProperty("log4j.configurationFile")); + logger.info("[jetty Version: {}]", Jetty.VERSION); + logger.info("[{} : {}]", JETTY_HOME_PROP, configProperties.get(JETTY_HOME_PROP)); + logger.info("[{} : {}]", JETTY_BASE_PROP, configProperties.get(JETTY_BASE_PROP)); + logger.info("[jetty configuration : {}]", jettyConfig.toAbsolutePath().toString()); + } - if (handler instanceof ContextHandler contextHandler) { - logger.info("{} ({})", contextHandler.getContextPath(), contextHandler.getDisplayName()); - } + private void launchJettyServer(final Path jettyConfig, final Map configProperties) throws Exception { + webAppStartupFailureDetail = null; + webAppStartedSuccessfully = false; - if (handler instanceof ServletContextHandler contextHandler) { - final ServiceLoader services = ServiceLoader.load(ExistExtensionServlet.class); + final List configuredObjects = loadConfiguredJettyObjects(jettyConfig, configProperties); + configureWebSocket(configuredObjects); - for (ExistExtensionServlet existExtensionServlet : services) { - final String pathSpec = existExtensionServlet.getPathSpec(); - final String contextPath = contextHandler.getContextPath(); + final Server server = startJetty(configuredObjects) + .orElseThrow(() -> { + logger.error("Unable to find a server to start in jetty configurations"); + return new IllegalStateException(); + }); - // Avoid "//" as logged prefix - final String normalizedPath = "/".equals(contextPath) - ? pathSpec - : contextPath + pathSpec; + updatePrimaryPortFromConnectors(server); + logServerStarted(server); - logger.info("{} ({})", normalizedPath, existExtensionServlet.getServletInfo()); + final List handlers = getAllHandlers(server.getHandler()); + registerExtensionServlets(handlers); - // Register servlet - contextHandler.addServlet(new ServletHolder(existExtensionServlet), pathSpec); - } - } + logger.info("-----------------------------------------------------"); + awaitWebAppContextsStarted(handlers); + } + + private List loadConfiguredJettyObjects( + final Path jettyConfig, + final Map configProperties) throws Exception { + final List configFiles = getEnabledConfigFiles(jettyConfig); + final List configuredObjects = new ArrayList<>(); + XmlConfiguration last = null; + for (final Path confFile : configFiles) { + logger.info("[loading jetty configuration : {}]", confFile.toString()); + final Resource resource = ResourceFactory.root().newResource(confFile); + final XmlConfiguration configuration = new XmlConfiguration(resource); + if (last != null) { + configuration.getIdMap().putAll(last.getIdMap()); } + configuration.getProperties().putAll(configProperties); + configuredObjects.add(configuration.configure()); + last = configuration; + } + return configuredObjects; + } - logger.info("-----------------------------------------------------"); + private void updatePrimaryPortFromConnectors(final Server server) { + for (final Connector connector : server.getConnectors()) { + if (connector instanceof NetworkConnector networkConnector) { + this.primaryPort = networkConnector.getLocalPort(); + return; + } + } + } - awaitWebAppContextsStarted(getAllHandlers(server.getHandler())); - webAppStartedSuccessfully = true; - webAppStartupFailureDetail = null; + private void logServerStarted(final Server server) { + final List serverUris = getSeverURIs(server); + if (!serverUris.isEmpty()) { + this.primaryPort = serverUris.getFirst().getPort(); + } - notifyJettyStartListeners(SIGNAL_STARTED); + logger.info("-----------------------------------------------------"); + logger.info("Server has started, listening on:"); + for (final URI serverUri : serverUris) { + logger.info("{}", serverUri.resolve("/")); + } - } catch (final SocketException e) { - recordStartupFailure("Could not bind to port: " + e.getMessage(), e); - notifyJettyStartListeners(SIGNAL_ERROR); + logger.info("Configured contexts:"); + for (final Handler handler : getAllHandlers(server.getHandler())) { + if (handler instanceof ContextHandler contextHandler) { + logger.info("{} ({})", contextHandler.getContextPath(), contextHandler.getDisplayName()); + } + } + } - } catch (final Exception e) { - if (webAppStartupFailureDetail == null) { - recordStartupFailure( - e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(), e); - } else { - recordStartupFailure(webAppStartupFailureDetail, e); + private void registerExtensionServlets(final List handlers) { + for (final Handler handler : handlers) { + if (handler instanceof ServletContextHandler contextHandler) { + final ServiceLoader services = ServiceLoader.load(ExistExtensionServlet.class); + for (final ExistExtensionServlet existExtensionServlet : services) { + final String pathSpec = existExtensionServlet.getPathSpec(); + final String contextPath = contextHandler.getContextPath(); + final String normalizedPath = "/".equals(contextPath) + ? pathSpec + : contextPath + pathSpec; + + logger.info("{} ({})", normalizedPath, existExtensionServlet.getServletInfo()); + contextHandler.addServlet(new ServletHolder(existExtensionServlet), pathSpec); + } } - notifyJettyStartListeners(SIGNAL_ERROR); } } @@ -563,90 +571,135 @@ private Optional startJetty(final List configuredObjects) throws * non-gating. */ private void awaitWebAppContextsStarted(final List handlers) throws InterruptedException { + final List webApps = collectWebAppContexts(handlers); + if (webApps.isEmpty()) { + return; + } + + new WebAppReadinessAwaiter(webApps, isDistributionLayout(webApps)) + .await(slowEnvironmentStartupDeadlineMs()); + } + + private static List collectWebAppContexts(final List handlers) { final List webApps = new ArrayList<>(); for (final Handler handler : handlers) { if (handler instanceof WebAppContext webApp) { webApps.add(webApp); } } - if (webApps.isEmpty()) { - return; - } + return webApps; + } - final boolean distributionLayout = isDistributionLayout(webApps); - final long timeoutMs = slowEnvironmentStartupDeadlineMs(); - final CountDownLatch readyLatch = new CountDownLatch(1); - final AtomicReference failure = new AtomicReference<>(); + /** + * Polls {@link WebAppContext} lifecycle events until all required contexts are ready or a failure occurs. + */ + private static final class WebAppReadinessAwaiter implements LifeCycle.Listener { - final LifeCycle.Listener readinessListener = new LifeCycle.Listener() { - @Override - public void lifeCycleStarted(final LifeCycle event) { - evaluateReadiness(); - } + private final List webApps; + private final boolean distributionLayout; + private final CountDownLatch readyLatch = new CountDownLatch(1); + private final AtomicReference failure = new AtomicReference<>(); - @Override - public void lifeCycleFailure(final LifeCycle event, final Throwable cause) { - if (event instanceof WebAppContext webApp) { - failure.compareAndSet(null, new IllegalStateException( - "Web application failed to start: " + webApp.getContextPath(), cause)); - } else { - failure.compareAndSet(null, new IllegalStateException("Web application failed to start", cause)); - } - readyLatch.countDown(); - } + WebAppReadinessAwaiter(final List webApps, final boolean distributionLayout) { + this.webApps = webApps; + this.distributionLayout = distributionLayout; + } - private void evaluateReadiness() { + void await(final long timeoutMs) throws InterruptedException { + for (final WebAppContext webApp : webApps) { + webApp.addEventListener(this); + } + try { + awaitReadyOrThrow(timeoutMs); + } finally { for (final WebAppContext webApp : webApps) { - if (webApp.isFailed()) { - failure.compareAndSet(null, new IllegalStateException( - "Web application failed to start: " + webApp.getContextPath())); - readyLatch.countDown(); - return; - } - } - if (allWebAppsReady(webApps, distributionLayout)) { - readyLatch.countDown(); + webApp.removeEventListener(this); } } - }; - - for (final WebAppContext webApp : webApps) { - webApp.addEventListener(readinessListener); } - try { + private void awaitReadyOrThrow(final long timeoutMs) throws InterruptedException { if (allWebAppsReady(webApps, distributionLayout)) { logger.info("All required web application contexts are ready."); return; } - for (final WebAppContext webApp : webApps) { - if (webApp.isFailed()) { - throw new IllegalStateException( - "Web application failed to start: " + webApp.getContextPath()); - } - } + throwIfAnyWebAppFailed(); if (!readyLatch.await(timeoutMs, TimeUnit.MILLISECONDS)) { - throw new IllegalStateException( - "Web application context did not become ready within " + timeoutMs + "ms: " - + describePendingWebApps(webApps, distributionLayout), - firstUnavailableCause(webApps)); + throw readinessTimeoutException(timeoutMs); } final IllegalStateException startupFailure = failure.get(); if (startupFailure != null) { throw startupFailure; } if (!allWebAppsReady(webApps, distributionLayout)) { - throw new IllegalStateException( - "Web application context did not become ready: " - + describePendingWebApps(webApps, distributionLayout), - firstUnavailableCause(webApps)); + throw readinessIncompleteException(); } logger.info("All required web application contexts are ready."); - } finally { + } + + private void throwIfAnyWebAppFailed() { for (final WebAppContext webApp : webApps) { - webApp.removeEventListener(readinessListener); + if (webApp.isFailed()) { + throw new IllegalStateException( + "Web application failed to start: " + webApp.getContextPath()); + } } } + + private IllegalStateException readinessTimeoutException(final long timeoutMs) { + return new IllegalStateException( + "Web application context did not become ready within " + timeoutMs + "ms: " + + describePendingWebApps(webApps, distributionLayout), + firstUnavailableCause(webApps)); + } + + private IllegalStateException readinessIncompleteException() { + return new IllegalStateException( + "Web application context did not become ready: " + + describePendingWebApps(webApps, distributionLayout), + firstUnavailableCause(webApps)); + } + + @Override + public void lifeCycleStarted(final LifeCycle event) { + evaluateReadiness(); + } + + @Override + public void lifeCycleFailure(final LifeCycle event, final Throwable cause) { + recordLifecycleFailure(event, cause); + readyLatch.countDown(); + } + + private void recordLifecycleFailure(final LifeCycle event, final Throwable cause) { + if (event instanceof WebAppContext webApp) { + failure.compareAndSet(null, new IllegalStateException( + "Web application failed to start: " + webApp.getContextPath(), cause)); + } else { + failure.compareAndSet(null, new IllegalStateException("Web application failed to start", cause)); + } + } + + private void evaluateReadiness() { + if (recordFirstFailedWebApp()) { + return; + } + if (allWebAppsReady(webApps, distributionLayout)) { + readyLatch.countDown(); + } + } + + private boolean recordFirstFailedWebApp() { + for (final WebAppContext webApp : webApps) { + if (webApp.isFailed()) { + failure.compareAndSet(null, new IllegalStateException( + "Web application failed to start: " + webApp.getContextPath())); + readyLatch.countDown(); + return true; + } + } + return false; + } } private static boolean allWebAppsReady(final List webApps, final boolean distributionLayout) { From 2239330d7050f05010aff2db553791648c1dd58f Mon Sep 17 00:00:00 2001 From: duncdrum Date: Mon, 25 May 2026 16:35:58 +0200 Subject: [PATCH 11/11] [test] Add distribution portal coverage and PR review follow-ups Add PortalRedirectTest for GET / in distribution layout. Deploy the portal with org.exist.jetty.WebAppContext so Windows PathResource wrapping applies, and skip BrokerPool.stopAll when that static-only context stops. Document CHAIN_PRIORITY spacing, WindowsPathResource instanceof audit, and LEGACY_TOKEN_SEPARATOR removal tied to HC4 Phase 5 in PR #6393. --- .../java/org/exist/indexing/IndexWorker.java | 3 + .../java/org/exist/jetty/WebAppContext.java | 25 +++++++- .../org/exist/jetty/WindowsPathResource.java | 6 +- .../org/exist/http/PortalRedirectTest.java | 63 +++++++++++++++++++ .../org/exist/jetty/etc/jetty-deploy.xml | 2 +- .../etc/webapps/portal/WEB-INF/jetty-web.xml | 2 +- .../persistentlogin/PersistentLogin.java | 9 ++- 7 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 exist-core/src/test/java/org/exist/http/PortalRedirectTest.java diff --git a/exist-core/src/main/java/org/exist/indexing/IndexWorker.java b/exist-core/src/main/java/org/exist/indexing/IndexWorker.java index 43674147b88..3941c4c6bb8 100644 --- a/exist-core/src/main/java/org/exist/indexing/IndexWorker.java +++ b/exist-core/src/main/java/org/exist/indexing/IndexWorker.java @@ -55,6 +55,9 @@ public interface IndexWorker { /** * Lower values run earlier in {@link IndexController} listener chains and {@link #flush()}. + * Gaps reserve room for future workers: {@code 0} structural, {@code 1–99} pre-statistics, + * {@code 100–999} pre-Lucene, {@code 1000+} Lucene and later; unranked workers use + * {@link Integer#MAX_VALUE}. */ int CHAIN_PRIORITY_STRUCTURAL = 0; int CHAIN_PRIORITY_STATISTICS = 100; diff --git a/exist-core/src/main/java/org/exist/jetty/WebAppContext.java b/exist-core/src/main/java/org/exist/jetty/WebAppContext.java index 3774527addb..b5deecd5067 100644 --- a/exist-core/src/main/java/org/exist/jetty/WebAppContext.java +++ b/exist-core/src/main/java/org/exist/jetty/WebAppContext.java @@ -27,7 +27,8 @@ /** * eXist {@link org.eclipse.jetty.ee10.webapp.WebAppContext} with Windows path handling for - * Jetty 12.1 ({@link WindowsPathResource}). + * Jetty 12.1 ({@link WindowsPathResource}). Used for the main webapp ({@code /exist} or + * standalone {@code /}) and the distribution portal at {@code /}. * * @author Dmitriy Shabanov */ @@ -52,6 +53,26 @@ public Resource newResource(final String urlOrPath) { protected void doStop() throws Exception { super.doStop(); - BrokerPool.stopAll(true); + if (ownsBrokerPoolLifecycle()) { + BrokerPool.stopAll(true); + } + } + + /** + * Main eXist webapps stop the embedded database; the distribution portal at {@code /} is + * static-only and must not tear down {@link BrokerPool} when Jetty stops it alongside {@code /exist}. + */ + private boolean ownsBrokerPoolLifecycle() { + if ("/exist".equals(getContextPath())) { + return true; + } + if (!"/".equals(getContextPath())) { + return false; + } + final Resource baseResource = getBaseResource(); + if (baseResource == null) { + return true; + } + return !baseResource.toString().replace('\\', '/').contains("/webapps/portal"); } } diff --git a/exist-core/src/main/java/org/exist/jetty/WindowsPathResource.java b/exist-core/src/main/java/org/exist/jetty/WindowsPathResource.java index 19ab4dc1884..bc6f1891042 100644 --- a/exist-core/src/main/java/org/exist/jetty/WindowsPathResource.java +++ b/exist-core/src/main/java/org/exist/jetty/WindowsPathResource.java @@ -42,8 +42,10 @@ * {@code WebAppContext.getWebInf()}. Remove when upstream Jetty restores safe Windows resolve. *

    * This class extends {@link Resource}, not {@link PathResource}. Jetty internals that use - * {@code instanceof PathResource} will not treat wrapped resources as path resources; CI and - * integration tests validate that the exploded-webapp startup path does not depend on that. + * {@code instanceof PathResource} will not treat wrapped resources as path resources. eXist + * production code has no {@code instanceof PathResource} checks on resources that may be wrapped; + * CI and integration tests validate that the exploded-webapp startup path does not depend on + * Jetty-internal type checks either. *

    * Only {@link #resolve(String)} differs from Jetty 12.1 {@link PathResource} behaviour. Other * {@link Resource} methods delegate to the wrapped resource or rely on inherited defaults that diff --git a/exist-core/src/test/java/org/exist/http/PortalRedirectTest.java b/exist-core/src/test/java/org/exist/http/PortalRedirectTest.java new file mode 100644 index 00000000000..a54963dcde9 --- /dev/null +++ b/exist-core/src/test/java/org/exist/http/PortalRedirectTest.java @@ -0,0 +1,63 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.http; + +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.client.fluent.Request; +import org.apache.http.util.EntityUtils; +import org.exist.test.ExistWebServer; +import org.junit.ClassRule; +import org.junit.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Distribution-mode portal at {@code /} — landing page and redirect target to {@code /exist}. + */ +public class PortalRedirectTest extends AbstractHttpTest { + + @ClassRule + public static final ExistWebServer existWebServer = new ExistWebServer(true, false, true, true, false); + + @Test + public void portalRootServesLandingPageWithExistRedirect() throws IOException { + final Request request = Request.Get(portalUri(existWebServer)); + final HttpResponse response = withHttpExecutor(existWebServer, + executor -> executor.execute(request).returnResponse()); + + assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); + + final String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + assertTrue("Expected portal title", body.contains("Open Source Native XML Database")); + assertTrue("Expected JS redirect to /exist", body.contains("window.location.replace(\"/exist\")")); + assertTrue("Expected noscript fallback link to /exist", body.contains("href=\"/exist\"")); + } + + private static String portalUri(final ExistWebServer existWebServer) { + return "http://localhost:" + existWebServer.getPort() + "/"; + } +} diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-deploy.xml b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-deploy.xml index b26d5157228..a922e26bbac 100644 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-deploy.xml +++ b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-deploy.xml @@ -38,7 +38,7 @@ - + / /../../../exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/portal/ /etc/webdefault.xml diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/portal/WEB-INF/jetty-web.xml b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/portal/WEB-INF/jetty-web.xml index dd4688fe4f6..d96335a31f4 100755 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/portal/WEB-INF/jetty-web.xml +++ b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/portal/WEB-INF/jetty-web.xml @@ -1,6 +1,6 @@ - + / /etc/webdefault.xml diff --git a/extensions/modules/persistentlogin/src/main/java/org/exist/xquery/modules/persistentlogin/PersistentLogin.java b/extensions/modules/persistentlogin/src/main/java/org/exist/xquery/modules/persistentlogin/PersistentLogin.java index 4286aaf3246..c95ea0505c4 100644 --- a/extensions/modules/persistentlogin/src/main/java/org/exist/xquery/modules/persistentlogin/PersistentLogin.java +++ b/extensions/modules/persistentlogin/src/main/java/org/exist/xquery/modules/persistentlogin/PersistentLogin.java @@ -66,7 +66,14 @@ public class PersistentLogin { */ private static final String TOKEN_SEPARATOR = "|"; - /** Historical separator; still accepted when parsing incoming cookies. */ + /** + * Historical separator; still accepted when parsing incoming cookies issued before the + * {@link #TOKEN_SEPARATOR} switch ({@code :} breaks HC4 Set-Cookie parsing — see + * PR #6393). Remove + * {@link #splitTokenValue} dual parsing once HC4 is fully removed from the codebase + * (Phase 5 in #6393, after expath Gate B). In-flight browser cookies are not a concern: + * a new eXist release install expects users to reauthenticate anyway. + */ private static final String LEGACY_TOKEN_SEPARATOR = ":"; private Map seriesMap = Collections.synchronizedMap(new HashMap<>());