mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

Valery Kharseko
yesterday 77ad03e14cc1bbc5471f332ba73430d4c472b366
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
/*
 * 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.
 * <p>
 * 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<Throwable> 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<Session> 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();
    }
  }
}