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 ec9d1c113a fix(admin): drop closed websocket sessions from the 
namespace map (#7317)
ec9d1c113a is described below

commit ec9d1c113a9c81290410f4b13c8d55524bb21328
Author: Burak Kalaycı <[email protected]>
AuthorDate: Sat Sep 26 14:16:21 2026 +0300

    fix(admin): drop closed websocket sessions from the namespace map (#7317)
    
    @OnClose runs after the session is already closed, so reading the
    namespace from the session skipped NAMESPACE_SESSION_MAP removal.
    Remember the namespace at registration and remove every index from
    that record. Reject a missing namespace before any index insert.
    
    Fixes #7315
    
    Co-authored-by: aias00 <[email protected]>
---
 .../listener/websocket/WebsocketCollector.java     |  62 +++++++++---
 .../listener/websocket/WebsocketCollectorTest.java | 105 +++++++++++++++++++++
 2 files changed, 153 insertions(+), 14 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 7260b5ac70..c4d7b578c5 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
@@ -47,6 +47,7 @@ import jakarta.websocket.Session;
 import jakarta.websocket.server.ServerEndpoint;
 
 import java.util.ArrayDeque;
+import java.util.ArrayList;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
@@ -69,6 +70,12 @@ public class WebsocketCollector {
     private static final Map<String, Set<Session>> NAMESPACE_SESSION_MAP = 
Maps.newConcurrentMap();
 
     private static final Map<Session, SessionSendQueue> SESSION_SEND_QUEUES = 
Maps.newConcurrentMap();
+
+    /**
+     * 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.
+     */
+    private static final Map<Session, String> SESSION_NAMESPACE_IDS = 
Maps.newConcurrentMap();
     
     private static final String SESSION_KEY = "sessionKey";
     
@@ -82,14 +89,18 @@ public class WebsocketCollector {
         String clientIp = getClientIp(session);
         LOG.info("websocket on client[{}] open successful, 
maxTextMessageBufferSize: {}",
                 clientIp, session.getMaxTextMessageBufferSize());
-        SESSION_SET.add(session);
-        
         String namespaceId = getNamespaceId(session);
         if (StringUtils.isBlank(namespaceId)) {
             throw new ShenyuException("websocket on client open failed, 
namespaceId is null");
         }
+        SESSION_SET.add(session);
+        SESSION_NAMESPACE_IDS.put(session, namespaceId);
         LOG.info("websocket on client[{}] open successful, namespaceId: {}", 
clientIp, namespaceId);
-        NAMESPACE_SESSION_MAP.computeIfAbsent(namespaceId, k -> 
Sets.newConcurrentHashSet()).add(session);
+        NAMESPACE_SESSION_MAP.compute(namespaceId, (id, sessions) -> {
+            Set<Session> registered = Objects.isNull(sessions) ? 
Sets.newConcurrentHashSet() : sessions;
+            registered.add(session);
+            return registered;
+        });
     }
     
     private static String getClientIp(final Session session) {
@@ -251,12 +262,17 @@ public class WebsocketCollector {
                 if (session.isOpen()) {
                     sendMessageBySession(session, message);
                 } else {
-                    SESSION_SET.remove(session);
-                    removeSessionSendQueue(session);
+                    removeSessionIndexes(session);
                 }
             }
         } else {
-            SESSION_SET.forEach(session -> sendMessageBySession(session, 
message));
+            for (Session registered : new ArrayList<>(SESSION_SET)) {
+                if (registered.isOpen()) {
+                    sendMessageBySession(registered, message);
+                } else {
+                    removeSessionIndexes(registered);
+                }
+            }
         }
         
     }
@@ -282,13 +298,21 @@ public class WebsocketCollector {
                 if (session.isOpen()) {
                     sendMessageBySession(session, message);
                 } else {
-                    NAMESPACE_SESSION_MAP.getOrDefault(namespaceId, 
Sets.newConcurrentHashSet()).remove(session);
-                    removeSessionSendQueue(session);
+                    removeSessionIndexes(session);
                 }
             }
         } else {
-            NAMESPACE_SESSION_MAP.getOrDefault(namespaceId, 
Sets.newConcurrentHashSet())
-                    .forEach(session -> sendMessageBySession(session, 
message));
+            Set<Session> sessions = NAMESPACE_SESSION_MAP.get(namespaceId);
+            if (Objects.isNull(sessions) || sessions.isEmpty()) {
+                return;
+            }
+            for (Session registered : new ArrayList<>(sessions)) {
+                if (registered.isOpen()) {
+                    sendMessageBySession(registered, message);
+                } else {
+                    removeSessionIndexes(registered);
+                }
+            }
         }
         
     }
@@ -304,14 +328,24 @@ public class WebsocketCollector {
         }
     }
     
