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 b2e1a8174b Fixes #7316: Resynchronize the gateway after failed 
websocket config updates (#7344)
b2e1a8174b is described below

commit b2e1a8174b25d500768811909fc0976d673b5139
Author: BobSong <[email protected]>
AuthorDate: Tue Oct 6 10:21:58 2026 +0800

    Fixes #7316: Resynchronize the gateway after failed websocket config 
updates (#7344)
    
    * fix(websocket): resynchronize the gateway after failed config updates
    
    * chore(ci): re-trigger e2e workflow after infra flake
    
    * fix(websocket): keep the recovery loop alive after bounded sync failures
    
    * test(websocket): verify delayed full resync and unknown events
    
    * fix(websocket): reset failure budget after complete full sync
    
    * fix(websocket): preserve failure budget across unframed sync payloads
    
    ---------
    
    Co-authored-by: BobSong-dev <[email protected]>
---
 .../data/websocket/client/InitialSyncState.java    |  21 ++
 .../websocket/client/ShenyuWebsocketClient.java    | 121 +++++++--
 .../websocket/client/InitialSyncStateTest.java     |  31 +++
 .../client/ShenyuWebsocketClientTest.java          | 273 +++++++++++++++++++++
 4 files changed, 420 insertions(+), 26 deletions(-)

diff --git 
a/shenyu-sync-data-center/shenyu-sync-data-websocket/src/main/java/org/apache/shenyu/plugin/sync/data/websocket/client/InitialSyncState.java
 
b/shenyu-sync-data-center/shenyu-sync-data-websocket/src/main/java/org/apache/shenyu/plugin/sync/data/websocket/client/InitialSyncState.java
index a504214786..fd3c024d33 100644
--- 
a/shenyu-sync-data-center/shenyu-sync-data-websocket/src/main/java/org/apache/shenyu/plugin/sync/data/websocket/client/InitialSyncState.java
+++ 
b/shenyu-sync-data-center/shenyu-sync-data-websocket/src/main/java/org/apache/shenyu/plugin/sync/data/websocket/client/InitialSyncState.java
@@ -33,6 +33,8 @@ public final class InitialSyncState {
 
     private final AtomicBoolean ready;
 
+    private final Runnable onComplete;
+
     private String requestId;
 
     private int sequence;
@@ -50,7 +52,17 @@ public final class InitialSyncState {
      * @param ready shared startup latch
      */
     public InitialSyncState(final AtomicBoolean ready) {
+        this(ready, () -> { });
+    }
+
+    /**
+     * Create connection state with a full-attempt completion callback.
+     * @param ready shared startup latch
+     * @param onComplete callback after the end frame and all application work 
succeed
+     */
+    public InitialSyncState(final AtomicBoolean ready, final Runnable 
onComplete) {
         this.ready = ready;
+        this.onComplete = Objects.requireNonNull(onComplete);
     }
 
     /**
@@ -140,9 +152,18 @@ public final class InitialSyncState {
         if (ended && pending == 0 && !failed && System.nanoTime() - startedAt 
< TimeUnit.SECONDS.toNanos(60)) {
             ready.set(true);
             requestId = null;
+            onComplete.run();
         }
     }
 
+    /**
+     * Whether the latest attempt completed successfully, independent of the 
startup latch.
+     * @return complete attempt
+     */
+    public synchronized boolean isComplete() {
+        return ended && !failed && Objects.isNull(requestId);
+    }
+
     /**
      * Invalidate the current attempt without revoking an earlier success.
      */
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 1d4fe3bb9a..64522afd22 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
@@ -88,10 +88,15 @@ public final class ShenyuWebsocketClient extends 
WebSocketClient {
 
     private static final long MAX_RECONNECT_BACKOFF_MS = 
TimeUnit.SECONDS.toMillis(60);
 
+    private static final int MAX_CONSECUTIVE_SYNC_FAILURES = 3;
+
     private volatile boolean alreadySync = Boolean.FALSE;
 
     private InitialSyncState initialSyncState;
 
+    private volatile long nextSyncRetryAt;
+
+
     private final WebsocketDataHandler websocketDataHandler;
 
     private final Timer timer;
@@ -110,6 +115,8 @@ public final class ShenyuWebsocketClient extends 
WebSocketClient {
 
     private final AtomicBoolean reconnecting = new AtomicBoolean(false);
 
+    private final AtomicInteger consecutiveSyncFailures = new AtomicInteger(0);
+
     private volatile long lastReconnectAttemptTime;
 
     private final AtomicInteger reconnectBackoff = new AtomicInteger(0);
@@ -205,7 +212,7 @@ public final class ShenyuWebsocketClient extends 
WebSocketClient {
                                  final AtomicBoolean initialSyncReady) {
         super(serverUri, headers);
         if (Objects.nonNull(initialSyncReady)) {
-            this.initialSyncState = new InitialSyncState(initialSyncReady);
+            this.initialSyncState = new InitialSyncState(initialSyncReady, 
this::resetSyncFailures);
         }
         this.namespaceId = namespaceId;
         LOG.info("shenyu bootstrap websocket namespaceId: {}", namespaceId);
@@ -270,25 +277,26 @@ public final class ShenyuWebsocketClient extends 
WebSocketClient {
     
     @Override
     public void onMessage(final String result) {
-        if (LOG.isDebugEnabled()) {
-            LOG.debug("onMessage server[{}] result({})", 
this.getURI().toString(), result);
+        final Map<String, Object> jsonToMap;
+        try {
+            jsonToMap = JsonUtils.jsonToMap(result);
+            if (Objects.isNull(jsonToMap)) {
+                return;
+            }
+        } catch (RuntimeException ex) {
+            LOG.warn("Ignoring unparseable websocket frame from server[{}]", 
this.getURI());
+            return;
         }
-
         try {
-            Map<String, Object> jsonToMap = JsonUtils.jsonToMap(result);
             Object eventType = jsonToMap.get(RunningModeConstants.EVENT_TYPE);
             if (Objects.equals(WebsocketSyncFrame.EVENT_TYPE, eventType) && 
Objects.nonNull(initialSyncState)) {
                 
initialSyncState.accept(GsonUtils.getInstance().fromJson(result, 
WebsocketSyncFrame.class), this::handleResult);
-                return;
-            }
-            if (Objects.equals(DataEventTypeEnum.RUNNING_MODE.name(), 
eventType)) {
-                LOG.info("server[{}] handle running mode result({})", 
this.getURI().toString(), result);
+            } 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)) {
-                    return;
+                if (!Objects.equals(RunningModeEnum.STANDALONE.name(), 
runningMode)) {
+                    this.masterUrl = 
String.valueOf(jsonToMap.get(RunningModeConstants.MASTER_URL));
+                    this.isConnectedToMaster = 
Boolean.TRUE.equals(jsonToMap.get(RunningModeConstants.IS_MASTER));
                 }
-                this.masterUrl = 
String.valueOf(jsonToMap.get(RunningModeConstants.MASTER_URL));
-                this.isConnectedToMaster = 
Boolean.TRUE.equals(jsonToMap.get(RunningModeConstants.IS_MASTER));
             } else if (Objects.nonNull(initialSyncState)) {
                 initialSyncState.applyIncremental(() -> handleResult(result));
             } else {
@@ -298,10 +306,10 @@ public final class ShenyuWebsocketClient extends 
WebSocketClient {
             if (Objects.nonNull(initialSyncState)) {
                 initialSyncState.invalidate();
             }
-            LOG.warn("Failed to handle websocket message from server[{}], the 
message will be ignored", this.getURI(), ex);
+            LOG.warn("Failed to handle websocket frame from server[{}]", 
this.getURI(), ex);
         }
     }
-    
+
     @Override
     public void onClose(final int i, final String s, final boolean b) {
         this.close();
@@ -344,6 +352,14 @@ public final class ShenyuWebsocketClient extends 
WebSocketClient {
             if (this.manuallyClosed.get()) {
                 return;
             }
+            if (nextSyncRetryAt != 0) {
+                if (System.nanoTime() - nextSyncRetryAt < 0) {
+                    return;
+                }
+                nextSyncRetryAt = 0;
+                close();
+                return;
+            }
             if (Objects.nonNull(initialSyncState) && this.isOpen() && 
initialSyncState.needsReconnect()) {
                 close();
                 return;
@@ -421,20 +437,73 @@ public final class ShenyuWebsocketClient extends 
WebSocketClient {
      */
     private void handleResult(final String result) {
         LOG.info("server [{}] handleResult({})", this.getURI().toString(), 
result);
-        WebsocketData<?> websocketData = 
GsonUtils.getInstance().fromJson(result, WebsocketData.class);
-        ConfigGroupEnum groupEnum = 
ConfigGroupEnum.acquireByName(websocketData.getGroupType());
-        String eventType = websocketData.getEventType();
-        String json = GsonUtils.getInstance().toJson(websocketData.getData());
-        if (websocketData.isFullSnapshot()) {
-            if (!DataEventTypeEnum.REFRESH.name().equals(eventType) && 
!DataEventTypeEnum.MYSELF.name().equals(eventType)) {
-                throw new IllegalArgumentException("Snapshot requires a 
refresh event");
+        WebsocketData<?> websocketData;
+        try {
+            websocketData = GsonUtils.getInstance().fromJson(result, 
WebsocketData.class);
+        } catch (RuntimeException ex) {
+            // a frame that cannot be interpreted as a config message is a 
protocol-shape
+            // mismatch, not a recoverable config change: ignore it like the 
previous
+            // behavior instead of dropping the connection
+            LOG.warn("Failed to parse websocket message from server[{}], the 
message will be ignored", this.getURI(), ex);
+            return;
+        }
+        ConfigGroupEnum groupEnum;
+        String eventType;
+        String json;
+        try {
+            groupEnum = 
ConfigGroupEnum.acquireByName(websocketData.getGroupType());
+            eventType = websocketData.getEventType();
+            DataEventTypeEnum.acquireByName(eventType);
+            json = GsonUtils.getInstance().toJson(websocketData.getData());
+        } catch (RuntimeException ex) {
+            LOG.warn("Failed to resolve websocket message group from 
server[{}], the message will be ignored", this.getURI(), ex);
+            return;
+        }
+        try {
+            if (websocketData.isFullSnapshot()) {
+                if (!DataEventTypeEnum.REFRESH.name().equals(eventType) && 
!DataEventTypeEnum.MYSELF.name().equals(eventType)) {
+                    throw new IllegalArgumentException("Snapshot requires a 
refresh event");
+                }
+                websocketDataHandler.snapshot(groupEnum, json, 
websocketData.getNamespaceId(), namespaceId);
+            } else {
+                websocketDataHandler.executor(groupEnum, json, eventType);
+            }
+            // Individual payloads, including legacy MYSELF and standalone 
snapshots,
+            // cannot prove that the complete sync cycle succeeded. Only the 
framed
+            // completion callback may reset the failure budget and cooldown.
+        } catch (RuntimeException ex) {
+            handleSyncFailure(ex, groupEnum.name(), eventType);
+            if 
(org.apache.shenyu.common.utils.InitialSyncApplication.isActive()) {
+                throw ex;
             }
-            websocketDataHandler.snapshot(groupEnum, json, 
websocketData.getNamespaceId(), namespaceId);
-        } else {
-            websocketDataHandler.executor(groupEnum, json, eventType);
         }
     }
-    
+
+    private void resetSyncFailures() {
+        consecutiveSyncFailures.set(0);
+        nextSyncRetryAt = 0;
+    }
+
+    /**
+     * Recover failed configuration application without abandoning the timer.
+     * @param ex application failure
+     * @param groupType configuration group
+     * @param eventType event type
+     */
+    private void handleSyncFailure(final RuntimeException ex, final String 
groupType, final String eventType) {
+        int failures = consecutiveSyncFailures.updateAndGet(value -> 
Math.min(value + 1, MAX_CONSECUTIVE_SYNC_FAILURES));
+        if (failures >= MAX_CONSECUTIVE_SYNC_FAILURES) {
+            if (nextSyncRetryAt == 0) {
+                nextSyncRetryAt = System.nanoTime() + MAX_RECONNECT_BACKOFF_MS 
* 1_000_000L;
+            }
+            LOG.warn("websocket sync failed, group={}, eventType={}; full 
resync scheduled after backoff", groupType, eventType, ex);
+            return;
+        }
+        LOG.warn("websocket sync failed, group={}, eventType={}, 
consecutiveFailures={}; reconnecting for full resync",
+                groupType, eventType, failures, ex);
+        this.close();
+    }
+
     /**
      * Gets the master url.
      *
diff --git 
a/shenyu-sync-data-center/shenyu-sync-data-websocket/src/test/java/org/apache/shenyu/plugin/sync/data/websocket/client/InitialSyncStateTest.java
 
b/shenyu-sync-data-center/shenyu-sync-data-websocket/src/test/java/org/apache/shenyu/plugin/sync/data/websocket/client/InitialSyncStateTest.java
index 7d98aab7bc..07a84327f9 100644
--- 
a/shenyu-sync-data-center/shenyu-sync-data-websocket/src/test/java/org/apache/shenyu/plugin/sync/data/websocket/client/InitialSyncStateTest.java
+++ 
b/shenyu-sync-data-center/shenyu-sync-data-websocket/src/test/java/org/apache/shenyu/plugin/sync/data/websocket/client/InitialSyncStateTest.java
@@ -29,7 +29,9 @@ import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -39,6 +41,35 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
  */
 class InitialSyncStateTest {
 
+    @Test
+    void testCompletionCallbackRejectsFailedAndAbandonedAttempts() {
+        AtomicBoolean ready = new AtomicBoolean(true);
+        AtomicInteger completed = new AtomicInteger();
+        InitialSyncState state = new InitialSyncState(ready, 
completed::incrementAndGet);
+        String failed = state.begin();
+        CompletableFuture<Void> failure = new CompletableFuture<>();
+        state.accept(new WebsocketSyncFrame(failed, 0, "data"), value -> 
InitialSyncApplication.register(failure));
+        state.accept(new WebsocketSyncFrame(failed, 1, null), value -> { });
+        failure.completeExceptionally(new IllegalStateException("poison"));
+        assertEquals(0, completed.get());
+        assertFalse(state.isComplete());
+        String abandoned = state.begin();
+        CompletableFuture<Void> pending = new CompletableFuture<>();
+        state.accept(new WebsocketSyncFrame(abandoned, 0, "data"), value -> 
InitialSyncApplication.register(pending));
+        state.accept(new WebsocketSyncFrame(abandoned, 1, null), value -> { });
+        state.invalidate();
+        final String retry = state.begin();
+        pending.complete(null);
+        assertEquals(0, completed.get());
+        assertFalse(state.isComplete());
+        state.accept(new WebsocketSyncFrame(retry, 0, null), value -> { });
+        assertEquals(1, completed.get());
+        assertTrue(state.isComplete());
+        state.accept(new WebsocketSyncFrame(retry, 0, null), value -> { });
+        assertEquals(1, completed.get());
+    }
+
+
     @Test
     void testInterleavedIncrementalMustFinishBeforeReadiness() {
         AtomicBoolean ready = new AtomicBoolean();
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 e2e345687f..deb8eeee9c 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
@@ -25,6 +25,8 @@ import org.apache.shenyu.common.enums.DataEventTypeEnum;
 import org.apache.shenyu.common.exception.ShenyuException;
 import org.apache.shenyu.common.timer.TimerTask;
 import org.apache.shenyu.common.utils.GsonUtils;
+import org.apache.shenyu.common.utils.InitialSyncApplication;
+import 
org.apache.shenyu.plugin.sync.data.websocket.handler.WebsocketDataHandler;
 import org.apache.shenyu.sync.data.api.AuthDataSubscriber;
 import org.apache.shenyu.sync.data.api.MetaDataSubscriber;
 import org.apache.shenyu.sync.data.api.PluginDataSubscriber;
@@ -44,8 +46,10 @@ import java.net.URI;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -61,6 +65,7 @@ import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.withSettings;
 
@@ -202,6 +207,14 @@ public class ShenyuWebsocketClientTest {
         setField(client, "reconnecting", new AtomicBoolean(false));
         setField(client, "reconnectBackoff", new AtomicInteger(0));
         setField(client, "lastReconnectAttemptTime", 0L);
+        setField(client, "consecutiveSyncFailures", new AtomicInteger(0));
+        setField(client, "websocketDataHandler", 
mock(WebsocketDataHandler.class));
+        return client;
+    }
+
+    private ShenyuWebsocketClient createSyncTestClient() {
+        ShenyuWebsocketClient client = createMockClient();
+        doReturn(URI.create("ws://localhost:9090")).when(client).getURI();
         return client;
     }
 
@@ -448,4 +461,264 @@ public class ShenyuWebsocketClientTest {
         assertTrue(Thread.interrupted(), "Interrupt status should be preserved 
after reconnect");
         assertFalse(((AtomicBoolean) getField(client, "reconnecting")).get());
     }
+
+    // ---------- sync failure recovery tests ----------
+
+    private WebsocketDataHandler getHandler(final ShenyuWebsocketClient 
client) {
+        return (WebsocketDataHandler) getField(client, "websocketDataHandler");
+    }
+
+    @Test
+    void testSyncFailureClosesConnectionToTriggerResync() {
+        ShenyuWebsocketClient client = createSyncTestClient();
+        doNothing().when(client).close();
+        doThrow(new RuntimeException("transient handler 
failure")).when(getHandler(client))
+                .executor(any(), anyString(), anyString());
+        String json = GsonUtils.getInstance().toJson(websocketData);
+
+        client.onMessage(json);
+
+        verify(client, times(1)).close();
+    }
+
+    @Test
+    void testPoisonDataKeepsConnectionAfterBoundedFailures() {
+        ShenyuWebsocketClient client = createSyncTestClient();
+        doNothing().when(client).close();
+        WebsocketDataHandler handler = getHandler(client);
+        doThrow(new RuntimeException("poison 
data")).when(handler).executor(any(), anyString(), anyString());
+        String json = GsonUtils.getInstance().toJson(websocketData);
+
+        // failures 1 and 2 close the connection so the reconnect pulls a full 
snapshot
+        client.onMessage(json);
+        client.onMessage(json);
+        verify(client, times(2)).close();
+        verify(client, never()).nowClose();
+
+        // at the cap: stop closing, keep the connection and the health check 
alive
+        client.onMessage(json);
+        verify(client, times(2)).close();
+        verify(client, never()).nowClose();
+
+        // A single successful legacy payload cannot prove a full-cycle 
recovery.
+        doNothing().when(handler).executor(any(), anyString(), anyString());
+        client.onMessage(json);
+        assertEquals(3, ((AtomicInteger) getField(client, 
"consecutiveSyncFailures")).get());
+        doThrow(new RuntimeException("poison 
again")).when(handler).executor(any(), anyString(), anyString());
+        client.onMessage(json);
+        verify(client, times(2)).close();
+        verify(client, never()).nowClose();
+    }
+
+    @Test
+    void testNonJsonFrameIsIgnoredWithoutClosingConnection() {
+        ShenyuWebsocketClient client = createSyncTestClient();
+        doNothing().when(client).close();
+        WebsocketDataHandler handler = getHandler(client);
+
+        // a frame the client cannot parse is ignored without dropping the 
connection
+        // and without consuming the bounded recovery budget
+        client.onMessage("{invalid json");
+        verify(client, never()).close();
+        verify(handler, never()).executor(any(), anyString(), anyString());
+
+        String json = GsonUtils.getInstance().toJson(websocketData);
+        doThrow(new 
RuntimeException("transient")).when(handler).executor(any(), anyString(), 
anyString());
+        client.onMessage(json);
+        verify(client, times(1)).close();
+    }
+
+    @Test
+    void testUnknownEventDoesNotConsumeFailureBudget() {
+        ShenyuWebsocketClient client = createSyncTestClient();
+        
client.onMessage("{\"groupType\":\"RULE\",\"eventType\":\"FUTURE_EVENT\",\"data\":[{}]}");
+        verify(getHandler(client), never()).executor(any(), anyString(), 
anyString());
+        verify(client, never()).close();
+        assertEquals(0, ((AtomicInteger) getField(client, 
"consecutiveSyncFailures")).get());
+        assertEquals(0L, getField(client, "nextSyncRetryAt"));
+    }
+
+    @Test
+    void testHealthCheckRetriesFullSyncAfterFailureCap() {
+        ShenyuWebsocketClient client = createSyncTestClient();
+        doNothing().when(client).close();
+        doNothing().when(client).send(anyString());
+        WebsocketDataHandler handler = getHandler(client);
+        doThrow(new RuntimeException("poison 
data")).when(handler).executor(any(), anyString(), anyString());
+        String json = GsonUtils.getInstance().toJson(websocketData);
+        client.onMessage(json);
+        client.onMessage(json);
+        client.onMessage(json);
+        long deadline = (long) getField(client, "nextSyncRetryAt");
+        assertTrue(deadline - System.nanoTime() > 0);
+        client.onMessage(json);
+        assertEquals(deadline, getField(client, "nextSyncRetryAt"));
+        invokePrivate(client, "healthCheck");
+        verify(client, times(2)).close();
+        setField(client, "nextSyncRetryAt", System.nanoTime() - 1);
+        invokePrivate(client, "healthCheck");
+        verify(client, times(3)).close();
+        client.onOpen(mock(ServerHandshake.class));
+        verify(client).send(DataEventTypeEnum.MYSELF.name());
+        doNothing().when(handler).executor(any(), anyString(), anyString());
+        client.onMessage(json);
+        assertEquals(3, ((AtomicInteger) getField(client, 
"consecutiveSyncFailures")).get());
+        verify(client, never()).nowClose();
+    }
+
+    @Test
+    void testGoodThenPoisonSnapshotReachesCooldownAndRecovers() {
+        ShenyuWebsocketClient client = createSyncTestClient();
+        doReturn(false).when(client).isOpen();
+        AtomicBoolean ready = new AtomicBoolean();
+        InitialSyncState state = new InitialSyncState(ready, () -> 
invokePrivate(client, "resetSyncFailures"));
+        setField(client, "initialSyncState", state);
+        AtomicReference<String> request = new AtomicReference<>();
+        doAnswer(invocation -> {
+            String message = invocation.getArgument(0);
+            if (message.startsWith(WebsocketSyncFrame.REQUEST_PREFIX)) {
+                
request.set(message.substring(WebsocketSyncFrame.REQUEST_PREFIX.length()));
+            }
+            return null;
+        }).when(client).send(anyString());
+        String good = 
"{\"groupType\":\"PLUGIN\",\"eventType\":\"REFRESH\",\"data\":[]}";
+        String poison = 
"{\"groupType\":\"PLUGIN\",\"eventType\":\"REFRESH\",\"data\":[{\"id\":\"poison\"}]}";
+        doAnswer(invocation -> {
+            if (!"[]".equals(invocation.getArgument(1))) {
+                throw new IllegalStateException("same poison item on every 
replay");
+            }
+            return null;
+        }).when(getHandler(client)).executor(any(), anyString(), anyString());
+        for (int attempt = 1; attempt <= 3; attempt++) {
+            client.onOpen(mock(ServerHandshake.class));
+            sendSyncFrame(client, request.get(), 0, good);
+            client.onMessage(good);
+            assertEquals(attempt - 1, ((AtomicInteger) getField(client, 
"consecutiveSyncFailures")).get());
+            sendSyncFrame(client, request.get(), 1, poison);
+            sendSyncFrame(client, request.get(), 2, null);
+            assertEquals(attempt, ((AtomicInteger) getField(client, 
"consecutiveSyncFailures")).get());
+            assertFalse(ready.get());
+        }
+        verify(client, times(2)).close();
+        long deadline = (long) getField(client, "nextSyncRetryAt");
+        assertTrue(deadline - System.nanoTime() > 0);
+        invokePrivate(client, "healthCheck");
+        verify(client, times(2)).close();
+        setField(client, "nextSyncRetryAt", System.nanoTime() - 1);
+        invokePrivate(client, "healthCheck");
+        verify(client, times(3)).close();
+        doNothing().when(getHandler(client)).executor(any(), anyString(), 
anyString());
+        client.onOpen(mock(ServerHandshake.class));
+        sendSyncFrame(client, request.get(), 0, good);
+        sendSyncFrame(client, request.get(), 1, poison);
+        assertEquals(3, ((AtomicInteger) getField(client, 
"consecutiveSyncFailures")).get());
+        sendSyncFrame(client, request.get(), 2, null);
+        assertTrue(ready.get());
+        assertEquals(0, ((AtomicInteger) getField(client, 
"consecutiveSyncFailures")).get());
+        assertEquals(0L, getField(client, "nextSyncRetryAt"));
+        verify(client, never()).nowClose();
+    }
+
+    @Test
+    void testFullSyncBudgetResetsOnlyAfterDeferredApplicationCompletes() {
+        ShenyuWebsocketClient client = createSyncTestClient();
+        AtomicBoolean ready = new AtomicBoolean();
+        InitialSyncState state = new InitialSyncState(ready, () -> 
invokePrivate(client, "resetSyncFailures"));
+        setField(client, "initialSyncState", state);
+        ((AtomicInteger) getField(client, "consecutiveSyncFailures")).set(2);
+        setField(client, "nextSyncRetryAt", System.nanoTime() + 
60_000_000_000L);
+        CompletableFuture<Void> pending = new CompletableFuture<>();
+        doAnswer(invocation -> {
+            InitialSyncApplication.register(pending);
+            return null;
+        }).when(getHandler(client)).executor(any(), anyString(), anyString());
+        String request = state.begin();
+        sendSyncFrame(client, request, 0, 
GsonUtils.getInstance().toJson(websocketData));
+        sendSyncFrame(client, request, 1, null);
+        assertFalse(ready.get());
+        assertEquals(2, ((AtomicInteger) getField(client, 
"consecutiveSyncFailures")).get());
+        pending.complete(null);
+        assertTrue(ready.get());
+        assertEquals(0, ((AtomicInteger) getField(client, 
"consecutiveSyncFailures")).get());
+        assertEquals(0L, getField(client, "nextSyncRetryAt"));
+    }
+
+    private void sendSyncFrame(final ShenyuWebsocketClient client, final 
String request, final int sequence, final String payload) {
+        client.onMessage(GsonUtils.getInstance().toJson(new 
WebsocketSyncFrame(request, sequence, payload)));
+    }
+
+    @Test
+    void testLegacyGoodThenPoisonSnapshotReachesCooldown() {
+        assertUnframedReplayIsBounded(false);
+    }
+
+    @Test
+    void testStandaloneGoodThenPoisonSnapshotReachesCooldown() {
+        assertUnframedReplayIsBounded(true);
+    }
+
+    private void assertUnframedReplayIsBounded(final boolean fullSnapshot) {
+        ShenyuWebsocketClient client = createSyncTestClient();
+        doReturn(false).when(client).isOpen();
+        doNothing().when(client).send(anyString());
+        setField(client, "namespaceId", "namespace-a");
+        WebsocketDataHandler handler = getHandler(client);
+        String envelope = "{\"groupType\":\"PLUGIN\",\"eventType\":\"MYSELF\","
+                + "\"namespaceId\":\"namespace-a\",\"fullSnapshot\":" + 
fullSnapshot + ",\"data\":";
+        String good = envelope + "[]}";
+        String poison = envelope + "[{\"id\":\"poison\"}]}";
+        org.mockito.stubbing.Answer<Void> apply = invocation -> {
+            if (!"[]".equals(invocation.getArgument(1))) {
+                throw new IllegalStateException("same poison item on every 
replay");
+            }
+            return null;
+        };
+        if (fullSnapshot) {
+            doAnswer(apply).when(handler).snapshot(any(), anyString(), 
anyString(), anyString());
+        } else {
+            doAnswer(apply).when(handler).executor(any(), anyString(), 
anyString());
+        }
+        for (int attempt = 1; attempt <= 3; attempt++) {
+            client.onOpen(mock(ServerHandshake.class));
+            client.onMessage(good);
+            assertEquals(attempt - 1, ((AtomicInteger) getField(client, 
"consecutiveSyncFailures")).get());
+            client.onMessage(poison);
+            assertEquals(attempt, ((AtomicInteger) getField(client, 
"consecutiveSyncFailures")).get());
+        }
+        verify(client, times(2)).close();
+        long deadline = (long) getField(client, "nextSyncRetryAt");
+        assertTrue(deadline - System.nanoTime() > 0);
+        client.onMessage(good);
+        assertEquals(3, ((AtomicInteger) getField(client, 
"consecutiveSyncFailures")).get());
+        assertEquals(deadline, getField(client, "nextSyncRetryAt"));
+        client.onMessage(poison);
+        assertEquals(deadline, getField(client, "nextSyncRetryAt"));
+        invokePrivate(client, "healthCheck");
+        verify(client, times(2)).close();
+        setField(client, "nextSyncRetryAt", System.nanoTime() - 1);
+        invokePrivate(client, "healthCheck");
+        verify(client, times(3)).close();
+        client.onOpen(mock(ServerHandshake.class));
+        verify(client, times(4)).send(DataEventTypeEnum.MYSELF.name());
+        verify(client, never()).nowClose();
+    }
+
+    @Test
+    void testSuccessfulLegacyPayloadPreservesFailureCounter() {
+        ShenyuWebsocketClient client = createSyncTestClient();
+        doNothing().when(client).close();
+        WebsocketDataHandler handler = getHandler(client);
+        String json = GsonUtils.getInstance().toJson(websocketData);
+
+        doThrow(new 
RuntimeException("transient")).when(handler).executor(any(), anyString(), 
anyString());
+        client.onMessage(json);
+        doNothing().when(handler).executor(any(), anyString(), anyString());
+        client.onMessage(json);
+        doThrow(new RuntimeException("transient 
again")).when(handler).executor(any(), anyString(), anyString());
+        client.onMessage(json);
+
+        assertEquals(2, ((AtomicInteger) getField(client, 
"consecutiveSyncFailures")).get());
+        verify(client, times(2)).close();
+        verify(client, never()).nowClose();
+    }
 }

Reply via email to