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/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/main/java/org/exist/indexing/IndexController.java b/exist-core/src/main/java/org/exist/indexing/IndexController.java
index 488446c4405..7eab1310d64 100644
--- a/exist-core/src/main/java/org/exist/indexing/IndexController.java
+++ b/exist-core/src/main/java/org/exist/indexing/IndexController.java
@@ -40,7 +40,9 @@
import org.w3c.dom.NodeList;
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 org.exist.security.PermissionDeniedException;
@@ -56,7 +58,14 @@ public enum CollectionIndexRemovalMode {
CONFIG_ONLY_REINDEX
}
- private final Map indexWorkers = new HashMap<>();
+ /**
+ * Stable iteration order for listener chains and {@link #flush()}.
+ */
+ private static final Comparator INDEX_WORKER_ORDER = Comparator
+ .comparingInt(IndexWorker::getChainPriority)
+ .thenComparing(IndexWorker::getIndexId);
+
+ private final Map indexWorkers = new LinkedHashMap<>();
private final DBBroker broker;
private StreamListener listener = null;
@@ -68,9 +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);
- }
+ 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..3941c4c6bb8 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,24 @@ public interface IndexWorker {
*/
public static final String VALUE_COUNT = "value_count";
+ /**
+ * 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;
+ 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 a7cad260f66..0e337cbf734 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;
@@ -56,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;
@@ -68,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);
@@ -97,6 +106,10 @@ 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;
+
+ private final CopyOnWriteArrayList jettyStartListeners = new CopyOnWriteArrayList<>();
public static void main(final String[] args) {
@@ -132,6 +145,16 @@ private static void consoleOut(final String msg) {
System.out.println(msg); //NOSONAR this has to go to the console
}
+ private synchronized void recordStartupFailure(final String detail, final Throwable cause) {
+ webAppStartedSuccessfully = false;
+ webAppStartupFailureDetail = detail;
+ if (cause != null) {
+ logger.fatal("Jetty startup failed: {}", detail, cause);
+ } else {
+ logger.fatal("Jetty startup failed: {}", detail);
+ }
+ }
+
public synchronized void run() {
run(true);
}
@@ -155,210 +178,243 @@ 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;
}
- 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 (observer != null) {
- addObserver(observer);
- }
+ 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));
+ }
- BrokerPool.configure(1, 5, config, Optional.ofNullable(observer));
+ 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) {
- logger.error("configuration error: {}", e.getMessage(), e);
- e.printStackTrace();
- return;
+ final URL jettyConfigUrl = getClass().getResource("etc/" + jettyConfigFileName);
+ if (jettyConfigUrl == null) {
+ logger.error("Unable to find configuration file on classpath!");
+ return Optional.empty();
}
try {
- // 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;
- }
-
- // configure WebSocket on any ServletContextHandler
- configureWebSocket(configuredObjects);
-
- // 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();
- }
+ 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();
+ }
+ }
- final Server server = maybeServer.get();
+ 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());
+
+ 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 Connector[] connectors = server.getConnectors();
+ if (listener != null) {
+ addJettyStartListener(listener);
+ }
- // Construct description of all ports opened.
- final StringBuilder allPorts = new StringBuilder();
+ logStartupEnvironment(configProperties, jettyConfig);
- if (connectors.length > 1) {
- // plural s
- allPorts.append("s");
- }
+ 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(""));
- boolean establishedPrimaryPort = false;
- for(final Connector connector : connectors) {
- if(connector instanceof NetworkConnector networkConnector) {
+ final Optional brokerPoolObserver = listener instanceof Observer observer
+ ? Optional.of(observer)
+ : Optional.empty();
+ BrokerPool.configure(1, 5, config, brokerPoolObserver);
- if(!establishedPrimaryPort) {
- this.primaryPort = networkConnector.getLocalPort();
- establishedPrimaryPort = true;
- }
+ final Database xmldb = new DatabaseImpl();
+ xmldb.setProperty("create-database", "false");
+ DatabaseManager.registerDatabase(xmldb);
- allPorts.append(" ");
- allPorts.append(networkConnector.getLocalPort());
- }
- }
+ return configProperties;
+ }
- //*************************************************************
- final List serverUris = getSeverURIs(server);
- if(!serverUris.isEmpty()) {
- this.primaryPort = serverUris.getFirst().getPort();
+ 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)"));
+
+ 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());
+ }
- }
- logger.info("-----------------------------------------------------");
- logger.info("Server has started, listening on:");
- for(final URI serverUri : serverUris) {
- logger.info("{}", serverUri.resolve("/"));
- }
+ private void launchJettyServer(final Path jettyConfig, final Map configProperties) throws Exception {
+ webAppStartupFailureDetail = null;
+ webAppStartedSuccessfully = false;
- logger.info("Configured contexts:");
- final List handlers = getAllHandlers(server.getHandler());
- for (final Handler handler: handlers) {
+ final List configuredObjects = loadConfiguredJettyObjects(jettyConfig, configProperties);
+ configureWebSocket(configuredObjects);
- if (handler instanceof ContextHandler contextHandler) {
- logger.info("{} ({})", contextHandler.getContextPath(), contextHandler.getDisplayName());
- }
+ final Server server = startJetty(configuredObjects)
+ .orElseThrow(() -> {
+ logger.error("Unable to find a server to start in jetty configurations");
+ return new IllegalStateException();
+ });
- if (handler instanceof ServletContextHandler contextHandler) {
- final ServiceLoader services = ServiceLoader.load(ExistExtensionServlet.class);
+ updatePrimaryPortFromConnectors(server);
+ logServerStarted(server);
- for (ExistExtensionServlet existExtensionServlet : services) {
- final String pathSpec = existExtensionServlet.getPathSpec();
- final String contextPath = contextHandler.getContextPath();
+ final List handlers = getAllHandlers(server.getHandler());
+ registerExtensionServlets(handlers);
- // Avoid "//" as logged prefix
- final String normalizedPath = "/".equals(contextPath)
- ? pathSpec
- : contextPath + pathSpec;
+ logger.info("-----------------------------------------------------");
+ awaitWebAppContextsStarted(handlers);
+ }
- logger.info("{} ({})", normalizedPath, existExtensionServlet.getServletInfo());
+ 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;
+ }
- // Register servlet
- contextHandler.addServlet(new ServletHolder(existExtensionServlet), pathSpec);
- }
- }
+ private void updatePrimaryPortFromConnectors(final Server server) {
+ for (final Connector connector : server.getConnectors()) {
+ if (connector instanceof NetworkConnector networkConnector) {
+ this.primaryPort = networkConnector.getLocalPort();
+ return;
}
+ }
+ }
- logger.info("-----------------------------------------------------");
+ private void logServerStarted(final Server server) {
+ final List serverUris = getSeverURIs(server);
+ if (!serverUris.isEmpty()) {
+ this.primaryPort = serverUris.getFirst().getPort();
+ }
- setChanged();
- notifyObservers(SIGNAL_STARTED);
+ logger.info("-----------------------------------------------------");
+ logger.info("Server has started, listening on:");
+ for (final URI serverUri : serverUris) {
+ logger.info("{}", serverUri.resolve("/"));
+ }
- } catch (final SocketException e) {
- logger.error("----------------------------------------------------------");
- logger.error("ERROR: Could not bind to port because {}", e.getMessage());
- logger.error(e.toString());
- logger.error("----------------------------------------------------------");
- setChanged();
- notifyObservers(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) {
- logger.fatal("An unexpected error occurred, web server can not be started: {}", e.getMessage(), e);
- setChanged();
- notifyObservers(SIGNAL_ERROR);
+ 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);
+ }
+ }
}
}
@@ -506,6 +562,236 @@ 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 {@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 {@link #EXIST_CONTEXT_PATH} and is
+ * 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);
+ }
+ }
+ return webApps;
+ }
+
+ /**
+ * Polls {@link WebAppContext} lifecycle events until all required contexts are ready or a failure occurs.
+ */
+ private static final class WebAppReadinessAwaiter implements LifeCycle.Listener {
+
+ private final List webApps;
+ private final boolean distributionLayout;
+ private final CountDownLatch readyLatch = new CountDownLatch(1);
+ private final AtomicReference failure = new AtomicReference<>();
+
+ WebAppReadinessAwaiter(final List webApps, final boolean distributionLayout) {
+ this.webApps = webApps;
+ this.distributionLayout = distributionLayout;
+ }
+
+ void await(final long timeoutMs) throws InterruptedException {
+ for (final WebAppContext webApp : webApps) {
+ webApp.addEventListener(this);
+ }
+ try {
+ awaitReadyOrThrow(timeoutMs);
+ } finally {
+ for (final WebAppContext webApp : webApps) {
+ webApp.removeEventListener(this);
+ }
+ }
+ }
+
+ private void awaitReadyOrThrow(final long timeoutMs) throws InterruptedException {
+ if (allWebAppsReady(webApps, distributionLayout)) {
+ logger.info("All required web application contexts are ready.");
+ return;
+ }
+ throwIfAnyWebAppFailed();
+ if (!readyLatch.await(timeoutMs, TimeUnit.MILLISECONDS)) {
+ throw readinessTimeoutException(timeoutMs);
+ }
+ final IllegalStateException startupFailure = failure.get();
+ if (startupFailure != null) {
+ throw startupFailure;
+ }
+ if (!allWebAppsReady(webApps, distributionLayout)) {
+ throw readinessIncompleteException();
+ }
+ logger.info("All required web application contexts are ready.");
+ }
+
+ private void throwIfAnyWebAppFailed() {
+ for (final WebAppContext webApp : webApps) {
+ 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) {
+ for (final WebAppContext webApp : webApps) {
+ if (!isWebAppContextReady(webApp, distributionLayout)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ 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_CONTEXT_PATH.equals(webApp.getContextPath()));
+ }
+
+ /**
+ * 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 && PORTAL_CONTEXT_PATH.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 && 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;
+ }
+ return 60_000L;
+ }
+
private Map getConfigProperties(final Path configDir) throws IOException {
final Map configProperties = new HashMap<>();
@@ -656,8 +942,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();
}
@@ -665,8 +950,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();
}
@@ -695,4 +979,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 (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/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/WebAppContext.java b/exist-core/src/main/java/org/exist/jetty/WebAppContext.java
index 660bd0cb545..b5deecd5067 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,58 @@
*/
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}). Used for the main webapp ({@code /exist} or
+ * standalone {@code /}) and the distribution portal at {@code /}.
*
+ * @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
+ public void setBaseResource(final Resource baseResource) {
+ super.setBaseResource(WindowsPathResource.wrapIfNeeded(baseResource, ResourceFactory.of(this)));
+ }
+
+ @Override
+ public Resource newResource(final String urlOrPath) {
+ return WindowsPathResource.wrapIfNeeded(super.newResource(urlOrPath), ResourceFactory.of(this));
+ }
@Override
- protected void doStop() throws Exception {
- super.doStop();
+ 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
new file mode 100644
index 00000000000..bc6f1891042
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/jetty/WindowsPathResource.java
@@ -0,0 +1,148 @@
+/*
+ * 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 org.exist.util.OSUtil;
+
+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.
+ *
+ * This class extends {@link Resource}, not {@link PathResource}. Jetty internals that use
+ * {@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
+ * route through {@link #getPath()}, {@link #getURI()}, {@link #iterator()}, and {@link #resolve(String)}.
+ */
+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 || !OSUtil.isWindows()) {
+ return resource;
+ }
+ if (resource instanceof WindowsPathResource) {
+ return resource;
+ }
+ if (!(resource instanceof PathResource)) {
+ 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 Resource other)) {
+ return false;
+ }
+ if (other instanceof WindowsPathResource wrapped) {
+ other = wrapped.delegate;
+ }
+ return delegate.equals(other);
+ }
+
+ @Override
+ public int hashCode() {
+ return delegate.hashCode();
+ }
+
+ @Override
+ public String toString() {
+ return delegate.toString();
+ }
+}
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 da058038fc0..747ba276385 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,34 @@
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}
+ * ({@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 {
@@ -69,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() {
@@ -123,16 +218,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 +232,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 +254,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 +274,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/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/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 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-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/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);
+ }
+ }
}
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/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/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..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
@@ -14,6 +14,7 @@
/exist
+ true
/../../../webapp/
/etc/webdefault.xml
@@ -37,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/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
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/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
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/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;
}
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/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 64c24b74689..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
@@ -48,24 +48,42 @@
*/
public class PersistentLogin {
- private final static PersistentLogin instance = new PersistentLogin();
-
- public static PersistentLogin getInstance() {
- return instance;
- }
-
private final static Logger LOG = LogManager.getLogger(PersistentLogin.class);
+ private final static PersistentLogin instance = new PersistentLogin();
+
public final static int DEFAULT_SERIES_LENGTH = 16;
public final static int DEFAULT_TOKEN_LENGTH = 16;
public final static int INVALIDATION_TIMEOUT = 20000;
+ /**
+ * Separator between series and token in newly issued cookie values.
+ * Must not appear in base64 output ({@code A-Za-z0-9+/=}).
+ * {@code :} was used historically but breaks Apache HttpClient's Set-Cookie parser
+ * (and some other clients) because unquoted cookie values containing {@code :} are rejected.
+ */
+ private static final String TOKEN_SEPARATOR = "|";
+
+ /**
+ * 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<>());
private SecureRandom random;
+ public static PersistentLogin getInstance() {
+ return instance;
+ }
+
public PersistentLogin() {
random = new SecureRandom();
}
@@ -100,7 +118,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 +157,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 +265,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 2a47297b45a..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,42 +68,35 @@ public class LoginModuleIT {
private final static String XQUERY_FILENAME = "test-login.xql";
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);
- }
- }
- }
+ private static CloseableHttpClient client;
+ private static BasicCookieStore cookieStore;
+ private static HttpClientContext httpContext;
@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");
@@ -109,12 +105,23 @@ public static void beforeClass() throws XMLDBException, InterruptedException {
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) {
@@ -141,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/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..824e654f5c0
--- /dev/null
+++ b/extensions/webdav/src/test/java/org/exist/jetty/WindowsPathResourceIT.java
@@ -0,0 +1,78 @@
+/*
+ * 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.exist.util.OSUtil;
+import org.junit.Test;
+
+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}).
+ */
+@SuppressWarnings("PMD.ClassNamingConventions") // Failsafe *IT suffix; not a JUnit *Test class
+public class WindowsPathResourceIT {
+
+ @Test
+ public void resolveWebInfOnWindowsDriveUri() throws Exception {
+ assumeTrue("Windows-only PathResource URI regression", OSUtil.isWindows());
+
+ 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 @@
+