This is an automated email from the ASF dual-hosted git repository.

smengcl pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ozone.git


The following commit(s) were added to refs/heads/master by this push:
     new a0f08cc3f0b HDDS-15480. Potential for NPE / infinite loop in 
StreamBlockReader (#10431)
a0f08cc3f0b is described below

commit a0f08cc3f0bd0f4d2d7625e8d83d0c81b340c263
Author: Stephen O'Donnell <[email protected]>
AuthorDate: Thu Jul 9 23:56:59 2026 +0100

    HDDS-15480. Potential for NPE / infinite loop in StreamBlockReader (#10431)
    
    Co-authored-by: S O'Donnell <[email protected]>
    Co-authored-by: Siyao Meng <[email protected]>
---
 .../hdds/scm/storage/StreamBlockInputStream.java   |  12 +-
 .../scm/storage/TestStreamBlockInputStream.java    | 132 +++++++++++++++++++++
 2 files changed, 142 insertions(+), 2 deletions(-)

diff --git 
a/hadoop-hdds/client/src/main/java/org/apache/hadoop/hdds/scm/storage/StreamBlockInputStream.java
 
b/hadoop-hdds/client/src/main/java/org/apache/hadoop/hdds/scm/storage/StreamBlockInputStream.java
index 975c0511832..b008abdb56c 100644
--- 
a/hadoop-hdds/client/src/main/java/org/apache/hadoop/hdds/scm/storage/StreamBlockInputStream.java
+++ 
b/hadoop-hdds/client/src/main/java/org/apache/hadoop/hdds/scm/storage/StreamBlockInputStream.java
@@ -482,17 +482,25 @@ private ByteBuffer read(int length, boolean preRead) 
throws IOException {
 
       while (true) {
         final ByteBuffer buf = readFromQueue();
-        if (buf != null && buf.hasRemaining()) {
+        if (buf == null) {
+          return null; // Stream ended
+        }
+        if (buf.hasRemaining()) {
           return buf;
         }
+        // buf is empty: the server aligned its response to a checksum boundary
+        // before our current position and all bytes were skipped. Fetch the 
next
+        // response, which should start at or after our position.
       }
     }
 
     ByteBuffer readFromQueue() throws IOException {
       final ReadBlockResponseProto readBlock = poll();
+      if (readBlock == null) {
+        return null; // Stream ended
+      }
       // The server always returns data starting from the last checksum 
boundary. Therefore if the reader position is
       // ahead of the position we received from the server, we need to adjust 
the buffer position accordingly.
-      // If the reader position is behind
       final ByteString data = readBlock.getData();
       final ByteBuffer dataBuffer = data.asReadOnlyByteBuffer();
       final long blockOffset = readBlock.getOffset();
diff --git 
a/hadoop-hdds/client/src/test/java/org/apache/hadoop/hdds/scm/storage/TestStreamBlockInputStream.java
 
b/hadoop-hdds/client/src/test/java/org/apache/hadoop/hdds/scm/storage/TestStreamBlockInputStream.java
index 9e1ff0893ec..4483945509b 100644
--- 
a/hadoop-hdds/client/src/test/java/org/apache/hadoop/hdds/scm/storage/TestStreamBlockInputStream.java
+++ 
b/hadoop-hdds/client/src/test/java/org/apache/hadoop/hdds/scm/storage/TestStreamBlockInputStream.java
@@ -17,6 +17,7 @@
 
 package org.apache.hadoop.hdds.scm.storage;
 
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.mockito.ArgumentMatchers.any;
@@ -309,6 +310,137 @@ private XceiverClientGrpc mockStreamingReadClient(byte[] 
data,
     return xceiverClient;
   }
 
+  /**
+   * Realistic test for the checksum-alignment skip path.
+   *
+   * After a seek, the server aligns its response to the nearest checksum 
boundary,
+   * which may be before the client's current position. With a small 
responseDataSize
+   * (4 bytes), the server sends two 4-byte chunks:
+   *   chunk 1: blockOffset=0, data=[0,1,2,3] — entirely before seek position 4
+   *   chunk 2: blockOffset=4, data=[4,5,6,7] — starts at seek position
+   *
+   * The while(true) loop in read() must:
+   *   iteration 1: receive chunk 1, skip all 4 bytes (pos-blockOffset=4 == 
data.size()),
+   *                empty buffer → continue
+   *   iteration 2: receive chunk 2, no skip needed → return buffer with 
[4,5,6,7]
+   *
+   * This was an infinite loop or MPE before fixes in the PR that added this 
test.
+   */
+  @Test
+  public void 
testSeekReadsCorrectBytesWhenFirstResponseIsFullyBeforePosition() throws 
Exception {
+    OzoneClientConfig clientConfig = newStreamReadConfig();
+    clientConfig.setStreamReadResponseDataSize(4); // 4-byte chunks match the 
test data
+    BlockID blockID = new BlockID(1L, 12L);
+    long length = 8;
+    Pipeline pipeline = mockStandalonePipeline();
+    ClientCallStreamObserver<ContainerCommandRequestProto> requestObserver =
+        mock(ClientCallStreamObserver.class);
+    StreamingReadResponse streamingReadResponse = 
mock(StreamingReadResponse.class);
+    
when(streamingReadResponse.getRequestObserver()).thenReturn(requestObserver);
+
+    AtomicReference<StreamingReaderSpi> readerRef = new AtomicReference<>();
+    XceiverClientGrpc xceiverClient = mock(XceiverClientGrpc.class);
+    doAnswer(inv -> {
+      StreamingReaderSpi reader = inv.getArgument(1);
+      reader.setStreamingReadResponse(streamingReadResponse);
+      readerRef.set(reader);
+      return null;
+    }).when(xceiverClient).initStreamRead(any(BlockID.class), any(), any());
+
+    // Server aligns to checksum boundary 0 and sends two 4-byte responses.
+    // The first chunk (bytes 0–3) is entirely before seek position 4 and will 
be
+    // fully skipped. The second chunk (bytes 4–7) starts at our position.
+    doAnswer(inv -> {
+      StreamingReaderSpi reader = readerRef.get();
+      reader.onNext(buildResponseProto(new byte[]{0, 1, 2, 3}, 0)); // fully 
skipped
+      reader.onNext(buildResponseProto(new byte[]{4, 5, 6, 7}, 4)); // has our 
data
+      reader.onCompleted();
+      return null;
+    }).when(xceiverClient).streamRead(any(), any());
+
+    XceiverClientFactory xceiverClientFactory = 
mock(XceiverClientFactory.class);
+    when(xceiverClientFactory.acquireClientForReadData(any(Pipeline.class)))
+        .thenReturn(xceiverClient);
+
+    try (StreamBlockInputStream sbis = new StreamBlockInputStream(
+        blockID, length, pipeline, null, xceiverClientFactory,
+        NO_REFRESH, clientConfig)) {
+
+      sbis.seek(4);
+
+      byte[] out = new byte[4];
+      int bytesRead = sbis.read(out, 0, 4);
+      assertEquals(4, bytesRead);
+      assertArrayEquals(new byte[]{4, 5, 6, 7}, out,
+          "should return bytes starting from seek position, skipping the 
checksum-aligned preamble");
+    }
+  }
+
+  /**
+   * Defensive test for readFromQueue() which NPE'ed when poll() returns null.
+   *
+   * This tests a server-error / edge-case scenario: the server sends only a
+   * single response whose data ends before the client's seek position, then
+   * immediately completes the stream. The while(true) loop in read() skips
+   * all bytes in the response (empty buffer), then calls poll() again. poll()
+   * finds the queue empty and isDone()==true and returns null.
+   *
+   * A well-behaved server would never complete the stream without covering the
+   * client's position, so this scenario represents a protocol violation rather
+   * than normal operation, but it serves to reproduce the NPE exception before
+   * fixing the code.
+   */
+  @Test
+  public void 
testReadFromQueueNpeWhenStreamCompletesWithoutCoveringSeekPosition() throws 
Exception {
+    OzoneClientConfig clientConfig = newStreamReadConfig();
+    // Short timeout so the test completes quickly rather than waiting 5 s.
+    clientConfig.setStreamReadTimeout(Duration.ofMillis(200));
+
+    BlockID blockID = new BlockID(1L, 13L);
+    long length = 8;
+    Pipeline pipeline = mockStandalonePipeline();
+    ClientCallStreamObserver<ContainerCommandRequestProto> requestObserver =
+        mock(ClientCallStreamObserver.class);
+    StreamingReadResponse streamingReadResponse = 
mock(StreamingReadResponse.class);
+    
when(streamingReadResponse.getRequestObserver()).thenReturn(requestObserver);
+
+    AtomicReference<StreamingReaderSpi> readerRef = new AtomicReference<>();
+    XceiverClientGrpc xceiverClient = mock(XceiverClientGrpc.class);
+    doAnswer(inv -> {
+      StreamingReaderSpi reader = inv.getArgument(1);
+      reader.setStreamingReadResponse(streamingReadResponse);
+      readerRef.set(reader);
+      return null;
+    }).when(xceiverClient).initStreamRead(any(BlockID.class), any(), any());
+
+    // Server only sends bytes 0–3 (before seek position 4) then completes —
+    // simulating a protocol violation or a truncated/corrupt response.
+    doAnswer(inv -> {
+      StreamingReaderSpi reader = readerRef.get();
+      reader.onNext(buildResponseProto(new byte[]{0, 1, 2, 3}, 0));
+      reader.onCompleted();
+      return null;
+    }).when(xceiverClient).streamRead(any(), any());
+
+    XceiverClientFactory xceiverClientFactory = 
mock(XceiverClientFactory.class);
+    when(xceiverClientFactory.acquireClientForReadData(any(Pipeline.class)))
+        .thenReturn(xceiverClient);
+
+    try (StreamBlockInputStream sbis = new StreamBlockInputStream(
+        blockID, length, pipeline, null, xceiverClientFactory,
+        NO_REFRESH, clientConfig)) {
+
+      sbis.seek(4);
+
+      ByteBuffer buf = ByteBuffer.allocate(4);
+      // Before the fixes: threw NullPointerException (Bug 1) or looped 
forever (Bug 3).
+      // After the fixes: returns gracefully with 0 / EOF rather than crashing.
+      int bytesRead = sbis.read(buf);
+      assertEquals(-1, bytesRead, "should reach EOF when the stream completes 
before the seek position");
+      assertEquals(0, buf.position(), "no bytes should be produced");
+    }
+  }
+
   /**
    * When the server delivers multiple responses plus onCompleted() inside a
    * single streamRead() call (all on the same call stack), the first response


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to