From 4b9ae71f7dd066ef3f6908c8ff2336d1f4a6d717 Mon Sep 17 00:00:00 2001 From: Jon Bartels Date: Fri, 23 Oct 2026 08:26:46 -0400 Subject: [PATCH 1/3] 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 472ca2ef912ed2daefe27a3ed89b79762dbd6c47 Mon Sep 17 00:00:00 2001 From: Jon Bartels Date: Fri, 23 Oct 2026 08:26:47 -0400 Subject: [PATCH 2/3] 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 From 67d2cc981f273994f1aa654fe4de96a8675a012a Mon Sep 17 00:00:00 2001 From: Jon Bartels Date: Sun, 1 Nov 2026 09:31:35 -0600 Subject: [PATCH 3/3] Address PR 508 review feedback Convert the WebServiceReceiver header to SPDX, add SPDX headers to the new tests, close the test's URLClassLoader with try-with-resources, and mark createExecutor @VisibleForTesting. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jon Bartels --- .../connectors/ws/WebServiceReceiver.java | 13 ++--- .../connectors/ws/WebServiceReceiverTest.java | 48 ++++++++++--------- .../smoketest/WebServiceListenerTest.java | 2 +- 3 files changed, 32 insertions(+), 31 deletions(-) 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 f2c42843a7..c24aeb2f61 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 @@ -1,11 +1,6 @@ -/* - * Copyright (c) Mirth Corporation. All rights reserved. - * - * http://www.mirthcorp.com - * - * The software in this package is published under the terms of the MPL license a copy of which has - * been included with this distribution in the LICENSE.txt file. - */ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: Mirth Corporation +// SPDX-FileCopyrightText: Open Integration Engine Contributors package com.mirth.connect.connectors.ws; @@ -35,6 +30,7 @@ import org.apache.http.client.utils.URLEncodedUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import com.google.common.annotations.VisibleForTesting; import com.mirth.connect.donkey.model.channel.ConnectorPluginProperties; import com.mirth.connect.donkey.model.event.ConnectionStatusEventType; import com.mirth.connect.donkey.model.event.ErrorEventType; @@ -236,6 +232,7 @@ public void onStart() throws ConnectorTaskException { * see server-lib, and lookups through the context classloader (e.g. the SAAJ implementation) * fail. Give the worker threads the given classloader explicitly instead. */ + @VisibleForTesting static ExecutorService createExecutor(int threads, ClassLoader contextClassLoader) { ThreadFactory defaultThreadFactory = Executors.defaultThreadFactory(); 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 index 22ea617bca..c6ba16328c 100644 --- a/server/src/test/java/com/mirth/connect/connectors/ws/WebServiceReceiverTest.java +++ b/server/src/test/java/com/mirth/connect/connectors/ws/WebServiceReceiverTest.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: Open Integration Engine Contributors + package com.mirth.connect.connectors.ws; import static org.junit.Assert.assertSame; @@ -19,28 +22,29 @@ 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(); + try (URLClassLoader 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 index b2a1b9f84b..63de561559 100644 --- a/smoketest/src/test/java/org/openintegrationengine/smoketest/WebServiceListenerTest.java +++ b/smoketest/src/test/java/org/openintegrationengine/smoketest/WebServiceListenerTest.java @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: Open Integration Engine +// SPDX-FileCopyrightText: Open Integration Engine Contributors package org.openintegrationengine.smoketest;