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 8b3b0d330f [ISSUE #6461] Follow advertised WebSocket master addresses 
(#7661)
8b3b0d330f is described below

commit 8b3b0d330fb566e2b99bc674cada10c7224a37df
Author: redyyang <[email protected]>
AuthorDate: Mon Oct 19 09:34:15 2026 +0800

    [ISSUE #6461] Follow advertised WebSocket master addresses (#7661)
    
    * fix(sync): follow advertised websocket master addresses
    
    * test(e2e): use local HTTP fixture for wait helper checks
    
    * test(e2e): use a local upstream for storage and cluster routing
    
    * test(e2e): retain RocketMQ logs produced before queue assignment
    
    * test(e2e): await discovered Spring Cloud route readiness
    
    * test(e2e): match the Spring Cloud controller response
    
    * test(e2e): explicitly assign RocketMQ log queues after topic creation
---
 .github/workflows/e2e-k8s.yml                      |   2 +
 .../shenyu-e2e-case/compose/fixtures/httpbin.py    |  57 ++++
 .../shenyu-e2e-case/compose/fixtures/httpbin.yml   |  31 +++
 .../compose/fixtures/test_httpbin.py               |  97 +++++++
 .../compose/storage/shenyu-storage-h2.yml          |   6 +
 .../compose/storage/shenyu-storage-mysql.yml       |   6 +
 .../compose/storage/shenyu-storage-opengauss.yml   |   6 +
 .../compose/storage/shenyu-storage-postgres.yml    |   6 +
 .../sync/shenyu-sync-websocket-cluster-jdbc.yml    |   6 +
 .../shenyu-sync-websocket-cluster-zookeeper.yml    |   6 +
 .../logging/rocketmq/DividePluginCases.java        |  75 +++--
 .../rocketmq/RocketMQLogConsumptionTest.java       |  94 +++++++
 .../shenyu-e2e-case-spring-cloud/pom.xml           |   9 +
 .../springcloud/SpringCloudPluginTest.java         |  28 +-
 .../springcloud/SpringCloudRouteReadinessTest.java |  57 ++++
 .../e2e/engine/function/WaitForHelperTest.java     |  69 ++++-
 .../data/websocket/WebsocketSyncDataService.java   |  36 ++-
 .../websocket/client/ShenyuWebsocketClient.java    |  12 +-
 .../websocket/WebsocketMasterRedirectTest.java     | 310 +++++++++++++++++++++
 .../client/ShenyuWebsocketClientTest.java          |  14 +
 20 files changed, 882 insertions(+), 45 deletions(-)

diff --git a/.github/workflows/e2e-k8s.yml b/.github/workflows/e2e-k8s.yml
index ea72398f4c..7788f34f70 100644
--- a/.github/workflows/e2e-k8s.yml
+++ b/.github/workflows/e2e-k8s.yml
@@ -63,6 +63,8 @@ jobs:
           submodules: true
       - name: Test conditional disk cleanup
         run: bash .github/scripts/free-disk-space-test.sh
+      - name: Test local HTTP upstream fixture and Compose wiring
+        run: python3 -B 
shenyu-e2e/shenyu-e2e-case/compose/fixtures/test_httpbin.py
       - uses: ./actions/paths-filter
         id: filter
         with:
diff --git a/shenyu-e2e/shenyu-e2e-case/compose/fixtures/httpbin.py 
b/shenyu-e2e/shenyu-e2e-case/compose/fixtures/httpbin.py
new file mode 100644
index 0000000000..08c291145c
--- /dev/null
+++ b/shenyu-e2e/shenyu-e2e-case/compose/fixtures/httpbin.py
@@ -0,0 +1,57 @@
+# 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.
+
+"""Local upstream for the Divide URI and HTTP method routing scenarios."""
+
+import json
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from urllib.parse import parse_qs, urlsplit
+
+
+class HttpbinHandler(BaseHTTPRequestHandler):
+    protocol_version = "HTTP/1.1"
+
+    def respond(self):
+        path = urlsplit(self.path)
+        data = self.rfile.read(int(self.headers.get("Content-Length", "0")))
+        status = 200
+        if path.path == "/status/200":
+            body = b""
+        elif path.path == "/anything" or path.path.startswith("/anything/"):
+            body = json.dumps({
+                "method": self.command,
+                "url": self.path,
+                "args": parse_qs(path.query),
+                "data": data.decode("utf-8"),
+            }).encode("utf-8")
+        else:
+            status = 404
+            body = b'{"error":"unknown fixture endpoint"}'
+        self.send_response(status)
+        self.send_header("Content-Type", "application/json")
+        self.send_header("Content-Length", str(len(body)))
+        self.end_headers()
+        if self.command != "HEAD":
+            self.wfile.write(body)
+
+    do_GET = respond
+    do_POST = respond
+    do_PUT = respond
+    do_DELETE = respond
+    do_HEAD = respond
+
+
+if __name__ == "__main__":
+    ThreadingHTTPServer(("0.0.0.0", 80), HttpbinHandler).serve_forever()
diff --git a/shenyu-e2e/shenyu-e2e-case/compose/fixtures/httpbin.yml 
b/shenyu-e2e/shenyu-e2e-case/compose/fixtures/httpbin.yml
new file mode 100644
index 0000000000..762d031d12
--- /dev/null
+++ b/shenyu-e2e/shenyu-e2e-case/compose/fixtures/httpbin.yml
@@ -0,0 +1,31 @@
+# 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.
+
+services:
+  httpbin:
+    image: python:3.12-alpine
+    command: ["python", "-B", "-u", "/opt/httpbin.py"]
+    volumes:
+      - ./httpbin.py:/opt/httpbin.py:ro
+    healthcheck:
+      test: ["CMD", "python", "-c", "import urllib.request; 
urllib.request.urlopen('http://127.0.0.1/anything', timeout=2)"]
+      interval: 2s
+      timeout: 3s
+      retries: 15
+    networks:
+      shenyu:
+        # Existing routing scenarios resolve their upstream on this test 
network.
+        aliases:
+          - httpbin.org
diff --git a/shenyu-e2e/shenyu-e2e-case/compose/fixtures/test_httpbin.py 
b/shenyu-e2e/shenyu-e2e-case/compose/fixtures/test_httpbin.py
new file mode 100644
index 0000000000..ec0741a1a6
--- /dev/null
+++ b/shenyu-e2e/shenyu-e2e-case/compose/fixtures/test_httpbin.py
@@ -0,0 +1,97 @@
+# 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.
+
+"""Exercise the upstream over HTTP and validate every consuming Compose 
file."""
+
+import http.client
+import json
+from pathlib import Path
+import subprocess
+import threading
+import unittest
+
+from httpbin import HttpbinHandler, ThreadingHTTPServer
+
+
+class HttpbinTest(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        cls.server = ThreadingHTTPServer(("127.0.0.1", 0), HttpbinHandler)
+        cls.thread = threading.Thread(target=cls.server.serve_forever)
+        cls.thread.start()
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.server.shutdown()
+        cls.server.server_close()
+        cls.thread.join()
+
+    def setUp(self):
+        self.connection = 
http.client.HTTPConnection(*self.server.server_address, timeout=3)
+
+    def tearDown(self):
+        self.connection.close()
+
+    def test_echo_methods_paths_and_request_bodies(self):
+        for method in ("GET", "POST", "PUT", "DELETE"):
+            for path in ("/anything", "/anything/xx", "/anything/xx/yy", 
"/anything/xxx"):
+                with self.subTest(method=method, path=path):
+                    self.connection.request(method, path + "?id=23", 
body="request body")
+                    response = self.connection.getresponse()
+                    self.assertEqual(200, response.status)
+                    body = json.loads(response.read())
+                    self.assertEqual(method, body["method"])
+                    self.assertEqual(path + "?id=23", body["url"])
+                    self.assertEqual({"id": ["23"]}, body["args"])
+                    self.assertEqual("request body", body["data"])
+
+    def test_status_200_has_empty_body_for_every_method(self):
+        for method in ("GET", "POST", "PUT", "DELETE", "HEAD"):
+            with self.subTest(method=method):
+                self.connection.request(method, "/status/200")
+                response = self.connection.getresponse()
+                self.assertEqual(200, response.status)
+                self.assertEqual(b"", response.read())
+
+    def test_unknown_path_is_not_a_success(self):
+        self.connection.request("GET", "/not-a-fixture")
+        response = self.connection.getresponse()
+        self.assertEqual(404, response.status)
+        response.read()
+
+    def test_compose_uses_healthy_local_upstream(self):
+        directory = Path(__file__).resolve().parent
+        compose = directory.parent
+        files = sorted((compose / "storage").glob("shenyu-storage-*.yml"))
+        files += sorted((compose / 
"sync").glob("shenyu-sync-websocket-cluster-*.yml"))
+        self.assertEqual(6, len(files))
+        for path in files:
+            with self.subTest(compose=path.name):
+                result = subprocess.run(
+                    ["docker", "compose", "-f", str(path), "config", 
"--format", "json"],
+                    check=True, capture_output=True, text=True,
+                )
+                services = json.loads(result.stdout)["services"]
+                fixture = services["httpbin"]
+                self.assertIn("httpbin.org", 
fixture["networks"]["shenyu"]["aliases"])
+                self.assertEqual("service_healthy", 
services["shenyu-bootstrap"]["depends_on"]["httpbin"]["condition"])
+                mount = next(v for v in fixture["volumes"] if v["target"] == 
"/opt/httpbin.py")
+                self.assertEqual(directory / "httpbin.py", 
Path(mount["source"]))
+                self.assertTrue(mount["read_only"])
+                self.assertIn("/anything", fixture["healthcheck"]["test"][-1])
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/shenyu-e2e/shenyu-e2e-case/compose/storage/shenyu-storage-h2.yml 
b/shenyu-e2e/shenyu-e2e-case/compose/storage/shenyu-storage-h2.yml
index c35ccd1dbe..a722a0501f 100644
--- a/shenyu-e2e/shenyu-e2e-case/compose/storage/shenyu-storage-h2.yml
+++ b/shenyu-e2e/shenyu-e2e-case/compose/storage/shenyu-storage-h2.yml
@@ -17,6 +17,10 @@
 version: '3.9'
 
 services:
+  httpbin:
+    extends:
+      file: ../fixtures/httpbin.yml
+      service: httpbin
 
   shenyu-admin:
     image: apache/shenyu-admin:latest
@@ -63,6 +67,8 @@ services:
       start_period: 30s
     restart: always
     depends_on:
+      httpbin:
+        condition: service_healthy
       shenyu-admin:
         condition: service_healthy
     networks:
diff --git 
a/shenyu-e2e/shenyu-e2e-case/compose/storage/shenyu-storage-mysql.yml 
b/shenyu-e2e/shenyu-e2e-case/compose/storage/shenyu-storage-mysql.yml
index 06f0b40476..2141c9d9a8 100644
--- a/shenyu-e2e/shenyu-e2e-case/compose/storage/shenyu-storage-mysql.yml
+++ b/shenyu-e2e/shenyu-e2e-case/compose/storage/shenyu-storage-mysql.yml
@@ -17,6 +17,10 @@
 version: '3.9'
 
 services:
+  httpbin:
+    extends:
+      file: ../fixtures/httpbin.yml
+      service: httpbin
   shenyu-mysql:
     image: mysql:8.0
     container_name: shenyu-mysql
@@ -98,6 +102,8 @@ services:
       start_period: 30s
     restart: always
     depends_on:
+      httpbin:
+        condition: service_healthy
       shenyu-admin:
         condition: service_healthy
     networks:
diff --git 
a/shenyu-e2e/shenyu-e2e-case/compose/storage/shenyu-storage-opengauss.yml 
b/shenyu-e2e/shenyu-e2e-case/compose/storage/shenyu-storage-opengauss.yml
index d0c3b66228..53f62e6303 100644
--- a/shenyu-e2e/shenyu-e2e-case/compose/storage/shenyu-storage-opengauss.yml
+++ b/shenyu-e2e/shenyu-e2e-case/compose/storage/shenyu-storage-opengauss.yml
@@ -17,6 +17,10 @@
 version: '3.9'
 
 services:
+  httpbin:
+    extends:
+      file: ../fixtures/httpbin.yml
+      service: httpbin
   shenyu-opengauss:
     image: enmotech/opengauss-lite:5.0.1
     container_name: shenyu-opengauss
@@ -97,6 +101,8 @@ services:
       start_period: 30s
     restart: always
     depends_on:
+      httpbin:
+        condition: service_healthy
       shenyu-admin:
         condition: service_healthy
     networks:
diff --git 
a/shenyu-e2e/shenyu-e2e-case/compose/storage/shenyu-storage-postgres.yml 
b/shenyu-e2e/shenyu-e2e-case/compose/storage/shenyu-storage-postgres.yml
index 29e3e87aca..147d78b699 100644
--- a/shenyu-e2e/shenyu-e2e-case/compose/storage/shenyu-storage-postgres.yml
+++ b/shenyu-e2e/shenyu-e2e-case/compose/storage/shenyu-storage-postgres.yml
@@ -17,6 +17,10 @@
 version: '3.9'
 
 services:
+  httpbin:
+    extends:
+      file: ../fixtures/httpbin.yml
+      service: httpbin
   shenyu-postgres:
     image: postgres:14
     container_name: shenyu-postgres
@@ -97,6 +101,8 @@ services:
       start_period: 30s
     restart: always
     depends_on:
+      httpbin:
+        condition: service_healthy
       shenyu-admin:
         condition: service_healthy
     networks:
diff --git 
a/shenyu-e2e/shenyu-e2e-case/compose/sync/shenyu-sync-websocket-cluster-jdbc.yml
 
b/shenyu-e2e/shenyu-e2e-case/compose/sync/shenyu-sync-websocket-cluster-jdbc.yml
index 85ac6bf35c..7142d5d8d5 100644
--- 
a/shenyu-e2e/shenyu-e2e-case/compose/sync/shenyu-sync-websocket-cluster-jdbc.yml
+++ 
b/shenyu-e2e/shenyu-e2e-case/compose/sync/shenyu-sync-websocket-cluster-jdbc.yml
@@ -17,6 +17,10 @@
 version: '3.9'
 
 services:
+  httpbin:
+    extends:
+      file: ../fixtures/httpbin.yml
+      service: httpbin
   shenyu-mysql:
     image: mysql:8.0
     container_name: shenyu-mysql
@@ -133,6 +137,8 @@ services:
       start_period: 30s
     restart: always
     depends_on:
+      httpbin:
+        condition: service_healthy
       shenyu-admin-1:
         condition: service_healthy
       shenyu-admin-2:
diff --git 
a/shenyu-e2e/shenyu-e2e-case/compose/sync/shenyu-sync-websocket-cluster-zookeeper.yml
 
b/shenyu-e2e/shenyu-e2e-case/compose/sync/shenyu-sync-websocket-cluster-zookeeper.yml
index 58d3d41b5b..79dea728be 100644
--- 
a/shenyu-e2e/shenyu-e2e-case/compose/sync/shenyu-sync-websocket-cluster-zookeeper.yml
+++ 
b/shenyu-e2e/shenyu-e2e-case/compose/sync/shenyu-sync-websocket-cluster-zookeeper.yml
@@ -17,6 +17,10 @@
 version: '3.9'
 
 services:
+  httpbin:
+    extends:
+      file: ../fixtures/httpbin.yml
+      service: httpbin
   shenyu-zookeeper-cluster:
     image: zookeeper:3.9
     container_name: shenyu-zookeeper-cluster
@@ -158,6 +162,8 @@ services:
       start_period: 30s
     restart: always
     depends_on:
+      httpbin:
+        condition: service_healthy
       shenyu-admin-1:
         condition: service_healthy
       shenyu-admin-2:
diff --git 
a/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-logging-rocketmq/src/test/java/org/apache/shenyu/e2e/testcase/logging/rocketmq/DividePluginCases.java
 
b/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-logging-rocketmq/src/test/java/org/apache/shenyu/e2e/testcase/logging/rocketmq/DividePluginCases.java
index 87ce0e4a96..a0cb0fdd6a 100644
--- 
a/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-logging-rocketmq/src/test/java/org/apache/shenyu/e2e/testcase/logging/rocketmq/DividePluginCases.java
+++ 
b/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-logging-rocketmq/src/test/java/org/apache/shenyu/e2e/testcase/logging/rocketmq/DividePluginCases.java
@@ -19,10 +19,11 @@ package org.apache.shenyu.e2e.testcase.logging.rocketmq;
 
 import com.google.common.collect.Lists;
 import io.restassured.http.Method;
-import org.apache.commons.collections.CollectionUtils;
-import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
-import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
-import 
org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
+import org.apache.rocketmq.client.consumer.DefaultLitePullConsumer;
+import org.apache.rocketmq.client.exception.MQClientException;
+import org.apache.rocketmq.common.consumer.ConsumeFromWhere;
+import org.apache.rocketmq.common.message.MessageQueue;
+import org.apache.rocketmq.common.protocol.ResponseCode;
 import org.apache.shenyu.e2e.engine.scenario.ShenYuScenarioProvider;
 import org.apache.shenyu.e2e.engine.scenario.specification.ScenarioSpec;
 import 
org.apache.shenyu.e2e.engine.scenario.specification.ShenYuBeforeEachSpec;
@@ -35,8 +36,12 @@ import org.junit.jupiter.api.Assertions;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.nio.charset.StandardCharsets;
 import java.time.Duration;
+import java.util.Collection;
 import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 import static 
org.apache.shenyu.e2e.engine.scenario.function.HttpCheckers.exists;
@@ -55,7 +60,7 @@ public class DividePluginCases implements 
ShenYuScenarioProvider {
 
     private static final String TEST = "/http/order/findById?id=123";
 
-    private static final Duration LOG_CONSUME_TIMEOUT = Duration.ofSeconds(30);
+    private static final Duration LOG_CONSUME_TIMEOUT = Duration.ofSeconds(60);
 
     private static final Logger LOG = 
LoggerFactory.getLogger(DividePluginCases.class);
 
@@ -79,6 +84,42 @@ public class DividePluginCases implements 
ShenYuScenarioProvider {
                 .build();
     }
 
+    static void awaitRequestLog(final DefaultLitePullConsumer consumer, final 
String endpoint) {
+        AtomicBoolean assigned = new AtomicBoolean();
+        await().alias("RocketMQ access log for " + endpoint)
+                
.atMost(LOG_CONSUME_TIMEOUT).pollInterval(Duration.ofMillis(200))
+                .until(() -> {
+                    if (!assigned.get()) {
+                        Collection<MessageQueue> queues;
+                        try {
+                            queues = consumer.fetchMessageQueues(TOPIC);
+                        } catch (MQClientException ex) {
+                            Throwable cause = ex;
+                            while (Objects.nonNull(cause)) {
+                                if (cause instanceof MQClientException && 
((MQClientException) cause).getResponseCode() == ResponseCode.TOPIC_NOT_EXIST) {
+                                    return false;
+                                }
+                                cause = cause.getCause();
+                            }
+                            throw ex;
+                        }
+                        if (queues.isEmpty()) {
+                            return false;
+                        }
+                        consumer.assign(queues);
+                        for (MessageQueue queue : queues) {
+                            consumer.seekToBegin(queue);
+                        }
+                        assigned.set(true);
+                    }
+                    return consumer.poll(200).stream().anyMatch(message -> {
+                        String body = new String(message.getBody(), 
StandardCharsets.UTF_8);
+                        LOG.info("RocketMQ access log: {}", body);
+                        return body.contains(endpoint);
+                    });
+                });
+    }
+
     private ShenYuScenarioSpec testRocketMQHello() {
         return ShenYuScenarioSpec.builder()
                 .name("testRocketMQHello")
@@ -102,27 +143,17 @@ public class DividePluginCases implements 
ShenYuScenarioProvider {
                 .caseSpec(
                         ShenYuCaseSpec.builder()
                                 .add(request -> {
-                                    AtomicBoolean isLog = new 
AtomicBoolean(false);
-                                    DefaultMQPushConsumer consumer = new 
DefaultMQPushConsumer(CONSUMERGROUP);
+                                    String requestId = 
UUID.randomUUID().toString();
+                                    String endpoint = 
"/http/order/findById?id=23&e2e=" + requestId;
+                                    DefaultLitePullConsumer consumer = new 
DefaultLitePullConsumer(CONSUMERGROUP + "-" + requestId);
                                     try {
                                         consumer.setNamesrvAddr(NAMESERVER);
-                                        consumer.subscribe(TOPIC, "*");
-                                        
consumer.registerMessageListener((MessageListenerConcurrently) (msgs, 
consumeConcurrentlyContext) -> {
-                                            LOG.info("Msg:{}", msgs);
-                                            if 
(CollectionUtils.isNotEmpty(msgs)) {
-                                                msgs.forEach(e -> {
-                                                    if (new 
String(e.getBody()).contains("/http/order/findById?id=23")) {
-                                                        isLog.set(true);
-                                                    }
-                                                });
-                                            }
-                                            return 
ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
-                                        });
+                                        
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
+                                        consumer.setAutoCommit(false);
                                         consumer.start();
                                         LOG.info("RocketMQ consumer started");
-                                        request.request(Method.GET, 
"/http/order/findById?id=23");
-                                        
await().atMost(LOG_CONSUME_TIMEOUT).untilTrue(isLog);
-                                        LOG.info("isLog.get():{}", 
isLog.get());
+                                        request.request(Method.GET, 
endpoint).then().statusCode(200);
+                                        awaitRequestLog(consumer, endpoint);
                                     } catch (Exception e) {
                                         LOG.error("error", e);
                                         Assertions.fail("Failed to consume 
RocketMQ access log", e);
diff --git 
a/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-logging-rocketmq/src/test/java/org/apache/shenyu/e2e/testcase/logging/rocketmq/RocketMQLogConsumptionTest.java
 
b/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-logging-rocketmq/src/test/java/org/apache/shenyu/e2e/testcase/logging/rocketmq/RocketMQLogConsumptionTest.java
new file mode 100644
index 0000000000..d620ca3c1f
--- /dev/null
+++ 
b/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-logging-rocketmq/src/test/java/org/apache/shenyu/e2e/testcase/logging/rocketmq/RocketMQLogConsumptionTest.java
@@ -0,0 +1,94 @@
+/*
+ * 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.rocketmq.client.consumer.DefaultLitePullConsumer;
+import org.apache.rocketmq.client.exception.MQClientException;
+import org.apache.rocketmq.common.message.MessageExt;
+import org.apache.rocketmq.common.message.MessageQueue;
+import org.apache.rocketmq.common.protocol.ResponseCode;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class RocketMQLogConsumptionTest {
+
+    @Test
+    void rejectsNonTransientBrokerErrors() {
+        DefaultLitePullConsumer consumer = new 
DefaultLitePullConsumer("denied-log-fixture") {
+            @Override
+            public Collection<MessageQueue> fetchMessageQueues(final String 
topic) throws MQClientException {
+                throw new MQClientException(ResponseCode.NO_PERMISSION, 
"access denied");
+            }
+        };
+
+        MQClientException exception = assertThrows(MQClientException.class, () 
-> DividePluginCases.awaitRequestLog(consumer, "/current-request"));
+        assertEquals(ResponseCode.NO_PERMISSION, exception.getResponseCode());
+    }
+
+    @Test
+    void waitsForTopicCreationAndRejectsUnrelatedLogs() {
+        AtomicInteger discoveries = new AtomicInteger();
+        AtomicInteger polls = new AtomicInteger();
+        AtomicInteger seeks = new AtomicInteger();
+        MessageQueue queue = new MessageQueue("shenyu-access-logging", 
"broker", 0);
+        String endpoint = "/http/order/findById?id=23&e2e=current-request";
+        DefaultLitePullConsumer consumer = new 
DefaultLitePullConsumer("log-fixture") {
+            @Override
+            public Collection<MessageQueue> fetchMessageQueues(final String 
topic) throws MQClientException {
+                if (discoveries.incrementAndGet() == 1) {
+                    throw new MQClientException("topic route pending", new 
MQClientException(ResponseCode.TOPIC_NOT_EXIST, "topic is not published yet"));
+                }
+                return List.of(queue);
+            }
+
+            @Override
+            public void assign(final Collection<MessageQueue> queues) {
+                assertEquals(List.of(queue), queues);
+            }
+
+            @Override
+            public void seekToBegin(final MessageQueue assignedQueue) {
+                assertEquals(queue, assignedQueue);
+                seeks.incrementAndGet();
+            }
+
+            @Override
+            public List<MessageExt> poll(final long timeout) {
+                assertTrue(seeks.get() > 0);
+                MessageExt message = new MessageExt();
+                String uri = polls.incrementAndGet() == 1 ? 
"/http/order/findById?id=23&e2e=old-request" : endpoint;
+                message.setBody(("{\"requestUri\":\"http://gateway"; + uri + 
"\"}").getBytes(StandardCharsets.UTF_8));
+                return List.of(message);
+            }
+        };
+
+        DividePluginCases.awaitRequestLog(consumer, endpoint);
+
+        assertEquals(2, discoveries.get());
+        assertEquals(1, seeks.get());
+        assertEquals(2, polls.get());
+    }
+}
diff --git a/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-spring-cloud/pom.xml 
b/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-spring-cloud/pom.xml
index 4ff46f9ed9..ef1d944bd2 100644
--- a/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-spring-cloud/pom.xml
+++ b/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-spring-cloud/pom.xml
@@ -28,4 +28,13 @@
 
     <artifactId>shenyu-e2e-case-spring-cloud</artifactId>
 
+    <dependencies>
+        <dependency>
+            <groupId>org.awaitility</groupId>
+            <artifactId>awaitility</artifactId>
+            <version>${awaitility.version}</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
 </project>
diff --git 
a/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-spring-cloud/src/test/java/org/apache/shenyu/e2e/testcase/springcloud/SpringCloudPluginTest.java
 
b/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-spring-cloud/src/test/java/org/apache/shenyu/e2e/testcase/springcloud/SpringCloudPluginTest.java
index d2847f639e..5166c418ed 100644
--- 
a/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-spring-cloud/src/test/java/org/apache/shenyu/e2e/testcase/springcloud/SpringCloudPluginTest.java
+++ 
b/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-spring-cloud/src/test/java/org/apache/shenyu/e2e/testcase/springcloud/SpringCloudPluginTest.java
@@ -20,7 +20,6 @@ package org.apache.shenyu.e2e.testcase.springcloud;
 import org.apache.shenyu.e2e.client.WaitDataSync;
 import org.apache.shenyu.e2e.client.admin.AdminClient;
 import org.apache.shenyu.e2e.client.gateway.GatewayClient;
-import org.apache.shenyu.e2e.constant.Constants;
 import org.apache.shenyu.e2e.engine.annotation.ShenYuScenario;
 import org.apache.shenyu.e2e.engine.annotation.ShenYuTest;
 import org.apache.shenyu.e2e.engine.scenario.specification.CaseSpec;
@@ -31,9 +30,11 @@ import org.junit.jupiter.api.BeforeEach;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.HashMap;
+import java.time.Duration;
 import java.util.List;
-import java.util.Map;
+
+import static org.awaitility.Awaitility.await;
+import static org.hamcrest.Matchers.equalTo;
 
 /**
  * Testing spring-cloud plugin.
@@ -74,17 +75,20 @@ public class SpringCloudPluginTest {
         List<SelectorCacheData> selectorCache = 
gatewayClient.getSelectorCache();
         LOG.info("selectorCache: {}", selectorCache);
 
-        Map<String, String> reqBody = new HashMap<>();
-        reqBody.put("pluginId", "5");
-        reqBody.put("name", "divide");
-        reqBody.put("enabled", "true");
-        reqBody.put("role", "Proxy");
-        reqBody.put("sort", "200");
-        reqBody.put("config", "{\"enabled\":true}");
-        reqBody.put("namespaceId", 
Constants.SYS_DEFAULT_NAMESPACE_NAMESPACE_ID);
-        adminClient.changePluginStatus("1801816010882822185", reqBody);
         WaitDataSync.waitGatewayPluginUse(gatewayClient, 
"org.apache.shenyu.plugin.divide.DividePlugin");
+        waitForRegisteredRoute(gatewayClient);
+    }
 
+    static void waitForRegisteredRoute(final GatewayClient gatewayClient) {
+        // Selector/rule synchronization can complete before Eureka publishes 
the upstream.
+        await().alias("Spring Cloud discovery upstream routes through the 
gateway")
+                .atMost(Duration.ofSeconds(90))
+                .pollInterval(Duration.ofMillis(200))
+                .untilAsserted(() -> 
gatewayClient.getHttpRequesterSupplier().get()
+                        .get("/springcloud/order/path/123/hahah")
+                        .then().statusCode(200)
+                        .body("id", equalTo("123"))
+                        .body("name", equalTo("hello world spring cloud 
restful: hahah")));
     }
 
     @ShenYuScenario(provider = SpringCloudPluginCases.class)
diff --git 
a/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-spring-cloud/src/test/java/org/apache/shenyu/e2e/testcase/springcloud/SpringCloudRouteReadinessTest.java
 
b/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-spring-cloud/src/test/java/org/apache/shenyu/e2e/testcase/springcloud/SpringCloudRouteReadinessTest.java
new file mode 100644
index 0000000000..ee1adb4612
--- /dev/null
+++ 
b/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-spring-cloud/src/test/java/org/apache/shenyu/e2e/testcase/springcloud/SpringCloudRouteReadinessTest.java
@@ -0,0 +1,57 @@
+/*
+ * 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.springcloud;
+
+import com.sun.net.httpserver.HttpServer;
+import org.apache.shenyu.e2e.client.gateway.GatewayClient;
+import org.junit.jupiter.api.Test;
+
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Properties;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SpringCloudRouteReadinessTest {
+
+    @Test
+    void waitsForDiscoveredUpstreamAndExpectedResponse() throws Exception {
+        AtomicInteger requests = new AtomicInteger();
+        HttpServer server = HttpServer.create(new 
InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext("/springcloud/order/path/123/hahah", exchange -> {
+            int attempt = requests.incrementAndGet();
+            byte[] body = (attempt < 3 ? 
"{\"code\":-107,\"message\":\"upstream not ready\"}"
+                    : "{\"id\":\"123\",\"name\":\"hello world spring cloud 
restful: hahah\"}").getBytes(StandardCharsets.UTF_8);
+            exchange.getResponseHeaders().set("Content-Type", 
"application/json");
+            exchange.sendResponseHeaders(attempt == 1 ? 503 : 200, 
body.length);
+            try (var output = exchange.getResponseBody()) {
+                output.write(body);
+            }
+        });
+        server.start();
+        try {
+            GatewayClient gateway = new GatewayClient("readiness-test", 
"gateway",
+                    "http://127.0.0.1:"; + server.getAddress().getPort(), new 
Properties());
+            SpringCloudPluginTest.waitForRegisteredRoute(gateway);
+            assertTrue(requests.get() >= 3);
+        } finally {
+            server.stop(0);
+        }
+    }
+}
diff --git 
a/shenyu-e2e/shenyu-e2e-engine/src/test/java/org/apache/shenyu/e2e/engine/function/WaitForHelperTest.java
 
b/shenyu-e2e/shenyu-e2e-engine/src/test/java/org/apache/shenyu/e2e/engine/function/WaitForHelperTest.java
index 10220adb54..9f07f4a5bd 100644
--- 
a/shenyu-e2e/shenyu-e2e-engine/src/test/java/org/apache/shenyu/e2e/engine/function/WaitForHelperTest.java
+++ 
b/shenyu-e2e/shenyu-e2e-engine/src/test/java/org/apache/shenyu/e2e/engine/function/WaitForHelperTest.java
@@ -17,13 +17,23 @@
 
 package org.apache.shenyu.e2e.engine.function;
 
-import org.apache.shenyu.e2e.engine.scenario.function.WaitForHelper;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
 import io.restassured.builder.ResponseSpecBuilder;
 import io.restassured.http.Method;
+import org.apache.shenyu.e2e.engine.scenario.function.WaitForHelper;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
 import java.time.Duration;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 
 import static io.restassured.RestAssured.given;
@@ -31,26 +41,73 @@ import static org.hamcrest.CoreMatchers.containsString;
 import static org.hamcrest.CoreMatchers.equalTo;
 
 public class WaitForHelperTest {
-    
+
+    private final CountDownLatch delayedRequest = new CountDownLatch(1);
+
+    private final CountDownLatch releaseResponse = new CountDownLatch(1);
+
+    private HttpServer server;
+
+    @BeforeEach
+    void setUp() throws IOException {
+        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext("/get", exchange -> writeJson(exchange, 
"{\"url\":\"" + exchange.getRequestURI() + "\"}"));
+        server.createContext("/delay/2", exchange -> {
+            delayedRequest.countDown();
+            try {
+                releaseResponse.await();
+                writeJson(exchange, "{\"delay\":2}");
+            } catch (InterruptedException ex) {
+                Thread.currentThread().interrupt();
+                exchange.close();
+            }
+        });
+        server.start();
+        // Initialize the HTTP client before measuring the helper's short 
timeout.
+        given().baseUri(baseUri()).get("/get").then().statusCode(200);
+    }
+
+    @AfterEach
+    void tearDown() {
+        releaseResponse.countDown();
+        server.stop(0);
+    }
+
     @Test
     void testSuccess() throws TimeoutException {
         new WaitForHelper().waitFor(
-                () -> given().baseUri("https://postman-echo.com";).when(),
+                () -> given().baseUri(baseUri()).when(),
                 Method.GET,
                 "/get?foo1=bar1&foo2=bar2",
                 new ResponseSpecBuilder().expectBody("url", 
containsString("/get")).build()
         );
     }
-    
+
     @Test
-    void testTimeout() {
+    void testTimeout() throws InterruptedException {
         Assertions.assertThrows(TimeoutException.class, () ->
             new WaitForHelper(10, Duration.ofSeconds(1), 
Duration.ofMillis(500)).waitFor(
-                    () -> given().baseUri("https://postman-echo.com";).when(),
+                    () -> given().baseUri(baseUri()).when(),
                     Method.GET,
                     "/delay/2",
                     new ResponseSpecBuilder().expectBody("delay", 
equalTo(2)).build()
             )
         );
+        Assertions.assertTrue(delayedRequest.await(2, TimeUnit.SECONDS));
+    }
+
+    private String baseUri() {
+        return "http://127.0.0.1:"; + server.getAddress().getPort();
+    }
+
+    private static void writeJson(final HttpExchange exchange, final String 
body) throws IOException {
+        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+        exchange.getResponseHeaders().set("Content-Type", "application/json");
+        exchange.sendResponseHeaders(200, bytes.length);
+        try (OutputStream output = exchange.getResponseBody()) {
+            output.write(bytes);
+        } finally {
+            exchange.close();
+        }
     }
 }
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 4bab04bf75..c2f32418a2 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
@@ -43,9 +43,11 @@ import 
org.springframework.boot.autoconfigure.web.ServerProperties;
 import java.net.URI;
 import java.util.HashMap;
 import java.util.Iterator;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 
@@ -150,6 +152,7 @@ public class WebsocketSyncDataService implements 
SyncDataService {
                 clients.add(createClient(url));
             }
         }
+        Set<URI> masterUrls = new LinkedHashSet<>();
         Iterator<ShenyuWebsocketClient> iterator = clients.iterator();
         while (iterator.hasNext()) {
             ShenyuWebsocketClient websocketClient = iterator.next();
@@ -166,11 +169,42 @@ public class WebsocketSyncDataService implements 
SyncDataService {
                 return;
             }
             
-            if (!websocketClient.isConnectedToMaster()) {
+            // An open socket may still be waiting for its running-mode reply.
+            if (Objects.equals(runningMode, RunningModeEnum.CLUSTER.name()) && 
!websocketClient.isConnectedToMaster()) {
+                URI masterUri = parseMasterUri(websocketClient.getMasterUrl());
+                if (Objects.nonNull(masterUri) && 
!masterUri.equals(websocketClient.getURI())) {
+                    masterUrls.add(masterUri);
+                }
                 websocketClient.nowClose();
                 iterator.remove();
             }
         }
+        for (URI masterUri : masterUrls) {
+            if (clients.stream().noneMatch(client -> 
masterUri.equals(client.getURI()))) {
+                try {
+                    clients.add(createClient(masterUri.toString()));
+                } catch (RuntimeException ex) {
+                    LOG.warn("websocket connection to advertised master [{}] 
failed", masterUri, ex);
+                }
+            }
+        }
+    }
+
+    private URI parseMasterUri(final String masterUrl) {
+        if (StringUtils.isBlank(masterUrl)) {
+            return null;
+        }
+        try {
+            URI uri = URI.create(masterUrl);
+            if (("ws".equalsIgnoreCase(uri.getScheme()) || 
"wss".equalsIgnoreCase(uri.getScheme())) && 
StringUtils.isNotBlank(uri.getHost())) {
+                return uri;
+            }
+        } catch (IllegalArgumentException ex) {
+            LOG.warn("Ignore invalid websocket master address [{}]", 
masterUrl, ex);
+            return null;
+        }
+        LOG.warn("Ignore invalid websocket master address [{}]", masterUrl);
+        return null;
     }
     
     @Override
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 2ad99b3569..86df7d5af9 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
@@ -105,9 +105,9 @@ public final class ShenyuWebsocketClient extends 
WebSocketClient {
 
     private TimerTask timerTask;
 
-    private String runningMode;
+    private volatile String runningMode;
 
-    private String masterUrl;
+    private volatile String masterUrl;
 
     private volatile boolean isConnectedToMaster;
 
@@ -265,6 +265,9 @@ public final class ShenyuWebsocketClient extends 
WebSocketClient {
     
     @Override
     public void onOpen(final ServerHandshake serverHandshake) {
+        runningMode = null;
+        masterUrl = null;
+        isConnectedToMaster = false;
         LOG.info("websocket connection server[{}] is opened, sending sync 
msg", this.getURI().toString());
         send(DataEventTypeEnum.RUNNING_MODE.name());
         if (!alreadySync) {
@@ -294,11 +297,12 @@ public final class ShenyuWebsocketClient extends 
WebSocketClient {
             if (Objects.equals(WebsocketSyncFrame.EVENT_TYPE, eventType) && 
Objects.nonNull(initialSyncState)) {
                 
initialSyncState.accept(GsonUtils.getInstance().fromJson(result, 
WebsocketSyncFrame.class), this::handleResult);
             } else if (Objects.equals(DataEventTypeEnum.RUNNING_MODE.name(), 
eventType)) {
-                this.runningMode = 
String.valueOf(jsonToMap.get(RunningModeConstants.RUNNING_MODE));
-                if (!Objects.equals(RunningModeEnum.STANDALONE.name(), 
runningMode)) {
+                String mode = 
String.valueOf(jsonToMap.get(RunningModeConstants.RUNNING_MODE));
+                if (!Objects.equals(RunningModeEnum.STANDALONE.name(), mode)) {
                     this.masterUrl = 
String.valueOf(jsonToMap.get(RunningModeConstants.MASTER_URL));
                     this.isConnectedToMaster = 
Boolean.TRUE.equals(jsonToMap.get(RunningModeConstants.IS_MASTER));
                 }
+                this.runningMode = mode;
             } else if (Objects.nonNull(initialSyncState)) {
                 initialSyncState.applyIncremental(() -> handleResult(result));
             } else {
diff --git 
a/shenyu-sync-data-center/shenyu-sync-data-websocket/src/test/java/org/apache/shenyu/plugin/sync/data/websocket/WebsocketMasterRedirectTest.java
 
b/shenyu-sync-data-center/shenyu-sync-data-websocket/src/test/java/org/apache/shenyu/plugin/sync/data/websocket/WebsocketMasterRedirectTest.java
new file mode 100644
index 0000000000..922085d44e
--- /dev/null
+++ 
b/shenyu-sync-data-center/shenyu-sync-data-websocket/src/test/java/org/apache/shenyu/plugin/sync/data/websocket/WebsocketMasterRedirectTest.java
@@ -0,0 +1,310 @@
+/*
+ * 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.plugin.sync.data.websocket;
+
+import org.apache.shenyu.common.config.ShenyuConfig;
+import org.apache.shenyu.common.constant.Constants;
+import org.apache.shenyu.common.timer.Timer;
+import org.apache.shenyu.common.timer.WheelTimerFactory;
+import org.apache.shenyu.common.utils.GsonUtils;
+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.PluginDataSubscriber;
+import org.java_websocket.WebSocket;
+import org.java_websocket.handshake.ClientHandshake;
+import org.java_websocket.server.WebSocketServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.MockedConstruction;
+import org.mockito.MockedStatic;
+import org.springframework.boot.autoconfigure.web.ServerProperties;
+
+import java.lang.reflect.Field;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Exercises master discovery through real sockets and manually triggered 
checks.
+ */
+class WebsocketMasterRedirectTest {
+
+    private final List<TestServer> servers = new ArrayList<>();
+
+    private MockedStatic<WheelTimerFactory> timers;
+
+    private WebsocketSyncDataService service;
+
+    private final CountDownLatch applied = new CountDownLatch(1);
+
+    @BeforeEach
+    void setUp() {
+        timers = mockStatic(WheelTimerFactory.class);
+        
timers.when(WheelTimerFactory::newWheelTimer).thenReturn(mock(Timer.class));
+        
timers.when(WheelTimerFactory::getSharedTimer).thenReturn(mock(Timer.class));
+    }
+
+    @AfterEach
+    void tearDown() throws InterruptedException {
+        try {
+            if (Objects.nonNull(service)) {
+                service.close();
+            }
+            for (TestServer server : servers) {
+                server.stop(1000);
+            }
+        } finally {
+            timers.close();
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testRedirectsFollowerConnectionsToOneMaster(final boolean 
multipleFollowers) throws Exception {
+        TestServer master = startServer(true);
+        TestServer follower = startServer(false);
+        follower.masterUrl = master.url();
+        final List<String> seeds = new 
ArrayList<>(Collections.singletonList(follower.url()));
+        if (multipleFollowers) {
+            TestServer other = startServer(false);
+            other.masterUrl = master.url();
+            seeds.add(other.url());
+        }
+        createService(seeds);
+        awaitModes();
+
+        check();
+
+        assertTrue(applied.await(5, TimeUnit.SECONDS), "Configuration should 
arrive from the advertised master");
+        await().atMost(Duration.ofSeconds(5)).until(() -> clients().size() == 
1 && clients().get(0).isConnectedToMaster());
+        assertEquals(URI.create(master.url()), clients().get(0).getURI());
+        assertEquals(1, master.opens.get());
+        assertTrue(follower.closed.await(5, TimeUnit.SECONDS));
+        assertEquals("test-token", 
master.handshake.getFieldValue(Constants.X_SHENYU_SYNC_TOKEN));
+        assertEquals("https://gateway.example";, 
master.handshake.getFieldValue("Origin"));
+        assertEquals("cluster-test", 
master.handshake.getFieldValue(Constants.SHENYU_NAMESPACE_ID));
+        assertEquals("9195", 
master.handshake.getFieldValue(Constants.CLIENT_PORT_NAME));
+        assertEquals(seeds, service.getWebsocketConfig().getUrls());
+        check();
+        assertEquals(1, master.opens.get());
+        assertEquals(1, follower.opens.get());
+    }
+
+    @Test
+    void testKeepsConnectionWhileRunningModeReplyIsPending() throws Exception {
+        TestServer server = startServer(true);
+        server.replyMode = false;
+        createService(Collections.singletonList(server.url()));
+        ShenyuWebsocketClient client = clients().get(0);
+
+        check();
+
+        assertEquals(1, clients().size());
+        assertSame(client, clients().get(0));
+        assertTrue(client.isOpen());
+        server.sendMode();
+        awaitModes();
+        check();
+        assertSame(client, clients().get(0));
+    }
+
+    @ParameterizedTest
+    @NullAndEmptySource
+    @ValueSource(strings = {"null", "/websocket", "http://admin/websocket";, 
"ws://[invalid"})
+    void testInvalidMasterAddressFallsBackToSeeds(final String address) throws 
Exception {
+        createService(Collections.emptyList());
+        ShenyuWebsocketClient follower = mock(ShenyuWebsocketClient.class);
+        when(follower.isOpen()).thenReturn(true);
+        when(follower.getRunningMode()).thenReturn("CLUSTER");
+        when(follower.getMasterUrl()).thenReturn(address);
+        clients().add(follower);
+        try (MockedConstruction<ShenyuWebsocketClient> constructed = 
mockConstruction(ShenyuWebsocketClient.class)) {
+            check();
+            assertTrue(clients().isEmpty());
+            assertTrue(constructed.constructed().isEmpty());
+            verify(follower).nowClose();
+        }
+    }
+
+    @Test
+    void testReusesExistingMasterConnection() throws Exception {
+        createService(Collections.emptyList());
+        ShenyuWebsocketClient follower = mock(ShenyuWebsocketClient.class);
+        when(follower.isOpen()).thenReturn(true);
+        when(follower.getRunningMode()).thenReturn("CLUSTER");
+        when(follower.getMasterUrl()).thenReturn("ws://master:9095/websocket");
+        ShenyuWebsocketClient master = mock(ShenyuWebsocketClient.class);
+        when(master.isOpen()).thenReturn(true);
+        when(master.getRunningMode()).thenReturn("CLUSTER");
+        when(master.isConnectedToMaster()).thenReturn(true);
+        
when(master.getURI()).thenReturn(URI.create("ws://master:9095/websocket"));
+        clients().add(follower);
+        clients().add(master);
+        try (MockedConstruction<ShenyuWebsocketClient> constructed = 
mockConstruction(ShenyuWebsocketClient.class)) {
+            check();
+            assertEquals(Collections.singletonList(master), clients());
+            assertTrue(constructed.constructed().isEmpty());
+        }
+    }
+
+    @Test
+    void testClosedRedirectConnectionReturnsToConfiguredSeed() throws 
Exception {
+        createService(Collections.emptyList());
+        
service.getWebsocketConfig().setUrls(Collections.singletonList("ws://seed:9095/websocket"));
+        ShenyuWebsocketClient failedMaster = mock(ShenyuWebsocketClient.class);
+        clients().add(failedMaster);
+        check();
+        verify(failedMaster).nowClose();
+        try (MockedConstruction<ShenyuWebsocketClient> constructed = 
mockConstruction(ShenyuWebsocketClient.class, (client, context) ->
+                assertEquals(URI.create("ws://seed:9095/websocket"), 
context.arguments().get(0)))) {
+            check();
+            assertEquals(1, constructed.constructed().size());
+        }
+    }
+
+    private void createService(final List<String> seeds) {
+        WebsocketConfig config = new WebsocketConfig();
+        config.setUrls(seeds);
+        config.setToken("test-token");
+        config.setAllowOrigin("https://gateway.example";);
+        ShenyuConfig shenyu = new ShenyuConfig();
+        shenyu.setNamespace("cluster-test");
+        ServerProperties properties = new ServerProperties();
+        properties.setPort(9195);
+        PluginDataSubscriber subscriber = mock(PluginDataSubscriber.class);
+        doAnswer(invocation -> {
+            applied.countDown();
+            return null;
+        }).when(subscriber).onSubscribe(any());
+        service = new WebsocketSyncDataService(config, shenyu, subscriber, 
Collections.emptyList(), Collections.emptyList(),
+                Collections.emptyList(), Collections.emptyList(), 
Collections.emptyList(), properties);
+    }
+
+    private TestServer startServer(final boolean master) throws 
InterruptedException {
+        TestServer server = new TestServer(master);
+        servers.add(server);
+        server.start();
+        assertTrue(server.started.await(5, TimeUnit.SECONDS));
+        return server;
+    }
+
+    private void awaitModes() {
+        await().atMost(Duration.ofSeconds(5)).until(() -> 
clients().stream().allMatch(client -> 
Objects.nonNull(client.getRunningMode())));
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<ShenyuWebsocketClient> clients() throws 
ReflectiveOperationException {
+        Field field = 
WebsocketSyncDataService.class.getDeclaredField("clients");
+        field.setAccessible(true);
+        return (List<ShenyuWebsocketClient>) field.get(service);
+    }
+
+    private void check() throws ReflectiveOperationException {
+        java.lang.reflect.Method method = 
WebsocketSyncDataService.class.getDeclaredMethod("masterCheck");
+        method.setAccessible(true);
+        method.invoke(service);
+    }
+
+    private static final class TestServer extends WebSocketServer {
+
+        private final boolean master;
+
+        private final CountDownLatch started = new CountDownLatch(1);
+
+        private final CountDownLatch closed = new CountDownLatch(1);
+
+        private final AtomicInteger opens = new AtomicInteger();
+
+        private volatile String masterUrl;
+
+        private volatile boolean replyMode = true;
+
+        private volatile WebSocket connection;
+
+        private volatile ClientHandshake handshake;
+
+        private TestServer(final boolean master) {
+            super(new InetSocketAddress("127.0.0.1", 0));
+            this.master = master;
+        }
+
+        private String url() {
+            return "ws://127.0.0.1:" + getPort() + "/websocket";
+        }
+
+        private void sendMode() {
+            connection.send(GsonUtils.getInstance().toJson(Map.of("eventType", 
"RUNNING_MODE", "runningMode", "CLUSTER",
+                    "isMaster", master, "masterUrl", master ? url() : 
masterUrl)));
+        }
+
+        @Override
+        public void onOpen(final WebSocket socket, final ClientHandshake 
clientHandshake) {
+            connection = socket;
+            handshake = clientHandshake;
+            opens.incrementAndGet();
+        }
+
+        @Override
+        public void onClose(final WebSocket socket, final int code, final 
String reason, final boolean remote) {
+            closed.countDown();
+        }
+
+        @Override
+        public void onMessage(final WebSocket socket, final String message) {
+            if ("RUNNING_MODE".equals(message) && replyMode) {
+                sendMode();
+            } else if ("MYSELF".equals(message) && master) {
+                
socket.send("{\"groupType\":\"PLUGIN\",\"eventType\":\"CREATE\",\"data\":[{\"id\":\"divide\",\"name\":\"divide\",\"enabled\":true}]}");
+            }
+        }
+
+        @Override
+        public void onError(final WebSocket socket, final Exception exception) 
{
+            throw new IllegalStateException(exception);
+        }
+
+        @Override
+        public void onStart() {
+            started.countDown();
+        }
+    }
+}
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 deb8eeee9c..7c51d9f92b 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
@@ -118,6 +118,20 @@ public class ShenyuWebsocketClientTest {
         verify(shenyuWebsocketClient).send(DataEventTypeEnum.MYSELF.name());
     }
 
+    @Test
+    void testOnOpenClearsPreviousRunningMode() {
+        ShenyuWebsocketClient client = spy(shenyuWebsocketClient);
+        doNothing().when(client).send(anyString());
+        
client.onMessage("{\"eventType\":\"RUNNING_MODE\",\"runningMode\":\"CLUSTER\",\"isMaster\":true,\"masterUrl\":\"ws://old-master/websocket\"}");
+        assertTrue(client.isConnectedToMaster());
+
+        client.onOpen(mock(ServerHandshake.class));
+
+        Assertions.assertNull(client.getRunningMode());
+        Assertions.assertNull(client.getMasterUrl());
+        assertFalse(client.isConnectedToMaster());
+    }
+
     @Test
     public void testOnMessage() {
         doNothing().when(pluginDataSubscriber).onPluginRefresh(any());

Reply via email to