-    private void clearSession(final Session session) {
+    private static void clearSession(final Session session) {
+        removeSessionIndexes(session);
+        ThreadLocalUtils.clear();
+    }
+
+    private static void removeSessionIndexes(final Session session) {
         SESSION_SET.remove(session);
         removeSessionSendQueue(session);
-        String namespaceId = getNamespaceId(session);
+        String namespaceId = SESSION_NAMESPACE_IDS.remove(session);
         if (StringUtils.isNotBlank(namespaceId)) {
-            NAMESPACE_SESSION_MAP.getOrDefault(namespaceId, 
Sets.newConcurrentHashSet()).remove(session);
+            NAMESPACE_SESSION_MAP.compute(namespaceId, (id, sessions) -> {
+                if (Objects.isNull(sessions)) {
+                    return null;
+                }
+                sessions.remove(session);
+                return sessions.isEmpty() ? null : sessions;
+            });
         }
-        ThreadLocalUtils.clear();
     }
     
     private static String maskSensitive(final String json) {
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 72e1f1ac7e..0c3cba451c 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
@@ -53,8 +53,10 @@ import java.util.Objects;
 import java.util.Set;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
@@ -132,6 +134,11 @@ public final class WebsocketCollectorTest {
         if (Objects.nonNull(sessionSendQueues)) {
             sessionSendQueues.clear();
         }
+        Map<Session, ?> sessionNamespaces =
+                (Map<Session, ?>) 
ReflectionTestUtils.getField(WebsocketCollector.class, "SESSION_NAMESPACE_IDS");
+        if (Objects.nonNull(sessionNamespaces)) {
+            sessionNamespaces.clear();
+        }
     }
 
     @Test
@@ -249,6 +256,89 @@ public final class WebsocketCollectorTest {
         assertNull(getSession());
     }
 
+    @Test
+    void testOnCloseRemovesAlreadyClosedSessionFromNamespaceMap() {
+        websocketCollector.onOpen(session);
+        assertEquals(1, 
namespaceSessionCount(Constants.SYS_DEFAULT_NAMESPACE_ID));
+        when(session.isOpen()).thenReturn(false);
+        doNothing().when(loggerSpy).warn(anyString(), anyString());
+
+        websocketCollector.onClose(session);
+
+        assertEquals(0L, getSessionSetSize());
+        
assertFalse(namespaceMap().containsKey(Constants.SYS_DEFAULT_NAMESPACE_ID));
+        assertEquals(0, sendQueueSize());
+    }
+
+    @Test
+    void testOnErrorRemovesAlreadyClosedSessionFromNamespaceMap() {
+        websocketCollector.onOpen(session);
+        when(session.isOpen()).thenReturn(false);
+        doNothing().when(loggerSpy).error(anyString(), anyString(), 
isA(Throwable.class));
+
+        websocketCollector.onError(session, new 
IllegalStateException("closed"));
+
+        assertEquals(0L, getSessionSetSize());
+        
assertFalse(namespaceMap().containsKey(Constants.SYS_DEFAULT_NAMESPACE_ID));
+    }
+
+    @Test
+    void testRepeatedCloseOfAlreadyClosedSessionIsIdempotent() {
+        websocketCollector.onOpen(session);
+        when(session.isOpen()).thenReturn(false);
+        doNothing().when(loggerSpy).warn(anyString(), anyString());
+
+        websocketCollector.onClose(session);
+        websocketCollector.onClose(session);
+        websocketCollector.onError(session, new IllegalStateException("closed 
again"));
+
+        assertEquals(0L, getSessionSetSize());
+        assertTrue(namespaceMap().isEmpty());
+    }
+
+    @Test
+    void testMissingNamespaceLeavesNoPartialRegistration() {
+        Map<String, Object> userProperties = new HashMap<>();
+        when(session.getUserProperties()).thenReturn(userProperties);
+
+        assertThrows(ShenyuException.class, () -> 
websocketCollector.onOpen(session));
+
+        assertEquals(0L, getSessionSetSize());
+        assertTrue(namespaceMap().isEmpty());
+        assertEquals(0, sendQueueSize());
+    }
+
+    @Test
+    void testReconnectDoesNotAccumulateClosedSessions() {
+        doNothing().when(loggerSpy).warn(anyString(), anyString());
+        for (int i = 0; i < 3; i++) {
+            Session reconnect = mock(Session.class);
+            Map<String, Object> props = new HashMap<>();
+            props.put(Constants.SHENYU_NAMESPACE_ID, 
Constants.SYS_DEFAULT_NAMESPACE_ID);
+            when(reconnect.isOpen()).thenReturn(true);
+            when(reconnect.getUserProperties()).thenReturn(props);
+            websocketCollector.onOpen(reconnect);
+            when(reconnect.isOpen()).thenReturn(false);
+            websocketCollector.onClose(reconnect);
+        }
+
+        assertEquals(0L, getSessionSetSize());
+        assertTrue(namespaceMap().isEmpty());
+    }
+
+    @Test
+    void testNamespaceBroadcastSkipsClosedSession() {
+        RemoteEndpoint.Async async = mock(RemoteEndpoint.Async.class);
+        when(session.getAsyncRemote()).thenReturn(async);
+        websocketCollector.onOpen(session);
+        when(session.isOpen()).thenReturn(false);
+
+        WebsocketCollector.send(Constants.SYS_DEFAULT_NAMESPACE_ID, 
"stale-msg", DataEventTypeEnum.CREATE);
+
+        verify(async, never()).sendText(anyString(), any(SendHandler.class));
+        
assertFalse(namespaceMap().containsKey(Constants.SYS_DEFAULT_NAMESPACE_ID));
+    }
+
     @Test
     void testOnCloseWithBlankNamespaceId() {
         // Session with no namespace — clearSession should still work (blank 
namespaceId branch)
@@ -454,6 +544,21 @@ public final class WebsocketCollectorTest {
         return Objects.isNull(sessionSet) ? -1 : sessionSet.size();
     }
 
+    @SuppressWarnings("unchecked")
+    private Map<String, Set<Session>> namespaceMap() {
+        return (Map<String, Set<Session>>) 
ReflectionTestUtils.getField(WebsocketCollector.class, "NAMESPACE_SESSION_MAP");
+    }
+
+    private int namespaceSessionCount(final String namespaceId) {
+        Set<Session> sessions = namespaceMap().get(namespaceId);
+        return Objects.isNull(sessions) ? 0 : sessions.size();
+    }
+
+    private int sendQueueSize() {
+        Map<?, ?> queues = (Map<?, ?>) 
ReflectionTestUtils.getField(WebsocketCollector.class, "SESSION_SEND_QUEUES");
+        return Objects.isNull(queues) ? 0 : queues.size();
+    }
+
     private Session getSession() {
         return (Session) ThreadLocalUtils.get("sessionKey");
     }

Reply via email to