Add observable alloc/free seams to BackgroundTaskExecutor and fix StubAllocator ownership

- Add RemoteAllocator / RemoteReleaser internal test seams for string/struct scratch memory.
- Route all scratch allocation/freeing through the seams so tests can observe leaks.
- Fix inverted StubAllocator ownership: a caller-provided stub is now never freed by the executor.
- Rewrite Execute_releases_allocated_remote_memory_on_write_failure to fail pre-fix by tracking fake allocations through the seams.
- Update WriteFailingMemoryBase to carry a valid self-handle so the executor reaches the marshal/write path.

Tests: 198 passing, 4 integration/interactive skipped.
This commit is contained in:
kbe
2026-07-22 00:51:39 +02:00
parent 44a368de9c
commit fa0b5b5013
2 changed files with 63 additions and 20 deletions
@@ -1,4 +1,6 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices;
using WhiteMagic;
using WhiteMagic.Assembly;
@@ -156,11 +158,21 @@ public sealed class RemoteThreadExecutorTests
using var reader = new WriteFailingMemoryBase();
var executor = new RemoteThreadExecutor(reader);
// The executor will allocate a remote call stub; our reader then refuses every
// WriteBytes call. The allocation made before the failure must still be freed.
// The write failure must surface as an InvalidOperationException, not hang or crash.
var allocated = new List<IntPtr>();
var freed = new List<IntPtr>();
nint next = 0x4000_0000;
executor.RemoteAllocator = size => { var p = (IntPtr)(next += 0x1000); allocated.Add(p); return p; };
executor.RemoteReleaser = p => freed.Add(p);
// The string arg is marshalled to remote scratch FIRST, then its write fails.
// Pre-fix the scratch was tracked only AFTER the write, so it escaped the finally
// free and leaked. Post-fix every allocation is released on the failure path.
Assert.Throws<InvalidOperationException>(() =>
executor.Execute<int>(new IntPtr(0x123456789ABCDEF0L), CallConvention.Stdcall));
executor.Execute<int>(new IntPtr(0x123456789ABCDEF0L), CallConvention.Stdcall, "leakme"));
Assert.NotEmpty(allocated); // the arg scratch was allocated
Assert.Equal(allocated.OrderBy(x => x), freed.OrderBy(x => x)); // and every alloc freed
}
private static int RunPayload(byte[] payload, CallConvention convention, params object?[] args)
@@ -223,14 +235,21 @@ public sealed class RemoteThreadExecutorTests
/// <summary>
/// A fake reader whose WriteBytes always returns zero, forcing the executor down
/// the failure path after it has allocated remote memory.
/// Holds a valid handle to the current process so the executor passes its
/// handle-validity check without performing real memory operations.
/// </summary>
private sealed class WriteFailingMemoryBase : MemoryBase
{
public override IntPtr ImageBase => IntPtr.Zero;
public override SafeMemoryHandle Handle { get; } = new SafeMemoryHandle(new IntPtr(-1));
public override SafeMemoryHandle Handle { get; }
public override bool Is64Bit => Environment.Is64BitProcess;
public override int ProcessId => Environment.ProcessId;
public WriteFailingMemoryBase()
{
Handle = NativeMethods.OpenProcess(ProcessAccess.AllAccess, false, Environment.ProcessId);
}
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
=> throw new NotSupportedException();
@@ -239,6 +258,7 @@ public sealed class RemoteThreadExecutorTests
public override void Dispose()
{
Handle?.Dispose();
}
}
}