diff --git a/server/src/main/java/com/mirth/connect/connectors/ws/WebServiceReceiver.java b/server/src/main/java/com/mirth/connect/connectors/ws/WebServiceReceiver.java index 0443a8b70d..f2c42843a7 100644 --- a/server/src/main/java/com/mirth/connect/connectors/ws/WebServiceReceiver.java +++ b/server/src/main/java/com/mirth/connect/connectors/ws/WebServiceReceiver.java @@ -21,6 +21,7 @@ import java.util.Map.Entry; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; import javax.servlet.http.HttpServletResponse; import javax.xml.ws.Binding; @@ -163,7 +164,7 @@ public void onStart() throws ConnectorTaskException { } // Allow more than the channel processing threads so WDSL requests can be accepted even if all processing threads are busy - executor = Executors.newFixedThreadPool(processingThreads + 4); + executor = createExecutor(processingThreads + 4, WebServiceReceiver.class.getClassLoader()); server.setExecutor(executor); server.start(); @@ -229,6 +230,22 @@ public void onStart() throws ConnectorTaskException { eventController.dispatchEvent(new ConnectionStatusEvent(getChannelId(), getMetaDataId(), getSourceName(), ConnectionStatusEventType.IDLE)); } + /* + * The HttpServer creates request worker threads from its dispatcher thread, so they inherit the + * dispatcher's context classloader. Since JDK 19 that is the system classloader, which cannot + * see server-lib, and lookups through the context classloader (e.g. the SAAJ implementation) + * fail. Give the worker threads the given classloader explicitly instead. + */ + static ExecutorService createExecutor(int threads, ClassLoader contextClassLoader) { + ThreadFactory defaultThreadFactory = Executors.defaultThreadFactory(); + + return Executors.newFixedThreadPool(threads, runnable -> { + Thread thread = defaultThreadFactory.newThread(runnable); + thread.setContextClassLoader(contextClassLoader); + return thread; + }); + } + @Override public void onStop() throws ConnectorTaskException { ConnectorTaskException firstCause = null; diff --git a/server/src/test/java/com/mirth/connect/connectors/ws/WebServiceReceiverTest.java b/server/src/test/java/com/mirth/connect/connectors/ws/WebServiceReceiverTest.java new file mode 100644 index 0000000000..22ea617bca --- /dev/null +++ b/server/src/test/java/com/mirth/connect/connectors/ws/WebServiceReceiverTest.java @@ -0,0 +1,46 @@ +package com.mirth.connect.connectors.ws; + +import static org.junit.Assert.assertSame; + +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; + +import com.sun.net.httpserver.HttpServer; + +public class WebServiceReceiverTest { + + @Test + public void testWorkerThreadsUseGivenContextClassLoader() throws Exception { + ClassLoader expected = new URLClassLoader(new URL[0], getClass().getClassLoader()); + CompletableFuture actual = new CompletableFuture<>(); + + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + ExecutorService executor = WebServiceReceiver.createExecutor(1, expected); + server.setExecutor(executor); + server.createContext("/", exchange -> { + actual.complete(Thread.currentThread().getContextClassLoader()); + exchange.sendResponseHeaders(204, -1); + exchange.close(); + }); + server.start(); + + try { + HttpURLConnection connection = (HttpURLConnection) URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/").toURL().openConnection(); + connection.getResponseCode(); + connection.disconnect(); + + assertSame(expected, actual.get(10, TimeUnit.SECONDS)); + } finally { + server.stop(0); + executor.shutdown(); + } + } +} diff --git a/smoketest/src/test/java/org/openintegrationengine/smoketest/WebServiceListenerTest.java b/smoketest/src/test/java/org/openintegrationengine/smoketest/WebServiceListenerTest.java new file mode 100644 index 0000000000..b2a1b9f84b --- /dev/null +++ b/smoketest/src/test/java/org/openintegrationengine/smoketest/WebServiceListenerTest.java @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: Open Integration Engine + +package org.openintegrationengine.smoketest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Sends a SOAP request to a deployed Web Service Listener. Messages submitted through the client + * API never reach the listener's HTTP worker threads, which is where SAAJ is resolved, so the + * fixture tests cannot cover this. + */ +@DisplayName("Web Service Listener") +class WebServiceListenerTest { + + /** The listener port and service name in web-service-listener/channel.xml. */ + private static final int PORT = 8081; + private static final String SERVICE = "Mirth"; + + private static final String ENVELOPE = """ + + + + smoke test + + + + """; + + private static String channelId; + + @BeforeAll + static void deploy() throws Exception { + channelId = Harness.deploy("web-service-listener/channel.xml"); + } + + @AfterAll + static void undeploy() { + Harness.undeploy(channelId); + } + + /** + * On JDK 19 and later the listener's worker threads lost the server classloader, so every + * request failed with "Unable to create SAAJ meta-factory" while the WSDL still served fine. + */ + @Test + @DisplayName("accepts a SOAP request") + void acceptsSoapRequest() throws Exception { + URI endpoint = new URI("http", null, URI.create(HarnessConfig.BASE_URL).getHost(), PORT, + "/services/" + SERVICE, null, null); + + HttpRequest request = HttpRequest.newBuilder(endpoint) + .timeout(Duration.ofMillis(HarnessConfig.REQUEST_TIMEOUT_MILLIS)) + .header("Content-Type", "text/xml; charset=utf-8") + .header("SOAPAction", "\"\"") + .POST(HttpRequest.BodyPublishers.ofString(ENVELOPE)) + .build(); + + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, response.statusCode(), () -> "status of POST " + endpoint + ": " + response.body()); + assertTrue(response.body().contains("acceptMessageResponse"), () -> "response body: " + response.body()); + } +} diff --git a/smoketest/src/test/resources/web-service-listener/channel.xml b/smoketest/src/test/resources/web-service-listener/channel.xml new file mode 100644 index 0000000000..8570a13638 --- /dev/null +++ b/smoketest/src/test/resources/web-service-listener/channel.xml @@ -0,0 +1,191 @@ + + f7ed972e-6845-45bf-92bc-0302d6b05204 + 2 + Web Service Listener + + 1 + + 0 + sourceConnector + + + + 0.0.0.0 + 8081 + + + None + true + false + false + 1 + + + Default Resource + [Default Resource] + + + 1000 + + com.mirth.connect.connectors.ws.DefaultAcceptMessage + Mirth + DEFAULT + + + + RAW + RAW + + + JavaScript + + + + + + JavaScript + + + + + + + + Web Service Listener + SOURCE + true + true + + + + 1 + Destination 1 + + + + false + false + 10000 + false + 0 + false + false + 1 + + false + + + Default Resource + [Default Resource] + + + 1000 + true + + none + ${message.encodedData} + + + + + RAW + RAW + + + JavaScript + + + + + + JavaScript + + + + + + + RAW + RAW + + + JavaScript + + + + + + JavaScript + + + + + + + + Channel Writer + DESTINATION + true + true + + + // Modify the message variable below to pre process data +return message; + // This script executes once after a message has been processed +// Responses returned from here will be stored as "Postprocessor" in the response map +return; + // This script executes once when the channel is deployed +// You only have access to the globalMap and globalChannelMap here to persist data +return; + // This script executes once when the channel is undeployed +// You only have access to the globalMap and globalChannelMap here to persist data +return; + + true + DEVELOPMENT + false + false + false + false + false + false + STARTED + true + + + SOURCE + STRING + mirth_source + + + TYPE + STRING + mirth_type + + + + None + + + + + Default Resource + [Default Resource] + + + + + + true + + + America/Chicago + + + true + false + + 1 + + + \ No newline at end of file