diff --git a/libs/CheatEngine.Client.Abstractions/Memory/IMemoryReadContext.cs b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryReadContext.cs
index ca411be..1b87aa5 100644
--- a/libs/CheatEngine.Client.Abstractions/Memory/IMemoryReadContext.cs
+++ b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryReadContext.cs
@@ -3,6 +3,11 @@
namespace CheatEngine.Client.Memory;
/// Provides the bounded raw-memory operations available to an application read codec.
+///
+/// The Client invalidates this context immediately when the codec invocation returns or throws. Codecs must not
+/// retain the context; a later member access throws
+/// .
+///
public interface IMemoryReadContext
{
/// Gets the selected target's pointer size in bytes.
diff --git a/libs/CheatEngine.Client.Abstractions/Memory/IMemoryWriteContext.cs b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryWriteContext.cs
index f7cc6ab..2c2a31d 100644
--- a/libs/CheatEngine.Client.Abstractions/Memory/IMemoryWriteContext.cs
+++ b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryWriteContext.cs
@@ -3,6 +3,11 @@
namespace CheatEngine.Client.Memory;
/// Provides the bounded raw-memory operations available to an application write codec.
+///
+/// The Client invalidates this context immediately when the codec invocation returns or throws. Codecs must not
+/// retain the context; a later member access throws
+/// .
+///
public interface IMemoryWriteContext
{
/// Gets the selected target's pointer size in bytes.
diff --git a/libs/CheatEngine.Client.Core/Domains/IMemoryCodecContextPort.cs b/libs/CheatEngine.Client.Core/Domains/IMemoryCodecContextPort.cs
new file mode 100644
index 0000000..6c0fc49
--- /dev/null
+++ b/libs/CheatEngine.Client.Core/Domains/IMemoryCodecContextPort.cs
@@ -0,0 +1,61 @@
+using CheatEngine.Client.Core.Infrastructure;
+using CheatEngine.SDK.Engine.Memory;
+using CheatEngine.SDK.Engine.Values;
+
+namespace CheatEngine.Client.Core.Domains;
+
+/// Provides the SDK-backed operations available to a scoped application memory codec.
+///
+/// The port is internal so Core tests can prove that an expired context never reaches SDK statics. It is not a
+/// replacement public memory abstraction.
+///
+internal interface IMemoryCodecContextPort
+{
+ public bool IsTarget64Bit();
+
+ public bool TryReadBytes(Address address, Span destination, out string? failure);
+
+ public bool TryWriteBytes(Address address, ReadOnlySpan source, out string? failure);
+}
+
+/// Calls the SDK memory primitives after the owning context has admitted the operation.
+internal sealed class SdkMemoryCodecContextPort : IMemoryCodecContextPort
+{
+ internal static SdkMemoryCodecContextPort Instance
+ {
+ get;
+ } = new();
+
+ private SdkMemoryCodecContextPort()
+ {
+ }
+
+ public bool IsTarget64Bit()
+ {
+ return ClientLuaGlobals.TargetIs64Bit();
+ }
+
+ public bool TryReadBytes(Address address, Span destination, out string? failure)
+ {
+ if (TargetMemory.TryReadBytes(address, destination, out MemoryAccessFailure sdkFailure))
+ {
+ failure = null;
+ return true;
+ }
+
+ failure = sdkFailure.ToString();
+ return false;
+ }
+
+ public bool TryWriteBytes(Address address, ReadOnlySpan source, out string? failure)
+ {
+ if (TargetMemory.TryWriteBytes(address, source, out MemoryAccessFailure sdkFailure))
+ {
+ failure = null;
+ return true;
+ }
+
+ failure = sdkFailure.ToString();
+ return false;
+ }
+}
diff --git a/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs b/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs
index 1318867..3cbd33e 100644
--- a/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs
+++ b/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs
@@ -14,10 +14,28 @@
namespace CheatEngine.Client.Core.Domains;
-internal sealed class MemoryClient(ICheatEngineDispatcher dispatcher) : IMemoryClient
+internal sealed class MemoryClient : IMemoryClient
{
- private readonly ICheatEngineDispatcher _dispatcher =
- dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
+ private readonly ICheatEngineDispatcher _dispatcher;
+
+ private readonly CoreLifetime _lifetime;
+
+ private readonly IMemoryCodecContextPort _codecContextPort;
+
+ internal MemoryClient(ICheatEngineDispatcher dispatcher, CoreLifetime lifetime)
+ : this(dispatcher, lifetime, SdkMemoryCodecContextPort.Instance)
+ {
+ }
+
+ internal MemoryClient(
+ ICheatEngineDispatcher dispatcher,
+ CoreLifetime lifetime,
+ IMemoryCodecContextPort codecContextPort)
+ {
+ _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
+ _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime));
+ _codecContextPort = codecContextPort ?? throw new ArgumentNullException(nameof(codecContextPort));
+ }
public bool TryReadPrimitive(Address address, [MaybeNullWhen(false)] out T value,
out CheatEngineFailure failure, CancellationToken cancellationToken = default)
@@ -440,32 +458,46 @@ private bool TryInvoke(TState state, Func call
return _dispatcher.TryInvoke(() => callback(state), out result, out failure, cancellationToken);
}
- private static bool TryReadCore(MemoryReadRequest request, [MaybeNullWhen(false)] out T value,
+ private bool TryReadCore(MemoryReadRequest request, [MaybeNullWhen(false)] out T value,
out string? failure)
{
failure = null;
- TargetMemoryCodecContext context = TargetMemoryCodecContext.Create();
- if (request.Codec.TryRead(context, request.Address, out value))
+ TargetMemoryCodecContext context = TargetMemoryCodecContext.Create(_lifetime, _dispatcher, _codecContextPort);
+ try
{
- return true;
- }
+ if (request.Codec.TryRead(context, request.Address, out value))
+ {
+ return true;
+ }
- failure = context.Failure ?? $"The codec for '{typeof(T).Name}' rejected the target-memory read.";
- return false;
+ failure = context.Failure ?? $"The codec for '{typeof(T).Name}' rejected the target-memory read.";
+ return false;
+ }
+ finally
+ {
+ context.Expire();
+ }
}
- private static bool TryWriteCore(MemoryWriteRequest request, out string? failure)
+ private bool TryWriteCore(MemoryWriteRequest request, out string? failure)
{
failure = null;
- TargetMemoryCodecContext context = TargetMemoryCodecContext.Create();
- T value = request.Value;
- if (request.Codec.TryWrite(context, request.Address, in value))
+ TargetMemoryCodecContext context = TargetMemoryCodecContext.Create(_lifetime, _dispatcher, _codecContextPort);
+ try
{
- return true;
- }
+ T value = request.Value;
+ if (request.Codec.TryWrite(context, request.Address, in value))
+ {
+ return true;
+ }
- failure = context.Failure ?? $"The codec for '{typeof(T).Name}' rejected the target-memory write.";
- return false;
+ failure = context.Failure ?? $"The codec for '{typeof(T).Name}' rejected the target-memory write.";
+ return false;
+ }
+ finally
+ {
+ context.Expire();
+ }
}
private static void ValidateBatch(ImmutableArray values, string parameterName)
@@ -706,8 +738,28 @@ private static PrimitiveWriteOutcome Write(Writer writer, Addres
private sealed class TargetMemoryCodecContext : IMemoryReadContext, IMemoryWriteContext
{
+ private const string _operation = "Memory.CodecContext";
+
+ private readonly long _activationEpoch;
+ private readonly ICheatEngineDispatcher _dispatcher;
+ private readonly CoreLifetime _lifetime;
+ private readonly IMemoryCodecContextPort _port;
+ private readonly int _threadId;
+ private int _expired;
private int _pointerSize;
+ private TargetMemoryCodecContext(
+ CoreLifetime lifetime,
+ ICheatEngineDispatcher dispatcher,
+ IMemoryCodecContextPort port)
+ {
+ _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime));
+ _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
+ _port = port ?? throw new ArgumentNullException(nameof(port));
+ _activationEpoch = lifetime.Epoch;
+ _threadId = Environment.CurrentManagedThreadId;
+ }
+
internal string? Failure
{
get;
@@ -718,12 +770,13 @@ public int PointerSize
{
get
{
+ ThrowIfUsable();
if (_pointerSize != 0)
{
return _pointerSize;
}
- int pointerSize = ClientLuaGlobals.TargetIs64Bit() ? sizeof(ulong) : sizeof(uint);
+ int pointerSize = _port.IsTarget64Bit() ? sizeof(ulong) : sizeof(uint);
_pointerSize = pointerSize;
return pointerSize;
}
@@ -731,31 +784,57 @@ public int PointerSize
public bool TryReadBytes(Address address, Span destination)
{
- if (TargetMemory.TryReadBytes(address, destination, out MemoryAccessFailure sdkFailure))
+ ThrowIfUsable();
+ if (_port.TryReadBytes(address, destination, out string? failure))
{
Failure = null;
return true;
}
- Failure = sdkFailure.ToString();
+ Failure = failure;
return false;
}
public bool TryWriteBytes(Address address, ReadOnlySpan source)
{
- if (TargetMemory.TryWriteBytes(address, source, out MemoryAccessFailure sdkFailure))
+ ThrowIfUsable();
+ if (_port.TryWriteBytes(address, source, out string? failure))
{
Failure = null;
return true;
}
- Failure = sdkFailure.ToString();
+ Failure = failure;
return false;
}
- internal static TargetMemoryCodecContext Create()
+ internal static TargetMemoryCodecContext Create(
+ CoreLifetime lifetime,
+ ICheatEngineDispatcher dispatcher,
+ IMemoryCodecContextPort port)
+ {
+ return new TargetMemoryCodecContext(lifetime, dispatcher, port);
+ }
+
+ internal void Expire()
+ {
+ Volatile.Write(ref _expired, 1);
+ }
+
+ private void ThrowIfUsable()
{
- return new TargetMemoryCodecContext();
+ if (Volatile.Read(ref _expired) != 0 ||
+ _activationEpoch != _lifetime.Epoch ||
+ !_lifetime.IsActivationCurrent ||
+ Environment.CurrentManagedThreadId != _threadId ||
+ !_dispatcher.IsMainThread)
+ {
+ throw new CheatEngineActivationExpiredException(
+ _operation,
+ "The memory codec context is no longer valid for the current Cheat Engine invocation.");
+ }
+
+ _lifetime.ThrowIfInactive(_operation);
}
}
}
diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs
index 4881b3b..e4d92aa 100644
--- a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs
+++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs
@@ -126,7 +126,9 @@ private static void AddCoreServices(IServiceCollection services)
serviceProvider.GetRequiredService());
services.TryAddSingleton(static serviceProvider =>
- new MemoryClient(serviceProvider.GetRequiredService()));
+ new MemoryClient(
+ serviceProvider.GetRequiredService(),
+ serviceProvider.GetRequiredService()));
services.TryAddSingleton(static serviceProvider =>
serviceProvider.GetRequiredService());
diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientBehaviorCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientBehaviorCoverageTests.cs
index 456f02e..6f52972 100644
--- a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientBehaviorCoverageTests.cs
+++ b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientBehaviorCoverageTests.cs
@@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using CheatEngine.Client.Core.Domains;
+using CheatEngine.Client.Core.Tests.TestSupport;
using CheatEngine.Client.Dispatching;
using CheatEngine.Client.Memory;
using CheatEngine.Client.Results;
@@ -18,7 +19,7 @@ public void TypedReadForwardsCancellationAndReturnsTheCodecValue()
CancellationToken cancellationToken = TestContext.Current.CancellationToken;
RecordingDispatcher dispatcher = new();
ProbeCodec codec = new() { ReadValue = 1337 };
- MemoryClient client = new(dispatcher);
+ MemoryClient client = new(dispatcher, InertCoreLifetime.Create());
bool succeeded = client.TryRead(new MemoryReadRequest(_address, codec), out int value,
out CheatEngineFailure failure, cancellationToken);
@@ -39,7 +40,7 @@ public void TypedWriteForwardsCancellationAndPreservesTheValueForTheCodec()
CancellationToken cancellationToken = TestContext.Current.CancellationToken;
RecordingDispatcher dispatcher = new();
ProbeCodec codec = new();
- MemoryClient client = new(dispatcher);
+ MemoryClient client = new(dispatcher, InertCoreLifetime.Create());
bool succeeded = client.TryWrite(new MemoryWriteRequest(_address, 42, codec),
out CheatEngineFailure failure, cancellationToken);
@@ -58,7 +59,7 @@ public void TypedWriteForwardsCancellationAndPreservesTheValueForTheCodec()
public void FailedCodecReadUsesTheFallbackFailureAndTheConvenienceMethodThrowsIt()
{
ProbeCodec codec = new() { ReadSucceeds = false };
- MemoryClient client = new(new RecordingDispatcher());
+ MemoryClient client = new(new RecordingDispatcher(), InertCoreLifetime.Create());
MemoryReadRequest request = new(_address, codec);
bool succeeded = client.TryRead(request, out int value, out CheatEngineFailure failure,
@@ -79,7 +80,7 @@ public void FailedCodecReadUsesTheFallbackFailureAndTheConvenienceMethodThrowsIt
public void FailedCodecWriteUsesTheFallbackFailureAndTheConvenienceMethodThrowsIt()
{
ProbeCodec codec = new() { WriteSucceeds = false };
- MemoryClient client = new(new RecordingDispatcher());
+ MemoryClient client = new(new RecordingDispatcher(), InertCoreLifetime.Create());
MemoryWriteRequest request = new(_address, 77, codec);
bool succeeded = client.TryWrite(request, out CheatEngineFailure failure,
@@ -104,7 +105,7 @@ public void CancelledDispatchPreventsCodecExecutionAndPreservesTheCancellationFa
CancellationToken cancellationToken = cancellation.Token;
CancellationAwareDispatcher dispatcher = new();
ProbeCodec codec = new();
- MemoryClient client = new(dispatcher);
+ MemoryClient client = new(dispatcher, InertCoreLifetime.Create());
MemoryReadRequest readRequest = new(_address, codec);
MemoryWriteRequest writeRequest = new(_address, 9, codec);
@@ -128,7 +129,8 @@ public void CancelledDispatchPreventsCodecExecutionAndPreservesTheCancellationFa
[Fact]
public void ConstructorRejectsANullDispatcher()
{
- Assert.Throws(() => new MemoryClient(null!));
+ Assert.Throws(() => new MemoryClient(null!, InertCoreLifetime.Create()));
+ Assert.Throws(() => new MemoryClient(new RecordingDispatcher(), null!));
}
private sealed class ProbeCodec : IMemoryCodec
diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientDispatchFailureTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientDispatchFailureTests.cs
index 534a84e..649d540 100644
--- a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientDispatchFailureTests.cs
+++ b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientDispatchFailureTests.cs
@@ -2,6 +2,7 @@
using System.Diagnostics.CodeAnalysis;
using CheatEngine.Client.Core.Domains;
+using CheatEngine.Client.Core.Tests.TestSupport;
using CheatEngine.Client.Dispatching;
using CheatEngine.Client.Memory;
using CheatEngine.Client.Results;
@@ -17,7 +18,7 @@ public sealed class MemoryClientDispatchFailureTests
public void PrimitiveAndCodecReadsPreserveTheDispatcherFailureWithoutExecutingTargetMemory()
{
CheatEngineFailure expected = Failure("Test.Read");
- MemoryClient client = new(new RejectingDispatcher(expected));
+ MemoryClient client = new(new RejectingDispatcher(expected), InertCoreLifetime.Create());
MemoryReadRequest request = new(Address, new NeverUsedCodec());
bool primitiveSucceeded = client.TryReadPrimitive(Address, out int primitive,
@@ -38,7 +39,7 @@ public void PrimitiveAndCodecReadsPreserveTheDispatcherFailureWithoutExecutingTa
public void PrimitiveAndCodecWritesPreserveTheDispatcherFailureWithoutExecutingTargetMemory()
{
CheatEngineFailure expected = Failure("Test.Write");
- MemoryClient client = new(new RejectingDispatcher(expected));
+ MemoryClient client = new(new RejectingDispatcher(expected), InertCoreLifetime.Create());
MemoryWriteRequest request = new(Address, 42, new NeverUsedCodec());
bool primitiveSucceeded = client.TryWritePrimitive(Address, 42, out CheatEngineFailure primitiveFailure,
@@ -56,7 +57,7 @@ public void PrimitiveAndCodecWritesPreserveTheDispatcherFailureWithoutExecutingT
public void ByteAndStringOperationsPreserveTheDispatcherFailureAndEmptyReadResults()
{
CheatEngineFailure expected = Failure("Test.Copy");
- MemoryClient client = new(new RejectingDispatcher(expected));
+ MemoryClient client = new(new RejectingDispatcher(expected), InertCoreLifetime.Create());
MemoryBytesReadRequest byteRead = new(Address, 2);
MemoryBytesWriteRequest byteWrite = new(Address, [0x10, 0x20]);
MemoryStringReadRequest stringRead = new(Address, 12, true);
@@ -83,7 +84,7 @@ public void ByteAndStringOperationsPreserveTheDispatcherFailureAndEmptyReadResul
public void PointerResolutionPreservesTheDispatcherFailureAndDefaultAddress()
{
CheatEngineFailure expected = Failure("Test.Pointer");
- MemoryClient client = new(new RejectingDispatcher(expected));
+ MemoryClient client = new(new RejectingDispatcher(expected), InertCoreLifetime.Create());
PointerChainRequest request = new(Address, [4L, 8L]);
bool succeeded = client.TryResolvePointerChain(request, out Address actual, out CheatEngineFailure failure,
@@ -98,7 +99,7 @@ public void PointerResolutionPreservesTheDispatcherFailureAndDefaultAddress()
public void PrimitiveBatchesPreserveTheDispatcherFailureWithoutAdmittingAnyTargetOperation()
{
CheatEngineFailure expected = Failure("Test.Batch");
- MemoryClient client = new(new RejectingDispatcher(expected));
+ MemoryClient client = new(new RejectingDispatcher(expected), InertCoreLifetime.Create());
MemoryPrimitiveBatchReadRequest reads = new([Address, Address + 4]);
MemoryPrimitiveBatchWriteRequest writes = new([new MemoryAddressValue(Address, 12)]);
@@ -115,7 +116,7 @@ public void PrimitiveBatchesPreserveTheDispatcherFailureWithoutAdmittingAnyTarge
[Fact]
public void DefaultPrimitiveBatchesAreRejectedBeforeDispatch()
{
- MemoryClient client = new(new RejectingDispatcher(Failure("Test.ShouldNotDispatch")));
+ MemoryClient client = new(new RejectingDispatcher(Failure("Test.ShouldNotDispatch")), InertCoreLifetime.Create());
Assert.Throws(() => client.TryReadPrimitiveBatch(default, out ImmutableArray _, out _,
TestContext.Current.CancellationToken));
@@ -127,7 +128,7 @@ public void DefaultPrimitiveBatchesAreRejectedBeforeDispatch()
[Fact]
public void UnsupportedPrimitiveTypesReturnTheSpecificUnsupportedFailureWithoutAccessingTheHost()
{
- MemoryClient client = new(new InlineDispatcher());
+ MemoryClient client = new(new InlineDispatcher(), InertCoreLifetime.Create());
bool readSucceeded = client.TryReadPrimitive(Address, out DateTime readValue,
out CheatEngineFailure readFailure, TestContext.Current.CancellationToken);
@@ -146,7 +147,7 @@ public void UnsupportedPrimitiveTypesReturnTheSpecificUnsupportedFailureWithoutA
[Fact]
public void ReadAndWriteConvenienceMethodsThrowTheClassifiedDispatcherFailure()
{
- MemoryClient client = new(new RejectingDispatcher(Failure("Test.Convenience")));
+ MemoryClient client = new(new RejectingDispatcher(Failure("Test.Convenience")), InertCoreLifetime.Create());
MemoryReadRequest read = new(Address, new NeverUsedCodec());
MemoryWriteRequest write = new(Address, 42, new NeverUsedCodec());
@@ -169,7 +170,7 @@ public void ReadAndWriteConvenienceMethodsThrowTheClassifiedDispatcherFailure()
[InlineData("pointer")]
public void InvalidDefaultRequestCannotReachTheDispatcher(string requestKind)
{
- MemoryClient client = new(new RejectingDispatcher(Failure("Test.ShouldNotDispatch")));
+ MemoryClient client = new(new RejectingDispatcher(Failure("Test.ShouldNotDispatch")), InertCoreLifetime.Create());
switch (requestKind)
{
@@ -191,7 +192,7 @@ public void InvalidDefaultRequestCannotReachTheDispatcher(string requestKind)
[Fact]
public void DefaultStringAndByteWritesAreRejectedBeforeDispatch()
{
- MemoryClient client = new(new RejectingDispatcher(Failure("Test.ShouldNotDispatch")));
+ MemoryClient client = new(new RejectingDispatcher(Failure("Test.ShouldNotDispatch")), InertCoreLifetime.Create());
Assert.Throws(() =>
client.TryWriteBytes(default, out _, TestContext.Current.CancellationToken));
diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientTests.cs
index 216d00c..0f3cef6 100644
--- a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientTests.cs
+++ b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientTests.cs
@@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using CheatEngine.Client.Core.Domains;
+using CheatEngine.Client.Core.Tests.TestSupport;
using CheatEngine.Client.Dispatching;
using CheatEngine.Client.Memory;
using CheatEngine.Client.Results;
@@ -14,7 +15,7 @@ public sealed class MemoryClientTests
public void TypedPrimitiveReadUsesTheCallerSuppliedCodec()
{
RecordingInt32Codec codec = new() { ReadValue = 1234 };
- MemoryClient client = new(new InlineDispatcher());
+ MemoryClient client = new(new InlineDispatcher(), InertCoreLifetime.Create());
Address address = 0x401000;
bool succeeded = client.TryRead(new MemoryReadRequest(address, codec), out int value,
@@ -32,7 +33,7 @@ public void TypedPrimitiveReadUsesTheCallerSuppliedCodec()
public void TypedPrimitiveWriteUsesTheCallerSuppliedCodec()
{
RecordingInt32Codec codec = new();
- MemoryClient client = new(new InlineDispatcher());
+ MemoryClient client = new(new InlineDispatcher(), InertCoreLifetime.Create());
Address address = 0x402000;
bool succeeded = client.TryWrite(new MemoryWriteRequest(address, 77, codec),
@@ -50,7 +51,7 @@ public void TypedPrimitiveWriteUsesTheCallerSuppliedCodec()
public void TypedCodecFailureBecomesAClassifiedMemoryReadFailure()
{
RecordingInt32Codec codec = new() { ReadSucceeds = false };
- MemoryClient client = new(new InlineDispatcher());
+ MemoryClient client = new(new InlineDispatcher(), InertCoreLifetime.Create());
bool succeeded = client.TryRead(new MemoryReadRequest(0x403000, codec), out int value,
out CheatEngineFailure failure,
diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryCodecContextLifetimeTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryCodecContextLifetimeTests.cs
new file mode 100644
index 0000000..d89d97b
--- /dev/null
+++ b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryCodecContextLifetimeTests.cs
@@ -0,0 +1,459 @@
+using System.Diagnostics.CodeAnalysis;
+
+using CheatEngine.Client.Core.Domains;
+using CheatEngine.Client.Core.Infrastructure;
+using CheatEngine.Client.Core.Tests.TestSupport;
+using CheatEngine.Client.Dispatching;
+using CheatEngine.Client.Memory;
+using CheatEngine.Client.Results;
+using CheatEngine.SDK.Engine.Values;
+
+namespace CheatEngine.Client.Core.Tests.Domains;
+
+public sealed class MemoryCodecContextLifetimeTests
+{
+ private static readonly Address _address = new(0x405000);
+
+ [Fact]
+ public void ReadContextAllowsPointerMetadataAndBytesOnlyDuringCodecInvocation()
+ {
+ using ControlledCoreLifetimeContext activation = new();
+ using CoreLifetime lifetime = new(activation);
+ RecordingCodecContextPort port = new() { PointerSize = sizeof(ulong) };
+ MemoryClient client = CreateClient(lifetime, port);
+ CapturingCodec codec = new()
+ {
+ ReadAction = static context =>
+ {
+ Assert.Equal(sizeof(ulong), context.PointerSize);
+ Assert.Equal(sizeof(ulong), context.PointerSize);
+ byte[] buffer = new byte[sizeof(int)];
+ Assert.True(context.TryReadBytes(_address, buffer));
+ }
+ };
+
+ bool succeeded = client.TryRead(new MemoryReadRequest(_address, codec), out int value,
+ out CheatEngineFailure failure, TestContext.Current.CancellationToken);
+
+ Assert.True(succeeded);
+ Assert.Equal(123, value);
+ Assert.Equal(default, failure);
+ Assert.Equal(1, port.PointerSizeReadCount);
+ Assert.Equal(1, port.ReadBytesCallCount);
+ }
+
+ [Fact]
+ public void WriteContextAllowsPointerMetadataAndBytesOnlyDuringCodecInvocation()
+ {
+ using ControlledCoreLifetimeContext activation = new();
+ using CoreLifetime lifetime = new(activation);
+ RecordingCodecContextPort port = new() { PointerSize = sizeof(uint) };
+ MemoryClient client = CreateClient(lifetime, port);
+ CapturingCodec codec = new()
+ {
+ WriteAction = static context =>
+ {
+ Assert.Equal(sizeof(uint), context.PointerSize);
+ Assert.Equal(sizeof(uint), context.PointerSize);
+ Assert.True(context.TryWriteBytes(_address, [0x0A, 0x0B]));
+ }
+ };
+
+ bool succeeded = client.TryWrite(new MemoryWriteRequest(_address, 456, codec),
+ out CheatEngineFailure failure, TestContext.Current.CancellationToken);
+
+ Assert.True(succeeded);
+ Assert.Equal(default, failure);
+ Assert.Equal(1, port.PointerSizeReadCount);
+ Assert.Equal(1, port.WriteBytesCallCount);
+ Assert.Equal(456, codec.LastWriteValue);
+ }
+
+ [Fact]
+ public void RetainedContextsExpireAfterSuccessfulReturnBeforeAnyPortCall()
+ {
+ using ControlledCoreLifetimeContext activation = new();
+ using CoreLifetime lifetime = new(activation);
+ RecordingCodecContextPort port = new();
+ MemoryClient client = CreateClient(lifetime, port);
+ CapturingCodec codec = new();
+
+ Assert.True(client.TryRead(new MemoryReadRequest(_address, codec), out _, out _,
+ TestContext.Current.CancellationToken));
+ Assert.True(client.TryWrite(new MemoryWriteRequest(_address, 456, codec), out _,
+ TestContext.Current.CancellationToken));
+
+ IMemoryReadContext readContext = Assert.IsAssignableFrom(codec.ReadContext);
+ IMemoryWriteContext writeContext = Assert.IsAssignableFrom(codec.WriteContext);
+ AssertExpired(() => _ = readContext.PointerSize);
+ AssertExpired(() => readContext.TryReadBytes(_address, new byte[1]));
+ AssertExpired(() => _ = writeContext.PointerSize);
+ AssertExpired(() => writeContext.TryWriteBytes(_address, [0x0A]));
+ Assert.Equal(0, port.TotalCallCount);
+ }
+
+ [Fact]
+ public void FailedCodecsExpireCapturedContextsAndPreserveMemoryFailures()
+ {
+ using ControlledCoreLifetimeContext activation = new();
+ using CoreLifetime lifetime = new(activation);
+ RecordingCodecContextPort port = new();
+ MemoryClient client = CreateClient(lifetime, port);
+ CapturingCodec codec = new() { ReadSucceeds = false, WriteSucceeds = false };
+
+ bool readSucceeded = client.TryRead(new MemoryReadRequest(_address, codec), out int readValue,
+ out CheatEngineFailure readFailure, TestContext.Current.CancellationToken);
+ bool writeSucceeded = client.TryWrite(new MemoryWriteRequest(_address, 456, codec),
+ out CheatEngineFailure writeFailure, TestContext.Current.CancellationToken);
+
+ Assert.False(readSucceeded);
+ Assert.Equal(default, readValue);
+ Assert.Equal(CheatEngineFailureKind.MemoryReadFailed, readFailure.Kind);
+ Assert.Equal("Memory.Read", readFailure.Operation);
+ Assert.False(writeSucceeded);
+ Assert.Equal(CheatEngineFailureKind.MemoryWriteFailed, writeFailure.Kind);
+ Assert.Equal("Memory.Write", writeFailure.Operation);
+ AssertExpired(() => _ = codec.ReadContext!.PointerSize);
+ AssertExpired(() => _ = codec.WriteContext!.PointerSize);
+ Assert.Equal(0, port.TotalCallCount);
+ }
+
+ [Fact]
+ public void ThrowingCodecsExpireCapturedContextsAndRethrowOriginalExceptions()
+ {
+ using ControlledCoreLifetimeContext activation = new();
+ using CoreLifetime lifetime = new(activation);
+ RecordingCodecContextPort port = new();
+ MemoryClient client = CreateClient(lifetime, port);
+ InvalidOperationException readException = new("read failure");
+ InvalidOperationException writeException = new("write failure");
+ CapturingCodec readCodec = new() { ReadException = readException };
+ CapturingCodec writeCodec = new() { WriteException = writeException };
+
+ InvalidOperationException actualRead = Assert.Throws(() =>
+ client.TryRead(new MemoryReadRequest(_address, readCodec), out _, out _,
+ TestContext.Current.CancellationToken));
+ InvalidOperationException actualWrite = Assert.Throws(() =>
+ client.TryWrite(new MemoryWriteRequest(_address, 456, writeCodec), out _,
+ TestContext.Current.CancellationToken));
+
+ Assert.Same(readException, actualRead);
+ Assert.Same(writeException, actualWrite);
+ AssertExpired(() => _ = readCodec.ReadContext!.PointerSize);
+ AssertExpired(() => _ = writeCodec.WriteContext!.PointerSize);
+ Assert.Equal(0, port.TotalCallCount);
+ }
+
+ [Fact]
+ public void WorkerThreadCannotUseContextsWhileTheirCodecInvocationsRemainActive()
+ {
+ using ControlledCoreLifetimeContext activation = new();
+ using CoreLifetime lifetime = new(activation);
+ RecordingCodecContextPort port = new();
+ MemoryClient client = CreateClient(lifetime, port);
+ CapturingCodec codec = new()
+ {
+ ReadAction = static context => AssertWorkerReadIsRejected(context),
+ WriteAction = static context => AssertWorkerWriteIsRejected(context)
+ };
+
+ Assert.True(client.TryRead(new MemoryReadRequest(_address, codec), out _, out _,
+ TestContext.Current.CancellationToken));
+ Assert.True(client.TryWrite(new MemoryWriteRequest(_address, 456, codec), out _,
+ TestContext.Current.CancellationToken));
+
+ Assert.Equal(0, port.TotalCallCount);
+ }
+
+ [Fact]
+ public void ContextFromPriorInvocationCannotBeRevivedDuringALaterInvocation()
+ {
+ using ControlledCoreLifetimeContext activation = new();
+ using CoreLifetime lifetime = new(activation);
+ RecordingCodecContextPort port = new();
+ MemoryClient client = CreateClient(lifetime, port);
+ CapturingCodec firstCodec = new();
+
+ Assert.True(client.TryRead(new MemoryReadRequest(_address, firstCodec), out _, out _,
+ TestContext.Current.CancellationToken));
+ IMemoryReadContext firstContext = Assert.IsAssignableFrom(firstCodec.ReadContext);
+ CapturingCodec secondCodec = new()
+ {
+ ReadAction = _ => AssertExpired(() => ConsumePointerSize(firstContext))
+ };
+
+ Assert.True(client.TryRead(new MemoryReadRequest(_address, secondCodec), out _, out _,
+ TestContext.Current.CancellationToken));
+ Assert.NotSame(firstContext, secondCodec.ReadContext);
+ Assert.Equal(0, port.TotalCallCount);
+ }
+
+ [Fact]
+ public void ContextRejectsActivationEpochChangesAndAReenabledClientDoesNotReviveIt()
+ {
+ using ControlledCoreLifetimeContext oldActivation = new();
+ using CoreLifetime oldLifetime = new(oldActivation);
+ RecordingCodecContextPort oldPort = new();
+ MemoryClient oldClient = CreateClient(oldLifetime, oldPort);
+ Exception? activeUseFailure = null;
+ CapturingCodec oldCodec = new()
+ {
+ ReadAction = context =>
+ {
+ oldActivation.Epoch++;
+ activeUseFailure = CaptureException(() => _ = context.PointerSize);
+ }
+ };
+
+ Assert.True(oldClient.TryRead(new MemoryReadRequest(_address, oldCodec), out _, out _,
+ TestContext.Current.CancellationToken));
+ Assert.IsType(activeUseFailure);
+ Assert.Equal(0, oldPort.TotalCallCount);
+ oldActivation.IsCurrent = false;
+
+ using ControlledCoreLifetimeContext newActivation = new();
+ using CoreLifetime newLifetime = new(newActivation);
+ RecordingCodecContextPort newPort = new();
+ MemoryClient newClient = CreateClient(newLifetime, newPort);
+ CapturingCodec newCodec = new();
+ Assert.True(newClient.TryRead(new MemoryReadRequest(_address, newCodec), out _, out _,
+ TestContext.Current.CancellationToken));
+
+ AssertExpired(() => _ = oldCodec.ReadContext!.PointerSize);
+ Assert.Equal(0, oldPort.TotalCallCount);
+ }
+
+ private static void AssertExpired(Action operation)
+ {
+ CheatEngineActivationExpiredException exception =
+ Assert.Throws(operation);
+ Assert.Equal("Memory.CodecContext", exception.Failure.Operation);
+ }
+
+ private static void ConsumePointerSize(IMemoryReadContext context)
+ {
+ _ = context.PointerSize;
+ }
+
+ private static void AssertWorkerReadIsRejected(IMemoryReadContext context)
+ {
+ Exception? pointerFailure = CaptureWorkerException(() => _ = context.PointerSize);
+ Exception? readFailure = CaptureWorkerException(() => context.TryReadBytes(_address, new byte[1]));
+
+ Assert.IsType(pointerFailure);
+ Assert.IsType(readFailure);
+ }
+
+ private static void AssertWorkerWriteIsRejected(IMemoryWriteContext context)
+ {
+ Exception? pointerFailure = CaptureWorkerException(() => _ = context.PointerSize);
+ Exception? writeFailure = CaptureWorkerException(() => context.TryWriteBytes(_address, [0x0A]));
+
+ Assert.IsType(pointerFailure);
+ Assert.IsType(writeFailure);
+ }
+
+ private static Exception? CaptureException(Action operation)
+ {
+ try
+ {
+ operation();
+ return null;
+ }
+ catch (Exception exception)
+ {
+ return exception;
+ }
+ }
+
+ private static Exception? CaptureWorkerException(Action operation)
+ {
+ Exception? captured = null;
+ Thread worker = new(() => captured = CaptureException(operation));
+ worker.Start();
+ worker.Join();
+ return captured;
+ }
+
+ private static MemoryClient CreateClient(CoreLifetime lifetime, RecordingCodecContextPort port)
+ {
+ return new MemoryClient(new ThreadBoundDispatcher(), lifetime, port);
+ }
+
+ private sealed class CapturingCodec : IMemoryCodec
+ {
+ internal Action? ReadAction
+ {
+ get;
+ init;
+ }
+
+ internal Exception? ReadException
+ {
+ get;
+ init;
+ }
+
+ internal bool ReadSucceeds
+ {
+ get;
+ init;
+ } = true;
+
+ internal IMemoryReadContext? ReadContext
+ {
+ get;
+ private set;
+ }
+
+ internal Action? WriteAction
+ {
+ get;
+ init;
+ }
+
+ internal Exception? WriteException
+ {
+ get;
+ init;
+ }
+
+ internal bool WriteSucceeds
+ {
+ get;
+ init;
+ } = true;
+
+ internal IMemoryWriteContext? WriteContext
+ {
+ get;
+ private set;
+ }
+
+ internal int LastWriteValue
+ {
+ get;
+ private set;
+ }
+
+ public bool TryRead(IMemoryReadContext context, Address address, out int value)
+ {
+ ReadContext = context;
+ ReadAction?.Invoke(context);
+ if (ReadException is not null)
+ {
+ throw ReadException;
+ }
+
+ value = 123;
+ return ReadSucceeds;
+ }
+
+ public bool TryWrite(IMemoryWriteContext context, Address address, in int value)
+ {
+ WriteContext = context;
+ WriteAction?.Invoke(context);
+ if (WriteException is not null)
+ {
+ throw WriteException;
+ }
+
+ LastWriteValue = value;
+ return WriteSucceeds;
+ }
+ }
+
+ private sealed class RecordingCodecContextPort : IMemoryCodecContextPort
+ {
+ internal int PointerSize
+ {
+ get;
+ init;
+ } = sizeof(ulong);
+
+ internal int PointerSizeReadCount
+ {
+ get;
+ private set;
+ }
+
+ internal int ReadBytesCallCount
+ {
+ get;
+ private set;
+ }
+
+ internal int TotalCallCount => PointerSizeReadCount + ReadBytesCallCount + WriteBytesCallCount;
+
+ internal int WriteBytesCallCount
+ {
+ get;
+ private set;
+ }
+
+ public bool IsTarget64Bit()
+ {
+ PointerSizeReadCount++;
+ return PointerSize == sizeof(ulong);
+ }
+
+ public bool TryReadBytes(Address address, Span destination, out string? failure)
+ {
+ ReadBytesCallCount++;
+ destination.Clear();
+ failure = null;
+ return true;
+ }
+
+ public bool TryWriteBytes(Address address, ReadOnlySpan source, out string? failure)
+ {
+ WriteBytesCallCount++;
+ failure = null;
+ return true;
+ }
+ }
+
+ private sealed class ThreadBoundDispatcher : ICheatEngineDispatcher
+ {
+ private readonly int _mainThreadId = Environment.CurrentManagedThreadId;
+
+ public bool IsMainThread => Environment.CurrentManagedThreadId == _mainThreadId;
+
+ public bool TryInvoke(Action callback, out CheatEngineFailure failure,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(callback);
+ callback();
+ failure = default;
+ return true;
+ }
+
+ public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result,
+ out CheatEngineFailure failure, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(callback);
+ result = callback();
+ failure = default;
+ return true;
+ }
+
+ public void Invoke(Action callback, CancellationToken cancellationToken = default)
+ {
+ if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken))
+ {
+ failure.Throw();
+ }
+ }
+
+ public T Invoke(Func callback, CancellationToken cancellationToken = default)
+ {
+ if (TryInvoke(callback, out T? result, out CheatEngineFailure failure, cancellationToken))
+ {
+ return result;
+ }
+
+ failure.Throw();
+ return default!;
+ }
+ }
+}
diff --git a/tests/CheatEngine.Client.Core.Tests/TestSupport/InertCoreLifetime.cs b/tests/CheatEngine.Client.Core.Tests/TestSupport/InertCoreLifetime.cs
index b7a9562..ca0b707 100644
--- a/tests/CheatEngine.Client.Core.Tests/TestSupport/InertCoreLifetime.cs
+++ b/tests/CheatEngine.Client.Core.Tests/TestSupport/InertCoreLifetime.cs
@@ -1,5 +1,3 @@
-using System.Runtime.CompilerServices;
-
using CheatEngine.Client.Core.Infrastructure;
namespace CheatEngine.Client.Core.Tests.TestSupport;
@@ -8,6 +6,17 @@ internal static class InertCoreLifetime
{
internal static CoreLifetime Create()
{
- return (CoreLifetime) RuntimeHelpers.GetUninitializedObject(typeof(CoreLifetime));
+ return new CoreLifetime(new AlwaysCurrentLifetimeContext());
+ }
+
+ private sealed class AlwaysCurrentLifetimeContext : ICoreLifetimeContext
+ {
+ public long Epoch => 1;
+
+ public bool IsCurrent => true;
+
+ public bool IsMainThread => true;
+
+ public CancellationToken Stopping => CancellationToken.None;
}
}