Unlike {@link SLF4JBridgeHandler#install()}, this only touches the JUL logger used by
+ * MockWebServer, not the JUL root logger, so unrelated JUL handlers elsewhere in the JVM are
+ * never removed. It also skips installing when the SLF4J binding is slf4j-jdk14, since bridging
+ * JUL to an SLF4J binding that itself delegates back to JUL creates an infinite loop. Calls are
+ * reference-counted so concurrently running mock servers don't uninstall the bridge out from
+ * under each other.
+ */
+final class JulSlf4jBridge {
+
+ private static final org.slf4j.Logger LOG = LoggerFactory.getLogger(JulSlf4jBridge.class);
+ private static final String MOCK_WEB_SERVER_LOGGER_NAME = "okhttp3.mockwebserver.MockWebServer";
+ private static final AtomicInteger ACTIVE = new AtomicInteger(0);
+
+ private static Handler[] originalHandlers;
+ private static boolean originalUseParentHandlers;
+ private static boolean bridged;
+ private static SLF4JBridgeHandler bridgeHandler;
+
+ private JulSlf4jBridge() {}
+
+ static synchronized void install() {
+ if (ACTIVE.getAndIncrement() > 0) {
+ return;
+ }
+ bridged = false;
+ if (isJdk14Binding(LoggerFactory.getILoggerFactory().getClass().getName())) {
+ LOG.warn("Skipping JUL-to-SLF4J bridge for MockWebServer logs because the SLF4J binding "
+ + "is slf4j-jdk14; bridging it would create an infinite JUL<->SLF4J logging loop.");
+ return;
+ }
+ Logger mockWebServerLogger = Logger.getLogger(MOCK_WEB_SERVER_LOGGER_NAME);
+ originalHandlers = mockWebServerLogger.getHandlers();
+ originalUseParentHandlers = mockWebServerLogger.getUseParentHandlers();
+ for (Handler handler : originalHandlers) {
+ mockWebServerLogger.removeHandler(handler);
+ }
+ bridgeHandler = new SLF4JBridgeHandler();
+ mockWebServerLogger.addHandler(bridgeHandler);
+ mockWebServerLogger.setUseParentHandlers(false);
+ bridged = true;
+ }
+
+ static synchronized void uninstall() {
+ if (ACTIVE.decrementAndGet() > 0 || !bridged) {
+ return;
+ }
+ Logger mockWebServerLogger = Logger.getLogger(MOCK_WEB_SERVER_LOGGER_NAME);
+ mockWebServerLogger.removeHandler(bridgeHandler);
+ for (Handler handler : originalHandlers) {
+ if (!containsHandler(mockWebServerLogger.getHandlers(), handler)) {
+ mockWebServerLogger.addHandler(handler);
+ }
+ }
+ mockWebServerLogger.setUseParentHandlers(originalUseParentHandlers);
+ originalHandlers = null;
+ bridgeHandler = null;
+ bridged = false;
+ }
+
+ private static boolean containsHandler(Handler[] handlers, Handler target) {
+ for (Handler handler : handlers) {
+ if (handler == target) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Package-visible for testing without depending on the actual runtime SLF4J binding. */
+ static boolean isJdk14Binding(String iLoggerFactoryClassName) {
+ return "org.slf4j.impl.JDK14LoggerFactory".equals(iLoggerFactoryClassName)
+ || "org.slf4j.jul.JDK14LoggerFactory".equals(iLoggerFactoryClassName);
+ }
+}
diff --git a/apollo-mockserver/src/test/java/com/ctrip/framework/apollo/mockserver/JulSlf4jBridgeTest.java b/apollo-mockserver/src/test/java/com/ctrip/framework/apollo/mockserver/JulSlf4jBridgeTest.java
new file mode 100644
index 00000000..bbce63f8
--- /dev/null
+++ b/apollo-mockserver/src/test/java/com/ctrip/framework/apollo/mockserver/JulSlf4jBridgeTest.java
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2026 Apollo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+package com.ctrip.framework.apollo.mockserver;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Field;
+import java.util.logging.Handler;
+import java.util.logging.Logger;
+import org.junit.jupiter.api.Test;
+import org.slf4j.bridge.SLF4JBridgeHandler;
+
+class JulSlf4jBridgeTest {
+
+ private static final String MOCK_WEB_SERVER_LOGGER_NAME = "okhttp3.mockwebserver.MockWebServer";
+
+ @Test
+ void isJdk14BindingDetectsKnownFactoryClassNames() {
+ assertTrue(JulSlf4jBridge.isJdk14Binding("org.slf4j.impl.JDK14LoggerFactory"));
+ assertTrue(JulSlf4jBridge.isJdk14Binding("org.slf4j.jul.JDK14LoggerFactory"));
+ assertFalse(JulSlf4jBridge.isJdk14Binding("ch.qos.logback.classic.LoggerContext"));
+ }
+
+ @Test
+ void installOnlyTouchesMockWebServerLoggerNotRoot() {
+ Logger root = Logger.getLogger("");
+ Handler[] rootHandlersBefore = root.getHandlers();
+
+ Logger mockWebServerLogger = Logger.getLogger(MOCK_WEB_SERVER_LOGGER_NAME);
+ Handler[] mwsHandlersBefore = mockWebServerLogger.getHandlers();
+ boolean useParentBefore = mockWebServerLogger.getUseParentHandlers();
+
+ JulSlf4jBridge.install();
+ try {
+ assertArrayEquals(rootHandlersBefore, root.getHandlers(),
+ "installing the bridge must not touch the JUL root logger's handlers");
+ assertTrue(hasBridgeHandler(mockWebServerLogger),
+ "MockWebServer's own logger should get the bridge handler");
+ assertFalse(mockWebServerLogger.getUseParentHandlers());
+ } finally {
+ JulSlf4jBridge.uninstall();
+ }
+
+ assertArrayEquals(rootHandlersBefore, root.getHandlers());
+ assertArrayEquals(mwsHandlersBefore, mockWebServerLogger.getHandlers());
+ assertEquals(useParentBefore, mockWebServerLogger.getUseParentHandlers());
+ }
+
+ @Test
+ void closingSameServerTwiceDoesNotDisruptAnotherActiveServer() throws Exception {
+ Logger mockWebServerLogger = Logger.getLogger(MOCK_WEB_SERVER_LOGGER_NAME);
+ ApolloTestingServer serverA = new ApolloTestingServer();
+ ApolloTestingServer serverB = new ApolloTestingServer();
+ try {
+ serverA.start();
+ serverB.start();
+ assertTrue(hasBridgeHandler(mockWebServerLogger));
+
+ serverA.close();
+ serverA.close();
+
+ assertTrue(hasBridgeHandler(mockWebServerLogger),
+ "bridge must remain installed while server B is still running");
+ } finally {
+ serverA.close();
+ serverB.close();
+ }
+
+ assertFalse(hasBridgeHandler(mockWebServerLogger));
+ }
+
+ @Test
+ void closingNeverStartedServerDoesNotDisruptActiveServer() throws Exception {
+ Logger mockWebServerLogger = Logger.getLogger(MOCK_WEB_SERVER_LOGGER_NAME);
+ ApolloTestingServer active = new ApolloTestingServer();
+ ApolloTestingServer neverStarted = new ApolloTestingServer();
+ try {
+ active.start();
+ assertTrue(hasBridgeHandler(mockWebServerLogger));
+
+ neverStarted.close();
+
+ assertTrue(hasBridgeHandler(mockWebServerLogger),
+ "closing a server that was never started must not release a bridge it never acquired");
+ } finally {
+ active.close();
+ neverStarted.close();
+ }
+
+ assertFalse(hasBridgeHandler(mockWebServerLogger));
+ }
+
+ @Test
+ void startupFailureAfterBridgeAcquisitionReleasesBridge() throws Exception {
+ Logger mockWebServerLogger = Logger.getLogger(MOCK_WEB_SERVER_LOGGER_NAME);
+ ApolloTestingServer server = new ApolloTestingServer();
+
+ Field locatorField = ApolloTestingServer.class.getDeclaredField("CONFIG_SERVICE_LOCATOR");
+ locatorField.setAccessible(true);
+ Object originalLocator = locatorField.get(null);
+ locatorField.set(null, null);
+ try {
+ assertThrows(IllegalStateException.class, server::start,
+ "start() must surface failures that happen after the JUL bridge is acquired");
+ assertFalse(hasBridgeHandler(mockWebServerLogger),
+ "a failed start() must release the JUL bridge it acquired");
+ } finally {
+ locatorField.set(null, originalLocator);
+ server.close();
+ }
+ }
+
+ private static boolean hasBridgeHandler(Logger logger) {
+ for (Handler handler : logger.getHandlers()) {
+ if (handler instanceof SLF4JBridgeHandler) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/pom.xml b/pom.xml
index a20f7096..bd64c6f1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -111,6 +111,11 @@