diff --git a/WhiteMagic/Thread/FrozenThread.cs b/WhiteMagic/Thread/FrozenThread.cs
new file mode 100644
index 0000000..25987e4
--- /dev/null
+++ b/WhiteMagic/Thread/FrozenThread.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace WhiteMagic.Thread;
+
+///
+/// A disposable scope that tracks a set of threads frozen by .
+/// Disposing the scope resumes exactly those threads, in reverse order, even if the guarded
+/// body throws.
+///
+public sealed class FrozenThread : IDisposable
+{
+ private readonly IReadOnlyList _threads;
+ private bool _disposed;
+
+ internal FrozenThread(IReadOnlyList threads)
+ {
+ _threads = threads ?? throw new ArgumentNullException(nameof(threads));
+ }
+
+ /// The threads suspended by this freeze scope.
+ public IEnumerable Threads => _threads;
+
+ ///
+ /// Resumes the frozen threads in reverse order. The original call is responsible for
+ /// disposing the instances afterwards.
+ ///
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+
+ foreach (RemoteThread thread in _threads.Reverse())
+ {
+ try
+ {
+ thread.Resume();
+ }
+ catch
+ {
+ // Resume-on-dispose is best-effort; callers keep the thread handles so
+ // they can diagnose or recover separately.
+ }
+ }
+ }
+}
diff --git a/WhiteMagic/Thread/RemoteThread.cs b/WhiteMagic/Thread/RemoteThread.cs
new file mode 100644
index 0000000..d8bd8f4
--- /dev/null
+++ b/WhiteMagic/Thread/RemoteThread.cs
@@ -0,0 +1,184 @@
+using System;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using WhiteMagic.Native;
+using WhiteMagic.ThreadEnvironment;
+
+namespace WhiteMagic.Thread;
+
+///
+/// A handle to an existing thread in the target process. Provides suspend/resume,
+/// context read/write, and TEB query.
+///
+public sealed class RemoteThread : IDisposable
+{
+ private readonly MemoryBase _memory;
+ private readonly SafeMemoryHandle _handle;
+ private readonly int _id;
+ private bool _disposed;
+
+ /// The operating-system identifier of this thread.
+ public int Id => _id;
+
+ /// The native thread handle.
+ internal SafeMemoryHandle Handle => _handle;
+
+ internal RemoteThread(MemoryBase memory, int threadId, SafeMemoryHandle handle)
+ {
+ _memory = memory ?? throw new ArgumentNullException(nameof(memory));
+ _id = threadId;
+ _handle = handle ?? throw new ArgumentNullException(nameof(handle));
+ }
+
+ ///
+ /// Opens the thread specified by in the target process
+ /// represented by .
+ ///
+ public RemoteThread(MemoryBase memory, int threadId)
+ : this(memory, threadId, OpenHandle(threadId))
+ {
+ }
+
+ private static SafeMemoryHandle OpenHandle(int threadId)
+ {
+ if (threadId <= 0)
+ throw new ArgumentException("Thread ID must be positive.", nameof(threadId));
+
+ const ThreadAccess requiredAccess =
+ ThreadAccess.SuspendResume |
+ ThreadAccess.GetContext |
+ ThreadAccess.SetContext |
+ ThreadAccess.QueryInformation;
+
+ SafeMemoryHandle handle = NativeMethods.OpenThread(requiredAccess, false, threadId);
+ if (handle.IsInvalid)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"OpenThread failed for thread {threadId}: error {error}.");
+ }
+
+ return handle;
+ }
+
+ ///
+ /// Suspends the thread and returns its previous suspend count.
+ ///
+ public uint Suspend()
+ {
+ uint result = NativeMethods.SuspendThread(_handle);
+ if (result == 0xFFFFFFFF)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"SuspendThread failed for thread {_id}: error {error}.");
+ }
+
+ return result;
+ }
+
+ ///
+ /// Resumes the thread and returns its previous suspend count.
+ ///
+ public uint Resume()
+ {
+ uint result = NativeMethods.ResumeThread(_handle);
+ if (result == 0xFFFFFFFF)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"ResumeThread failed for thread {_id}: error {error}.");
+ }
+
+ return result;
+ }
+
+ ///
+ /// Reads the 64-bit native context of the thread. Valid only for 64-bit targets.
+ ///
+ public unsafe void GetContext64(out Context64 context)
+ {
+ nint size = Marshal.SizeOf();
+ void* ptr = NativeMemory.AlignedAlloc((nuint)size, 16);
+ try
+ {
+ Unsafe.InitBlock(ptr, 0, (uint)size);
+ ((Context64*)ptr)->ContextFlags = ContextFlags.Amd64Full;
+
+ if (!NativeMethods.GetThreadContext(_handle, ref *(Context64*)ptr))
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"GetThreadContext failed for thread {_id}: error {error}.");
+ }
+
+ context = *(Context64*)ptr;
+ }
+ finally
+ {
+ NativeMemory.AlignedFree(ptr);
+ }
+ }
+
+ ///
+ /// Writes the 64-bit native context of the thread. Valid only for 64-bit targets.
+ ///
+ public unsafe void SetContext64(ref Context64 context)
+ {
+ nint size = Marshal.SizeOf();
+ void* ptr = NativeMemory.AlignedAlloc((nuint)size, 16);
+ try
+ {
+ *(Context64*)ptr = context;
+ if (!NativeMethods.SetThreadContext(_handle, ref *(Context64*)ptr))
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"SetThreadContext failed for thread {_id}: error {error}.");
+ }
+ }
+ finally
+ {
+ NativeMemory.AlignedFree(ptr);
+ }
+ }
+
+ ///
+ /// Reads the 32-bit native context of the thread. Valid for 32-bit targets or
+ /// WOW64 threads selected by a 64-bit caller.
+ ///
+ public void GetContext32(out Context32 context)
+ {
+ context = new Context32 { ContextFlags = ContextFlags.X86Full };
+ if (!NativeMethods.GetThreadContext(_handle, ref context))
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"GetThreadContext failed for thread {_id}: error {error}.");
+ }
+ }
+
+ ///
+ /// Writes the 32-bit native context of the thread.
+ ///
+ public void SetContext32(ref Context32 context)
+ {
+ if (!NativeMethods.SetThreadContext(_handle, ref context))
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"SetThreadContext failed for thread {_id}: error {error}.");
+ }
+ }
+
+ ///
+ /// Returns a managed reader for this thread's Thread Environment Block.
+ ///
+ public ManagedTeb GetTeb()
+ {
+ return new ManagedTeb(_memory, _id);
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (!_disposed)
+ {
+ _disposed = true;
+ _handle.Dispose();
+ }
+ }
+}
diff --git a/WhiteMagic/Thread/ThreadFactory.cs b/WhiteMagic/Thread/ThreadFactory.cs
new file mode 100644
index 0000000..7faf73e
--- /dev/null
+++ b/WhiteMagic/Thread/ThreadFactory.cs
@@ -0,0 +1,230 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.InteropServices;
+using WhiteMagic.Native;
+
+namespace WhiteMagic.Thread;
+
+///
+/// Enumerates and selects threads belonging to the target process.
+///
+public sealed class ThreadFactory
+{
+ private readonly MemoryBase _memory;
+
+ /// Creates a factory bound to the target process represented by .
+ public ThreadFactory(MemoryBase memory)
+ {
+ _memory = memory ?? throw new ArgumentNullException(nameof(memory));
+ }
+
+ ///
+ /// Enumerates every thread that belongs to the target process.
+ ///
+ public IEnumerable Enumerate()
+ {
+ foreach (int threadId in CollectThreadIds())
+ {
+ SafeMemoryHandle handle = NativeMethods.OpenThread(
+ ThreadAccess.SuspendResume |
+ ThreadAccess.GetContext |
+ ThreadAccess.SetContext |
+ ThreadAccess.QueryInformation,
+ false,
+ threadId);
+
+ if (handle.IsInvalid)
+ continue;
+
+ yield return new RemoteThread(_memory, threadId, handle);
+ }
+ }
+
+ private int[] CollectThreadIds()
+ {
+ using SafeMemoryHandle snapshot = NativeMethods.CreateToolhelp32Snapshot(SnapshotFlags.Thread, 0);
+ if (snapshot.IsInvalid)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"CreateToolhelp32Snapshot failed: error {error}.");
+ }
+
+ var entry = new ThreadEntry32
+ {
+ dwSize = (uint)Marshal.SizeOf()
+ };
+
+ var ids = new List();
+
+ if (!NativeMethods.Thread32First(snapshot, ref entry))
+ {
+ int error = Marshal.GetLastPInvokeError();
+ if (error == 18 || error == 259) // ERROR_NO_MORE_FILES / ERROR_NO_MORE_ITEMS
+ return ids.ToArray();
+
+ throw new InvalidOperationException($"Thread32First failed: error {error}.");
+ }
+
+ do
+ {
+ if (entry.th32OwnerProcessID == (uint)_memory.ProcessId)
+ ids.Add((int)entry.th32ThreadID);
+ }
+ while (NativeMethods.Thread32Next(snapshot, ref entry));
+
+ return ids.ToArray();
+ }
+
+ ///
+ /// Returns the thread with the specified operating-system identifier if it belongs
+ /// to the target process.
+ ///
+ /// The thread does not belong to the target process.
+ public RemoteThread GetThreadById(int threadId)
+ {
+ if (threadId <= 0)
+ throw new ArgumentException("Thread ID must be positive.", nameof(threadId));
+
+ SafeMemoryHandle handle = NativeMethods.OpenThread(ThreadAccess.QueryInformation, false, threadId);
+ if (handle.IsInvalid)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"OpenThread failed for thread {threadId}: error {error}.");
+ }
+
+ try
+ {
+ var info = new ThreadBasicInformation();
+ int status = NativeMethods.NtQueryInformationThread(
+ handle,
+ 0,
+ ref info,
+ (uint)Marshal.SizeOf(),
+ out _);
+
+ if (status < 0)
+ {
+ throw new InvalidOperationException(
+ $"NtQueryInformationThread failed for thread {threadId} (NTSTATUS {status:X8}).");
+ }
+
+ if ((uint)(nint)info.ClientId.UniqueProcess != (uint)_memory.ProcessId)
+ {
+ throw new InvalidOperationException(
+ $"Thread {threadId} does not belong to process {_memory.ProcessId}.");
+ }
+
+ // Open a handle with the rights the public RemoteThread surface needs.
+ return new RemoteThread(_memory, threadId);
+ }
+ finally
+ {
+ handle.Dispose();
+ }
+ }
+
+ ///
+ /// Returns the earliest-created thread of the target process.
+ ///
+ public RemoteThread MainThread
+ {
+ get
+ {
+ RemoteThread? earliest = null;
+ long earliestTime = long.MaxValue;
+
+ foreach (RemoteThread thread in Enumerate())
+ {
+ long creationTime = GetCreationTime(thread.Id);
+ if (creationTime < earliestTime)
+ {
+ earliestTime = creationTime;
+ earliest?.Dispose();
+ earliest = thread;
+ }
+ else
+ {
+ thread.Dispose();
+ }
+ }
+
+ if (earliest is null)
+ {
+ throw new InvalidOperationException(
+ $"Process {_memory.ProcessId} has no observable threads.");
+ }
+
+ return earliest;
+ }
+ }
+
+ ///
+ /// Suspends the supplied threads and returns a disposable scope that resumes exactly
+ /// those threads when disposed, including when an exception escapes the guarded body.
+ ///
+ ///
+ /// Do not freeze the target's threads while executing target code through a remote
+ /// thread or main-thread pump; doing so can deadlock because the frozen thread is the
+ /// one responsible for running the code.
+ ///
+ public FrozenThread Freeze(IEnumerable threads)
+ {
+ ArgumentNullException.ThrowIfNull(threads);
+
+ var suspended = new List();
+ try
+ {
+ foreach (RemoteThread thread in threads)
+ {
+ thread.Suspend();
+ suspended.Add(thread);
+ }
+
+ return new FrozenThread(suspended);
+ }
+ catch
+ {
+ foreach (RemoteThread thread in suspended)
+ {
+ try
+ {
+ thread.Resume();
+ }
+ catch
+ {
+ // Best-effort unwind.
+ }
+ }
+
+ throw;
+ }
+ }
+
+ ///
+ /// Suspends all threads selected by .
+ ///
+ public FrozenThread Freeze(Func predicate)
+ {
+ ArgumentNullException.ThrowIfNull(predicate);
+ return Freeze(Enumerate().Where(predicate));
+ }
+
+ private long GetCreationTime(int threadId)
+ {
+ using SafeMemoryHandle handle = NativeMethods.OpenThread(ThreadAccess.QueryInformation, false, threadId);
+ if (handle.IsInvalid)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"OpenThread failed for thread {threadId}: error {error}.");
+ }
+
+ if (!NativeMethods.GetThreadTimes(handle, out long creationTime, out _, out _, out _))
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"GetThreadTimes failed for thread {threadId}: error {error}.");
+ }
+
+ return creationTime;
+ }
+}
diff --git a/WhiteMagicTest/Thread/FrozenThreadTests.cs b/WhiteMagicTest/Thread/FrozenThreadTests.cs
new file mode 100644
index 0000000..9490df1
--- /dev/null
+++ b/WhiteMagicTest/Thread/FrozenThreadTests.cs
@@ -0,0 +1,192 @@
+using System.Linq;
+using System.Threading;
+using SysThread = System.Threading.Thread;
+using WhiteMagic;
+using WhiteMagic.Native;
+using WhiteMagic.Thread;
+using Xunit;
+
+namespace WhiteMagicTest.Thread;
+
+///
+/// Tests for scoped thread freeze via and .
+///
+public sealed class FrozenThreadTests
+{
+ [Fact]
+ public void Freeze_suspends_selected_workers_until_disposed()
+ {
+ using var magic = Magic.OpenInProcess();
+ var factory = new ThreadFactory(magic.Memory);
+
+ using var cts1 = new CancellationTokenSource();
+ using var cts2 = new CancellationTokenSource();
+ var started1 = new ManualResetEventSlim(false);
+ var started2 = new ManualResetEventSlim(false);
+ int osThreadId1 = 0;
+ int osThreadId2 = 0;
+
+ var worker1 = new SysThread(() =>
+ {
+ osThreadId1 = (int)NativeMethods.GetCurrentThreadId();
+ started1.Set();
+ while (!cts1.IsCancellationRequested)
+ SysThread.Sleep(10);
+ });
+
+ var worker2 = new SysThread(() =>
+ {
+ osThreadId2 = (int)NativeMethods.GetCurrentThreadId();
+ started2.Set();
+ while (!cts2.IsCancellationRequested)
+ SysThread.Sleep(10);
+ });
+
+ worker1.Start();
+ worker2.Start();
+ started1.Wait();
+ started2.Wait();
+
+ int[] targetIds = [osThreadId1, osThreadId2];
+
+ try
+ {
+ var selected = factory.Enumerate().Where(t => targetIds.Contains(t.Id)).ToList();
+ Assert.Equal(2, selected.Count);
+
+ using (factory.Freeze(selected))
+ {
+ cts1.Cancel();
+ cts2.Cancel();
+
+ Assert.False(worker1.Join(100));
+ Assert.False(worker2.Join(100));
+ }
+
+ Assert.True(worker1.Join(1000));
+ Assert.True(worker2.Join(1000));
+ }
+ finally
+ {
+ if (worker1.IsAlive)
+ {
+ cts1.Cancel();
+ using var t = new RemoteThread(magic.Memory, osThreadId1);
+ t.Resume();
+ worker1.Join(1000);
+ }
+
+ if (worker2.IsAlive)
+ {
+ cts2.Cancel();
+ using var t = new RemoteThread(magic.Memory, osThreadId2);
+ t.Resume();
+ worker2.Join(1000);
+ }
+ }
+ }
+
+ [Fact]
+ public void Dispose_resumes_only_frozen_threads_leaving_external_suspends_intact()
+ {
+ using var magic = Magic.OpenInProcess();
+ var factory = new ThreadFactory(magic.Memory);
+
+ using var cts = new CancellationTokenSource();
+ var started = new ManualResetEventSlim(false);
+ int osThreadId = 0;
+
+ var worker = new SysThread(() =>
+ {
+ osThreadId = (int)NativeMethods.GetCurrentThreadId();
+ started.Set();
+ while (!cts.IsCancellationRequested)
+ SysThread.Sleep(10);
+ });
+
+ worker.Start();
+ started.Wait();
+
+ try
+ {
+ // Suspend the worker externally first.
+ using (var external = new RemoteThread(magic.Memory, osThreadId))
+ {
+ external.Suspend();
+
+ var selected = factory.Enumerate().Where(t => t.Id == osThreadId).ToList();
+ using (factory.Freeze(selected))
+ {
+ // Frozen scope adds one more suspend count.
+ }
+
+ // After the freeze scope disposes, the worker was resumed once.
+ // Because it was already externally suspended, it should still be suspended.
+ cts.Cancel();
+ Assert.False(worker.Join(100));
+
+ external.Resume();
+ }
+
+ Assert.True(worker.Join(1000));
+ }
+ finally
+ {
+ if (worker.IsAlive)
+ {
+ cts.Cancel();
+ using var t = new RemoteThread(magic.Memory, osThreadId);
+ t.Resume();
+ worker.Join(1000);
+ }
+ }
+ }
+
+ [Fact]
+ public void Exception_in_body_still_resumes_frozen_threads()
+ {
+ using var magic = Magic.OpenInProcess();
+ var factory = new ThreadFactory(magic.Memory);
+
+ using var cts = new CancellationTokenSource();
+ var started = new ManualResetEventSlim(false);
+ int osThreadId = 0;
+
+ var worker = new SysThread(() =>
+ {
+ osThreadId = (int)NativeMethods.GetCurrentThreadId();
+ started.Set();
+ while (!cts.IsCancellationRequested)
+ SysThread.Sleep(10);
+ });
+
+ worker.Start();
+ started.Wait();
+
+ try
+ {
+ var selected = factory.Enumerate().Where(t => t.Id == osThreadId).ToList();
+
+ Assert.Throws(new Action(() =>
+ {
+ using (factory.Freeze(selected))
+ {
+ throw new InvalidOperationException("Intentional failure inside freeze scope.");
+ }
+ }));
+
+ cts.Cancel();
+ Assert.True(worker.Join(1000));
+ }
+ finally
+ {
+ if (worker.IsAlive)
+ {
+ cts.Cancel();
+ using var t = new RemoteThread(magic.Memory, osThreadId);
+ t.Resume();
+ worker.Join(1000);
+ }
+ }
+ }
+}
diff --git a/WhiteMagicTest/Thread/RemoteThreadContextTests.cs b/WhiteMagicTest/Thread/RemoteThreadContextTests.cs
new file mode 100644
index 0000000..aaf8545
--- /dev/null
+++ b/WhiteMagicTest/Thread/RemoteThreadContextTests.cs
@@ -0,0 +1,131 @@
+using System.Threading;
+using Thread = System.Threading.Thread;
+using WhiteMagic;
+using WhiteMagic.Native;
+using WhiteMagic.Thread;
+using Xunit;
+
+namespace WhiteMagicTest.Thread;
+
+///
+/// Tests for / .
+/// 32-bit/WOW64 context is tested on a 32-bit host run.
+///
+public sealed class RemoteThreadContextTests
+{
+ [Fact]
+ public void GetContext64_SetContext64_round_trip_on_suspended_self_thread()
+ {
+ if (!Environment.Is64BitProcess)
+ return;
+
+ using var magic = Magic.OpenInProcess();
+ using var cts = new CancellationTokenSource();
+ var started = new ManualResetEventSlim(false);
+ int osThreadId = 0;
+
+ var worker = new System.Threading.Thread(() =>
+ {
+ osThreadId = (int)NativeMethods.GetCurrentThreadId();
+ started.Set();
+ while (!cts.IsCancellationRequested)
+ System.Threading.Thread.Sleep(10);
+ });
+
+ worker.Start();
+ started.Wait();
+
+ try
+ {
+ using var thread = new RemoteThread(magic.Memory, osThreadId);
+ thread.Suspend();
+ System.Threading.Thread.Sleep(100);
+
+ thread.GetContext64(out Context64 context);
+ Assert.NotEqual(0uL, context.Rip);
+
+ const ulong sentinel = 0x123456789ABCDEF0uL;
+ ulong originalRax = context.Rax;
+ context.Rax = sentinel;
+ thread.SetContext64(ref context);
+
+ thread.GetContext64(out context);
+ Assert.Equal(sentinel, context.Rax);
+
+ // Restore the original register before resuming so the worker keeps running.
+ context.Rax = originalRax;
+ thread.SetContext64(ref context);
+
+ thread.Resume();
+ cts.Cancel();
+ Assert.True(worker.Join(1000));
+ }
+ finally
+ {
+ if (worker.IsAlive)
+ {
+ cts.Cancel();
+ using var thread = new RemoteThread(magic.Memory, osThreadId);
+ thread.Resume();
+ worker.Join(1000);
+ }
+ }
+ }
+
+ [Fact]
+ public void GetContext32_SetContext32_round_trip_on_suspended_self_thread()
+ {
+ if (Environment.Is64BitProcess)
+ return;
+
+ using var magic = Magic.OpenInProcess();
+ using var cts = new CancellationTokenSource();
+ var started = new ManualResetEventSlim(false);
+ int osThreadId = 0;
+
+ var worker = new System.Threading.Thread(() =>
+ {
+ osThreadId = (int)NativeMethods.GetCurrentThreadId();
+ started.Set();
+ while (!cts.IsCancellationRequested)
+ System.Threading.Thread.Sleep(10);
+ });
+
+ worker.Start();
+ started.Wait();
+
+ try
+ {
+ using var thread = new RemoteThread(magic.Memory, osThreadId);
+ thread.Suspend();
+
+ thread.GetContext32(out Context32 context);
+ Assert.NotEqual(0u, context.Eip);
+
+ const uint sentinel = 0x89ABCDEFu;
+ uint originalEax = context.Eax;
+ context.Eax = sentinel;
+ thread.SetContext32(ref context);
+
+ thread.GetContext32(out context);
+ Assert.Equal(sentinel, context.Eax);
+
+ context.Eax = originalEax;
+ thread.SetContext32(ref context);
+
+ thread.Resume();
+ cts.Cancel();
+ Assert.True(worker.Join(1000));
+ }
+ finally
+ {
+ if (worker.IsAlive)
+ {
+ cts.Cancel();
+ using var thread = new RemoteThread(magic.Memory, osThreadId);
+ thread.Resume();
+ worker.Join(1000);
+ }
+ }
+ }
+}
diff --git a/WhiteMagicTest/Thread/RemoteThreadTests.cs b/WhiteMagicTest/Thread/RemoteThreadTests.cs
new file mode 100644
index 0000000..db14435
--- /dev/null
+++ b/WhiteMagicTest/Thread/RemoteThreadTests.cs
@@ -0,0 +1,130 @@
+using System.Threading;
+using Thread = System.Threading.Thread;
+using WhiteMagic;
+using WhiteMagic.Native;
+using WhiteMagic.Thread;
+using Xunit;
+
+namespace WhiteMagicTest.Thread;
+
+///
+/// Tests for open/suspend/resume and context round-trip.
+///
+public sealed class RemoteThreadTests
+{
+ [Fact]
+ public void Open_by_id_succeeds_for_current_thread()
+ {
+ using var magic = Magic.OpenInProcess();
+ int currentId = (int)NativeMethods.GetCurrentThreadId();
+
+ using var thread = new RemoteThread(magic.Memory, currentId);
+ Assert.Equal(currentId, thread.Id);
+ }
+
+ [Fact]
+ public void Suspend_returns_prior_count_and_stops_worker()
+ {
+ using var magic = Magic.OpenInProcess();
+ using var cts = new CancellationTokenSource();
+ var started = new ManualResetEventSlim(false);
+ int osThreadId = 0;
+
+ var worker = new System.Threading.Thread(() =>
+ {
+ osThreadId = (int)NativeMethods.GetCurrentThreadId();
+ started.Set();
+ while (!cts.IsCancellationRequested)
+ System.Threading.Thread.Sleep(10);
+ });
+
+ worker.Start();
+ started.Wait();
+
+ try
+ {
+ using var thread = new RemoteThread(magic.Memory, osThreadId);
+
+ uint prior = thread.Suspend();
+ Assert.True(prior < 0xFFFFFFFF);
+
+ cts.Cancel();
+ // Worker cannot observe cancellation while suspended.
+ Assert.False(worker.Join(100));
+
+ thread.Resume();
+ Assert.True(worker.Join(1000));
+ }
+ finally
+ {
+ if (worker.IsAlive)
+ {
+ cts.Cancel();
+ using var thread = new RemoteThread(magic.Memory, osThreadId);
+ thread.Resume();
+ worker.Join(1000);
+ }
+ }
+ }
+
+ [Fact]
+ public void Resume_restarts_a_suspended_worker()
+ {
+ using var magic = Magic.OpenInProcess();
+ using var cts = new CancellationTokenSource();
+ var started = new ManualResetEventSlim(false);
+ var resumed = new ManualResetEventSlim(false);
+ int osThreadId = 0;
+
+ var worker = new System.Threading.Thread(() =>
+ {
+ osThreadId = (int)NativeMethods.GetCurrentThreadId();
+ started.Set();
+ while (!cts.IsCancellationRequested)
+ {
+ resumed.Set();
+ System.Threading.Thread.Sleep(10);
+ }
+ });
+
+ worker.Start();
+ started.Wait();
+
+ try
+ {
+ using var thread = new RemoteThread(magic.Memory, osThreadId);
+ thread.Suspend();
+ resumed.Reset();
+
+ uint prior = thread.Resume();
+ Assert.True(prior < 0xFFFFFFFF);
+
+ // Worker must reach the resumed flag again.
+ Assert.True(resumed.Wait(1000));
+ cts.Cancel();
+ Assert.True(worker.Join(1000));
+ }
+ finally
+ {
+ if (worker.IsAlive)
+ {
+ cts.Cancel();
+ using var thread = new RemoteThread(magic.Memory, osThreadId);
+ thread.Resume();
+ worker.Join(1000);
+ }
+ }
+ }
+
+ [Fact]
+ public void GetTeb_returns_managed_teb_for_thread()
+ {
+ using var magic = Magic.OpenInProcess();
+ int currentId = (int)NativeMethods.GetCurrentThreadId();
+
+ using var thread = new RemoteThread(magic.Memory, currentId);
+ using var teb = thread.GetTeb();
+
+ Assert.NotEqual(IntPtr.Zero, teb.ReadTebAddress());
+ }
+}
diff --git a/WhiteMagicTest/Thread/ThreadFactoryTests.cs b/WhiteMagicTest/Thread/ThreadFactoryTests.cs
new file mode 100644
index 0000000..90d3790
--- /dev/null
+++ b/WhiteMagicTest/Thread/ThreadFactoryTests.cs
@@ -0,0 +1,61 @@
+using System.Linq;
+using System.Threading;
+using SysThread = System.Threading.Thread;
+using WhiteMagic;
+using WhiteMagic.Native;
+using WhiteMagic.Thread;
+using Xunit;
+
+namespace WhiteMagicTest.Thread;
+
+///
+/// Tests for enumeration and main-thread selection.
+///
+public sealed class ThreadFactoryTests
+{
+ [Fact]
+ public void Enumerate_returns_only_target_threads()
+ {
+ using var magic = Magic.OpenInProcess();
+ var factory = new ThreadFactory(magic.Memory);
+
+ int currentOsId = (int)NativeMethods.GetCurrentThreadId();
+ var ids = factory.Enumerate().Select(t => t.Id).ToList();
+
+ Assert.True(ids.Count > 0);
+ Assert.Contains(currentOsId, ids);
+ }
+
+ [Fact]
+ public void GetThreadById_returns_matching_thread()
+ {
+ using var magic = Magic.OpenInProcess();
+ var factory = new ThreadFactory(magic.Memory);
+
+ int currentOsId = (int)NativeMethods.GetCurrentThreadId();
+ using RemoteThread thread = factory.GetThreadById(currentOsId);
+ Assert.Equal(currentOsId, thread.Id);
+ }
+
+ [Fact]
+ public void GetThreadById_throws_for_nonexistent_thread()
+ {
+ using var magic = Magic.OpenInProcess();
+ var factory = new ThreadFactory(magic.Memory);
+
+ Assert.Throws(() => factory.GetThreadById(0x7FFFFFFF));
+ }
+
+ [Fact]
+ public void MainThread_returns_a_thread_belonging_to_the_target()
+ {
+ using var magic = Magic.OpenInProcess();
+ var factory = new ThreadFactory(magic.Memory);
+
+ using RemoteThread main = factory.MainThread;
+ Assert.NotNull(main);
+
+ var ids = factory.Enumerate().Select(t => t.Id).ToList();
+ Assert.Contains(main.Id, ids);
+ }
+}