diff --git a/common/main/java/com/couchbase/lite/internal/replicator/AbstractCBLWebSocket.java b/common/main/java/com/couchbase/lite/internal/replicator/AbstractCBLWebSocket.java index 0ce3228c4..06284621c 100644 --- a/common/main/java/com/couchbase/lite/internal/replicator/AbstractCBLWebSocket.java +++ b/common/main/java/com/couchbase/lite/internal/replicator/AbstractCBLWebSocket.java @@ -428,12 +428,40 @@ public final void coreWrites(@NonNull byte[] data) { @Override public void coreAcksWrite(long n) { Log.d(LOG_DOMAIN, "%s.coreAckReceive: %d", this, n); } - // Core wants to break the connection + /** + * Core wants to break the connection. + * + *

C4SocketFactory.requestClose callback (kC4NoFraming mode). LiteCore + * calls this method once per socket to have us send the WebSocket close + * frame to the remote — by calling {@link SocketToRemote#closeRemote}, + * which calls OkHttp's {@code ws.close()}. In a local close it sends + * our initial close; in a remote close it sends the echo for a close + * the remote initiated. + * + *

Local close (e.g. {@code replicator.stop()}): + *

+     *   1. LiteCore invokes this method directly.
+     *   2. ensureState advances OPEN → CLOSING; closeRemote sends the close frame.
+     *   3. Server echoes; OkHttp delivers it via remoteRequestsClose (no-op — state already CLOSING).
+     *   4. OkHttp → remoteClosed (CLOSING → CLOSED, c4socket_closed).
+     * 
+ * + *

Remote close (remote-initiated): + *

+     *   1. Server sends close; OkHttp delivers it via remoteRequestsClose, which advances
+     *      OPEN → CLOSING and notifies LiteCore (c4socket_closeRequested).
+     *   2. LiteCore drains in-flight work, then calls this method back.
+     *   3. ensureState sees state already CLOSING and proceeds; closeRemote sends the close echo.
+     *   4. OkHttp → remoteClosed (CLOSING → CLOSED, c4socket_closed).
+     * 
+ */ @Override public final void coreRequestsClose(@NonNull CloseStatus status) { Log.d(LOG_DOMAIN, "%s.coreRequestsClose: %s", this, status); - if (!changeState(SocketState.CLOSING)) { return; } + // State may already be CLOSING in a remote close (remoteRequestsClose ran first). + // Refuse only when CLOSING is unreachable (e.g. already CLOSED). + if (!ensureState(SocketState.CLOSING)) { return; } // We've told Core to leave the connection to us, so it might pass us the HTTP status // If it does, we need to convert it to a WS status for the other side. @@ -566,6 +594,13 @@ private boolean assertState(@NonNull SocketState... expectedStates) { synchronized (getLock()) { return state.assertState(expectedStates); } } + // Idempotent transition to {@code target}: succeeds if we are already there + // or can legally transition there. Returned under getLock() so the check + // and the transition are atomic. + private boolean ensureState(@NonNull SocketState target) { + synchronized (getLock()) { return state.enterState(target); } + } + @Nullable private byte[] encodeHeaders(@Nullable Map headers) { try (FLEncoder enc = FLEncoder.getManagedEncoder()) { diff --git a/common/main/java/com/couchbase/lite/internal/utils/StateMachine.java b/common/main/java/com/couchbase/lite/internal/utils/StateMachine.java index 58140e92e..95a507aa2 100644 --- a/common/main/java/com/couchbase/lite/internal/utils/StateMachine.java +++ b/common/main/java/com/couchbase/lite/internal/utils/StateMachine.java @@ -141,6 +141,22 @@ public final boolean assertState(@NonNull T... expected) { return false; } + /** + * Idempotent transition. Returns true if the state machine is already in + * {@code target}, or if it can legally transition there. Returns false if + * no such transition is legal from the current state (e.g. {@code target} + * is unreachable from a terminal state). Unlike {@link #setState(Enum)}, + * a no-op "already in target" outcome is silent — no debug exception + * trace is logged. + * + * @param target the desired state. + * @return true if the current state ended up equal to {@code target}. + */ + public boolean enterState(@NonNull T target) { + if (state == target) { return true; } + return setState(target); + } + /** * Set the new state. * If it is legal to transition to the new state, from the current state, do so,