571 lines
19 KiB
C#
571 lines
19 KiB
C#
// Copyright (C) 2026 SharpEmu Emulator Project
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
using SharpEmu.HLE;
|
|
using Xunit;
|
|
|
|
namespace SharpEmu.Libs.Tests.Memory;
|
|
|
|
/// <summary>
|
|
/// The write generation lets GPU caches detect guest CPU rewrites even after
|
|
/// another cache owner consumed the (single) dirty flag: the generation is
|
|
/// monotonic and survives consume/re-arm cycles and range replacement. These
|
|
/// invariants back the presenter's stale-upload detection for CPU-rewritten
|
|
/// images (video planes, streamed font atlases).
|
|
/// </summary>
|
|
public sealed unsafe class GuestImageWriteTrackerTests
|
|
{
|
|
// The tracker aligns to the guest's 4 KiB pages; the mprotect underneath
|
|
// operates on host pages, which are 16 KiB on Apple Silicon (the emulator
|
|
// itself always runs with 4 KiB host pages under Rosetta, but this test
|
|
// host may not). Align the allocation to the largest host page size so
|
|
// the kernel's rounding stays inside memory this test owns instead of
|
|
// spilling onto neighbouring heap pages.
|
|
private const nuint TrackedByteCount = 4096;
|
|
private const nuint HostPageAlignment = 16384;
|
|
|
|
// The tests arm the tracker themselves; its warm-up needs the POSIX page protection.
|
|
static GuestImageWriteTrackerTests()
|
|
{
|
|
if (!OperatingSystem.IsWindows())
|
|
{
|
|
GuestImageWriteTracker.Configure(true);
|
|
}
|
|
}
|
|
|
|
// Protect only pages from the host allocator: heap pages share allocator metadata.
|
|
private static ulong AllocateTrackedPages(out void* allocation)
|
|
{
|
|
allocation = HostMemory.Alloc(
|
|
null,
|
|
HostPageAlignment,
|
|
HostMemory.MEM_COMMIT | HostMemory.MEM_RESERVE,
|
|
HostMemory.PAGE_READWRITE);
|
|
Assert.NotEqual((nint)0, (nint)allocation);
|
|
return (ulong)allocation;
|
|
}
|
|
|
|
private static void FreeTrackedPages(void* allocation) =>
|
|
_ = HostMemory.Free(allocation, 0, HostMemory.MEM_RELEASE);
|
|
|
|
[NativePageProtectionFact]
|
|
public void GenerationSurvivesDirtyConsume()
|
|
{
|
|
// Page protection uses mprotect: the tracker serves only the macOS backend.
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
return;
|
|
}
|
|
|
|
var address = AllocateTrackedPages(out var allocation);
|
|
try
|
|
{
|
|
GuestImageWriteTracker.Track(address, TrackedByteCount);
|
|
Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out var generation));
|
|
Assert.Equal(0, generation);
|
|
|
|
Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address));
|
|
Assert.True(GuestImageWriteTracker.ConsumeDirty(address));
|
|
|
|
// Consuming the dirty flag must not roll back the generation:
|
|
// that is exactly what lets a second cache owner still observe
|
|
// the rewrite after the first owner consumed the flag.
|
|
Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out generation));
|
|
Assert.Equal(1, generation);
|
|
}
|
|
finally
|
|
{
|
|
GuestImageWriteTracker.Untrack(address);
|
|
FreeTrackedPages(allocation);
|
|
}
|
|
}
|
|
|
|
[NativePageProtectionFact]
|
|
public void GenerationIncrementsOncePerArmedLifetime()
|
|
{
|
|
// Page protection uses mprotect: the tracker serves only the macOS backend.
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
return;
|
|
}
|
|
|
|
var address = AllocateTrackedPages(out var allocation);
|
|
try
|
|
{
|
|
GuestImageWriteTracker.Track(address, TrackedByteCount);
|
|
Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address));
|
|
// The first fault disarmed the range; later writes are free-running
|
|
// and do not enter the handler until the owner re-arms.
|
|
Assert.False(GuestImageWriteTracker.TryHandleWriteFault(address));
|
|
Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out var generation));
|
|
Assert.Equal(1, generation);
|
|
|
|
GuestImageWriteTracker.Rearm(address);
|
|
Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address));
|
|
Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out generation));
|
|
Assert.Equal(2, generation);
|
|
}
|
|
finally
|
|
{
|
|
GuestImageWriteTracker.Untrack(address);
|
|
FreeTrackedPages(allocation);
|
|
}
|
|
}
|
|
|
|
[NativePageProtectionFact]
|
|
public void GenerationCarriesAcrossRangeReplacement()
|
|
{
|
|
// Page protection uses mprotect: the tracker serves only the macOS backend.
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
return;
|
|
}
|
|
|
|
var address = AllocateTrackedPages(out var allocation);
|
|
try
|
|
{
|
|
GuestImageWriteTracker.Track(address, TrackedByteCount);
|
|
Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address));
|
|
|
|
// Re-registering the same allocation with a different size retires
|
|
// the range object (the signal handler may still see the old
|
|
// snapshot) but must carry the generation, otherwise a resize
|
|
// would hide the rewrite from cache owners.
|
|
GuestImageWriteTracker.Track(address, 2 * TrackedByteCount);
|
|
Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out var generation));
|
|
Assert.Equal(1, generation);
|
|
}
|
|
finally
|
|
{
|
|
GuestImageWriteTracker.Untrack(address);
|
|
FreeTrackedPages(allocation);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void UntrackedAddressHasNoGeneration()
|
|
{
|
|
Assert.False(GuestImageWriteTracker.TryGetWriteGeneration(0xDEAD_0000_0000UL, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void WatchOnlyTrackDoesNotArmWriteProtection()
|
|
{
|
|
var address = AllocateTrackedPages(out var allocation);
|
|
try
|
|
{
|
|
GuestImageWriteTracker.Track(
|
|
address,
|
|
TrackedByteCount,
|
|
source: "test.watch-only",
|
|
protect: false);
|
|
Assert.True(
|
|
GuestImageWriteTracker.TryGetProtectionState(
|
|
address,
|
|
out var protect,
|
|
out var armed));
|
|
Assert.False(protect);
|
|
Assert.False(armed);
|
|
|
|
// Pages stay writable: a native store must not require a fault handler.
|
|
*(byte*)address = 0xAB;
|
|
Assert.Equal(0xAB, *(byte*)address);
|
|
}
|
|
finally
|
|
{
|
|
GuestImageWriteTracker.Untrack(address);
|
|
FreeTrackedPages(allocation);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void WatchOnlyManagedWriteMarksRangeDirty()
|
|
{
|
|
var address = AllocateTrackedPages(out var allocation);
|
|
try
|
|
{
|
|
GuestImageWriteTracker.Track(
|
|
address,
|
|
TrackedByteCount,
|
|
source: "test.watch-only",
|
|
protect: false);
|
|
GuestImageWriteTracker.NotifyManagedWrite(address, sizeof(uint));
|
|
Assert.True(GuestImageWriteTracker.PeekDirty(address));
|
|
Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out var generation));
|
|
Assert.Equal(1, generation);
|
|
Assert.True(GuestImageWriteTracker.ConsumeDirty(address));
|
|
Assert.True(
|
|
GuestImageWriteTracker.TryGetProtectionState(
|
|
address,
|
|
out var protect,
|
|
out var armed));
|
|
Assert.False(protect);
|
|
Assert.False(armed);
|
|
}
|
|
finally
|
|
{
|
|
GuestImageWriteTracker.Untrack(address);
|
|
FreeTrackedPages(allocation);
|
|
}
|
|
}
|
|
|
|
[NativePageProtectionFact]
|
|
public void ReadSnapshotRejectsAnOverlappingWrite()
|
|
{
|
|
// Page protection uses mprotect: the tracker serves only the macOS backend.
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
return;
|
|
}
|
|
|
|
var allocation = HostMemory.Alloc(
|
|
null,
|
|
HostPageAlignment,
|
|
HostMemory.MEM_COMMIT | HostMemory.MEM_RESERVE,
|
|
HostMemory.PAGE_READWRITE);
|
|
Assert.NotEqual((nint)0, (nint)allocation);
|
|
var address = (ulong)allocation;
|
|
try
|
|
{
|
|
var snapshot = GuestImageWriteTracker.BeginReadSnapshot(
|
|
address,
|
|
TrackedByteCount,
|
|
source: "test.snapshot");
|
|
Assert.True(snapshot.Active);
|
|
Assert.True(GuestImageWriteTracker.IsReadSnapshotStable(snapshot));
|
|
|
|
Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address));
|
|
Assert.False(GuestImageWriteTracker.IsReadSnapshotStable(snapshot));
|
|
|
|
var retry = GuestImageWriteTracker.BeginReadSnapshot(
|
|
address,
|
|
TrackedByteCount,
|
|
source: "test.snapshot-retry");
|
|
Assert.True(retry.Active);
|
|
Assert.True(GuestImageWriteTracker.IsReadSnapshotStable(retry));
|
|
Assert.True(retry.Generation > snapshot.Generation);
|
|
}
|
|
finally
|
|
{
|
|
GuestImageWriteTracker.Untrack(address);
|
|
_ = HostMemory.Free(allocation, 0, HostMemory.MEM_RELEASE);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void WatchOnlyManagedWriteIgnoresUnrelatedRange()
|
|
{
|
|
const ulong address = 0x0000_0002_0000_0000UL;
|
|
try
|
|
{
|
|
GuestImageWriteTracker.Track(
|
|
address,
|
|
TrackedByteCount,
|
|
source: "test.watch-only",
|
|
protect: false);
|
|
|
|
GuestImageWriteTracker.NotifyManagedWrite(
|
|
address + (2 * (ulong)TrackedByteCount),
|
|
sizeof(uint));
|
|
|
|
Assert.False(GuestImageWriteTracker.PeekDirty(address));
|
|
Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out var generation));
|
|
Assert.Equal(0, generation);
|
|
}
|
|
finally
|
|
{
|
|
GuestImageWriteTracker.Untrack(address);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void WatchOnlyManagedWriteMarksEveryOverlappingOwner()
|
|
{
|
|
const ulong first = 0x0000_0002_1000_0000UL;
|
|
var second = first + 2048;
|
|
try
|
|
{
|
|
GuestImageWriteTracker.Track(
|
|
first,
|
|
TrackedByteCount,
|
|
source: "test.watch-only.first",
|
|
protect: false);
|
|
GuestImageWriteTracker.Track(
|
|
second,
|
|
TrackedByteCount,
|
|
source: "test.watch-only.second",
|
|
protect: false);
|
|
|
|
GuestImageWriteTracker.NotifyManagedWrite(second, sizeof(uint));
|
|
|
|
Assert.True(GuestImageWriteTracker.PeekDirty(first));
|
|
Assert.True(GuestImageWriteTracker.PeekDirty(second));
|
|
}
|
|
finally
|
|
{
|
|
GuestImageWriteTracker.Untrack(first);
|
|
GuestImageWriteTracker.Untrack(second);
|
|
}
|
|
}
|
|
|
|
[NativePageProtectionFact]
|
|
public void ProtectedTrackArmsWriteProtection()
|
|
{
|
|
// Page protection uses mprotect: the tracker serves only the macOS backend.
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
return;
|
|
}
|
|
|
|
var address = AllocateTrackedPages(out var allocation);
|
|
try
|
|
{
|
|
GuestImageWriteTracker.Track(address, TrackedByteCount);
|
|
Assert.True(
|
|
GuestImageWriteTracker.TryGetProtectionState(
|
|
address,
|
|
out var protect,
|
|
out var armed));
|
|
Assert.True(protect);
|
|
Assert.True(armed);
|
|
}
|
|
finally
|
|
{
|
|
GuestImageWriteTracker.Untrack(address);
|
|
FreeTrackedPages(allocation);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ManagedWriterSnapshotDoesNotArmWriteProtection()
|
|
{
|
|
// Page protection uses mprotect: the tracker serves only the macOS backend.
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
return;
|
|
}
|
|
|
|
var address = AllocateTrackedPages(out var allocation);
|
|
try
|
|
{
|
|
GuestImageWriteTracker.TrackManagedWriter(
|
|
address,
|
|
TrackedByteCount,
|
|
source: "test.managed-writer");
|
|
|
|
var snapshot = GuestImageWriteTracker.BeginReadSnapshot(
|
|
address,
|
|
TrackedByteCount,
|
|
source: "test.managed-writer-snapshot");
|
|
|
|
Assert.True(snapshot.Active);
|
|
Assert.True(GuestImageWriteTracker.IsReadSnapshotStable(snapshot));
|
|
Assert.True(
|
|
GuestImageWriteTracker.TryGetProtectionState(
|
|
address,
|
|
out var protect,
|
|
out var armed));
|
|
Assert.False(protect);
|
|
Assert.False(armed);
|
|
}
|
|
finally
|
|
{
|
|
GuestImageWriteTracker.Untrack(address);
|
|
FreeTrackedPages(allocation);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ManagedWriterNotificationInvalidatesReadSnapshot()
|
|
{
|
|
// Page protection uses mprotect: the tracker serves only the macOS backend.
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
return;
|
|
}
|
|
|
|
var address = AllocateTrackedPages(out var allocation);
|
|
try
|
|
{
|
|
GuestImageWriteTracker.TrackManagedWriter(
|
|
address,
|
|
TrackedByteCount,
|
|
source: "test.managed-writer");
|
|
var snapshot = GuestImageWriteTracker.BeginReadSnapshot(
|
|
address,
|
|
TrackedByteCount,
|
|
source: "test.managed-writer-snapshot");
|
|
|
|
GuestImageWriteTracker.NotifyManagedWrite(address, sizeof(uint));
|
|
|
|
Assert.False(GuestImageWriteTracker.IsReadSnapshotStable(snapshot));
|
|
Assert.True(GuestImageWriteTracker.PeekDirty(address));
|
|
}
|
|
finally
|
|
{
|
|
GuestImageWriteTracker.Untrack(address);
|
|
FreeTrackedPages(allocation);
|
|
}
|
|
}
|
|
|
|
[NativePageProtectionFact]
|
|
public void WriteFaultRemovesTheDirtyImagesPageWatchers()
|
|
{
|
|
// Page protection uses mprotect: the tracker serves only the macOS backend.
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
return;
|
|
}
|
|
|
|
var address = AllocateTrackedPages(out var allocation);
|
|
try
|
|
{
|
|
GuestImageWriteTracker.Track(address, 2 * TrackedByteCount);
|
|
Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address));
|
|
|
|
Assert.NotEqual(0u, HostMemory.Query((void*)address, out var firstPage));
|
|
Assert.NotEqual(
|
|
0u,
|
|
HostMemory.Query(
|
|
(void*)(address + (ulong)TrackedByteCount),
|
|
out var secondPage));
|
|
Assert.Equal(HostMemory.PAGE_READWRITE, firstPage.Protect & 0xFFu);
|
|
Assert.Equal(HostMemory.PAGE_READWRITE, secondPage.Protect & 0xFFu);
|
|
}
|
|
finally
|
|
{
|
|
GuestImageWriteTracker.Untrack(address);
|
|
FreeTrackedPages(allocation);
|
|
}
|
|
}
|
|
|
|
[NativePageProtectionFact]
|
|
public void SharedPageStaysProtectedForAnotherImageOwner()
|
|
{
|
|
// Page protection uses mprotect: the tracker serves only the macOS backend.
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
return;
|
|
}
|
|
|
|
var address = AllocateTrackedPages(out var allocation);
|
|
var overlapAddress = address + (ulong)TrackedByteCount;
|
|
try
|
|
{
|
|
GuestImageWriteTracker.Track(address, 2 * TrackedByteCount);
|
|
GuestImageWriteTracker.Track(overlapAddress, 2 * TrackedByteCount);
|
|
Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address));
|
|
|
|
Assert.True(GuestImageWriteTracker.PeekDirty(address));
|
|
Assert.False(GuestImageWriteTracker.PeekDirty(overlapAddress));
|
|
Assert.Equal(HostMemory.PAGE_READWRITE, NativePageProtection.Query(address));
|
|
Assert.Equal(HostMemory.PAGE_READONLY, NativePageProtection.Query(overlapAddress));
|
|
}
|
|
finally
|
|
{
|
|
GuestImageWriteTracker.Untrack(address);
|
|
GuestImageWriteTracker.Untrack(overlapAddress);
|
|
FreeTrackedPages(allocation);
|
|
}
|
|
}
|
|
|
|
[NativePageProtectionFact]
|
|
public void BoundaryPageFaultInvalidatesEveryPageOwner()
|
|
{
|
|
// Page protection uses mprotect: the tracker serves only the macOS backend.
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
return;
|
|
}
|
|
|
|
var page = AllocateTrackedPages(out var allocation);
|
|
var first = page + 128;
|
|
var second = page + 512;
|
|
try
|
|
{
|
|
GuestImageWriteTracker.Track(first, 128);
|
|
GuestImageWriteTracker.Track(second, 128);
|
|
Assert.True(GuestImageWriteTracker.TryHandleWriteFault(first));
|
|
|
|
Assert.True(GuestImageWriteTracker.PeekDirty(first));
|
|
Assert.True(GuestImageWriteTracker.PeekDirty(second));
|
|
Assert.NotEqual(0u, HostMemory.Query((void*)page, out var pageInfo));
|
|
Assert.Equal(HostMemory.PAGE_READWRITE, pageInfo.Protect & 0xFFu);
|
|
}
|
|
finally
|
|
{
|
|
GuestImageWriteTracker.Untrack(first);
|
|
GuestImageWriteTracker.Untrack(second);
|
|
FreeTrackedPages(allocation);
|
|
}
|
|
}
|
|
|
|
[NativePageProtectionFact]
|
|
public void ProtectedTrackPreservesExecutePermission()
|
|
{
|
|
// Page protection uses mprotect: the tracker serves only the macOS backend.
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
return;
|
|
}
|
|
|
|
var allocation = HostMemory.Alloc(
|
|
null,
|
|
HostPageAlignment,
|
|
HostMemory.MEM_COMMIT | HostMemory.MEM_RESERVE,
|
|
HostMemory.PAGE_EXECUTE_READWRITE);
|
|
Assert.NotEqual((nint)0, (nint)allocation);
|
|
var address = (ulong)allocation;
|
|
try
|
|
{
|
|
GuestImageWriteTracker.Track(address, TrackedByteCount);
|
|
Assert.Equal(
|
|
HostMemory.PAGE_EXECUTE_READ,
|
|
NativePageProtection.Query(address));
|
|
|
|
Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address));
|
|
Assert.Equal(
|
|
HostMemory.PAGE_EXECUTE_READWRITE,
|
|
NativePageProtection.Query(address));
|
|
}
|
|
finally
|
|
{
|
|
GuestImageWriteTracker.Untrack(address);
|
|
_ = HostMemory.Free(allocation, 0, HostMemory.MEM_RELEASE);
|
|
}
|
|
}
|
|
|
|
[NativePageProtectionFact]
|
|
public void WatchOnlyTrackDoesNotDowngradeProtectedRange()
|
|
{
|
|
// Page protection uses mprotect: the tracker serves only the macOS backend.
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
return;
|
|
}
|
|
|
|
var address = AllocateTrackedPages(out var allocation);
|
|
try
|
|
{
|
|
GuestImageWriteTracker.Track(address, TrackedByteCount, source: "test.rt");
|
|
GuestImageWriteTracker.Track(
|
|
address,
|
|
TrackedByteCount,
|
|
source: "test.texture-cache",
|
|
protect: false);
|
|
Assert.True(
|
|
GuestImageWriteTracker.TryGetProtectionState(
|
|
address,
|
|
out var protect,
|
|
out var armed));
|
|
Assert.True(protect);
|
|
Assert.True(armed);
|
|
}
|
|
finally
|
|
{
|
|
GuestImageWriteTracker.Untrack(address);
|
|
FreeTrackedPages(allocation);
|
|
}
|
|
}
|
|
}
|