287 lines
12 KiB
C#
287 lines
12 KiB
C#
// Copyright (C) 2026 SharpEmu Emulator Project
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
using System.Reflection;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Runtime.InteropServices;
|
|
using Microsoft.Win32.SafeHandles;
|
|
using SharpEmu.Core.Cpu.Native;
|
|
using SharpEmu.HLE;
|
|
using SharpEmu.Libs.Tests.Gpu.Buffers;
|
|
using Xunit;
|
|
|
|
namespace SharpEmu.Libs.Tests.Cpu;
|
|
|
|
public sealed class WindowsThreadContextCaptureTests
|
|
{
|
|
private const string WorkerVariable = "SHARPEMU_THREAD_CONTEXT_TEST_WORKER";
|
|
private static bool CanRun => OperatingSystem.IsWindows() && RuntimeInformation.ProcessArchitecture == Architecture.X64;
|
|
|
|
[Theory]
|
|
[InlineData(uint.MaxValue, 1u, 1u, false, 0x1)]
|
|
[InlineData(0u, 0u, 1u, false, 0x123)]
|
|
[InlineData(0u, 1u, 1u, true, 0x123)]
|
|
[InlineData(2u, 1u, 3u, true, 0x123)]
|
|
[InlineData(0u, 1u, uint.MaxValue, false, 0x123)]
|
|
public unsafe void NativeSequence_BalancesSuccessfulSuspension(
|
|
uint suspendResult, uint contextResult, uint resumeResult, bool expectedResult, int expectedCalls)
|
|
{
|
|
if (!CanRun) return;
|
|
using var suspend = new NativeCode(BuildRecordingFunction(1, suspendResult));
|
|
using var capture = new NativeCode(BuildRecordingFunction(2, contextResult));
|
|
using var resume = new NativeCode(BuildRecordingFunction(3, resumeResult));
|
|
using var sequence = new NativeCode(DirectExecutionBackend.BuildThreadContextCaptureCode(
|
|
suspend.Address, capture.Address, resume.Address));
|
|
int* recorded = stackalloc int[2];
|
|
recorded[0] = 0;
|
|
recorded[1] = 1;
|
|
|
|
var result = ((delegate* unmanaged<nint, nint, int>)sequence.Address)((nint)recorded, 0);
|
|
|
|
Assert.Equal(expectedResult, result != 0);
|
|
Assert.Equal(expectedCalls, recorded[0]);
|
|
Assert.Equal(1, recorded[1]);
|
|
}
|
|
|
|
[Fact]
|
|
public unsafe void ContextDecoder_ReadsEveryIntegerRegister()
|
|
{
|
|
var context = stackalloc byte[0x4D0];
|
|
new Span<byte>(context, 0x4D0).Clear();
|
|
var fields = new (string Name, int Offset)[]
|
|
{
|
|
("Rax", 120), ("Rcx", 128), ("Rdx", 136), ("Rbx", 144), ("Rsp", 152),
|
|
("Rbp", 160), ("Rsi", 168), ("Rdi", 176), ("R8", 184), ("R9", 192),
|
|
("R10", 200), ("R11", 208), ("R12", 216), ("R13", 224), ("R14", 232), ("R15", 240), ("Rip", 248)
|
|
};
|
|
foreach (var field in fields) *(ulong*)(context + field.Offset) = (ulong)field.Offset * 17;
|
|
var decoder = FindMethod("DecodeHostThreadContext");
|
|
var snapshot = decoder.Invoke(null, [123u, Pointer.Box(context, typeof(void*))]);
|
|
Assert.NotNull(snapshot);
|
|
Assert.Equal(123u, ReadProperty<uint>(snapshot, "ThreadId"));
|
|
foreach (var field in fields)
|
|
Assert.Equal((ulong)field.Offset * 17, ReadProperty<ulong>(snapshot, field.Name));
|
|
}
|
|
|
|
[Fact]
|
|
public unsafe void NativeCaptureCode_ReleasesItsAllocation()
|
|
{
|
|
if (!CanRun) return;
|
|
var codeType = typeof(DirectExecutionBackend).GetNestedType("ThreadContextCaptureCode", BindingFlags.NonPublic)!;
|
|
using var code = Assert.IsAssignableFrom<SafeHandle>(Activator.CreateInstance(codeType));
|
|
Assert.False(code.IsInvalid);
|
|
var address = code.DangerousGetHandle();
|
|
Assert.NotEqual((nuint)0, HostMemory.Query((void*)address, out var before));
|
|
Assert.Equal(HostMemory.PAGE_EXECUTE_READ, before.Protect);
|
|
code.Dispose();
|
|
// Another thread may reuse the freed range at once; only the original executable
|
|
// allocation still being there is a leak.
|
|
Assert.NotEqual((nuint)0, HostMemory.Query((void*)address, out var after));
|
|
Assert.False(after.State == HostMemory.MEM_COMMIT && after.Protect == HostMemory.PAGE_EXECUTE_READ &&
|
|
after.AllocationBase == (ulong)address);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NativeCapture_ResumesThreadsAndReleasesResources()
|
|
{
|
|
if (!CanRun) return;
|
|
if (GuestFaultWorker.IsWorker(WorkerVariable))
|
|
{
|
|
RunNativeCases();
|
|
GuestFaultWorker.Report(GuestFaultWorker.Completed, "native context and cleanup checks passed");
|
|
return;
|
|
}
|
|
await GuestFaultWorker.RunIsolatedAsync(WorkerVariable, typeof(WindowsThreadContextCaptureTests),
|
|
nameof(NativeCapture_ResumesThreadsAndReleasesResources));
|
|
}
|
|
|
|
private static void RunNativeCases()
|
|
{
|
|
using var worker = new NativeWorker();
|
|
Assert.False(TryCapture(null, out _));
|
|
using (var self = new SafeWaitHandle(OpenThread(0x080A, false, GetCurrentThreadId()), true))
|
|
Assert.False(TryCapture(self, out _));
|
|
using (var missing = new SafeWaitHandle(0, false))
|
|
Assert.False(TryCapture(missing, out _));
|
|
|
|
Assert.True(TryCapture(worker.Handle, out var captured));
|
|
Assert.Equal(worker.ThreadId, ReadProperty<uint>(captured!, "ThreadId"));
|
|
Assert.NotEqual(0UL, ReadProperty<ulong>(captured!, "Rip"));
|
|
Assert.NotEqual(0UL, ReadProperty<ulong>(captured!, "Rsp"));
|
|
Assert.Equal(0u, ResumeThread(worker.Handle.DangerousGetHandle()));
|
|
|
|
Assert.Equal(0u, SuspendThread(worker.Handle.DangerousGetHandle()));
|
|
try
|
|
{
|
|
Assert.True(TryCapture(worker.Handle, out _));
|
|
}
|
|
finally
|
|
{
|
|
Assert.Equal(1u, ResumeThread(worker.Handle.DangerousGetHandle()));
|
|
}
|
|
|
|
// Context access is denied after suspension; the native sequence must still resume.
|
|
using (var restricted = new SafeWaitHandle(OpenThread(0x0802, false, worker.ThreadId), true))
|
|
{
|
|
Assert.False(restricted.IsInvalid);
|
|
Assert.False(TryCapture(restricted, out _));
|
|
Assert.Equal(0u, ResumeThread(worker.Handle.DangerousGetHandle()));
|
|
}
|
|
|
|
var borrowed = new SafeWaitHandle(OpenThread(0x080A, false, worker.ThreadId), true);
|
|
var borrowedAddress = borrowed.DangerousGetHandle();
|
|
Assert.True(TryCapture(borrowed, out _));
|
|
borrowed.Dispose();
|
|
Assert.False(TryCapture(borrowed, out _));
|
|
Assert.Equal(0u, GetThreadId(borrowedAddress));
|
|
|
|
VerifyMainThreadReport(worker.Handle);
|
|
VerifyManagedAllocationStress();
|
|
worker.Stop();
|
|
Assert.False(TryCapture(worker.Handle, out _));
|
|
}
|
|
|
|
private static void VerifyMainThreadReport(SafeWaitHandle threadHandle)
|
|
{
|
|
var backend = (DirectExecutionBackend)RuntimeHelpers.GetUninitializedObject(typeof(DirectExecutionBackend));
|
|
var field = typeof(DirectExecutionBackend).GetField("_mainExecutionThreadHandle",
|
|
BindingFlags.NonPublic | BindingFlags.Instance)!;
|
|
field.SetValue(backend, threadHandle);
|
|
var log = typeof(DirectExecutionBackend).GetMethod("LogMainThreadStallContext",
|
|
BindingFlags.NonPublic | BindingFlags.Instance)!;
|
|
var originalError = Console.Error;
|
|
using var output = new StringWriter();
|
|
try
|
|
{
|
|
Console.SetError(output);
|
|
log.Invoke(backend, null);
|
|
Assert.Contains("Live hardware context", output.ToString());
|
|
Assert.Contains("R15=0x", output.ToString());
|
|
Assert.Contains("Live stack pointer candidates", output.ToString());
|
|
output.GetStringBuilder().Clear();
|
|
field.SetValue(backend, null);
|
|
log.Invoke(backend, null);
|
|
Assert.Equal(string.Empty, output.ToString());
|
|
}
|
|
finally
|
|
{
|
|
Console.SetError(originalError);
|
|
field.SetValue(backend, null);
|
|
}
|
|
}
|
|
|
|
private static void VerifyManagedAllocationStress()
|
|
{
|
|
using var ready = new ManualResetEventSlim();
|
|
var stop = 0;
|
|
uint threadId = 0;
|
|
var thread = new Thread(() =>
|
|
{
|
|
threadId = GetCurrentThreadId();
|
|
ready.Set();
|
|
while (Volatile.Read(ref stop) == 0) GC.KeepAlive(new byte[4096]);
|
|
}) { IsBackground = true };
|
|
thread.Start();
|
|
try
|
|
{
|
|
Assert.True(ready.Wait(TimeSpan.FromSeconds(5)));
|
|
using var handle = new SafeWaitHandle(OpenThread(0x080A, false, threadId), true);
|
|
for (var sampleIndex = 0; sampleIndex < 50; sampleIndex++)
|
|
{
|
|
Assert.True(TryCapture(handle, out _));
|
|
Assert.Equal(0u, ResumeThread(handle.DangerousGetHandle()));
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
Volatile.Write(ref stop, 1);
|
|
Assert.True(thread.Join(TimeSpan.FromSeconds(5)));
|
|
}
|
|
}
|
|
|
|
private static bool TryCapture(SafeWaitHandle? handle, out object? snapshot)
|
|
{
|
|
object?[] arguments = [handle, null];
|
|
var result = Assert.IsType<bool>(FindMethod("TryCaptureThreadContext").Invoke(null, arguments));
|
|
snapshot = arguments[1];
|
|
return result;
|
|
}
|
|
|
|
private static MethodInfo FindMethod(string methodName) => typeof(DirectExecutionBackend).GetMethod(
|
|
methodName, BindingFlags.NonPublic | BindingFlags.Static)!;
|
|
|
|
private static TProperty ReadProperty<TProperty>(object value, string propertyName) =>
|
|
Assert.IsType<TProperty>(value.GetType().GetProperty(propertyName)!.GetValue(value));
|
|
|
|
private static byte[] BuildRecordingFunction(byte step, uint result)
|
|
{
|
|
// Check call alignment and record the call order without entering managed code.
|
|
return [0x48, 0x89, 0xE0, 0x83, 0xE0, 0x0F, 0x83, 0xF8, 0x08,
|
|
0x0F, 0x94, 0xC0, 0x0F, 0xB6, 0xC0, 0x21, 0x41, 0x04,
|
|
0xC1, 0x21, 0x04, 0x83, 0x09, step, 0xB8, .. BitConverter.GetBytes(result), 0xC3];
|
|
}
|
|
|
|
private sealed unsafe class NativeCode : IDisposable
|
|
{
|
|
public nint Address { get; }
|
|
|
|
public NativeCode(byte[] instructions)
|
|
{
|
|
Address = (nint)HostMemory.Alloc(null, (nuint)instructions.Length, 0x3000, HostMemory.PAGE_READWRITE);
|
|
Assert.NotEqual(0, Address);
|
|
instructions.CopyTo(new Span<byte>((void*)Address, instructions.Length));
|
|
Assert.True(HostMemory.Protect((void*)Address, (nuint)instructions.Length, HostMemory.PAGE_EXECUTE_READ, out _));
|
|
HostMemory.FlushInstructionCache((void*)Address, (nuint)instructions.Length);
|
|
}
|
|
|
|
public void Dispose() => Assert.True(HostMemory.Free((void*)Address, 0, HostMemory.MEM_RELEASE));
|
|
}
|
|
|
|
private sealed unsafe class NativeWorker : IDisposable
|
|
{
|
|
private readonly NativeCode _code = new([0xC7, 0x01, 1, 0, 0, 0,
|
|
0xFF, 0x41, 0x08, 0xF3, 0x90, 0x83, 0x79, 0x04, 0, 0x74, 0xF5, 0x31, 0xC0, 0xC3]);
|
|
private readonly nint _state = (nint)NativeMemory.AllocZeroed(16);
|
|
public SafeWaitHandle Handle { get; }
|
|
public uint ThreadId { get; }
|
|
|
|
public NativeWorker()
|
|
{
|
|
Handle = new SafeWaitHandle(CreateThread(0, 0, _code.Address, _state, 0, out var threadId), true);
|
|
ThreadId = threadId;
|
|
Assert.False(Handle.IsInvalid);
|
|
Assert.True(SpinWait.SpinUntil(() => Marshal.ReadInt32(_state) == 1, TimeSpan.FromSeconds(5)));
|
|
}
|
|
|
|
public void Stop()
|
|
{
|
|
Marshal.WriteInt32(_state + 4, 1);
|
|
Assert.Equal(0u, WaitForSingleObject(Handle.DangerousGetHandle(), 5000));
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Stop();
|
|
Handle.Dispose();
|
|
_code.Dispose();
|
|
NativeMemory.Free((void*)_state);
|
|
}
|
|
}
|
|
|
|
[DllImport("kernel32.dll")]
|
|
private static extern nint OpenThread(uint desiredAccess, bool inheritHandle, uint threadId);
|
|
[DllImport("kernel32.dll")]
|
|
private static extern uint GetCurrentThreadId();
|
|
[DllImport("kernel32.dll")]
|
|
private static extern uint GetThreadId(nint threadHandle);
|
|
[DllImport("kernel32.dll")]
|
|
private static extern uint SuspendThread(nint threadHandle);
|
|
[DllImport("kernel32.dll")]
|
|
private static extern uint ResumeThread(nint threadHandle);
|
|
[DllImport("kernel32.dll")]
|
|
private static extern uint WaitForSingleObject(nint handle, uint milliseconds);
|
|
[DllImport("kernel32.dll")]
|
|
private static extern nint CreateThread(nint attributes, nuint stackSize, nint entryPoint,
|
|
nint argument, uint flags, out uint threadId);
|
|
}
|