/*
* The contents of this file are subject to the terms of the Common Development and
* Distribution License (the License). You may not use this file except in compliance with the
* License.
*
* You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
* specific language governing permission and limitations under the License.
*
* When distributing Covered Software, include this CDDL Header Notice in each file and include
* the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
* Header, with the fields enclosed by brackets [] replaced by your own identifying
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2026 3A Systems, LLC.
*/
package org.opends.server.replication.protocol;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.Closeable;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.opends.server.TestCaseUtils;
import org.opends.server.replication.ReplicationTestCase;
import org.opends.server.replication.common.CSN;
import org.opends.server.replication.common.CSNGenerator;
import org.opends.server.util.StaticUtils;
import org.testng.annotations.Test;
/**
* A message published to a running session is handed to the session thread, which writes it to
* the socket later. A caller which needs to know when that happened - the replication server
* forwarding a ReplicaOfflineMsg, whose shutdown must not close the session before the message
* is on the wire - attaches a callback to the message, and the session runs it once, from the
* thread which wrote it, only after the write returned.
*
* The peer of each test reads nothing until the test lets it, and both ends of the connection
* have socket buffers far smaller than {@link #BLOCKING_MESSAGE_SIZE}, so that a test which
* publishes a message of that size holds the session thread of the end under test inside its
* write for as long as it wants - the state in which a message published behind it is queued
* and not written.
*/
@SuppressWarnings("javadoc")
public class SessionTest extends ReplicationTestCase
{
private static final int SOCKET_TIMEOUT_MS = 30000;
/**
* Socket buffers small enough that {@link #BLOCKING_MESSAGE_SIZE} bytes cannot be written
* through them: the write blocks until the peer reads. Set explicitly on both ends, since the
* buffers the kernel picks on its own grow well beyond it on a loopback link - and the message
* is larger by far than what they hold, since a kernel which does not honour the size asked
* for on the receiving side, as macOS does not, must still be unable to take the whole of it.
*/
private static final int SOCKET_BUFFER_SIZE = 8 * 1024;
private static final int BLOCKING_MESSAGE_SIZE = 4 * 1024 * 1024;
/** Time given to a callback which must not run, to see that it does not. */
private static final long SETTLE_MS = 500;
private static final int SENDER_ID = 1;
private static final int PEER_ID = 2;
@Test
public void theCallbackRunsOnceTheMessageIsWrittenAndNotWhenItIsQueued() throws Exception
{
try (SessionPair pair = connectSessionPair())
{
pair.publisher.start();
pair.publisher.waitForStartup();
// The session thread is inside the write of this message until the peer reads it.
pair.publisher.publish(newBlockingMsg());
pair.awaitBytesReachedThePeer();
final CountDownLatch written = new CountDownLatch(1);
final boolean accepted = pair.publisher.publish(new HeartbeatMsg(), written::countDown);
assertThat(accepted).as("the message was refused by a running session").isTrue();
assertThat(written.await(SETTLE_MS, TimeUnit.MILLISECONDS))
.as("the callback ran while the message was still queued behind a message the peer "
+ "had not read")
.isFalse();
assertThat(pair.peer.receive()).isInstanceOf(EntryMsg.class);
assertThat(written.await(SOCKET_TIMEOUT_MS, TimeUnit.MILLISECONDS))
.as("the callback did not run once the message had been written")
.isTrue();
assertThat(pair.peer.receive()).isInstanceOf(HeartbeatMsg.class);
}
}
@Test
public void aMessageWhichCannotBeEncodedForThePeerIsRefusedWithoutRunningTheCallback()
throws Exception
{
try (SessionPair pair = connectSessionPair())
{
pair.publisher.start();
pair.publisher.waitForStartup();
// A ReplicaOfflineMsg has no encoding before protocol version 8.
pair.publisher.setProtocolVersion(ProtocolVersion.REPLICATION_PROTOCOL_V7);
final CountDownLatch written = new CountDownLatch(1);
final boolean accepted =
pair.publisher.publish(new ReplicaOfflineMsg(newCSN()), written::countDown);
assertThat(accepted)
.as("a message the peer cannot decode was reported as accepted")
.isFalse();
assertThat(written.await(SETTLE_MS, TimeUnit.MILLISECONDS))
.as("the callback ran for a message which was never written")
.isFalse();
}
}
/**
* Before its thread is started, and once that thread is gone, a session writes on the
* publishing thread itself; the callback then runs on that same thread, after the write.
*/
@Test
public void theCallbackRunsAfterAMessageWrittenOnThePublishingThread() throws Exception
{
try (SessionPair pair = connectSessionPair())
{
final CountDownLatch written = new CountDownLatch(1);
final boolean accepted = pair.publisher.publish(new HeartbeatMsg(), written::countDown);
assertThat(accepted).as("the message was refused by a session with no thread").isTrue();
assertThat(written.getCount())
.as("the callback had not run when the publish which wrote the message returned")
.isZero();
assertThat(pair.peer.receive()).isInstanceOf(HeartbeatMsg.class);
}
}
/**
* A session with no thread writes on the publishing thread, as the session of a directory server
* does: a peer which reads nothing holds that write, and with it a close of the session, which
* waits for the lock of the writes. An abort ends both, and writes nothing more: the peer finds
* the message cut short and then the end of the stream, with no StopMsg.
*/
@Test(timeOut = 60_000)
public void anAbortEndsAWriteThePeerDoesNotReadAndACloseWhichWaitsForIt() throws Exception
{
try (SessionPair pair = connectSessionPair())
{
final AtomicReference endOfTheWrite = new AtomicReference<>();
final Thread writer = start("writer", () -> {
try
{
pair.publisher.publish(newBlockingMsg());
}
catch (Throwable e)
{
endOfTheWrite.set(e);
}
});
pair.awaitBytesReachedThePeer();
final Thread closer = start("closer", pair.publisher::close);
awaitWaitingIn(closer, "close");
final Thread aborter = start("aborter",
() -> pair.publisher.abort(new IOException("the test gives the session up")));
aborter.join(SOCKET_TIMEOUT_MS);
writer.join(SOCKET_TIMEOUT_MS);
closer.join(SOCKET_TIMEOUT_MS);
assertThat(aborter.isAlive()).as("the abort waits for the write").isFalse();
assertThat(writer.isAlive()).as("the write still blocks after the abort").isFalse();
assertThat(endOfTheWrite.get()).as("how the write ended").isInstanceOf(IOException.class);
assertThat(closer.isAlive()).as("the close still waits for the write after the abort").isFalse();
assertThatThrownBy(pair.peer::receive)
.as("the peer read a message after the one the abort cut short")
.isInstanceOf(IOException.class);
}
}
/**
* The thread of a session can be the one held in a write, and any close joins that thread first:
* the abort ends the write before it joins it.
*/
@Test(timeOut = 60_000)
public void anAbortEndsAWriteOfTheThreadOfTheSession() throws Exception
{
try (SessionPair pair = connectSessionPair())
{
pair.publisher.start();
pair.publisher.waitForStartup();
pair.publisher.publish(newBlockingMsg());
pair.awaitBytesReachedThePeer();
final Thread aborter = start("aborter",
() -> pair.publisher.abort(new IOException("the test gives the session up")));
aborter.join(SOCKET_TIMEOUT_MS);
assertThat(aborter.isAlive()).as("the abort waits for the thread of the session, held in its write")
.isFalse();
assertThat(pair.publisher.isAlive()).as("the thread of the session outlived the abort").isFalse();
assertThatThrownBy(pair.peer::receive)
.as("the peer read a message after the one the abort cut short")
.isInstanceOf(IOException.class);
}
}
/**
* An abort marks the close begun before it closes the sockets: a reader whose read the close of
* the sockets ends then finds the close begun, and does not take it for a failure of its peer -
* which the broker of a domain reports as a disconnection by its replication server.
*/
@Test(timeOut = 60_000)
public void anAbortMarksTheCloseBegunBeforeItClosesTheSockets() throws Exception
{
try (SessionPair pair = connectSessionPair())
{
pair.peer.abort(new IOException("the test gives the session up"));
assertThat(pair.peerSocket.closeBegunWhenClosed())
.as("whether the close of the session was marked begun when its socket was closed")
.isTrue();
}
}
private static Thread start(final String name, final Runnable runnable)
{
final Thread thread = new Thread(runnable, SessionTest.class.getSimpleName() + " " + name);
thread.setDaemon(true);
thread.start();
return thread;
}
/** Waits for the thread to wait, for a lock or for a time, inside the method of a session. */
private static void awaitWaitingIn(final Thread thread, final String method) throws Exception
{
final long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(SOCKET_TIMEOUT_MS);
while (!waitsIn(thread, method))
{
assertThat(System.nanoTime() < deadline)
.as(thread.getName() + " does not wait in Session." + method + "()")
.isTrue();
Thread.sleep(10);
}
}
private static boolean waitsIn(final Thread thread, final String method)
{
final Thread.State state = thread.getState();
if (state != Thread.State.WAITING && state != Thread.State.TIMED_WAITING)
{
return false;
}
for (final StackTraceElement frame : thread.getStackTrace())
{
if (frame.getClassName().equals(Session.class.getName()) && frame.getMethodName().equals(method))
{
return true;
}
}
return false;
}
private static EntryMsg newBlockingMsg()
{
return new EntryMsg(SENDER_ID, PEER_ID, new byte[BLOCKING_MESSAGE_SIZE], 1);
}
private static CSN newCSN()
{
return new CSNGenerator(SENDER_ID, 0).newCSN();
}
/**
* Connects the end under test, in the server role of the replication protocol, with a peer
* which reads only when a test does. Both ends exchange one message under TLS and then drop the
* security layer, as the replication handshake does when encryption is not required, so that
* the socket buffers alone decide when a write blocks: a message read under TLS by each end is
* what consumes the records TLS itself sends after its negotiation, which would otherwise be
* read as the start of a replication message once the layer is gone.
*/
private static SessionPair connectSessionPair() throws Exception
{
final ReplSessionSecurity security = getReplSessionSecurity();
final ExecutorService executor = Executors.newSingleThreadExecutor();
final ObservedSocket peerSocket = new ObservedSocket();
Socket publisherSocket = null;
Session publisher = null;
boolean connected = false;
try (ServerSocket listen = TestCaseUtils.bindFreePort())
{
listen.setSoTimeout(SOCKET_TIMEOUT_MS);
peerSocket.setReceiveBufferSize(SOCKET_BUFFER_SIZE);
peerSocket.setTcpNoDelay(true);
peerSocket.connect(new InetSocketAddress("127.0.0.1", listen.getLocalPort()), SOCKET_TIMEOUT_MS);
// The TLS negotiation needs both ends handshaking at the same time.
final Future peerEnd =
executor.submit(() -> security.createClientSession(peerSocket, SOCKET_TIMEOUT_MS));
publisherSocket = listen.accept();
publisherSocket.setSendBufferSize(SOCKET_BUFFER_SIZE);
publisherSocket.setTcpNoDelay(true);
publisher = security.createServerSession(publisherSocket, SOCKET_TIMEOUT_MS);
assertThat(publisher).as("could not create the session under test").isNotNull();
final Session peer = peerEnd.get(SOCKET_TIMEOUT_MS, TimeUnit.MILLISECONDS);
peerSocket.observe(peer);
publisher.publish(new HeartbeatMsg());
assertThat(peer.receive()).isInstanceOf(HeartbeatMsg.class);
peer.publish(new HeartbeatMsg());
assertThat(publisher.receive()).isInstanceOf(HeartbeatMsg.class);
publisher.stopEncryption();
peer.stopEncryption();
connected = true;
return new SessionPair(publisher, peer, peerSocket);
}
finally
{
executor.shutdownNow();
if (!connected)
{
if (publisher != null)
{
publisher.close();
}
StaticUtils.close(publisherSocket, peerSocket);
}
}
}
/**
* The socket of the peer, which records, when it is first closed, whether the close of the
* session it carries was marked begun by then.
*/
private static final class ObservedSocket extends Socket
{
private volatile Session session;
private volatile Boolean closeBegunWhenClosed;
void observe(final Session session)
{
this.session = session;
}
/** Returns null until the socket of a session observed is closed. */
Boolean closeBegunWhenClosed()
{
return closeBegunWhenClosed;
}
@Override
public void close() throws IOException
{
final Session observed = session;
if (observed != null && closeBegunWhenClosed == null)
{
closeBegunWhenClosed = observed.closeInitiated();
}
super.close();
}
}
private static final class SessionPair implements Closeable
{
private final Session publisher;
private final Session peer;
private final ObservedSocket peerSocket;
private SessionPair(Session publisher, Session peer, ObservedSocket peerSocket)
{
this.publisher = publisher;
this.peer = peer;
this.peerSocket = peerSocket;
}
/**
* Waits for the first bytes of a message to reach the peer: the session thread of the end
* under test is then inside the write of that message, and stays there until the peer
* reads, since the message is larger than the buffers on both sides of the connection.
*/
void awaitBytesReachedThePeer() throws Exception
{
final long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(SOCKET_TIMEOUT_MS);
while (peerSocket.getInputStream().available() == 0)
{
assertThat(System.nanoTime() < deadline)
.as("nothing was written to the peer")
.isTrue();
Thread.sleep(10);
}
}
@Override
public void close()
{
// The peer first: a session thread held inside a write is released by the peer going away,
// and close() joins that thread.
peer.close();
publisher.close();
}
}
}