// Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later using SharpEmu.Core.Memory; using SharpEmu.HLE; using SharpEmu.HLE.GpuMemory; using SharpEmu.HLE.Host; using SharpEmu.Libs.Tests.Memory.HostViews; using Xunit; namespace SharpEmu.Libs.Tests.Memory.GpuMemory; // The tracker over real host memory: host protection is read back with the platform query. [Collection(GpuMemoryStateCollection.Name)] public sealed class GuestPageTrackerTests : IDisposable { private const ulong Page = TrackerLayout.PageBytes; private const ulong Region = TrackerLayout.BlockBytes; private const GuestPageProtection Read = GuestPageProtection.Read; private const GuestPageProtection ReadWrite = GuestPageProtection.Read | GuestPageProtection.Write; private sealed class TrackerFatalException(string message) : Exception(message); // Forwards to the real address space and records every protection call. private sealed class LoggingSpace(PhysicalVirtualMemory memory) : IGuestAddressSpace { public List<(ulong Address, ulong Size, GuestPageProtection Protection)> Protects { get; } = new(); public bool TryProtect(ulong address, ulong size, GuestPageProtection protection) { lock (Protects) { Protects.Add((address, size, protection)); } return memory.TryProtect(address, size, protection); } public ulong AllocateAt(ulong desiredAddress, ulong size, bool executable = true, bool allowAlternative = true) => memory.AllocateAt(desiredAddress, size, executable, allowAlternative); public bool TryBackFixedRange(ulong address, ulong size, bool executable) => memory.TryBackFixedRange(address, size, executable); public bool TryAllocateAtOrAbove(ulong desiredAddress, ulong size, bool executable, ulong alignment, out ulong actualAddress) => memory.TryAllocateAtOrAbove(desiredAddress, size, executable, alignment, out actualAddress); public bool TryEnsureRangeCommitted(ulong address, ulong size) => memory.TryEnsureRangeCommitted(address, size); public bool TryAllocateGuestMemory(ulong size, ulong alignment, out ulong address) => memory.TryAllocateGuestMemory(size, alignment, out address); public bool TryFreeGuestMemory(ulong address) => memory.TryFreeGuestMemory(address); } private readonly Action _previousFatal = PageGuard.OnFatal; private readonly IHostMemory _host = HostViewTestSupport.PlatformMemory; private readonly PhysicalVirtualMemory _memory; private readonly LoggingSpace _space; private readonly GuestGpuMemory _gpu; private readonly GuestPageTracker _tracker; public GuestPageTrackerTests() { PageGuard.OnFatal = message => throw new TrackerFatalException(message); _memory = new PhysicalVirtualMemory(hostMemory: _host); _space = new LoggingSpace(_memory); _gpu = new GuestGpuMemory(_space); _tracker = new GuestPageTracker(_gpu.Pages); } public void Dispose() { try { _gpu.Dispose(); } finally { try { _memory.Dispose(); } finally { PageGuard.OnFatal = _previousFatal; } } } [NativePageProtectionFact] public void QueriesDoNotRequireMappedOwnership() { const ulong address = 0x2_0300_0000; Assert.Equal(0ul, _tracker.CountCpuWriteHotBytes(address, Page)); Assert.False(_tracker.HasRegion(address, Page)); Assert.True(_tracker.HasCpuDirtyPages(address, Page)); Assert.False(_tracker.HasGpuDirtyPages(address, Page)); Assert.True(_tracker.HasRegion(address, Page)); Assert.False(_tracker.HasRegion(address + Region, Page)); } [NativePageProtectionFact] public void ConcurrentRegionPublicationKeepsInitialCpuOwnership() { var address = Allocate(1); var results = 0; using var start = new ManualResetEventSlim(); var threads = new[] { Query(), Query() }; foreach (var thread in threads) { thread.Start(); } start.Set(); foreach (var thread in threads) { thread.Join(); } _tracker.UntrackMemory(address, Page); Release(address, Page); Assert.Equal(2, results); Thread Query() => new(() => { start.Wait(); if (_tracker.HasCpuDirtyPages(address, Page)) { Interlocked.Increment(ref results); } }); } [NativePageProtectionFact] public void CpuDirtyUploadArmsWriteProtectionAndExplicitDirtinessReleasesIt() { var address = Allocate(2); Assert.True(_tracker.HasCpuDirtyPages(address + 16, 32)); var ranges = 0; var uploaded = false; _tracker.ForEachUploadRange(address + 16, 32, false, (uploadAddress, uploadSize) => { Assert.Equal(address, uploadAddress); Assert.Equal(Page, uploadSize); ranges++; }, () => uploaded = true); Assert.Equal(1, ranges); Assert.True(uploaded); Assert.False(_tracker.HasCpuDirtyPages(address, Page)); Assert.Equal(HostPageProtection.ReadOnly, Protection(address)); _tracker.MarkCpuDirtyPages(address + 16, 32); Assert.True(_tracker.HasCpuDirtyPages(address, Page)); Assert.True(IsWritable(address)); _tracker.UntrackMemory(address, Page * 2); Release(address, Page * 2); } [NativePageProtectionFact] public void LockFreeCpuDirtyQueryAgreesWithTheLockedQuery() { var address = Allocate(2); Assert.True(_tracker.MayHaveCpuDirtyPages(address, Page * 2)); _tracker.ForEachUploadRange(address, Page * 2, false, NoRange, NoUpload, preserveCpuWriteHotPages: false); Assert.False(_tracker.HasCpuDirtyPages(address, Page * 2)); Assert.False(_tracker.MayHaveCpuDirtyPages(address, Page * 2)); _tracker.MarkCpuDirtyPages(address + Page + 16, 32); Assert.False(_tracker.MayHaveCpuDirtyPages(address, Page)); Assert.True(_tracker.MayHaveCpuDirtyPages(address, Page * 2)); Assert.True(_tracker.MayHaveCpuDirtyPages(address + Page + 64, 4)); _tracker.UntrackMemory(address, Page * 2); Release(address, Page * 2); } // The lock-free block summary must agree with the precise page masks through every // CPU-dirty transition: upload, explicit mark, write-fault invalidation and GPU writes. [NativePageProtectionFact] public void CpuDirtySummaryFollowsEveryTransitionOfTheBlock() { var address = AllocateAligned(Region, Region); Assert.True(_tracker.HasCpuDirtyPages(address, Region)); _tracker.ForEachUploadRange(address, Region, false, NoRange, NoUpload, preserveCpuWriteHotPages: false); Assert.False(_tracker.HasCpuDirtyPages(address, Region)); Assert.False(_tracker.HasCpuDirtyPages(address + Region / 2, Page)); _tracker.MarkCpuDirtyPages(address + Page * 3, 8); Assert.True(_tracker.HasCpuDirtyPages(address, Region)); Assert.True(_tracker.HasCpuDirtyPages(address + Page * 3, Page)); // A clean page inside a block that has a dirty page takes the precise path. Assert.False(_tracker.HasCpuDirtyPages(address + Page * 9, Page)); _tracker.ForEachUploadRange(address, Region, false, NoRange, NoUpload, preserveCpuWriteHotPages: false); Assert.False(_tracker.HasCpuDirtyPages(address, Region)); Assert.True(_tracker.InvalidateRegion(address + Page * 5, 4, () => { })); Assert.True(_tracker.HasCpuDirtyPages(address + Page * 5, Page)); _tracker.ForEachUploadRange(address, Region, false, NoRange, NoUpload, preserveCpuWriteHotPages: false); _tracker.ForEachUploadRange(address + Page, Page, true, NoRange, NoUpload); Assert.False(_tracker.HasCpuDirtyPages(address, Region)); Assert.True(_tracker.HasGpuDirtyPages(address + Page, Page)); _tracker.ForEachDownloadRange(address + Page, Page, clear: true, null, NoRange); _tracker.UntrackMemory(address, Region); Assert.True(_tracker.HasCpuDirtyPages(address, Region)); Release(address, Region); } [NativePageProtectionFact] public void PossiblyCpuDirtyRangesSkipOnlyBlocksKnownClean() { var address = AllocateAligned(Region * 3, Region); List<(ulong Address, ulong Size)> Runs(ulong start, ulong size) { var runs = new List<(ulong Address, ulong Size)>(); _tracker.ForEachPossiblyCpuDirtyRange(start, size, (runAddress, runSize) => runs.Add((runAddress, runSize))); return runs; } // Blocks without a region start all dirty. Assert.Equal([(address, Region * 3)], Runs(address, Region * 3)); _tracker.ForEachUploadRange(address, Region * 3, false, NoRange, NoUpload, preserveCpuWriteHotPages: false); Assert.Empty(Runs(address, Region * 3)); _tracker.MarkCpuDirtyPages(address + Region + Page * 2, 8); Assert.Equal([(address + Region, Region)], Runs(address, Region * 3)); // A run is clipped to the requested range. Assert.Equal([(address + Region + Page, Region - Page)], Runs(address + Region + Page, Region * 2 - Page)); _tracker.MarkCpuDirtyPages(address + Region * 2, 8); Assert.Equal([(address + Region, Region * 2)], Runs(address, Region * 3)); _tracker.ForEachUploadRange(address, Region * 3, false, NoRange, NoUpload, preserveCpuWriteHotPages: false); Assert.Empty(Runs(address, Region * 3)); _tracker.UntrackMemory(address, Region * 3); Release(address, Region * 3); } [NativePageProtectionFact] public void PossiblyCpuDirtyRangesCrossSummaryWords() { var address = AllocateAligned(Region * 2, Region * 64); List<(ulong Address, ulong Size)> Runs(ulong start, ulong size) { var runs = new List<(ulong Address, ulong Size)>(); _tracker.ForEachPossiblyCpuDirtyRange(start, size, (runAddress, runSize) => runs.Add((runAddress, runSize))); return runs; } _tracker.ForEachUploadRange(address, Region * 2, false, NoRange, NoUpload, preserveCpuWriteHotPages: false); Assert.Equal([(address - Region * 2 + Page, Region * 2 - Page)], Runs(address - Region * 2 + Page, Region * 4 - Page * 2)); _tracker.MarkCpuDirtyPages(address + Region + Page, 8); Assert.Equal([(address - Region * 2, Region * 2), (address + Region, Region - Page)], Runs(address - Region * 2, Region * 4 - Page)); Assert.Equal([(address + Region, Region)], Runs(address, Region * 2)); Assert.Equal([(address + Region, Region * 64)], Runs(address, Region * 65)); _tracker.ForEachUploadRange(address, Region * 2, false, NoRange, NoUpload, preserveCpuWriteHotPages: false); Assert.Empty(Runs(address, Region * 2)); Assert.Equal([(address + Region * 2, Region * 63)], Runs(address, Region * 65)); _tracker.UntrackMemory(address, Region * 2); Release(address, Region * 2); } [NativePageProtectionFact] public void RangeInvalidationBatchesOwnershipTransferAcrossRegions() { const ulong size = Region * 2; var address = AllocateAligned(size, Region); _tracker.ForEachUploadRange(address, size, true, NoRange, NoUpload); Assert.True(_tracker.HasGpuDirtyPages(address, size)); Assert.False(IsWritable(address)); var flushes = 0; _tracker.InvalidateRegion(address + 16, size - 32, () => { flushes++; _tracker.ForEachDownloadRange(address + 16, size - 32, clear: true, null, NoRange); _tracker.MarkCpuDirtyPages(address + 16, size - 32); }); Assert.Equal(1, flushes); Assert.False(_tracker.HasGpuDirtyPages(address, size)); Assert.True(_tracker.HasCpuDirtyPages(address, size)); Assert.True(IsWritable(address)); Assert.True(IsWritable(address + size - 1)); _tracker.InvalidateRegion(address + 16, size - 32, () => flushes++); Assert.Equal(1, flushes); _tracker.UntrackMemory(address, size); Release(address, size); } [NativePageProtectionFact] public void InvalidationAcceptsANewGenerationOfGpuOwnership() { var address = Allocate(1); _tracker.ForEachUploadRange(address, Page, true, NoRange, NoUpload); Assert.True(_tracker.HasGpuDirtyPages(address, Page)); Assert.False(_tracker.HasCpuDirtyPages(address, Page)); var flushes = 0; var uploads = 0; using var reacquire = new SemaphoreSlim(0); using var reacquired = new SemaphoreSlim(0); var publisher = new Thread(() => { reacquire.Wait(); _tracker.ForEachUploadRange(address + 16, 32, true, (_, _) => uploads++, NoUpload); reacquired.Release(); }); publisher.Start(); _tracker.InvalidateRegion(address + 16, 32, () => { flushes++; _tracker.ForEachDownloadRange(address + 16, 32, clear: true, null, NoRange); _tracker.MarkCpuDirtyPages(address + 16, 32); reacquire.Release(); reacquired.Wait(); }); publisher.Join(); Assert.Equal(1, flushes); Assert.Equal(1, uploads); Assert.True(_tracker.HasGpuDirtyPages(address, Page)); Assert.False(_tracker.HasCpuDirtyPages(address, Page)); Assert.False(IsWritable(address)); _tracker.ClearGpuDirtyPages(address, Page); _tracker.MarkCpuDirtyPages(address, Page); _tracker.UntrackMemory(address, Page); Release(address, Page); } [NativePageProtectionFact] public void GpuDirtyBitsStayInsideTheRequestedRange() { var address = Allocate(2); _tracker.ForEachUploadRange(address, Page, true, NoRange, NoUpload); Assert.True(_tracker.HasGpuDirtyPages(address, Page)); Assert.False(_tracker.HasGpuDirtyPages(address + Page, Page)); Assert.Equal(HostPageProtection.NoAccess, Protection(address)); _tracker.ClearGpuDirtyPages(address, Page); Assert.False(_tracker.HasGpuDirtyPages(address, Page)); Assert.Equal(HostPageProtection.ReadOnly, Protection(address)); _tracker.MarkCpuDirtyPages(address, Page); _tracker.UntrackMemory(address, Page * 2); Release(address, Page * 2); } [NativePageProtectionFact] public void ExactDirtyIntervalsShareOneTrackerPage() { var address = Allocate(1); _tracker.ForEachUploadRange(address, Page, false, NoRange, NoUpload); var exactDirty = new SpanSet(); exactDirty.Add(address + 64, 16); exactDirty.Add(address + 192, 32); ResetLog(); _tracker.MarkGpuDirtyPages(address + 64, 16); _tracker.MarkGpuDirtyPages(address + 192, 32); Assert.Single(_space.Protects); Assert.True(_tracker.HasGpuDirtyPages(address, Page)); Assert.Equal(HostPageProtection.NoAccess, Protection(address)); exactDirty.Remove(address + 64, 16); if (!exactDirty.Overlaps(address, Page)) { _tracker.ClearGpuDirtyPages(address, Page); } Assert.Single(_space.Protects); Assert.True(_tracker.HasGpuDirtyPages(address, Page)); Assert.Equal(HostPageProtection.NoAccess, Protection(address)); exactDirty.Remove(address + 192, 32); if (!exactDirty.Overlaps(address, Page)) { _tracker.ClearGpuDirtyPages(address, Page); } Assert.Equal(2, _space.Protects.Count); Assert.False(_tracker.HasGpuDirtyPages(address, Page)); Assert.Equal(HostPageProtection.ReadOnly, Protection(address)); _tracker.UntrackMemory(address, Page); Release(address, Page); } [NativePageProtectionFact] public void DownloadsMirrorTheProtectionOfEachSide() { var address = Allocate(4); _tracker.ForEachUploadRange(address, Page * 4, false, NoRange, NoUpload); _tracker.MarkGpuDirtyPages(address + 16, 32); _tracker.MarkGpuDirtyPages(address + Page * 2 + 16, 32); var visited = new List<(ulong Address, ulong Size)>(); ResetLog(); _tracker.ForEachDownloadRange(address, Page * 3, clear: false, null, (rangeAddress, rangeSize) => visited.Add((rangeAddress, rangeSize))); Assert.Equal(new[] { (address, Page), (address + Page * 2, Page) }, visited); Assert.Empty(_space.Protects); Assert.True(_tracker.HasGpuDirtyPages(address, Page * 3)); visited.Clear(); _tracker.ForEachDownloadRange(address + 16, 32, clear: true, null, (rangeAddress, rangeSize) => visited.Add((rangeAddress, rangeSize))); Assert.Equal(new[] { (address, Page) }, visited); Assert.Equal(new[] { (address, Page, Read) }, _space.Protects); Assert.False(_tracker.HasGpuDirtyPages(address, Page)); Assert.True(_tracker.HasGpuDirtyPages(address + Page * 2, Page)); Assert.Equal(HostPageProtection.ReadOnly, Protection(address)); Assert.Equal(HostPageProtection.NoAccess, Protection(address + Page * 2)); visited.Clear(); ResetLog(); _tracker.ForEachDownloadRange(address + 16, 32, clear: true, null, (rangeAddress, rangeSize) => visited.Add((rangeAddress, rangeSize))); Assert.Empty(visited); Assert.Empty(_space.Protects); Assert.True(_tracker.HasGpuDirtyPages(address + Page * 2, Page)); _tracker.ClearGpuDirtyPages(address, Page * 3); Assert.False(_tracker.HasGpuDirtyPages(address, Page * 3)); Assert.Equal(HostPageProtection.ReadOnly, Protection(address + Page * 2)); ResetLog(); _tracker.MarkCpuDirtyPages(address + 16, 32); Assert.Equal(new[] { (address, Page, ReadWrite) }, _space.Protects); Assert.True(IsWritable(address)); Assert.False(IsWritable(address + Page)); _tracker.UntrackMemory(address, Page * 4); Release(address, Page * 4); } [NativePageProtectionFact] public void CrossRegionUploadClearsAndProtectsBothRegions() { var address = AllocateAligned(Region * 2, Region); var boundary = address + Region; var ranges = 0; _tracker.ForEachUploadRange(boundary - Page, Page * 2, false, (_, _) => ranges++, NoUpload); Assert.Equal(2, ranges); Assert.False(_tracker.HasCpuDirtyPages(boundary - Page, Page * 2)); Assert.False(IsWritable(boundary - Page)); Assert.False(IsWritable(boundary)); _tracker.MarkCpuDirtyPages(boundary - Page, Page * 2); _tracker.UntrackMemory(address, Region * 2); Release(address, Region * 2); } [NativePageProtectionFact] public void UploadDoesNotSerializeADisjointRegion() { var address = AllocateAligned(Region * 2, Region); var secondRegion = address + Region; Assert.True(_tracker.HasCpuDirtyPages(address, Page)); Assert.True(_tracker.HasCpuDirtyPages(secondRegion, Page)); using var uploadEntered = new SemaphoreSlim(0); using var finishUpload = new SemaphoreSlim(0); using var queryFinished = new SemaphoreSlim(0); var queryResult = false; var uploader = new Thread(() => _tracker.ForEachUploadRange(address, Page, true, NoRange, () => { uploadEntered.Release(); finishUpload.Wait(); })); uploader.Start(); uploadEntered.Wait(); var query = new Thread(() => { queryResult = _tracker.HasCpuDirtyPages(secondRegion, Page); queryFinished.Release(); }); query.Start(); var completedWhileUploadBlocked = queryFinished.Wait(TimeSpan.FromSeconds(5)); finishUpload.Release(); uploader.Join(); query.Join(); _tracker.ClearGpuDirtyPages(address, Page); _tracker.MarkCpuDirtyPages(address, Page); _tracker.UntrackMemory(address, Region * 2); Release(address, Region * 2); Assert.True(completedWhileUploadBlocked); Assert.True(queryResult); } [NativePageProtectionFact] public void DownloadDoesNotSerializeADisjointRegion() { var address = AllocateAligned(Region * 2, Region); var secondRegion = address + Region; _tracker.ForEachUploadRange(address, Page, true, NoRange, NoUpload); _tracker.ForEachUploadRange(secondRegion, Page, false, NoRange, NoUpload); using var downloadEntered = new SemaphoreSlim(0); using var finishDownload = new SemaphoreSlim(0); using var mutationFinished = new SemaphoreSlim(0); var downloader = new Thread(() => _tracker.ForEachDownloadRange(address, Page, clear: false, null, (_, _) => { downloadEntered.Release(); finishDownload.Wait(); })); downloader.Start(); downloadEntered.Wait(); var mutation = new Thread(() => { _tracker.MarkGpuDirtyPages(secondRegion, Page); mutationFinished.Release(); }); mutation.Start(); var completedWhileDownloadBlocked = mutationFinished.Wait(TimeSpan.FromSeconds(5)); finishDownload.Release(); downloader.Join(); mutation.Join(); var bothGpuOwned = _tracker.HasGpuDirtyPages(address, Page) && _tracker.HasGpuDirtyPages(secondRegion, Page); _tracker.ClearGpuDirtyPages(address, Page); _tracker.ClearGpuDirtyPages(secondRegion, Page); _tracker.MarkCpuDirtyPages(address, Page); _tracker.MarkCpuDirtyPages(secondRegion, Page); _tracker.UntrackMemory(address, Region * 2); Release(address, Region * 2); Assert.True(completedWhileDownloadBlocked); Assert.True(bothGpuOwned); } [NativePageProtectionFact] public void GpuUnmarkUsesOneRegionMaskPerRegion() { var address = AllocateAligned(Region * 2, Region); var sparseBegin = address + Page; _tracker.ForEachUploadRange(sparseBegin, Page * 3, false, NoRange, NoUpload); _tracker.MarkGpuDirtyPages(sparseBegin, Page); _tracker.MarkGpuDirtyPages(sparseBegin + Page * 2, Page); ResetLog(); _tracker.ClearGpuDirtyPages(sparseBegin, Page * 3); Assert.Equal(new[] { (sparseBegin, Page * 3, Read) }, _space.Protects); Assert.False(_tracker.HasGpuDirtyPages(sparseBegin, Page * 3)); Assert.Equal(HostPageProtection.ReadOnly, Protection(sparseBegin)); Assert.Equal(HostPageProtection.ReadOnly, Protection(sparseBegin + Page)); Assert.Equal(HostPageProtection.ReadOnly, Protection(sparseBegin + Page * 2)); ResetLog(); _tracker.ClearGpuDirtyPages(sparseBegin, Page * 3); Assert.Empty(_space.Protects); var boundary = address + Region; var crossBegin = boundary - Page; _tracker.ForEachUploadRange(crossBegin, Page * 2, false, NoRange, NoUpload); _tracker.MarkGpuDirtyPages(crossBegin, Page * 2); ResetLog(); _tracker.ClearGpuDirtyPages(crossBegin, Page * 2); Assert.Equal(new[] { (crossBegin, Page, Read), (boundary, Page, Read) }, _space.Protects); Assert.False(_tracker.HasGpuDirtyPages(crossBegin, Page * 2)); _tracker.UntrackMemory(address, Region * 2); Release(address, Region * 2); } [NativePageProtectionFact] public void FullRegionGpuUnmarkUsesOneProtectionRequest() { var address = AllocateAligned(Region, Region); _tracker.ForEachUploadRange(address, Region, false, NoRange, NoUpload); _tracker.MarkGpuDirtyPages(address, Region); Assert.True(_tracker.HasGpuDirtyPages(address, Region)); Assert.Equal(HostPageProtection.NoAccess, Protection(address)); Assert.Equal(HostPageProtection.NoAccess, Protection(address + Region - Page)); ResetLog(); _tracker.ClearGpuDirtyPages(address, Region); Assert.Equal(new[] { (address, Region, Read) }, _space.Protects); Assert.False(_tracker.HasGpuDirtyPages(address, Region)); Assert.Equal(HostPageProtection.ReadOnly, Protection(address)); Assert.Equal(HostPageProtection.ReadOnly, Protection(address + Region - Page)); _tracker.UntrackMemory(address, Region); Release(address, Region); } [NativePageProtectionFact] public void ExplicitCpuDirtinessOnGpuDirtyMemoryIsFatal() { var address = Allocate(1); _tracker.ForEachUploadRange(address, Page, true, NoRange, NoUpload); var fatal = Assert.Throws(() => _tracker.MarkCpuDirtyPages(address, Page)); Assert.Equal("Cannot mark GPU-dirty pages as CPU-dirty.", fatal.Message); _tracker.ClearGpuDirtyPages(address, Page); _tracker.MarkCpuDirtyPages(address, Page); _tracker.UntrackMemory(address, Page); Release(address, Page); } [NativePageProtectionFact] public void CleanUploadStillRunsCallbackAndTransfersWritableOwnership() { var address = Allocate(1); _tracker.ForEachUploadRange(address, Page, false, NoRange, NoUpload); var callbacks = 0; var ranges = 0; _tracker.ForEachUploadRange(address, Page, false, (_, _) => ranges++, () => callbacks++); Assert.False(_tracker.HasGpuDirtyPages(address, Page)); _tracker.ForEachUploadRange(address, Page, true, (_, _) => ranges++, () => callbacks++); Assert.Equal(0, ranges); Assert.Equal(2, callbacks); Assert.True(_tracker.HasGpuDirtyPages(address, Page)); Assert.False(_tracker.HasCpuDirtyPages(address, Page)); _tracker.ClearGpuDirtyPages(address, Page); _tracker.UntrackMemory(address, Page); Release(address, Page); } [NativePageProtectionFact] public void RepeatedCpuWritesKeepReadOnlyHotPagesWritableAndDirty() { var address = Allocate(1); _tracker.ForEachUploadRange(address, Page, false, NoRange, NoUpload); _tracker.MarkCpuDirtyPages(address, Page); Assert.False(_tracker.IsCpuWriteHotRange(address, Page)); _tracker.ForEachUploadRange(address, Page, false, NoRange, NoUpload); _tracker.MarkCpuDirtyPages(address, Page); Assert.True(_tracker.IsCpuWriteHotRange(address, Page)); Assert.Equal(Page, _tracker.CountCpuWriteHotBytes(address, Page)); Assert.Equal(123ul, _tracker.CountCpuWriteHotBytes(address + 7, 123)); var uploads = 0; _tracker.ForEachUploadRange(address, Page, false, (_, _) => uploads++, NoUpload); _tracker.ForEachUploadRange(address, Page, false, (_, _) => uploads++, NoUpload); Assert.Equal(2, uploads); Assert.True(_tracker.HasCpuDirtyPages(address, Page)); Assert.True(IsWritable(address)); _tracker.ForEachUploadRange(address, Page, true, (_, _) => uploads++, NoUpload); Assert.Equal(3, uploads); Assert.False(_tracker.IsCpuWriteHotRange(address, Page)); Assert.False(_tracker.HasCpuDirtyPages(address, Page)); Assert.Equal(0ul, _tracker.CountCpuWriteHotBytes(address, Page)); Assert.True(_tracker.HasGpuDirtyPages(address, Page)); _tracker.ClearGpuDirtyPages(address, Page); _tracker.UntrackMemory(address, Page); Release(address, Page); } [NativePageProtectionTheory] [InlineData(false)] [InlineData(true)] public void TrackedReadOnlyUploadRearmsHotPagesAndRetriesFailures(bool failUpload) { var address = Allocate(1); _tracker.ForEachUploadRange(address, Page, false, NoRange, NoUpload); _tracker.MarkCpuDirtyPages(address, Page); _tracker.ForEachUploadRange(address, Page, false, NoRange, NoUpload); _tracker.MarkCpuDirtyPages(address, Page); Assert.True(_tracker.IsCpuWriteHotRange(address, Page)); if (failUpload) { Assert.Throws(() => _tracker.ForEachUploadRange( address, Page, false, NoRange, () => throw new InvalidOperationException(), preserveCpuWriteHotPages: false)); Assert.True(_tracker.HasCpuDirtyPages(address, Page)); Assert.True(IsWritable(address)); } var uploads = 0; for (var repeat = 0; repeat < 3; repeat++) _tracker.ForEachUploadRange(address, Page, false, (_, _) => uploads++, NoUpload, preserveCpuWriteHotPages: false); Assert.Equal(1, uploads); Assert.False(_tracker.HasCpuDirtyPages(address, Page)); Assert.False(_tracker.HasGpuDirtyPages(address, Page)); Assert.False(IsWritable(address)); Assert.True(_tracker.IsCpuWriteHotRange(address, Page)); _tracker.MarkCpuDirtyPages(address, Page); _tracker.ForEachUploadRange(address, Page, false, (_, _) => uploads++, NoUpload, preserveCpuWriteHotPages: false); Assert.Equal(2, uploads); Assert.False(IsWritable(address)); _tracker.UntrackMemory(address, Page); Release(address, Page); } [NativePageProtectionFact] public void ReadOnlyUploadClearsColdPagesAndPreservesHotPages() { var address = Allocate(2); _tracker.ForEachUploadRange(address, Page * 2, false, NoRange, NoUpload); _tracker.MarkCpuDirtyPages(address, Page); _tracker.ForEachUploadRange(address, Page, false, NoRange, NoUpload); _tracker.MarkCpuDirtyPages(address, Page); var uploads = new List<(ulong Address, ulong Size)>(); _tracker.ForEachUploadRange(address, Page * 2, false, (rangeAddress, rangeSize) => uploads.Add((rangeAddress, rangeSize)), NoUpload); Assert.Single(uploads); Assert.Equal((address, Page), uploads[0]); Assert.True(_tracker.HasCpuDirtyPages(address, Page)); Assert.False(_tracker.HasCpuDirtyPages(address + Page, Page)); Assert.Equal(Page, _tracker.CountCpuWriteHotBytes(address, Page * 2)); Assert.Equal(7ul, _tracker.CountCpuWriteHotBytes(address + Page - 7, 14)); Assert.True(IsWritable(address)); Assert.False(IsWritable(address + Page)); _tracker.UntrackMemory(address, Page * 2); Release(address, Page * 2); } [NativePageProtectionFact] public void ReenteringTheTrackerFromAnUploadCallbackIsFatal() { var address = Allocate(1); var fatal = Assert.Throws(() => _tracker.ForEachUploadRange(address, Page, false, NoRange, () => _tracker.HasCpuDirtyPages(address, Page))); Assert.Equal("Cannot enter the memory tracker from its upload callback.", fatal.Message); _tracker.MarkCpuDirtyPages(address, Page); _tracker.UntrackMemory(address, Page); Release(address, Page); } [NativePageProtectionTheory] [InlineData(false, true)] [InlineData(true, true)] [InlineData(true, false)] public void FailedUploadReturnsTheRunsToTheCpuAndReleasesEveryLock(bool isWritten, bool failInRange) { var address = AllocateAligned(Region * 2, Region); var boundary = address + Region; var ranges = 0; // The second region has already been cleared when the callback fails. var failure = Assert.Throws(() => _tracker.ForEachUploadRange( boundary - Page, Page * 2, isWritten, (_, _) => { if (failInRange && ++ranges == 2) { throw new InvalidOperationException("range"); } }, () => throw new InvalidOperationException("upload"))); Assert.Equal(failInRange ? "range" : "upload", failure.Message); Assert.True(_tracker.HasCpuDirtyPages(boundary - Page, Page * 2)); Assert.False(_tracker.HasGpuDirtyPages(boundary - Page, Page * 2)); Assert.True(IsWritable(boundary - Page)); Assert.True(IsWritable(boundary)); // Both region locks are free again: the same thread can upload the range once more. var retried = 0; _tracker.ForEachUploadRange(boundary - Page, Page * 2, false, (_, _) => retried++, NoUpload); Assert.Equal(2, retried); _tracker.MarkCpuDirtyPages(boundary - Page, Page * 2); _tracker.UntrackMemory(address, Region * 2); Release(address, Region * 2); } [NativePageProtectionFact] public void FailedReadOnlyUploadKeepsAnOwnershipInstalledAfterItsLockOpened() { var address = Allocate(2); using var released = new SemaphoreSlim(0); using var proceed = new SemaphoreSlim(0); var writer = new Thread(() => { released.Wait(); _tracker.ForEachUploadRange(address, Page, true, NoRange, NoUpload); proceed.Release(); }); writer.Start(); // The read-only upload has released its region lock when uploadFunc runs; the writer // takes only the first of the two cleared pages. Assert.Throws(() => _tracker.ForEachUploadRange(address, Page * 2, false, NoRange, () => { released.Release(); proceed.Wait(); throw new InvalidOperationException("upload"); })); writer.Join(); Assert.True(_tracker.HasGpuDirtyPages(address, Page)); Assert.False(_tracker.HasCpuDirtyPages(address, Page)); Assert.Equal(HostPageProtection.NoAccess, Protection(address)); Assert.False(_tracker.HasGpuDirtyPages(address + Page, Page)); Assert.True(_tracker.HasCpuDirtyPages(address + Page, Page)); Assert.True(IsWritable(address + Page)); _tracker.ClearGpuDirtyPages(address, Page); _tracker.MarkCpuDirtyPages(address, Page); _tracker.UntrackMemory(address, Page * 2); Release(address, Page * 2); } [NativePageProtectionFact] public void UntrackingGpuDirtyMemoryIsFatal() { var address = Allocate(1); _tracker.ForEachUploadRange(address, Page, true, NoRange, NoUpload); var fatal = Assert.Throws(() => _tracker.UntrackMemory(address, Page)); Assert.Equal("Cannot remove tracking while the memory is GPU-dirty.", fatal.Message); _tracker.ClearGpuDirtyPages(address, Page); _tracker.MarkCpuDirtyPages(address, Page); _tracker.UntrackMemory(address, Page); Release(address, Page); } private static void NoRange(ulong address, ulong size) { } private static void NoUpload() { } private ulong Allocate(ulong pages) => AllocateAligned(pages * Page, Page); // Allocate inside the low 2^40 bytes covered by the tracker. private ulong AllocateAligned(ulong size, ulong alignment) { Assert.True(_memory.TryAllocateAtOrAbove(0x2_0000_0000, size, executable: false, alignment, out var address)); Assert.True(address % alignment == 0 && address + size < TrackerLayout.SpaceBytes); _gpu.Register(address, size, ReadWrite); return address; } private void Release(ulong address, ulong size) => _gpu.Unregister(address, size); private HostPageProtection Protection(ulong address) { Assert.True(_host.Query(address, out var info)); return info.Protection; } private bool IsWritable(ulong address) => Protection(address) == HostPageProtection.ReadWrite; private void ResetLog() { lock (_space.Protects) { _space.Protects.Clear(); } } }