diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Abstractions/ITargetReadCache.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Abstractions/ITargetReadCache.cs
new file mode 100644
index 00000000000000..1dec7860568b4f
--- /dev/null
+++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Abstractions/ITargetReadCache.cs
@@ -0,0 +1,40 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+
+namespace Microsoft.Diagnostics.DataContractReader;
+
+///
+/// Fetches .Length bytes starting at
+/// directly from the underlying target, bypassing any active cache. Supplied to
+/// implementations as the cache-miss fallback.
+///
+public delegate void RawReadDelegate(ulong address, Span destination);
+
+///
+/// A pluggable read cache installed for the lifetime of a
+/// scope. Implementations decide whether to satisfy a
+/// request from cached state or fall through to the underlying target via
+/// the supplied .
+///
+///
+/// Implementations are not required to be thread-safe; the cDAC reader serializes
+/// access to a target.
+///
+public interface ITargetReadCache : IDisposable
+{
+ ///
+ /// Read destination.Length bytes starting at . On a cache
+ /// miss the implementation must call to fetch the bytes from the
+ /// underlying target. Exceptions thrown by must be allowed to
+ /// propagate to the caller.
+ ///
+ void ReadBuffer(ulong address, Span destination, RawReadDelegate fallback);
+
+ ///
+ /// Drop any cached state. Called by the host when the cache scope ends, and may be called
+ /// by users to invalidate mid-scope (for example after a target write).
+ ///
+ void Invalidate();
+}
diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Abstractions/Target.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Abstractions/Target.cs
index b091152bcc7a66..aae04ba5855d57 100644
--- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Abstractions/Target.cs
+++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Abstractions/Target.cs
@@ -340,4 +340,36 @@ public virtual void Flush(FlushScope scope)
ProcessedData.Clear();
Contracts.Flush(scope);
}
+
+ ///
+ /// Begin a scope during which reads from this target may be served by .
+ /// Returns a handle that ends the scope when disposed. Callers should typically wrap the
+ /// scope in a using block.
+ ///
+ /// The caching strategy to install for the duration of the scope.
+ ///
+ /// Only one cache scope may be active per target at a time; opening a second scope while one
+ /// is still active throws . The default implementation
+ /// does not route reads through the cache and is suitable for targets (such as test stubs)
+ /// that do not need caching; it still invalidates and disposes the cache when the scope ends
+ /// so that implementations see a consistent lifecycle.
+ ///
+ public virtual IDisposable BeginCacheScope(ITargetReadCache cache)
+ {
+ ArgumentNullException.ThrowIfNull(cache);
+ return new DefaultCacheScope(cache);
+ }
+
+ private sealed class DefaultCacheScope(ITargetReadCache cache) : IDisposable
+ {
+ private bool _disposed;
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ cache.Invalidate();
+ cache.Dispose();
+ }
+ }
}
diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Legacy/Dbi/Helpers/HeapWalk.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Legacy/Dbi/Helpers/HeapWalk.cs
index 3c039ee0148fb4..e3462a0495a707 100644
--- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Legacy/Dbi/Helpers/HeapWalk.cs
+++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Legacy/Dbi/Helpers/HeapWalk.cs
@@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
-using System.Buffers.Binary;
using System.Linq;
using Microsoft.Diagnostics.DataContractReader.Contracts;
@@ -11,10 +10,10 @@ namespace Microsoft.Diagnostics.DataContractReader.Legacy;
internal sealed class HeapWalk : IEnum
{
+ private readonly Target _target;
private readonly IGC _gc;
private readonly IRuntimeTypeSystem _rts;
private readonly TargetPointer _freeObjectMT;
- private readonly LinearReadCache _cache;
private readonly uint _numComponentsOffsetArray;
private readonly uint _numComponentsOffsetString;
private readonly uint _methodTableOffset;
@@ -25,11 +24,12 @@ internal sealed class HeapWalk : IEnum
public HeapWalk(Target target)
{
+ _target = target;
_gc = target.Contracts.GC;
_rts = target.Contracts.RuntimeTypeSystem;
_freeObjectMT = _rts.GetWellKnownMethodTable(WellKnownMethodTable.Free);
- _cache = new LinearReadCache(target);
- // use these fields directly instead of through RuntimeTypeSystem so that we can use our cache that we really only need for heap walking
+ // use these fields directly instead of through RuntimeTypeSystem so that the heap walk
+ // can amortize reads through the active cache scope (see Walk).
_numComponentsOffsetArray = (uint)target.GetTypeInfo(DataType.Array).Fields[Constants.FieldNames.Array.NumComponents].Offset;
_numComponentsOffsetString = (uint)target.GetTypeInfo(DataType.String).Fields["m_StringLength"].Offset;
_methodTableOffset = (uint)target.GetTypeInfo(DataType.Object).Fields["m_pMethTab"].Offset;
@@ -40,6 +40,11 @@ public HeapWalk(Target target)
private IEnumerable Walk()
{
+ // Hold a linear-page cache for the entire walk: each iteration tends to touch the same
+ // page (object header + a trailing component-count field), so a single cache scope
+ // yields a far higher hit rate than activating per-read.
+ using IDisposable _ = _target.BeginCacheScope(new LinearReadCache());
+
bool pendingFailure = false;
foreach ((GCHeapSegmentInfo seg, GCHeapData _) in EnumerateAllSegments())
{
@@ -49,7 +54,7 @@ private IEnumerable Walk()
TargetPointer currentObj = _gc.GetPotentialNextObjectAddress(seg.Start, 0, seg);
while (currentObj.Value < seg.End.Value)
{
- if (!_cache.TryReadPointer(currentObj.Value + _methodTableOffset, out TargetPointer mt))
+ if (!_target.TryReadPointer(currentObj.Value + _methodTableOffset, out TargetPointer mt))
{
pendingFailure = true;
break;
@@ -136,7 +141,7 @@ private bool TryGetObjectSize(TargetPointer objAddr, TargetPointer mt, out ulong
numComponentsOffset = _numComponentsOffsetString;
else
return false; // unrecognized component type
- if (!_cache.TryReadUInt32(objAddr.Value + numComponentsOffset, out uint numComponents))
+ if (!_target.TryRead(objAddr.Value + numComponentsOffset, out uint numComponents))
return false;
baseSize += (ulong)componentSize * numComponents;
}
@@ -162,98 +167,4 @@ private static IEnumerable EnumerateHeaps(IGC gc, bool isWorkstation
yield return gc.GetHeapData(heapAddress);
}
}
-
- // Linear page cache used by the per-object heap walk.
- private sealed class LinearReadCache
- {
- // Typical page size
- private const uint PageSize = 0x1000;
-
- private readonly Target _target;
- private readonly byte[] _page = new byte[PageSize];
- private ulong _currPageStart;
- private uint _currPageSize;
-
- public LinearReadCache(Target target)
- {
- _target = target;
- }
-
- public bool TryReadPointer(ulong addr, out TargetPointer value)
- {
- Span buffer = stackalloc byte[sizeof(ulong)];
- buffer = buffer.Slice(0, _target.PointerSize);
- if (!TryRead(addr, buffer))
- {
- value = TargetPointer.Null;
- return false;
- }
- value = _target.ReadPointerFromSpan(buffer);
- return true;
- }
-
- public bool TryReadUInt32(ulong addr, out uint value)
- {
- Span buffer = stackalloc byte[sizeof(uint)];
- if (!TryRead(addr, buffer))
- {
- value = 0;
- return false;
- }
- value = _target.IsLittleEndian
- ? BinaryPrimitives.ReadUInt32LittleEndian(buffer)
- : BinaryPrimitives.ReadUInt32BigEndian(buffer);
- return true;
- }
-
- private bool TryRead(ulong addr, Span dest)
- {
- // If the request misses the currently-cached page, try to load the page
- // containing it. If that fails (e.g. the page is unmapped), or the request
- // straddles the end of the cached page, fall back to a direct read.
- if (addr < _currPageStart || addr - _currPageStart >= _currPageSize)
- {
- if (!MoveToPage(addr))
- return DirectRead(addr, dest);
- }
-
- ulong offset = addr - _currPageStart;
- if (offset + (ulong)dest.Length > _currPageSize)
- return DirectRead(addr, dest);
-
- _page.AsSpan((int)offset, dest.Length).CopyTo(dest);
- return true;
- }
-
- private bool MoveToPage(ulong addr)
- {
- ulong pageStart = addr - (addr % PageSize);
- try
- {
- _target.ReadBuffer(pageStart, _page.AsSpan(0, (int)PageSize));
- _currPageStart = pageStart;
- _currPageSize = PageSize;
- return true;
- }
- catch
- {
- _currPageStart = 0;
- _currPageSize = 0;
- return false;
- }
- }
-
- private bool DirectRead(ulong addr, Span dest)
- {
- try
- {
- _target.ReadBuffer(addr, dest);
- return true;
- }
- catch
- {
- return false;
- }
- }
- }
}
diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader/ContractDescriptorTarget.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader/ContractDescriptorTarget.cs
index 633484f17e2578..07961ce49a25cf 100644
--- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader/ContractDescriptorTarget.cs
+++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader/ContractDescriptorTarget.cs
@@ -138,11 +138,41 @@ private ContractDescriptorTarget(Descriptor mainDescriptor, DataTargetDelegates
_config = mainDescriptor.Config;
_dataTargetDelegates = dataTargetDelegates;
+ _rawReadBuffer = ReadBufferRaw;
AddDescriptor(mainDescriptor);
BuildDescriptors(forceBuild: true);
}
+ // Cached delegate over ReadBufferRaw so opening a cache scope does not allocate.
+ private readonly RawReadDelegate _rawReadBuffer;
+ private ITargetReadCache? _activeCache;
+
+ public override IDisposable BeginCacheScope(ITargetReadCache cache)
+ {
+ ArgumentNullException.ThrowIfNull(cache);
+ if (_activeCache is not null)
+ throw new InvalidOperationException("A cache scope is already active on this target.");
+
+ _activeCache = cache;
+ return new CacheScope(this, cache);
+ }
+
+ private sealed class CacheScope(ContractDescriptorTarget target, ITargetReadCache cache) : IDisposable
+ {
+ private bool _disposed;
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ Debug.Assert(ReferenceEquals(target._activeCache, cache), "Cache scope disposed out of order");
+ target._activeCache = null;
+ cache.Invalidate();
+ cache.Dispose();
+ }
+ }
+
public override void Flush(FlushScope scope)
{
base.Flush(scope);
@@ -438,7 +468,7 @@ public override T Read(ulong address)
/// Value read from the target
public override T ReadLittleEndian(ulong address)
{
- if (!TryRead(address, true, _dataTargetDelegates, out T value))
+ if (!TryReadCore(address, isLittleEndian: true, out T value))
throw new VirtualReadException($"Failed to read {typeof(T)} at 0x{address:x8}.");
return value;
@@ -453,13 +483,27 @@ public override T ReadLittleEndian(ulong address)
public override bool TryRead(ulong address, out T value)
{
value = default;
- if (!TryRead(address, _config.IsLittleEndian, _dataTargetDelegates, out T readValue))
+ if (!TryReadCore(address, _config.IsLittleEndian, out T readValue))
return false;
value = readValue;
return true;
}
+ private bool TryReadCore(ulong address, bool isLittleEndian, out T value) where T : unmanaged, IBinaryInteger, IMinMaxValue
+ {
+ value = default;
+ Span buffer = stackalloc byte[sizeof(T)];
+ if (!TryReadBuffer(address, buffer))
+ return false;
+
+ return isLittleEndian
+ ? T.TryReadLittleEndian(buffer, !IsSigned(), out value)
+ : T.TryReadBigEndian(buffer, !IsSigned(), out value);
+ }
+
+ // Bootstrap-time read helper: runs before a target instance exists, so it cannot route
+ // through TryReadBuffer / the cache. Used by TryReadContractDescriptor only.
private static bool TryRead(ulong address, bool isLittleEndian, DataTargetDelegates dataTargetDelegates, out T value) where T : unmanaged, IBinaryInteger, IMinMaxValue
{
value = default;
@@ -537,9 +581,30 @@ public override void ReadBuffer(ulong address, Span buffer)
private bool TryReadBuffer(ulong address, Span buffer)
{
+ if (_activeCache is { } cache)
+ {
+ try
+ {
+ cache.ReadBuffer(address, buffer, _rawReadBuffer);
+ return true;
+ }
+ catch (VirtualReadException)
+ {
+ return false;
+ }
+ }
+
return _dataTargetDelegates.ReadFromTarget(address, buffer) >= 0;
}
+ // Read directly from the underlying target, bypassing any active cache. Used as the
+ // miss fallback handed to ITargetReadCache implementations.
+ private void ReadBufferRaw(ulong address, Span buffer)
+ {
+ if (_dataTargetDelegates.ReadFromTarget(address, buffer) < 0)
+ throw new VirtualReadException($"Failed to read {buffer.Length} bytes at 0x{address:x8}.");
+ }
+
public override void WriteBuffer(ulong address, Span buffer)
{
if (!TryWriteBuffer(address, buffer))
@@ -575,14 +640,62 @@ private static bool IsSigned() where T : struct, INumberBase, IMinMaxValue
/// Pointer read from the target
public override TargetPointer ReadPointer(ulong address)
{
- if (!TryReadPointer(address, _config, _dataTargetDelegates, out TargetPointer pointer))
+ if (!TryReadPointerCore(address, out TargetPointer pointer))
throw new VirtualReadException($"Failed to read pointer at 0x{address:x8}.");
return pointer;
}
public override bool TryReadPointer(ulong address, out TargetPointer value)
- => TryReadPointer(address, _config, _dataTargetDelegates, out value);
+ => TryReadPointerCore(address, out value);
+
+ private bool TryReadPointerCore(ulong address, out TargetPointer pointer)
+ {
+ pointer = TargetPointer.Null;
+ if (!TryReadNUIntCore(address, out ulong value))
+ return false;
+
+ pointer = new TargetPointer(value);
+ return true;
+ }
+
+ private bool TryReadNUIntCore(ulong address, out ulong value)
+ {
+ value = 0;
+ if (_config.PointerSize == sizeof(uint)
+ && TryReadCore(address, _config.IsLittleEndian, out uint value32))
+ {
+ value = value32;
+ return true;
+ }
+ else if (_config.PointerSize == sizeof(ulong)
+ && TryReadCore(address, _config.IsLittleEndian, out ulong value64))
+ {
+ value = value64;
+ return true;
+ }
+
+ return false;
+ }
+
+ private bool TryReadNIntCore(ulong address, out long value)
+ {
+ value = 0;
+ if (_config.PointerSize == sizeof(uint)
+ && TryReadCore(address, _config.IsLittleEndian, out int value32))
+ {
+ value = value32;
+ return true;
+ }
+ else if (_config.PointerSize == sizeof(ulong)
+ && TryReadCore(address, _config.IsLittleEndian, out long value64))
+ {
+ value = value64;
+ return true;
+ }
+
+ return false;
+ }
public override TargetPointer ReadPointerFromSpan(ReadOnlySpan bytes)
{
@@ -707,7 +820,7 @@ public override string ReadUtf16String(ulong address)
/// Value read from the target
public override TargetNUInt ReadNUInt(ulong address)
{
- if (!TryReadNUInt(address, _config, _dataTargetDelegates, out ulong value))
+ if (!TryReadNUIntCore(address, out ulong value))
throw new VirtualReadException($"Failed to read nuint at 0x{address:x8}.");
return new TargetNUInt(value);
@@ -715,7 +828,7 @@ public override TargetNUInt ReadNUInt(ulong address)
public override TargetNInt ReadNInt(ulong address)
{
- if (!TryReadNInt(address, _config, _dataTargetDelegates, out long value))
+ if (!TryReadNIntCore(address, out long value))
throw new VirtualReadException($"Failed to read nint at 0x{address:x8}.");
return new TargetNInt(value);
diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader/LinearReadCache.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader/LinearReadCache.cs
new file mode 100644
index 00000000000000..59bc8eac3ad0f1
--- /dev/null
+++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader/LinearReadCache.cs
@@ -0,0 +1,94 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+
+namespace Microsoft.Diagnostics.DataContractReader;
+
+///
+/// A page-buffered tuned for spatial locality: the cache holds a
+/// single fixed-size page and serves any read that lies entirely within the currently-cached
+/// page. Reads that miss the cache - or that span page boundaries, or that are larger than the
+/// page itself - fall through to the supplied .
+///
+///
+/// Best suited to walks over contiguous memory (for example a heap walk that touches each
+/// object's header and a couple of trailing fields). Caches that need random-access locality or
+/// multi-page coverage should implement directly.
+///
+public sealed class LinearReadCache : ITargetReadCache
+{
+ /// Default page size used when none is supplied to the constructor.
+ public const uint DefaultPageSize = 0x1000;
+
+ private readonly uint _pageSize;
+ private readonly byte[] _page;
+ private ulong _currPageStart;
+ private uint _currPageSize;
+
+ public LinearReadCache()
+ : this(DefaultPageSize)
+ {
+ }
+
+ public LinearReadCache(uint pageSize)
+ {
+ ArgumentOutOfRangeException.ThrowIfZero(pageSize);
+ _pageSize = pageSize;
+ _page = new byte[pageSize];
+ }
+
+ public void ReadBuffer(ulong address, Span destination, RawReadDelegate fallback)
+ {
+ ArgumentNullException.ThrowIfNull(fallback);
+
+ // Requests larger than a page can't fit in the cache buffer - read directly.
+ if ((uint)destination.Length >= _pageSize)
+ {
+ fallback(address, destination);
+ return;
+ }
+
+ // Refresh the cached page if the request misses it.
+ if (address < _currPageStart || address - _currPageStart >= _currPageSize)
+ {
+ ulong pageStart = address - (address % _pageSize);
+ try
+ {
+ fallback(pageStart, _page.AsSpan(0, (int)_pageSize));
+ _currPageStart = pageStart;
+ _currPageSize = _pageSize;
+ }
+ catch
+ {
+ // The page is unreadable (e.g. unmapped). Drop any cached state and let the
+ // direct read surface the failure.
+ _currPageStart = 0;
+ _currPageSize = 0;
+ fallback(address, destination);
+ return;
+ }
+ }
+
+ ulong offset = address - _currPageStart;
+ if (offset + (ulong)destination.Length > _currPageSize)
+ {
+ // Request straddles the end of the cached page.
+ fallback(address, destination);
+ return;
+ }
+
+ _page.AsSpan((int)offset, destination.Length).CopyTo(destination);
+ }
+
+ public void Invalidate()
+ {
+ _currPageStart = 0;
+ _currPageSize = 0;
+ }
+
+ public void Dispose()
+ {
+ // No unmanaged resources; Invalidate handles state cleanup.
+ }
+}
diff --git a/src/native/managed/cdac/tests/UnitTests/ContractDescriptor/CacheScopeTests.cs b/src/native/managed/cdac/tests/UnitTests/ContractDescriptor/CacheScopeTests.cs
new file mode 100644
index 00000000000000..c7ea9195e1305c
--- /dev/null
+++ b/src/native/managed/cdac/tests/UnitTests/ContractDescriptor/CacheScopeTests.cs
@@ -0,0 +1,152 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using Microsoft.Diagnostics.DataContractReader.TestInfrastructure;
+using Microsoft.Diagnostics.DataContractReader.TestInfrastructure.ContractDescriptor;
+using Xunit;
+
+namespace Microsoft.Diagnostics.DataContractReader.Tests.ContractDescriptor;
+
+public class CacheScopeTests
+{
+ private static ContractDescriptorTarget CreateTarget(MockTarget.Architecture arch)
+ {
+ TargetTestHelpers helpers = new(arch);
+ ContractDescriptorBuilder builder = new(helpers);
+ ContractDescriptorBuilder.DescriptorBuilder descriptorBuilder = new(builder);
+ descriptorBuilder.SetTypes(new Dictionary())
+ .SetGlobals(Array.Empty<(string, ulong, string?)>())
+ .SetContracts(Array.Empty());
+
+ // Add a 0x200-byte region with deterministic byte pattern starting at 0x10000.
+ const ulong baseAddr = 0x10000;
+ byte[] data = new byte[0x200];
+ for (int i = 0; i < data.Length; i++)
+ data[i] = (byte)i;
+ builder.AddHeapFragment(new MockMemorySpace.HeapFragment
+ {
+ Address = baseAddr,
+ Data = data,
+ Name = "Pattern"
+ });
+
+ Assert.True(builder.TryCreateTarget(descriptorBuilder, out ContractDescriptorTarget? target));
+ return target!;
+ }
+
+ private sealed class CountingCache : ITargetReadCache
+ {
+ public int InvalidateCount { get; private set; }
+ public int DisposeCount { get; private set; }
+ public int ReadCount { get; private set; }
+ public int FallbackCount { get; private set; }
+
+ public void ReadBuffer(ulong address, Span destination, RawReadDelegate fallback)
+ {
+ ReadCount++;
+ FallbackCount++;
+ fallback(address, destination);
+ }
+
+ public void Invalidate() => InvalidateCount++;
+ public void Dispose() => DisposeCount++;
+ }
+
+ [Theory]
+ [ClassData(typeof(MockTarget.StdArch))]
+ public void ReadsInsideScope_RouteThroughCache(MockTarget.Architecture arch)
+ {
+ ContractDescriptorTarget target = CreateTarget(arch);
+ CountingCache cache = new();
+
+ using (target.BeginCacheScope(cache))
+ {
+ Span buf = stackalloc byte[8];
+ target.ReadBuffer(0x10000, buf);
+ target.ReadBuffer(0x10010, buf);
+ }
+
+ Assert.Equal(2, cache.ReadCount);
+ Assert.Equal(2, cache.FallbackCount);
+ Assert.Equal(1, cache.InvalidateCount);
+ Assert.Equal(1, cache.DisposeCount);
+ }
+
+ [Theory]
+ [ClassData(typeof(MockTarget.StdArch))]
+ public void ReadsOutsideScope_BypassCache(MockTarget.Architecture arch)
+ {
+ ContractDescriptorTarget target = CreateTarget(arch);
+ CountingCache cache = new();
+
+ using (target.BeginCacheScope(cache))
+ {
+ Span buf = stackalloc byte[4];
+ target.ReadBuffer(0x10000, buf);
+ }
+
+ Assert.Equal(1, cache.ReadCount);
+
+ Span outsideBuf = stackalloc byte[4];
+ target.ReadBuffer(0x10020, outsideBuf);
+ Assert.Equal(1, cache.ReadCount); // unchanged: cache was disposed
+ }
+
+ [Theory]
+ [ClassData(typeof(MockTarget.StdArch))]
+ public void TypedReadsInsideScope_RouteThroughCache(MockTarget.Architecture arch)
+ {
+ ContractDescriptorTarget target = CreateTarget(arch);
+ CountingCache cache = new();
+
+ using (target.BeginCacheScope(cache))
+ {
+ Assert.True(target.TryRead(0x10000, out _));
+ Assert.True(target.TryReadPointer(0x10010, out _));
+ }
+
+ // TryRead and TryReadPointer (which decomposes to TryReadCore) both must funnel
+ // through TryReadBuffer and hit the cache.
+ Assert.Equal(2, cache.ReadCount);
+ }
+
+ [Fact]
+ public void NestedScope_Throws()
+ {
+ ContractDescriptorTarget target = CreateTarget(new MockTarget.Architecture { IsLittleEndian = true, Is64Bit = true });
+ using IDisposable outer = target.BeginCacheScope(new CountingCache());
+ Assert.Throws(() => target.BeginCacheScope(new CountingCache()));
+ }
+
+ [Fact]
+ public void ScopeDispose_IsIdempotent()
+ {
+ ContractDescriptorTarget target = CreateTarget(new MockTarget.Architecture { IsLittleEndian = true, Is64Bit = true });
+ CountingCache cache = new();
+ IDisposable scope = target.BeginCacheScope(cache);
+
+ scope.Dispose();
+ scope.Dispose();
+
+ Assert.Equal(1, cache.InvalidateCount);
+ Assert.Equal(1, cache.DisposeCount);
+ }
+
+ [Fact]
+ public void ScopeDispose_AllowsNewScope()
+ {
+ ContractDescriptorTarget target = CreateTarget(new MockTarget.Architecture { IsLittleEndian = true, Is64Bit = true });
+ using (target.BeginCacheScope(new CountingCache())) { }
+ using IDisposable second = target.BeginCacheScope(new CountingCache());
+ // Did not throw.
+ }
+
+ [Fact]
+ public void BeginCacheScope_NullCache_Throws()
+ {
+ ContractDescriptorTarget target = CreateTarget(new MockTarget.Architecture { IsLittleEndian = true, Is64Bit = true });
+ Assert.Throws(() => target.BeginCacheScope(null!));
+ }
+}
diff --git a/src/native/managed/cdac/tests/UnitTests/LinearReadCacheTests.cs b/src/native/managed/cdac/tests/UnitTests/LinearReadCacheTests.cs
new file mode 100644
index 00000000000000..8b9ebc5b314d9f
--- /dev/null
+++ b/src/native/managed/cdac/tests/UnitTests/LinearReadCacheTests.cs
@@ -0,0 +1,163 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using Xunit;
+
+namespace Microsoft.Diagnostics.DataContractReader.Tests;
+
+public class LinearReadCacheTests
+{
+ private sealed class ReadCounter
+ {
+ public List<(ulong Address, int Length)> Reads { get; } = new();
+ public Func? Source { get; init; }
+ public Action? OnRead { get; init; }
+
+ public void Read(ulong address, Span destination)
+ {
+ Reads.Add((address, destination.Length));
+ OnRead?.Invoke(address, destination.Length);
+ if (Source is { } src)
+ src(address, destination.Length).AsSpan().CopyTo(destination);
+ }
+ }
+
+ [Fact]
+ public void ReadWithinPage_PopulatesPageOnce()
+ {
+ var counter = new ReadCounter
+ {
+ Source = (addr, len) =>
+ {
+ byte[] b = new byte[len];
+ for (int i = 0; i < len; i++)
+ b[i] = (byte)((addr + (ulong)i) & 0xFF);
+ return b;
+ }
+ };
+ using var cache = new LinearReadCache(pageSize: 0x100);
+
+ Span first = stackalloc byte[4];
+ Span second = stackalloc byte[8];
+ cache.ReadBuffer(0x1010, first, counter.Read);
+ cache.ReadBuffer(0x1020, second, counter.Read);
+
+ Assert.Single(counter.Reads);
+ Assert.Equal((ulong)0x1000, counter.Reads[0].Address);
+ Assert.Equal(0x100, counter.Reads[0].Length);
+ Assert.Equal(new byte[] { 0x10, 0x11, 0x12, 0x13 }, first.ToArray());
+ Assert.Equal(new byte[] { 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27 }, second.ToArray());
+ }
+
+ [Fact]
+ public void ReadCrossingPageBoundary_FallsBackToDirectRead()
+ {
+ var counter = new ReadCounter
+ {
+ Source = (addr, len) => new byte[len]
+ };
+ using var cache = new LinearReadCache(pageSize: 0x100);
+
+ Span straddling = stackalloc byte[16];
+ cache.ReadBuffer(0x10F8, straddling, counter.Read);
+
+ // Expect a page load at 0x1000 followed by a direct read at 0x10F8 (request straddles end).
+ Assert.Equal(2, counter.Reads.Count);
+ Assert.Equal((ulong)0x1000, counter.Reads[0].Address);
+ Assert.Equal(0x100, counter.Reads[0].Length);
+ Assert.Equal((ulong)0x10F8, counter.Reads[1].Address);
+ Assert.Equal(16, counter.Reads[1].Length);
+ }
+
+ [Fact]
+ public void ReadLargerThanPage_BypassesCache()
+ {
+ var counter = new ReadCounter { Source = (a, l) => new byte[l] };
+ using var cache = new LinearReadCache(pageSize: 0x100);
+
+ Span big = new byte[0x200];
+ cache.ReadBuffer(0x2000, big, counter.Read);
+
+ Assert.Single(counter.Reads);
+ Assert.Equal((ulong)0x2000, counter.Reads[0].Address);
+ Assert.Equal(0x200, counter.Reads[0].Length);
+ }
+
+ [Fact]
+ public void ReadAcrossPages_TriggersNewPageLoad()
+ {
+ var counter = new ReadCounter { Source = (a, l) => new byte[l] };
+ using var cache = new LinearReadCache(pageSize: 0x100);
+
+ Span a = stackalloc byte[4];
+ Span b = stackalloc byte[4];
+ cache.ReadBuffer(0x1000, a, counter.Read);
+ cache.ReadBuffer(0x1200, b, counter.Read);
+
+ Assert.Equal(2, counter.Reads.Count);
+ Assert.Equal((ulong)0x1000, counter.Reads[0].Address);
+ Assert.Equal((ulong)0x1200, counter.Reads[1].Address);
+ }
+
+ [Fact]
+ public void Invalidate_ForcesPageReload()
+ {
+ var counter = new ReadCounter { Source = (a, l) => new byte[l] };
+ using var cache = new LinearReadCache(pageSize: 0x100);
+
+ Span a = stackalloc byte[4];
+ cache.ReadBuffer(0x1000, a, counter.Read);
+ cache.Invalidate();
+ cache.ReadBuffer(0x1000, a, counter.Read);
+
+ Assert.Equal(2, counter.Reads.Count);
+ Assert.All(counter.Reads, r => Assert.Equal((ulong)0x1000, r.Address));
+ }
+
+ [Fact]
+ public void PageLoadFailure_FallsBackAndKeepsCacheEmpty()
+ {
+ int callIndex = 0;
+ var counter = new ReadCounter
+ {
+ Source = (a, l) => new byte[l],
+ OnRead = (a, l) =>
+ {
+ // First call (the page load) throws; subsequent calls (the fallback direct read,
+ // then a follow-up cached read) succeed.
+ if (callIndex++ == 0)
+ throw new VirtualReadException("simulated page fault");
+ }
+ };
+ using var cache = new LinearReadCache(pageSize: 0x100);
+
+ Span a = stackalloc byte[4];
+ cache.ReadBuffer(0x1010, a, counter.Read);
+
+ // Page load + direct read.
+ Assert.Equal(2, counter.Reads.Count);
+ Assert.Equal((ulong)0x1000, counter.Reads[0].Address);
+ Assert.Equal((ulong)0x1010, counter.Reads[1].Address);
+
+ // Second access should retry the page load because the first one was discarded.
+ cache.ReadBuffer(0x1010, a, counter.Read);
+ Assert.Equal(3, counter.Reads.Count);
+ Assert.Equal((ulong)0x1000, counter.Reads[2].Address);
+ }
+
+ [Fact]
+ public void Constructor_RejectsZeroPageSize()
+ {
+ Assert.Throws(() => new LinearReadCache(pageSize: 0));
+ }
+
+ [Fact]
+ public void ReadBuffer_NullFallback_Throws()
+ {
+ using var cache = new LinearReadCache(pageSize: 0x100);
+ byte[] buf = new byte[4];
+ Assert.Throws(() => cache.ReadBuffer(0x1000, buf, null!));
+ }
+}