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 db7f1e4  fix: Saturate NativeBuffer growth instead of overflowing past 
half of int.MaxValue (#419)
db7f1e4 is described below

commit db7f1e4118cbc29f871b82bbb3e6c2aa642cb50f
Author: Curt Hagenlocher <[email protected]>
AuthorDate: Sat Aug 22 08:13:12 2026 -0700

    fix: Saturate NativeBuffer growth instead of overflowing past half of 
int.MaxValue (#419)
    
    ## What's Changed
    
    `Grow` doubled the current length in a `checked` context without
    saturating:
    
    ```csharp
    int newCount = Math.Max(newElementCount, checked(Length * 2));
    ```
    
    So once a buffer passed half of the addressable maximum, its **next**
    grow threw `OverflowException`
    however small the requested increase, and even though the requested size
    still fit. For a
    `NativeBuffer<byte, …>` that is a hard ceiling near 1 GiB, with no way
    for a caller to work around it:
    asking for a smaller increment does not help, because the overflow is in
    the doubling rather than in
    the request.
    
    Growth now saturates at the largest addressable element count, so it
    stays amortised right up to the
    ceiling. A request that genuinely cannot be addressed still fails at the
    byte-size calculation, as it
    did before — behaviour is unchanged for anything that could not have
    worked.
    
    This is what the `TODO` those lines carried proposed:
    
    > There might be a size that's big enough to work for this case but not
    too big to overflow. We could
    > use that instead of blindly doubling.
    
    ### On testing it
    
    Reaching the boundary through `Grow` means allocating more than a
    gigabyte, which does not belong in
    a unit test. The count arithmetic is extracted to `ComputeGrowCount` so
    the boundary can be tested
    directly and exhaustively, including the per-element-size ceiling — the
    limit is a byte count, so a
    wider element type saturates at proportionally fewer elements.
    
    Verified the new tests fail against the previous arithmetic before
    fixing it: with `checked(Length *
    2)` restored, `ComputeGrowCountSaturatesInsteadOfOverflowing` and
    `ComputeGrowCountSaturatesPerElementSize` both fail; the rest pass
    either way.
    
    `Apache.Arrow.Tests` is green on net8.0: 1870 passed, 28 skipped (the
    Python interop cases).
    
    ### Scope
    
    This does not change the 2 GiB ceiling on `ArrowBuffer` itself
    (`ReadOnlyMemory<byte>`, `int Length`)
    — it only stops buffers failing at half of it.
    
    Closes #418.
    
    ---------
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 src/Apache.Arrow/Memory/NativeBuffer.cs      | 24 ++++++++---
 test/Apache.Arrow.Tests/NativeBufferTests.cs | 63 ++++++++++++++++++++++++++++
 2 files changed, 81 insertions(+), 6 deletions(-)

diff --git a/src/Apache.Arrow/Memory/NativeBuffer.cs 
b/src/Apache.Arrow/Memory/NativeBuffer.cs
index f5b8f61..b042999 100644
--- a/src/Apache.Arrow/Memory/NativeBuffer.cs
+++ b/src/Apache.Arrow/Memory/NativeBuffer.cs
@@ -15,6 +15,7 @@
 
 using System;
 using System.Buffers;
+using System.Diagnostics;
 using System.Runtime.CompilerServices;
 using System.Runtime.InteropServices;
 using System.Threading;
@@ -90,15 +91,11 @@ namespace Apache.Arrow.Memory
             if (newElementCount <= Length)
                 return;
 
-            // Exponential growth (2x) to amortise repeated grows
-            // TODO: There might be a size that's big enough to work for this 
case but not too big to overflow.
-            // We could use that instead of blindly doubling.
-            int newCount = Math.Max(newElementCount, checked(Length * 2));
             int elementSize = Unsafe.SizeOf<TItem>();
+            int newCount = ComputeGrowCount(Length, newElementCount, 
elementSize);
             int needed = checked(newCount * elementSize);
 
-            var owner = _owner ?? throw new 
ObjectDisposedException(nameof(NativeBuffer<TItem, TTracker>));
-            owner.Reallocate(needed);
+            _owner.Reallocate(needed);
 
             if (zeroFill)
             {
@@ -109,6 +106,21 @@ namespace Apache.Arrow.Memory
             Length = newCount;
         }
 
+        /// <summary>
+        /// The element count to grow to: double the current length to 
amortise repeated grows, but never
+        /// past the largest buffer that can be addressed, and never below 
what the caller asked for.
+        /// </summary>
+        internal static int ComputeGrowCount(int length, int newElementCount, 
int elementSize)
+        {
+            // Always Unsafe.SizeOf<TItem>() for an unmanaged TItem, so never 
below one; the parameter
+            // exists so the boundary can be tested without allocating a 
buffer of that size.
+            Debug.Assert(elementSize > 0);
+
+            int maxCount = int.MaxValue / elementSize;
+            long doubled = (long)length * 2;
+            return (int)Math.Max(newElementCount, Math.Min(doubled, maxCount));
+        }
+
         public void Dispose()
         {
             IDisposable disposable = _owner;
diff --git a/test/Apache.Arrow.Tests/NativeBufferTests.cs 
b/test/Apache.Arrow.Tests/NativeBufferTests.cs
index 84d050c..87ea440 100644
--- a/test/Apache.Arrow.Tests/NativeBufferTests.cs
+++ b/test/Apache.Arrow.Tests/NativeBufferTests.cs
@@ -84,6 +84,69 @@ namespace Apache.Arrow.Tests
             Assert.Equal(42, buf.Span[0]);
         }
 
+        // Growth doubles to stay amortised, but must saturate rather than 
overflow. Doubling used to be
+        // unconditional and checked, so a buffer past half the maximum threw 
OverflowException on its
+        // next grow however little was asked for — a byte buffer could not 
grow beyond about 1 GiB.
+        //
+        // The arithmetic is tested directly: reproducing it through Grow 
would mean allocating more than
+        // a gigabyte, which is not something to put in a unit test.
+        [Theory]
+        // length, requested, elementSize, expected
+        [InlineData(0, 1, 1, 1)]                    // nothing to double yet
+        [InlineData(3, 10, 4, 10)]                  // request exceeds the 
doubling
+        [InlineData(8, 10, 4, 16)]                  // doubling exceeds the 
request
+        [InlineData(5, 5, 4, 10)]                   // equal: doubling still 
wins
+        public void ComputeGrowCountDoublesWhileItFits(
+            int length, int requested, int elementSize, int expected)
+        {
+            Assert.Equal(
+                expected,
+                NativeBuffer<byte, 
NoOpAllocationTracker>.ComputeGrowCount(length, requested, elementSize));
+        }
+
+        [Fact]
+        public void ComputeGrowCountSaturatesInsteadOfOverflowing()
+        {
+            // Past half the maximum, doubling would overflow. The result 
saturates at the largest
+            // addressable count and still covers the request.
+            const int elementSize = 1;
+            int overHalf = (int.MaxValue / 2) + 1000;
+
+            int grown = NativeBuffer<byte, 
NoOpAllocationTracker>.ComputeGrowCount(
+                overHalf, overHalf + 1, elementSize);
+
+            Assert.Equal(int.MaxValue, grown);
+            Assert.True(grown >= overHalf + 1);
+        }
+
+        [Fact]
+        public void ComputeGrowCountSaturatesPerElementSize()
+        {
+            // The ceiling is a byte count, so a wider element saturates at 
proportionally fewer of them.
+            const int elementSize = 8;
+            int maxCount = int.MaxValue / elementSize;
+            int overHalf = (maxCount / 2) + 1000;
+
+            int grown = NativeBuffer<long, 
NoOpAllocationTracker>.ComputeGrowCount(
+                overHalf, overHalf + 1, elementSize);
+
+            Assert.Equal(maxCount, grown);
+            Assert.True((long)grown * elementSize <= int.MaxValue);
+        }
+
+        [Fact]
+        public void ComputeGrowCountNeverReturnsLessThanRequested()
+        {
+            // A request larger than the ceiling is not silently truncated; 
Grow still refuses it when it
+            // works out the byte size.
+            const int elementSize = 8;
+            int beyond = (int.MaxValue / elementSize) + 1;
+
+            Assert.Equal(
+                beyond,
+                NativeBuffer<long, NoOpAllocationTracker>.ComputeGrowCount(0, 
beyond, elementSize));
+        }
+
         [Fact]
         public void BuildTransfersOwnershipToArrowBuffer()
         {

Reply via email to