This is an automated email from the ASF dual-hosted git repository.
Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git
The following commit(s) were added to refs/heads/master by this push:
new 58dd5a517e fix: improve websocket client closure safety, timer
shutdown robustness, and lifecycle management (#6982)
58dd5a517e is described below
commit 58dd5a517ee32d1bf8b58e479881894439c54537
Author: Limbo <[email protected]>
AuthorDate: Wed Aug 26 16:41:31 2026 +0800
fix: improve websocket client closure safety, timer shutdown robustness,
and lifecycle management (#6982)
Co-authored-by: aias00 <[email protected]>
---
.../common/timer/HierarchicalWheelTimer.java | 24 +++++-
.../common/timer/HierarchicalWheelTimerTest.java | 41 +++++++++
.../data/websocket/WebsocketSyncDataService.java | 34 +++++---
.../websocket/client/ShenyuWebsocketClient.java | 22 ++++-
.../websocket/WebsocketSyncDataServiceTest.java | 98 +++++++++++++++++++---
.../client/ShenyuWebsocketClientTest.java | 58 +++++++++++++
6 files changed, 249 insertions(+), 28 deletions(-)
diff --git
a/shenyu-common/src/main/java/org/apache/shenyu/common/timer/HierarchicalWheelTimer.java
b/shenyu-common/src/main/java/org/apache/shenyu/common/timer/HierarchicalWheelTimer.java
index bb6f4f6f6b..1abffc5095 100644
---
a/shenyu-common/src/main/java/org/apache/shenyu/common/timer/HierarchicalWheelTimer.java
+++
b/shenyu-common/src/main/java/org/apache/shenyu/common/timer/HierarchicalWheelTimer.java
@@ -113,10 +113,16 @@ public class HierarchicalWheelTimer implements Timer {
@Override
public void advanceClock(final long timeoutMs) throws InterruptedException
{
+ if (taskExecutor.isShutdown()) {
+ return;
+ }
TimerTaskList bucket = delayQueue.poll(timeoutMs,
TimeUnit.MILLISECONDS);
if (Objects.nonNull(bucket)) {
writeLock.lock();
try {
+ if (taskExecutor.isShutdown()) {
+ return;
+ }
while (Objects.nonNull(bucket)) {
timingWheel.advanceClock(bucket.getExpiration());
bucket.flush(this::addTimerTaskEntry);
@@ -129,6 +135,9 @@ public class HierarchicalWheelTimer implements Timer {
}
private void start() {
+ if (taskExecutor.isShutdown()) {
+ throw new IllegalStateException("Timer already shutdown");
+ }
int state = WORKER_STATE_UPDATER.get(this);
if (state == 0) {
if (WORKER_STATE_UPDATER.compareAndSet(this, 0, 1)) {
@@ -144,28 +153,35 @@ public class HierarchicalWheelTimer implements Timer {
@Override
public void shutdown() {
- taskExecutor.shutdown();
+ writeLock.lock();
+ try {
+ workerThread.interrupt();
+ taskExecutor.shutdown();
+ } finally {
+ writeLock.unlock();
+ }
}
private static class Worker implements Runnable {
- private final Timer timer;
+ private final HierarchicalWheelTimer timer;
/**
* Instantiates a new Worker.
*
* @param timer the timer
*/
- Worker(final Timer timer) {
+ Worker(final HierarchicalWheelTimer timer) {
this.timer = timer;
}
@Override
public void run() {
- while (true) {
+ while (!Thread.currentThread().isInterrupted()) {
try {
timer.advanceClock(100L);
} catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
}
}
}
diff --git
a/shenyu-common/src/test/java/org/apache/shenyu/common/timer/HierarchicalWheelTimerTest.java
b/shenyu-common/src/test/java/org/apache/shenyu/common/timer/HierarchicalWheelTimerTest.java
index 755d42af34..a910ea3abf 100644
---
a/shenyu-common/src/test/java/org/apache/shenyu/common/timer/HierarchicalWheelTimerTest.java
+++
b/shenyu-common/src/test/java/org/apache/shenyu/common/timer/HierarchicalWheelTimerTest.java
@@ -17,14 +17,19 @@
package org.apache.shenyu.common.timer;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import java.lang.reflect.Field;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* HierarchicalWheelTimerTest .
@@ -54,6 +59,14 @@ public class HierarchicalWheelTimerTest {
timer = WheelTimerFactory.newWheelTimer();
timerTaskList = new TimerTaskList(taskCount);
}
+
+ /**
+ * Tear down.
+ */
+ @AfterEach
+ public void tearDown() {
+ timer.shutdown();
+ }
/**
* Test timer.
@@ -86,6 +99,34 @@ public class HierarchicalWheelTimerTest {
timerTask.cancel();
assertEquals(timer.size(), 0);
}
+
+ /**
+ * Test shutdown.
+ *
+ * @throws Exception reflection exception
+ */
+ @Test
+ public void testShutdownStopsWorkerAndRejectsNewTasks() throws Exception {
+ timer.add(new TimerTask(TimeUnit.MINUTES.toMillis(1)) {
+ @Override
+ public void run(final TaskEntity taskEntity) {
+ }
+ });
+ Field workerThreadField =
HierarchicalWheelTimer.class.getDeclaredField("workerThread");
+ workerThreadField.setAccessible(true);
+ Thread workerThread = (Thread) workerThreadField.get(timer);
+ assertTrue(workerThread.isAlive());
+
+ timer.shutdown();
+
+ workerThread.join(TimeUnit.SECONDS.toMillis(1));
+ assertFalse(workerThread.isAlive());
+ assertThrows(IllegalStateException.class, () -> timer.add(new
TimerTask(1) {
+ @Override
+ public void run(final TaskEntity taskEntity) {
+ }
+ }));
+ }
/**
* Test list foreach.
diff --git
a/shenyu-sync-data-center/shenyu-sync-data-websocket/src/main/java/org/apache/shenyu/plugin/sync/data/websocket/WebsocketSyncDataService.java
b/shenyu-sync-data-center/shenyu-sync-data-websocket/src/main/java/org/apache/shenyu/plugin/sync/data/websocket/WebsocketSyncDataService.java
index 2b5abaa950..481ad9854b 100644
---
a/shenyu-sync-data-center/shenyu-sync-data-websocket/src/main/java/org/apache/shenyu/plugin/sync/data/websocket/WebsocketSyncDataService.java
+++
b/shenyu-sync-data-center/shenyu-sync-data-websocket/src/main/java/org/apache/shenyu/plugin/sync/data/websocket/WebsocketSyncDataService.java
@@ -82,6 +82,8 @@ public class WebsocketSyncDataService implements
SyncDataService {
private TimerTask timerTask;
+ private boolean closed;
+
private final ServerProperties serverProperties;
/**
@@ -107,7 +109,7 @@ public class WebsocketSyncDataService implements
SyncDataService {
final
List<org.apache.shenyu.sync.data.api.AiProxyApiKeyDataSubscriber>
aiProxyApiKeyDataSubscribers,
final ServerProperties serverProperties) {
- this.timer = WheelTimerFactory.getSharedTimer();
+ this.timer = WheelTimerFactory.newWheelTimer();
this.websocketConfig = websocketConfig;
this.pluginDataSubscriber = pluginDataSubscriber;
this.metaDataSubscribers = metaDataSubscribers;
@@ -131,7 +133,10 @@ public class WebsocketSyncDataService implements
SyncDataService {
});
}
- private void masterCheck() {
+ private synchronized void masterCheck() {
+ if (closed) {
+ return;
+ }
if (LOG.isDebugEnabled()) {
LOG.debug("master checking task start...");
}
@@ -165,18 +170,25 @@ public class WebsocketSyncDataService implements
SyncDataService {
}
@Override
- public void close() {
- if (CollectionUtils.isNotEmpty(clients)) {
- for (ShenyuWebsocketClient client : clients) {
- if (Objects.nonNull(client)) {
- client.close();
+ public synchronized void close() {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ try {
+ if (Objects.nonNull(timerTask)) {
+ timerTask.cancel();
+ }
+ if (CollectionUtils.isNotEmpty(clients)) {
+ for (ShenyuWebsocketClient client : clients) {
+ if (Objects.nonNull(client)) {
+ client.nowClose();
+ }
}
}
+ } finally {
+ timer.shutdown();
}
- if (Objects.nonNull(timerTask)) {
- timerTask.cancel();
- }
- timer.shutdown();
}
private ShenyuWebsocketClient createClient(final String url) {
diff --git
a/shenyu-sync-data-center/shenyu-sync-data-websocket/src/main/java/org/apache/shenyu/plugin/sync/data/websocket/client/ShenyuWebsocketClient.java
b/shenyu-sync-data-center/shenyu-sync-data-websocket/src/main/java/org/apache/shenyu/plugin/sync/data/websocket/client/ShenyuWebsocketClient.java
index ddf5878055..76917229d0 100644
---
a/shenyu-sync-data-center/shenyu-sync-data-websocket/src/main/java/org/apache/shenyu/plugin/sync/data/websocket/client/ShenyuWebsocketClient.java
+++
b/shenyu-sync-data-center/shenyu-sync-data-websocket/src/main/java/org/apache/shenyu/plugin/sync/data/websocket/client/ShenyuWebsocketClient.java
@@ -103,12 +103,16 @@ public final class ShenyuWebsocketClient extends
WebSocketClient {
private final String namespaceId;
+ private final AtomicBoolean manuallyClosed = new AtomicBoolean(false);
+
private final AtomicBoolean reconnecting = new AtomicBoolean(false);
private volatile long lastReconnectAttemptTime;
private final AtomicInteger reconnectBackoff = new AtomicInteger(0);
+ private volatile Thread reconnectThread;
+
/**
* Instantiates a new shenyu websocket client.
*
@@ -263,14 +267,22 @@ public final class ShenyuWebsocketClient extends
WebSocketClient {
* now close. will cancel the task execution.
*/
public void nowClose() {
- this.close();
+ this.manuallyClosed.set(true);
if (Objects.nonNull(timerTask)) {
timerTask.cancel();
}
+ Thread currentReconnectThread = this.reconnectThread;
+ if (Objects.nonNull(currentReconnectThread)) {
+ currentReconnectThread.interrupt();
+ }
+ this.close();
}
private void healthCheck() {
try {
+ if (this.manuallyClosed.get()) {
+ return;
+ }
if (!this.isOpen()) {
if (this.reconnecting.compareAndSet(false, true)) {
RECONNECT_EXECUTOR.submit(this::doReconnect);
@@ -287,7 +299,11 @@ public final class ShenyuWebsocketClient extends
WebSocketClient {
}
private void doReconnect() {
+ this.reconnectThread = Thread.currentThread();
try {
+ if (this.manuallyClosed.get()) {
+ return;
+ }
long backoff = calculateBackoff();
long since = System.currentTimeMillis() - lastReconnectAttemptTime;
long waitMs = backoff - since;
@@ -305,7 +321,11 @@ public final class ShenyuWebsocketClient extends
WebSocketClient {
reconnectBackoff.set(Math.min(reconnectBackoff.get() + 1, 10));
LOG.error("websocket reconnect server[{}] error", this.getURI(),
e);
} finally {
+ this.reconnectThread = null;
this.reconnecting.set(false);
+ if (this.manuallyClosed.get()) {
+ this.close();
+ }
}
}
diff --git
a/shenyu-sync-data-center/shenyu-sync-data-websocket/src/test/java/org/apache/shenyu/plugin/sync/data/websocket/WebsocketSyncDataServiceTest.java
b/shenyu-sync-data-center/shenyu-sync-data-websocket/src/test/java/org/apache/shenyu/plugin/sync/data/websocket/WebsocketSyncDataServiceTest.java
index a34ef976d6..65da2272a7 100644
---
a/shenyu-sync-data-center/shenyu-sync-data-websocket/src/test/java/org/apache/shenyu/plugin/sync/data/websocket/WebsocketSyncDataServiceTest.java
+++
b/shenyu-sync-data-center/shenyu-sync-data-websocket/src/test/java/org/apache/shenyu/plugin/sync/data/websocket/WebsocketSyncDataServiceTest.java
@@ -18,6 +18,9 @@
package org.apache.shenyu.plugin.sync.data.websocket;
import org.apache.shenyu.common.config.ShenyuConfig;
+import org.apache.shenyu.common.timer.Timer;
+import org.apache.shenyu.common.timer.TimerTask;
+import org.apache.shenyu.common.timer.WheelTimerFactory;
import
org.apache.shenyu.plugin.sync.data.websocket.client.ShenyuWebsocketClient;
import org.apache.shenyu.plugin.sync.data.websocket.config.WebsocketConfig;
import org.apache.shenyu.sync.data.api.AiProxyApiKeyDataSubscriber;
@@ -27,6 +30,8 @@ import org.apache.shenyu.sync.data.api.MetaDataSubscriber;
import org.apache.shenyu.sync.data.api.PluginDataSubscriber;
import org.apache.shenyu.sync.data.api.ProxySelectorDataSubscriber;
import org.junit.jupiter.api.Test;
+import org.mockito.InOrder;
+import org.mockito.MockedStatic;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import java.lang.reflect.Field;
@@ -34,8 +39,14 @@ import java.lang.reflect.Method;
import java.util.Collections;
import java.util.List;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -44,18 +55,7 @@ public final class WebsocketSyncDataServiceTest {
@Test
@SuppressWarnings("unchecked")
public void testMasterCheckClosesRemovedClient() throws Exception {
- WebsocketConfig websocketConfig = new WebsocketConfig();
- websocketConfig.setUrls(Collections.emptyList());
- WebsocketSyncDataService websocketSyncDataService = new
WebsocketSyncDataService(
- websocketConfig,
- new ShenyuConfig(),
- mock(PluginDataSubscriber.class),
- Collections.<MetaDataSubscriber>emptyList(),
- Collections.<AuthDataSubscriber>emptyList(),
- Collections.<ProxySelectorDataSubscriber>emptyList(),
- Collections.<DiscoveryUpstreamDataSubscriber>emptyList(),
- Collections.<AiProxyApiKeyDataSubscriber>emptyList(),
- mock(ServerProperties.class));
+ WebsocketSyncDataService websocketSyncDataService =
createWebsocketSyncDataService();
ShenyuWebsocketClient websocketClient =
mock(ShenyuWebsocketClient.class);
when(websocketClient.isOpen()).thenReturn(false);
Field clientsField =
WebsocketSyncDataService.class.getDeclaredField("clients");
@@ -74,4 +74,78 @@ public final class WebsocketSyncDataServiceTest {
websocketSyncDataService.close();
}
}
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testCloseShutsDownPrivateTimer() throws Exception {
+ Timer sharedTimer = mock(Timer.class);
+ Timer privateTimer = mock(Timer.class);
+ try (MockedStatic<WheelTimerFactory> wheelTimerFactory =
mockStatic(WheelTimerFactory.class)) {
+
wheelTimerFactory.when(WheelTimerFactory::getSharedTimer).thenReturn(sharedTimer);
+
wheelTimerFactory.when(WheelTimerFactory::newWheelTimer).thenReturn(privateTimer);
+ final WebsocketSyncDataService websocketSyncDataService =
createWebsocketSyncDataService();
+ ShenyuWebsocketClient websocketClient =
mock(ShenyuWebsocketClient.class);
+ Field clientsField =
WebsocketSyncDataService.class.getDeclaredField("clients");
+ clientsField.setAccessible(true);
+ List<ShenyuWebsocketClient> clients =
(List<ShenyuWebsocketClient>) clientsField
+ .get(websocketSyncDataService);
+ clients.add(websocketClient);
+ TimerTask timerTask = mock(TimerTask.class);
+ Field timerTaskField =
WebsocketSyncDataService.class.getDeclaredField("timerTask");
+ timerTaskField.setAccessible(true);
+ timerTaskField.set(websocketSyncDataService, timerTask);
+
+ websocketSyncDataService.close();
+ Method masterCheck =
WebsocketSyncDataService.class.getDeclaredMethod("masterCheck");
+ masterCheck.setAccessible(true);
+ masterCheck.invoke(websocketSyncDataService);
+ websocketSyncDataService.close();
+
+ InOrder closeOrder = inOrder(timerTask, websocketClient);
+ closeOrder.verify(timerTask).cancel();
+ closeOrder.verify(websocketClient).nowClose();
+ verify(websocketClient, times(1)).nowClose();
+ verify(timerTask, times(1)).cancel();
+ verify(privateTimer, times(1)).shutdown();
+ verify(sharedTimer, never()).shutdown();
+ wheelTimerFactory.verify(WheelTimerFactory::getSharedTimer,
never());
+ }
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testCloseShutsDownPrivateTimerWhenClientCloseFails() throws
Exception {
+ final Timer privateTimer = mock(Timer.class);
+ try (MockedStatic<WheelTimerFactory> wheelTimerFactory =
mockStatic(WheelTimerFactory.class)) {
+
wheelTimerFactory.when(WheelTimerFactory::newWheelTimer).thenReturn(privateTimer);
+ final WebsocketSyncDataService websocketSyncDataService =
createWebsocketSyncDataService();
+ final ShenyuWebsocketClient websocketClient =
mock(ShenyuWebsocketClient.class);
+ final IllegalStateException clientCloseException = new
IllegalStateException("client close failed");
+ doThrow(clientCloseException).when(websocketClient).nowClose();
+ final Field clientsField =
WebsocketSyncDataService.class.getDeclaredField("clients");
+ clientsField.setAccessible(true);
+ final List<ShenyuWebsocketClient> clients =
(List<ShenyuWebsocketClient>) clientsField
+ .get(websocketSyncDataService);
+ clients.add(websocketClient);
+
+ assertThrows(IllegalStateException.class,
websocketSyncDataService::close);
+
+ verify(privateTimer).shutdown();
+ }
+ }
+
+ private WebsocketSyncDataService createWebsocketSyncDataService() {
+ WebsocketConfig websocketConfig = new WebsocketConfig();
+ websocketConfig.setUrls(Collections.emptyList());
+ return new WebsocketSyncDataService(
+ websocketConfig,
+ new ShenyuConfig(),
+ mock(PluginDataSubscriber.class),
+ Collections.<MetaDataSubscriber>emptyList(),
+ Collections.<AuthDataSubscriber>emptyList(),
+ Collections.<ProxySelectorDataSubscriber>emptyList(),
+ Collections.<DiscoveryUpstreamDataSubscriber>emptyList(),
+ Collections.<AiProxyApiKeyDataSubscriber>emptyList(),
+ mock(ServerProperties.class));
+ }
}
diff --git
a/shenyu-sync-data-center/shenyu-sync-data-websocket/src/test/java/org/apache/shenyu/plugin/sync/data/websocket/client/ShenyuWebsocketClientTest.java
b/shenyu-sync-data-center/shenyu-sync-data-websocket/src/test/java/org/apache/shenyu/plugin/sync/data/websocket/client/ShenyuWebsocketClientTest.java
index dcb42b7db6..db730837c0 100644
---
a/shenyu-sync-data-center/shenyu-sync-data-websocket/src/test/java/org/apache/shenyu/plugin/sync/data/websocket/client/ShenyuWebsocketClientTest.java
+++
b/shenyu-sync-data-center/shenyu-sync-data-websocket/src/test/java/org/apache/shenyu/plugin/sync/data/websocket/client/ShenyuWebsocketClientTest.java
@@ -22,6 +22,7 @@ import org.apache.shenyu.common.dto.WebsocketData;
import org.apache.shenyu.common.enums.ConfigGroupEnum;
import org.apache.shenyu.common.enums.DataEventTypeEnum;
import org.apache.shenyu.common.exception.ShenyuException;
+import org.apache.shenyu.common.timer.TimerTask;
import org.apache.shenyu.common.utils.GsonUtils;
import org.apache.shenyu.sync.data.api.AuthDataSubscriber;
import org.apache.shenyu.sync.data.api.MetaDataSubscriber;
@@ -47,14 +48,17 @@ import java.util.concurrent.atomic.AtomicInteger;
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 org.mockito.Answers;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.withSettings;
@@ -135,6 +139,7 @@ public class ShenyuWebsocketClientTest {
private ShenyuWebsocketClient createMockClient() {
ShenyuWebsocketClient client = mock(ShenyuWebsocketClient.class,
withSettings().defaultAnswer(Answers.CALLS_REAL_METHODS));
+ setField(client, "manuallyClosed", new AtomicBoolean(false));
setField(client, "reconnecting", new AtomicBoolean(false));
setField(client, "reconnectBackoff", new AtomicInteger(0));
setField(client, "lastReconnectAttemptTime", 0L);
@@ -264,6 +269,59 @@ public class ShenyuWebsocketClientTest {
verify(client).sendPing();
}
+ @Test
+ void testNowClosePreventsPendingReconnect() throws InterruptedException {
+ ShenyuWebsocketClient client = createMockClient();
+ TimerTask timerTask = mock(TimerTask.class);
+ setField(client, "timerTask", timerTask);
+ doReturn(false).when(client).isOpen();
+
+ client.nowClose();
+ invokePrivate(client, "doReconnect");
+
+ verify(timerTask).cancel();
+ verify(client, never()).reconnectBlocking();
+ }
+
+ @Test
+ void testNowCloseInterruptsRunningReconnect() {
+ ShenyuWebsocketClient client = createMockClient();
+ Thread reconnectThread = mock(Thread.class);
+ setField(client, "reconnectThread", reconnectThread);
+ doReturn(false).when(client).isOpen();
+
+ client.nowClose();
+
+ verify(reconnectThread).interrupt();
+ }
+
+ @Test
+ void testNowCloseCancelsTimerTaskWhenSocketCloseFails() {
+ ShenyuWebsocketClient client = createMockClient();
+ TimerTask timerTask = mock(TimerTask.class);
+ setField(client, "timerTask", timerTask);
+ IllegalStateException closeException = new
IllegalStateException("socket close failed");
+ doThrow(closeException).when(client).close();
+
+ assertThrows(IllegalStateException.class, client::nowClose);
+
+ verify(timerTask).cancel();
+ }
+
+ @Test
+ void testReconnectClosesConnectionWhenNowClosedDuringAttempt() throws
InterruptedException {
+ ShenyuWebsocketClient client = createMockClient();
+ doAnswer(invocation -> {
+ ((AtomicBoolean) getField(client, "manuallyClosed")).set(true);
+ return true;
+ }).when(client).reconnectBlocking();
+ doNothing().when(client).close();
+
+ invokePrivate(client, "doReconnect");
+
+ verify(client).close();
+ }
+
// ---------- doReconnect tests ----------
// reconnectBlocking() is stubbed to throw, so no real socket connection
is attempted.