From c9cfd2f3406bbde16949f66342b8f8aaf44e44ed Mon Sep 17 00:00:00 2001 From: Jon Bartels Date: Fri, 23 Oct 2026 08:26:46 -0400 Subject: [PATCH 1/2] Give Web Service Listener worker threads the server classloader On JDK 19 and later every SOAP request to a Web Service Listener fails with "Unable to create SAAJ meta-factory". The HttpServer creates its worker threads from its dispatcher thread, which since JDK 19 gets the system classloader instead of inheriting one. The workers inherit that, cannot see server-lib, and SAAJ's context-classloader lookup misses the implementation. GET ?wsdl keeps working because it never touches SAAJ. Build the worker pool with a ThreadFactory that sets the classloader that loaded WebServiceReceiver, which is what the workers inherited on JDK 18 and earlier. No -Xbootclasspath workaround is needed. Reported against the BridgeLink fork: https://github.com/Innovar-Healthcare/BridgeLink/issues/205 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jon Bartels --- .../connectors/ws/WebServiceReceiver.java | 19 +++++++- .../connectors/ws/WebServiceReceiverTest.java | 46 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 server/src/test/java/com/mirth/connect/connectors/ws/WebServiceReceiverTest.java 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(); + } + } +} From 98e6b66ef8e5ac6a9d541168f23b02ede5939b49 Mon Sep 17 00:00:00 2001 From: Jon Bartels Date: Fri, 23 Oct 2026 08:26:47 -0400 Subject: [PATCH 2/2] Smoke test a SOAP request to a Web Service Listener Fixture tests submit messages through the client API, which never reaches the listener's HTTP worker threads, so they could not catch the JDK 19 SAAJ failure. This Java test deploys a Web Service Listener on port 8081 and POSTs a SOAP envelope to it from the harness container. Without the previous commit it fails with HTTP 500 on Temurin 21. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jon Bartels --- .../smoketest/WebServiceListenerTest.java | 76 +++++++ .../web-service-listener/channel.xml | 191 ++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 smoketest/src/test/java/org/openintegrationengine/smoketest/WebServiceListenerTest.java create mode 100644 smoketest/src/test/resources/web-service-listener/channel.xml 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