From c28f85c1c5ba8f334111e01cdc8e715771008fef Mon Sep 17 00:00:00 2001 From: AriusII Date: Mon, 21 Sep 2026 19:05:11 +0200 Subject: [PATCH] Expire memory codec contexts per invocation --- .../Memory/IMemoryReadContext.cs | 5 + .../Memory/IMemoryWriteContext.cs | 5 + .../Domains/IMemoryCodecContextPort.cs | 61 +++ .../Domains/MemoryClient.cs | 129 ++++- ...EngineClientServiceCollectionExtensions.cs | 4 +- .../MemoryClientBehaviorCoverageTests.cs | 14 +- .../MemoryClientDispatchFailureTests.cs | 21 +- .../Domains/MemoryClientTests.cs | 7 +- .../MemoryCodecContextLifetimeTests.cs | 459 ++++++++++++++++++ .../TestSupport/InertCoreLifetime.cs | 15 +- 10 files changed, 672 insertions(+), 48 deletions(-) create mode 100644 libs/CheatEngine.Client.Core/Domains/IMemoryCodecContextPort.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/MemoryCodecContextLifetimeTests.cs 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; } }