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