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 08b2b16 fix: Add memory lifetime warnings to ReadOnlySpan properties
(#406)
08b2b16 is described below
commit 08b2b16269a146cdc1495d04c2aaf0900f37b949
Author: Abhilash S Hathwar <[email protected]>
AuthorDate: Wed Aug 19 04:05:50 2026 +0530
fix: Add memory lifetime warnings to ReadOnlySpan properties (#406)
## What's Changed
Adds explicit XML documentation remarks (`<remarks>`) to properties and
methods that return a `ReadOnlySpan` over managed or unmanaged memory
across Apache Arrow array and buffer classes.
### Motivation & Relation to PR #393
When an `ArrowBuffer` or `Array` is backed by unmanaged memory (e.g.,
allocated via a custom `MemoryAllocator` or `NativeMemoryManager`),
extracting a `ReadOnlySpan` into a local variable and subsequently
disposing the parent buffer/array can lead to use-after-free memory
access issues.
While PR #393 explores structural lifecycle/pinning mechanisms, this PR
provides an immediate, non-breaking developer safety improvement:
- Added `<remarks>` warning callouts to `ArrowBuffer.Span`,
`PrimitiveArray.Values`, `BooleanArray.Values`, and
`BinaryArray.GetBytes`. These surface as visible safety notes in IDE
tooltips and IntelliSense (Visual Studio, VS Code, Rider) without
breaking builds or requiring API breaking changes under
`TreatWarningsAsErrors=true`.
- Added `PoisonMemoryAllocator` and unit test
`TestNativeMemoryManagerUseAfterFree` to `Apache.Arrow.Tests` to verify
memory poisoning on buffer release.
Relates to #397 and #393.
---
src/Apache.Arrow/Arrays/BinaryArray.cs | 6 +-
src/Apache.Arrow/Arrays/BooleanArray.cs | 12 ++-
src/Apache.Arrow/Arrays/PrimitiveArray.cs | 9 ++-
src/Apache.Arrow/ArrowBuffer.cs | 9 ++-
test/Apache.Arrow.Tests/ArrowBufferTests.cs | 24 +++++-
test/Apache.Arrow.Tests/PoisonMemoryAllocator.cs | 93 ++++++++++++++++++++++++
6 files changed, 147 insertions(+), 6 deletions(-)
diff --git a/src/Apache.Arrow/Arrays/BinaryArray.cs
b/src/Apache.Arrow/Arrays/BinaryArray.cs
index 80985d0..387c644 100644
--- a/src/Apache.Arrow/Arrays/BinaryArray.cs
+++ b/src/Apache.Arrow/Arrays/BinaryArray.cs
@@ -1,4 +1,4 @@
-// Licensed to the Apache Software Foundation (ASF) under one or more
+// 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
@@ -383,6 +383,10 @@ namespace Apache.Arrow
/// <summary>
/// Get the collection of bytes, as a read-only span, at a given index
in the array.
/// </summary>
+ /// <remarks>
+ /// CAUTION: The returned ReadOnlySpan is not GC-tracked if backed by
unmanaged memory.
+ /// Ensure the BinaryArray object remains in scope and undisposed
while accessing this span.
+ /// </remarks>
/// <param name="index">Index at which to get bytes.</param>
/// <param name="isNull">Set to <see langword="true"/> if the value at
the given index is null.</param>
/// <returns>Returns a <see cref="ReadOnlySpan{Byte}"/>
object.</returns>
diff --git a/src/Apache.Arrow/Arrays/BooleanArray.cs
b/src/Apache.Arrow/Arrays/BooleanArray.cs
index f87c2ec..32824a1 100644
--- a/src/Apache.Arrow/Arrays/BooleanArray.cs
+++ b/src/Apache.Arrow/Arrays/BooleanArray.cs
@@ -1,4 +1,4 @@
-// Licensed to the Apache Software Foundation (ASF) under one or more
+// 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
@@ -162,7 +162,15 @@ namespace Apache.Arrow
}
public ArrowBuffer ValueBuffer => Data.Buffers[1];
- public ReadOnlySpan<byte> Values => ValueBuffer.Span.Slice(0,
(int)Math.Ceiling(Length / 8.0));
+
+ /// <summary>
+ /// Gets the boolean array values as a span of bytes (a bitmap).
+ /// </summary>
+ /// <remarks>
+ /// CAUTION: The returned ReadOnlySpan is not GC-tracked if backed by
unmanaged memory.
+ /// Ensure the BooleanArray object remains in scope and undisposed
while accessing this span.
+ /// </remarks>
+ public ReadOnlySpan<byte> Values => ValueBuffer.Span.Slice(0,
(int)Math.Ceiling((double)Length / 8));
public BooleanArray(
ArrowBuffer valueBuffer, ArrowBuffer nullBitmapBuffer,
diff --git a/src/Apache.Arrow/Arrays/PrimitiveArray.cs
b/src/Apache.Arrow/Arrays/PrimitiveArray.cs
index 05d659b..992ffb6 100644
--- a/src/Apache.Arrow/Arrays/PrimitiveArray.cs
+++ b/src/Apache.Arrow/Arrays/PrimitiveArray.cs
@@ -1,4 +1,4 @@
-// Licensed to the Apache Software Foundation (ASF) under one or more
+// 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
@@ -31,6 +31,13 @@ namespace Apache.Arrow
public ArrowBuffer ValueBuffer => Data.Buffers[1];
+ /// <summary>
+ /// Gets the array values as a span.
+ /// </summary>
+ /// <remarks>
+ /// CAUTION: The returned ReadOnlySpan is not GC-tracked if backed by
unmanaged memory.
+ /// Ensure the PrimitiveArray object remains in scope and undisposed
while accessing this span.
+ /// </remarks>
public ReadOnlySpan<T> Values =>
ValueBuffer.Span.CastTo<T>().Slice(Offset, Length);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
diff --git a/src/Apache.Arrow/ArrowBuffer.cs b/src/Apache.Arrow/ArrowBuffer.cs
index d3cd939..c2f531f 100644
--- a/src/Apache.Arrow/ArrowBuffer.cs
+++ b/src/Apache.Arrow/ArrowBuffer.cs
@@ -1,4 +1,4 @@
-// Licensed to the Apache Software Foundation (ASF) under one or more
+// 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
@@ -52,6 +52,13 @@ namespace Apache.Arrow
public int Length => Memory.Length;
+ /// <summary>
+ /// Gets a read-only span over the buffer contents.
+ /// </summary>
+ /// <remarks>
+ /// CAUTION: The returned ReadOnlySpan points directly to memory
managed by this ArrowBuffer.
+ /// Ensure the ArrowBuffer instance remains rooted and undisposed
while using this span to prevent use-after-free.
+ /// </remarks>
public ReadOnlySpan<byte> Span
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
diff --git a/test/Apache.Arrow.Tests/ArrowBufferTests.cs
b/test/Apache.Arrow.Tests/ArrowBufferTests.cs
index 28f22c2..a9b0e77 100644
--- a/test/Apache.Arrow.Tests/ArrowBufferTests.cs
+++ b/test/Apache.Arrow.Tests/ArrowBufferTests.cs
@@ -1,4 +1,4 @@
-// Licensed to the Apache Software Foundation (ASF) under one or more
+// 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
@@ -14,7 +14,9 @@
// limitations under the License.
using System;
+using System.Buffers;
using System.Threading;
+using Apache.Arrow.Memory;
using Apache.Arrow.Tests.Fixtures;
using Xunit;
@@ -111,6 +113,26 @@ namespace Apache.Arrow.Tests
Assert.Equal(10, buffer.Span.CastTo<int>()[2]);
}
+ [Fact]
+ public void TestNativeMemoryManagerUseAfterFree()
+ {
+ using var allocator = new PoisonMemoryAllocator();
+ // Allocate using the Builder pattern
+ var builder = new ArrowBuffer.Builder<byte>(100000);
+ builder.Append(new byte[100000]);
+ ArrowBuffer buffer = builder.Build(allocator);
+
+ // Extract the unmanaged Span
+ ReadOnlySpan<byte> span = buffer.Span;
+
+ // Dispose the buffer to trigger memory poisoning and release
+ buffer.Dispose();
+
+ // span[50000] is poisoned with 0xFF after free, it's not the
initial 0 value
+ byte b = span[50000];
+ Assert.Equal(0xFF, b);
+ }
+
public class Retain
{
[Fact]
diff --git a/test/Apache.Arrow.Tests/PoisonMemoryAllocator.cs
b/test/Apache.Arrow.Tests/PoisonMemoryAllocator.cs
new file mode 100644
index 0000000..8ed050b
--- /dev/null
+++ b/test/Apache.Arrow.Tests/PoisonMemoryAllocator.cs
@@ -0,0 +1,93 @@
+// 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.Buffers;
+using System.Collections.Generic;
+using Apache.Arrow.Memory;
+
+namespace Apache.Arrow.Tests
+{
+ public class PoisonMemoryAllocator : MemoryAllocator, IDisposable
+ {
+ private readonly List<IMemoryOwner<byte>> _allocatedOwners = new
List<IMemoryOwner<byte>>();
+ private bool _disposed;
+
+ public PoisonMemoryAllocator(int alignment = DefaultAlignment) :
base(alignment)
+ {
+ }
+
+ protected override IMemoryOwner<byte> AllocateInternal(int length, out
int bytesAllocated)
+ {
+ var innerOwner =
NativeMemoryAllocator.Default.Value.Allocate(length);
+ bytesAllocated = length;
+ lock (_allocatedOwners)
+ {
+ _allocatedOwners.Add(innerOwner);
+ }
+ return new PoisonMemoryOwner(innerOwner);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+ _disposed = true;
+ lock (_allocatedOwners)
+ {
+ foreach (IMemoryOwner<byte> owner in _allocatedOwners)
+ {
+ owner.Dispose();
+ }
+ _allocatedOwners.Clear();
+ }
+ }
+
+ private sealed class PoisonMemoryOwner : IMemoryOwner<byte>
+ {
+ private readonly IMemoryOwner<byte> _inner;
+ private bool _disposed;
+
+ public PoisonMemoryOwner(IMemoryOwner<byte> inner)
+ {
+ _inner = inner ?? throw new
ArgumentNullException(nameof(inner));
+ }
+
+ public Memory<byte> Memory
+ {
+ get
+ {
+ if (_disposed)
+ {
+ throw new
ObjectDisposedException(nameof(PoisonMemoryOwner));
+ }
+ return _inner.Memory;
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+ _disposed = true;
+ _inner.Memory.Span.Fill(0xFF);
+ }
+ }
+ }
+}