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

CurtHagenlocher pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-dotnet.git


The following commit(s) were added to refs/heads/main by this push:
     new 07db113  fix: Return immediately from a zero-length 
ReadFullBufferAsync/ReadFullBuffer instead of calling into the stream (#426)
07db113 is described below

commit 07db1136ddfafe47ffbba4dda5c48e18bd3fedb0
Author: Rusty Conover <[email protected]>
AuthorDate: Tue Aug 25 15:56:44 2026 -0400

    fix: Return immediately from a zero-length 
ReadFullBufferAsync/ReadFullBuffer instead of calling into the stream (#426)
    
    ## What's Changed
    
    `StreamExtensions.ReadFullBufferAsync`/`ReadFullBuffer` called
    `stream.ReadAsync`/`stream.Read` with a zero-length buffer
    unconditionally, whenever a message body is legitimately empty (e.g. a
    `RecordBatch` built from a zero-column schema, which has no buffers).
    Over a `MemoryStream` this is a harmless no-op, but over a real
    socket-backed `NetworkStream` a zero-byte read does not complete
    immediately — it blocks as though waiting for the peer to send more
    data, instead of trivially returning `0`. In a lockstep/RPC-style
    protocol this blocks indefinitely, since the peer is itself waiting for
    a response before sending anything further.
    
    Both helpers now short-circuit `buffer.Length == 0` and return `0`
    immediately, before ever touching the stream — matching Go's
    `io.ReadFull`, which documents the same special-case for a zero-length
    buffer.
    
    Full repro (real `pyarrow` client, real socket, `strace` evidence
    pinning the exact hang to this call) is in the linked issue.
    
    Added `StreamExtensionsTests.cs`: a `Stream` subclass whose
    `Read`/`ReadAsync` throw if ever invoked confirms the zero-length fast
    path never touches the underlying stream, plus two tests confirming
    normal non-empty reads are unaffected. `dotnet test
    test/Apache.Arrow.Tests` passes in full (1874 passed, 28 skipped —
    unrelated Python interop tests, pre-existing).
    
    Closes #425.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
---
 src/Apache.Arrow/Extensions/StreamExtensions.cs  |  20 +++++
 test/Apache.Arrow.Tests/StreamExtensionsTests.cs | 102 +++++++++++++++++++++++
 2 files changed, 122 insertions(+)

diff --git a/src/Apache.Arrow/Extensions/StreamExtensions.cs 
b/src/Apache.Arrow/Extensions/StreamExtensions.cs
index ac1e975..1ee7df1 100644
--- a/src/Apache.Arrow/Extensions/StreamExtensions.cs
+++ b/src/Apache.Arrow/Extensions/StreamExtensions.cs
@@ -24,6 +24,19 @@ namespace Apache.Arrow
     {
         public static async ValueTask<int> ReadFullBufferAsync(this Stream 
stream, Memory<byte> buffer, CancellationToken cancellationToken = default)
         {
+            // A zero-length request is trivially satisfied — 0 bytes were 
asked for, 0 were
+            // read — and must return WITHOUT ever calling stream.ReadAsync. 
Socket-backed streams
+            // (NetworkStream et al.) do not treat a zero-length ReadAsync as 
an immediate no-op
+            // the way MemoryStream does: it behaves as a "wait for the socket 
to become readable"
+            // probe, blocking until the peer sends *something* (or closes). A 
RecordBatch message
+            // body is legitimately zero-length whenever the batch has no 
buffers (e.g. a
+            // zero-column schema), so without this fast path, reading such a 
batch's empty body
+            // over a real socket blocks indefinitely instead of completing 
immediately.
+            if (buffer.Length == 0)
+            {
+                return 0;
+            }
+
             int totalBytesRead = 0;
             do
             {
@@ -48,6 +61,13 @@ namespace Apache.Arrow
 
         public static int ReadFullBuffer(this Stream stream, Memory<byte> 
buffer)
         {
+            // See the matching guard in ReadFullBufferAsync above — same
+            // zero-length-buffer-blocks-on-socket-streams rationale applies 
to the sync path.
+            if (buffer.Length == 0)
+            {
+                return 0;
+            }
+
             int totalBytesRead = 0;
             do
             {
diff --git a/test/Apache.Arrow.Tests/StreamExtensionsTests.cs 
b/test/Apache.Arrow.Tests/StreamExtensionsTests.cs
new file mode 100644
index 0000000..20e101d
--- /dev/null
+++ b/test/Apache.Arrow.Tests/StreamExtensionsTests.cs
@@ -0,0 +1,102 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Apache.Arrow.Tests
+{
+    public class StreamExtensionsTests
+    {
+        /// <summary>
+        /// A stream whose Read/ReadAsync overrides throw if ever invoked, 
standing in for a
+        /// socket-backed stream (e.g. NetworkStream) whose zero-length 
ReadAsync/Read does not
+        /// complete immediately the way MemoryStream's does — it blocks as 
though waiting for the
+        /// peer to send more data. Used to prove 
ReadFullBufferAsync/ReadFullBuffer never call
+        /// into the underlying stream for a zero-length request.
+        /// </summary>
+        private sealed class ThrowsIfReadStream : Stream
+        {
+            public override bool CanRead => true;
+            public override bool CanSeek => false;
+            public override bool CanWrite => false;
+            public override long Length => throw new NotSupportedException();
+            public override long Position
+            {
+                get => throw new NotSupportedException();
+                set => throw new NotSupportedException();
+            }
+
+            public override int Read(byte[] buffer, int offset, int count) =>
+                throw new InvalidOperationException("Read should not be called 
for a zero-length buffer.");
+
+            public override Task<int> ReadAsync(byte[] buffer, int offset, int 
count, CancellationToken cancellationToken) =>
+                throw new InvalidOperationException("ReadAsync should not be 
called for a zero-length buffer.");
+
+#if NETCOREAPP
+            // Stream.ReadAsync(Memory<byte>, CancellationToken) is only 
overridable on
+            // netcoreapp targets — net462/net472 don't declare it as virtual 
on Stream.
+            public override ValueTask<int> ReadAsync(Memory<byte> buffer, 
CancellationToken cancellationToken = default) =>
+                throw new InvalidOperationException("ReadAsync should not be 
called for a zero-length buffer.");
+#endif
+
+            public override void Flush() => throw new NotSupportedException();
+            public override long Seek(long offset, SeekOrigin origin) => throw 
new NotSupportedException();
+            public override void SetLength(long value) => throw new 
NotSupportedException();
+            public override void Write(byte[] buffer, int offset, int count) 
=> throw new NotSupportedException();
+        }
+
+        [Fact]
+        public async Task 
ReadFullBufferAsync_ZeroLengthBuffer_ReturnsWithoutTouchingStream()
+        {
+            var stream = new ThrowsIfReadStream();
+            int bytesRead = await 
stream.ReadFullBufferAsync(Memory<byte>.Empty);
+            Assert.Equal(0, bytesRead);
+        }
+
+        [Fact]
+        public void 
ReadFullBuffer_ZeroLengthBuffer_ReturnsWithoutTouchingStream()
+        {
+            var stream = new ThrowsIfReadStream();
+            int bytesRead = stream.ReadFullBuffer(Memory<byte>.Empty);
+            Assert.Equal(0, bytesRead);
+        }
+
+        [Fact]
+        public async Task ReadFullBufferAsync_NonEmptyBuffer_ReadsFromStream()
+        {
+            var data = new byte[] { 1, 2, 3, 4 };
+            using var stream = new MemoryStream(data);
+            var buffer = new byte[4];
+            int bytesRead = await stream.ReadFullBufferAsync(buffer);
+            Assert.Equal(4, bytesRead);
+            Assert.Equal(data, buffer);
+        }
+
+        [Fact]
+        public void ReadFullBuffer_NonEmptyBuffer_ReadsFromStream()
+        {
+            var data = new byte[] { 1, 2, 3, 4 };
+            using var stream = new MemoryStream(data);
+            var buffer = new byte[4];
+            int bytesRead = stream.ReadFullBuffer(buffer);
+            Assert.Equal(4, bytesRead);
+            Assert.Equal(data, buffer);
+        }
+    }
+}

Reply via email to