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 40e6879b7d Fixes #7313: Close stalled websocket sessions and bound the
admin send queue (#7343)
40e6879b7d is described below
commit 40e6879b7d3884b7f8874fc09b8775924297d849
Author: BobSong <[email protected]>
AuthorDate: Thu Oct 1 11:02:15 2026 +0800
Fixes #7313: Close stalled websocket sessions and bound the admin send
queue (#7343)
---
.../listener/websocket/WebsocketCollector.java | 144 +++++++++++++++++++--
.../listener/websocket/WebsocketCollectorTest.java | 87 ++++++++++++-
.../logging/rocketmq/DividePluginTest.java | 19 ++-
.../logging/rocketmq/LoggingRuleSyncTest.java | 79 +++++++++++
4 files changed, 319 insertions(+), 10 deletions(-)
diff --git
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/listener/websocket/WebsocketCollector.java
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/listener/websocket/WebsocketCollector.java
index 968f17bc7c..e100045639 100644
---
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/listener/websocket/WebsocketCollector.java
+++
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/listener/websocket/WebsocketCollector.java
@@ -44,6 +44,7 @@ import jakarta.websocket.OnClose;
import jakarta.websocket.OnError;
import jakarta.websocket.OnMessage;
import jakarta.websocket.OnOpen;
+import jakarta.websocket.SendResult;
import jakarta.websocket.Session;
import jakarta.websocket.server.ServerEndpoint;
@@ -56,6 +57,10 @@ import java.util.Queue;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArraySet;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
/**
* The type Websocket data changed listener.
@@ -73,6 +78,25 @@ public class WebsocketCollector {
private static final Map<Session, SessionSendQueue> SESSION_SEND_QUEUES =
Maps.newConcurrentMap();
+ private static final long DEFAULT_SEND_TIMEOUT_MILLIS = 30_000L;
+
+ private static final int DEFAULT_MAX_QUEUED_MESSAGES = 256;
+
+ /**
+ * Watchdog that detects async sends whose container callback never runs,
+ * e.g. on half-open connections, and closes the session so the gateway
+ * reconnects and performs a full synchronization.
+ */
+ private static final ScheduledExecutorService SEND_WATCHDOG =
Executors.newSingleThreadScheduledExecutor(runnable -> {
+ Thread thread = new Thread(runnable, "shenyu-websocket-send-watchdog");
+ thread.setDaemon(true);
+ return thread;
+ });
+
+ private static volatile long sendTimeoutMillis =
DEFAULT_SEND_TIMEOUT_MILLIS;
+
+ private static volatile int maxQueuedMessages =
DEFAULT_MAX_QUEUED_MESSAGES;
+
/**
* Namespace captured at registration. {@code Session#isOpen()} is already
false when
* {@code @OnClose} runs, so the namespace cannot be read from the session
at teardown.
@@ -412,6 +436,32 @@ public class WebsocketCollector {
}
}
+ /**
+ * Adjust the per-message send timeout, intended for tests.
+ *
+ * @param timeoutMillis the send timeout in milliseconds
+ */
+ static void setSendTimeoutMillis(final long timeoutMillis) {
+ sendTimeoutMillis = timeoutMillis;
+ }
+
+ /**
+ * Adjust the per-session queued message limit, intended for tests.
+ *
+ * @param limit the maximum number of queued messages
+ */
+ static void setMaxQueuedMessages(final int limit) {
+ maxQueuedMessages = limit;
+ }
+
+ /**
+ * Reset the send timeout and queue limit to their defaults, intended for
tests.
+ */
+ static void resetSendGuards() {
+ sendTimeoutMillis = DEFAULT_SEND_TIMEOUT_MILLIS;
+ maxQueuedMessages = DEFAULT_MAX_QUEUED_MESSAGES;
+ }
+
private static final class InitialSync {
private final Session session;
@@ -432,6 +482,10 @@ public class WebsocketCollector {
private final Queue<String> messages = new ArrayDeque<>();
+ private ScheduledFuture<?> timeoutFuture;
+
+ private String inFlightMessage;
+
private boolean sending;
private boolean closed;
@@ -446,6 +500,14 @@ public class WebsocketCollector {
if (closed) {
return;
}
+ if (messages.size() >= WebsocketCollector.maxQueuedMessages) {
+ // silently dropping messages is not acceptable: close the
session
+ // so the gateway reconnects and performs a full
synchronization
+ LOG.error("websocket send queue overflow on session {},
queued {}, closing session for resync",
+ session.getId(), messages.size());
+ forceClose("send queue overflow");
+ return;
+ }
messages.offer(message);
if (!sending) {
sending = true;
@@ -459,6 +521,7 @@ public class WebsocketCollector {
private void sendNext() {
final String message;
+ final ScheduledFuture<?> future;
synchronized (this) {
if (closed) {
sending = false;
@@ -470,23 +533,88 @@ public class WebsocketCollector {
sending = false;
return;
}
+ inFlightMessage = message;
+ future = SEND_WATCHDOG.schedule(
+ () -> onSendTimeout(message), sendTimeoutMillis,
TimeUnit.MILLISECONDS);
+ timeoutFuture = future;
}
+ boolean submitted;
try {
- session.getAsyncRemote().sendText(message, result -> {
- if (!result.isOK()) {
- LOG.error("websocket send result is exception: ",
result.getException());
- }
- sendNext();
- });
+ session.getAsyncRemote().sendText(message, result ->
onSendResult(future, result));
+ submitted = true;
} catch (RuntimeException ex) {
- LOG.error("websocket send result is exception: ", ex);
- sendNext();
+ LOG.error("websocket send failed synchronously on session {}",
session.getId(), ex);
+ submitted = false;
+ }
+ if (!submitted) {
+ future.cancel(false);
+ forceClose("synchronous send failure");
+ }
+ }
+
+ private void onSendResult(final ScheduledFuture<?> future, final
SendResult result) {
+ future.cancel(false);
+ synchronized (this) {
+ if (closed) {
+ return;
+ }
+ if (timeoutFuture == future) {
+ timeoutFuture = null;
+ }
+ inFlightMessage = null;
+ if (!result.isOK()) {
+ LOG.error("websocket send result is exception on session
{}, closing session for resync",
+ session.getId(), result.getException());
+ forceClose("send failure");
+ return;
+ }
+ }
+ sendNext();
+ }
+
+ private void onSendTimeout(final String scheduledMessage) {
+ synchronized (this) {
+ if (closed || !sending || !Objects.equals(inFlightMessage,
scheduledMessage)) {
+ return;
+ }
+ LOG.error("websocket send callback not observed within {} ms
on session {},"
+ + " treating the connection as broken and
closing it for resync",
+ sendTimeoutMillis, session.getId());
+ forceClose("send timeout");
+ }
+ }
+
+ private void forceClose(final String reason) {
+ synchronized (this) {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ sending = false;
+ inFlightMessage = null;
+ if (Objects.nonNull(timeoutFuture)) {
+ timeoutFuture.cancel(false);
+ timeoutFuture = null;
+ }
+ messages.clear();
+ }
+ LOG.warn("closing websocket session {} to force gateway resync,
reason={}", session.getId(), reason);
+ removeSessionIndexes(session);
+ try {
+ session.close();
+ } catch (Exception ex) {
+ LOG.warn("error closing websocket session {}: {}",
session.getId(), ex.getMessage());
}
}
private void close() {
synchronized (this) {
closed = true;
+ inFlightMessage = null;
+ if (Objects.nonNull(timeoutFuture)) {
+ timeoutFuture.cancel(false);
+ timeoutFuture = null;
+ }
messages.clear();
}
}
diff --git
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/listener/websocket/WebsocketCollectorTest.java
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/listener/websocket/WebsocketCollectorTest.java
index bf038a24f7..38d5d7fbb4 100644
---
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/listener/websocket/WebsocketCollectorTest.java
+++
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/listener/websocket/WebsocketCollectorTest.java
@@ -112,6 +112,7 @@ public final class WebsocketCollectorTest {
websocketCollector = new WebsocketCollector();
// Clear shared static state between tests
clearStaticSessionState();
+ WebsocketCollector.resetSendGuards();
when(session.isOpen()).thenReturn(true);
Map<String, Object> userProperties = new HashMap<>();
userProperties.put(Constants.SHENYU_NAMESPACE_ID,
Constants.SYS_DEFAULT_NAMESPACE_ID);
@@ -549,7 +550,7 @@ public final class WebsocketCollectorTest {
}
@Test
- void testSendBySessionFailure() {
+ void testSendBySessionFailure() throws Exception {
final RemoteEndpoint.Async async = mock(RemoteEndpoint.Async.class);
when(session.getAsyncRemote()).thenReturn(async);
when(session.isOpen()).thenReturn(true);
@@ -562,10 +563,87 @@ public final class WebsocketCollectorTest {
}).when(async).sendText(anyString(), any(SendHandler.class));
WebsocketCollector.send(Constants.SYS_DEFAULT_NAMESPACE_ID,
"fail-msg", DataEventTypeEnum.CREATE);
verify(async, times(1)).sendText(eq("fail-msg"),
any(SendHandler.class));
+ // a failed send closes the session so the gateway reconnects and
resynchronizes
+ verify(session, times(1)).close();
+ assertEquals(0, sendQueueSize());
websocketCollector.onClose(session);
}
+ @Test
+ void testCloseCancelsInFlightWatchdog() {
+ WebsocketCollector.setSendTimeoutMillis(60000L);
+ final RemoteEndpoint.Async async = mock(RemoteEndpoint.Async.class);
+ when(session.getAsyncRemote()).thenReturn(async);
+ websocketCollector.onOpen(session);
+ WebsocketCollector.send(Constants.SYS_DEFAULT_NAMESPACE_ID,
"stalled-message", DataEventTypeEnum.CREATE);
+ Map<?, ?> queues = (Map<?, ?>)
ReflectionTestUtils.getField(WebsocketCollector.class, "SESSION_SEND_QUEUES");
+ Object queue = java.util.Objects.requireNonNull(queues).get(session);
+ java.util.concurrent.ScheduledFuture<?> future =
(java.util.concurrent.ScheduledFuture<?>)
+ ReflectionTestUtils.getField(queue, "timeoutFuture");
+ org.junit.jupiter.api.Assertions.assertNotNull(future);
+ assertFalse(future.isCancelled());
+ websocketCollector.onClose(session);
+ assertTrue(future.isCancelled());
+ assertNull(ReflectionTestUtils.getField(queue, "timeoutFuture"));
+ WebsocketCollector.resetSendGuards();
+ }
+
+ @Test
+ void testMissingSendCallbackTimesOutAndClosesSession() throws Exception {
+ WebsocketCollector.setSendTimeoutMillis(150);
+ final RemoteEndpoint.Async async = mock(RemoteEndpoint.Async.class);
+ when(session.getAsyncRemote()).thenReturn(async);
+ // container never invokes the send handler — the stalled-connection
case
+ doAnswer(invocation -> null).when(async).sendText(anyString(),
any(SendHandler.class));
+ websocketCollector.onOpen(session);
+
+ WebsocketCollector.send(Constants.SYS_DEFAULT_NAMESPACE_ID,
"stuck-message", DataEventTypeEnum.CREATE);
+ WebsocketCollector.send(Constants.SYS_DEFAULT_NAMESPACE_ID,
"second-message", DataEventTypeEnum.CREATE);
+ // only the first message reaches sendText; the second stays queued
+ verify(async, times(1)).sendText(eq("stuck-message"),
any(SendHandler.class));
+ verify(async, never()).sendText(eq("second-message"),
any(SendHandler.class));
+
+ // the watchdog closes the broken session within a bounded time
+ waitUntil(() -> getSessionSetSize() == 0L);
+ verify(session, times(1)).close();
+ assertEquals(0, sendQueueSize());
+ }
+
+ @Test
+ void testSynchronousSendExceptionClosesSession() throws Exception {
+ final RemoteEndpoint.Async async = mock(RemoteEndpoint.Async.class);
+ when(session.getAsyncRemote()).thenReturn(async);
+ when(session.isOpen()).thenReturn(true);
+ websocketCollector.onOpen(session);
+
+ doAnswer(invocation -> {
+ throw new IllegalStateException("broken pipe");
+ }).when(async).sendText(anyString(), any(SendHandler.class));
+ WebsocketCollector.send(Constants.SYS_DEFAULT_NAMESPACE_ID,
"boom-message", DataEventTypeEnum.CREATE);
+
+ verify(session, times(1)).close();
+ assertEquals(0, sendQueueSize());
+ }
+
+ @Test
+ void testQueueOverflowClosesSession() throws Exception {
+ WebsocketCollector.setMaxQueuedMessages(2);
+ final RemoteEndpoint.Async async = mock(RemoteEndpoint.Async.class);
+ when(session.getAsyncRemote()).thenReturn(async);
+ doAnswer(invocation -> null).when(async).sendText(anyString(),
any(SendHandler.class));
+ websocketCollector.onOpen(session);
+
+ WebsocketCollector.send(Constants.SYS_DEFAULT_NAMESPACE_ID, "m1",
DataEventTypeEnum.CREATE);
+ WebsocketCollector.send(Constants.SYS_DEFAULT_NAMESPACE_ID, "m2",
DataEventTypeEnum.CREATE);
+ WebsocketCollector.send(Constants.SYS_DEFAULT_NAMESPACE_ID, "m3",
DataEventTypeEnum.CREATE);
+ WebsocketCollector.send(Constants.SYS_DEFAULT_NAMESPACE_ID, "m4",
DataEventTypeEnum.CREATE);
+
+ verify(async, times(1)).sendText(eq("m1"), any(SendHandler.class));
+ verify(session, times(1)).close();
+ assertEquals(0, sendQueueSize());
+ }
+
@Test
void testSendDoesNotWaitForOtherSession() {
Session anotherSession = mock(Session.class);
@@ -640,4 +718,11 @@ public final class WebsocketCollectorTest {
private Session getSession() {
return (Session) ThreadLocalUtils.get("sessionKey");
}
+
+ private void waitUntil(final java.util.function.Supplier<Boolean>
condition) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + 3000L;
+ while (!condition.get() && System.currentTimeMillis() < deadline) {
+ Thread.sleep(20L);
+ }
+ }
}
diff --git
a/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-logging-rocketmq/src/test/java/org/apache/shenyu/e2e/testcase/logging/rocketmq/DividePluginTest.java
b/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-logging-rocketmq/src/test/java/org/apache/shenyu/e2e/testcase/logging/rocketmq/DividePluginTest.java
index 66f6ee5bbe..1cedb6e687 100644
---
a/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-logging-rocketmq/src/test/java/org/apache/shenyu/e2e/testcase/logging/rocketmq/DividePluginTest.java
+++
b/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-logging-rocketmq/src/test/java/org/apache/shenyu/e2e/testcase/logging/rocketmq/DividePluginTest.java
@@ -30,6 +30,8 @@ import
org.apache.shenyu.e2e.engine.scenario.specification.CaseSpec;
import org.apache.shenyu.e2e.enums.ServiceTypeEnum;
import org.apache.shenyu.e2e.model.ResourcesData;
import org.apache.shenyu.e2e.model.data.BindingData;
+import org.apache.shenyu.e2e.model.data.RuleCacheData;
+import org.apache.shenyu.e2e.model.data.SelectorCacheData;
import org.apache.shenyu.e2e.model.response.SelectorDTO;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
@@ -37,12 +39,15 @@ import org.junit.jupiter.api.BeforeEach;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.stream.Collectors;
import static
org.apache.shenyu.e2e.constant.Constants.SYS_DEFAULT_NAMESPACE_NAMESPACE_ID;
+import static org.awaitility.Awaitility.await;
@ShenYuTest(environments = {
@ShenYuTest.Environment(
@@ -75,12 +80,13 @@ public class DividePluginTest {
spec.getChecker().check(gateway);
ResourcesData resources = spec.getResources();
+ List<String> ruleIds = Lists.newArrayList();
for (ResourcesData.Resource res : resources.getResources()) {
SelectorDTO dto = client.create(res.getSelector());
selectorIds.add(dto.getId());
res.getRules().forEach(rule -> {
rule.setSelectorId(dto.getId());
- client.create(rule);
+ ruleIds.add(client.create(rule).getId());
});
BindingData bindingData = res.getBindingData();
if (Objects.nonNull(bindingData)) {
@@ -90,6 +96,10 @@ public class DividePluginTest {
}
}
+ // Admin creation does not imply that the gateway has received the
logging rules.
+ if (!selectorIds.isEmpty()) {
+ waitForLoggingRules(gateway, selectorIds, ruleIds);
+ }
spec.getWaiting().waitFor(gateway);
}
@@ -100,6 +110,13 @@ public class DividePluginTest {
selectorIds = Lists.newArrayList();
}
+ static void waitForLoggingRules(final GatewayClient gateway, final
List<String> selectors, final List<String> rules) {
+ await().alias("RocketMQ selectors and rules synchronized to gateway")
+ .atMost(Duration.ofSeconds(30))
+ .until(() ->
gateway.getSelectorCache().stream().map(SelectorCacheData::getId).collect(Collectors.toSet()).containsAll(selectors)
+ &&
gateway.getRuleCache().stream().map(RuleCacheData::getId).collect(Collectors.toSet()).containsAll(rules));
+ }
+
@BeforeAll
void setup(final AdminClient adminClient, final GatewayClient
gatewayClient) throws Exception {
adminClient.login();
diff --git
a/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-logging-rocketmq/src/test/java/org/apache/shenyu/e2e/testcase/logging/rocketmq/LoggingRuleSyncTest.java
b/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-logging-rocketmq/src/test/java/org/apache/shenyu/e2e/testcase/logging/rocketmq/LoggingRuleSyncTest.java
new file mode 100644
index 0000000000..b0403346f9
--- /dev/null
+++
b/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-logging-rocketmq/src/test/java/org/apache/shenyu/e2e/testcase/logging/rocketmq/LoggingRuleSyncTest.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.shenyu.e2e.testcase.logging.rocketmq;
+
+import org.apache.shenyu.e2e.client.gateway.GatewayClient;
+import org.apache.shenyu.e2e.model.data.RuleCacheData;
+import org.apache.shenyu.e2e.model.data.SelectorCacheData;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class LoggingRuleSyncTest {
+
+ @Test
+ void waitsForNewIdsEvenWhenCacheSizesMatch() {
+ AtomicInteger selectorReads = new AtomicInteger();
+ AtomicInteger ruleReads = new AtomicInteger();
+ GatewayClient gateway = new GatewayClient("sync-test", "gateway",
"http://localhost", new Properties()) {
+ @Override
+ public List<SelectorCacheData> getSelectorCache() {
+ SelectorCacheData selector = new SelectorCacheData();
+ selector.setId(selectorReads.incrementAndGet() < 3 ?
"old-selector" : "new-selector");
+ return List.of(selector);
+ }
+
+ @Override
+ public List<RuleCacheData> getRuleCache() {
+ RuleCacheData rule = new RuleCacheData();
+ rule.setId(ruleReads.incrementAndGet() < 3 ? "old-rule" :
"new-rule");
+ return List.of(rule);
+ }
+ };
+
+ DividePluginTest.waitForLoggingRules(gateway, List.of("new-selector"),
List.of("new-rule"));
+
+ assertTrue(selectorReads.get() >= 3);
+ assertTrue(ruleReads.get() >= 3);
+ }
+
+ @Test
+ void acceptsAlreadySynchronizedRulesAlongsideUnrelatedData() {
+ GatewayClient gateway = new GatewayClient("sync-test", "gateway",
"http://localhost", new Properties()) {
+ @Override
+ public List<SelectorCacheData> getSelectorCache() {
+ SelectorCacheData selector = new SelectorCacheData();
+ selector.setId("new-selector");
+ return List.of(selector, new SelectorCacheData());
+ }
+
+ @Override
+ public List<RuleCacheData> getRuleCache() {
+ RuleCacheData rule = new RuleCacheData();
+ rule.setId("new-rule");
+ return List.of(rule, new RuleCacheData());
+ }
+ };
+
+ DividePluginTest.waitForLoggingRules(gateway, List.of("new-selector"),
List.of("new-rule"));
+ }
+}