diff --git a/CHANGELOG.md b/CHANGELOG.md index 313951c..980e823 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ Versions 0.1.0 to 0.2.1 were published under the former `CESDK` package ID befor rewrite published as `CheatEngine.SDK`; it is neither source nor binary compatible with them. Read the breaking changes under [1.0.0](#100---2026-09-20) before upgrading. +## [Unreleased] + +### Fixed + +- Lua operation admission rejects a host-provided state with a negative stack top and checks that runtime stamp + verification preserves that top. Refusal releases the operation admission without resetting or retrying the state. + These guards contain invalid provider state; they do not establish the cause or resolution of a live host failure. + ## [2.0.0] - 2026-09-24 Every pack is validated against the published 1.0.0 package, and each intentional break is declared in diff --git a/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs b/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs index acaf947..28eaa50 100644 --- a/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs +++ b/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs @@ -890,6 +890,26 @@ internal static void ExitOperation() } } + // Numeric checks are separate from native state access so invalid observations can be tested without + // fabricating a dangling or corrupted lua_State. Admission cleanup remains with the caller's catch path. + internal static void ValidateProviderStackTop(int providerTop) + { + if (providerTop < 0) + { + throw new InvalidOperationException(FormattableString.Invariant( + $"The host returned a Lua state with a negative stack top ({providerTop}).")); + } + } + + internal static void ValidateVerifiedStackTop(int providerTop, int verifiedTop) + { + if (verifiedTop != providerTop) + { + throw new InvalidOperationException(FormattableString.Invariant( + $"The Lua stack changed during the host-state check (before {providerTop}, after {verifiedTop}).")); + } + } + // Evaluates the 2.0 admission policy under SOperationGate, entirely before services.Provider() ever runs on a // refused thread (pitfall 2): detached, external reset, transitioning and thread-not-admitted are all decided // without creating a coroutine. Only then is the provider called and the universe stamp verified. @@ -919,7 +939,14 @@ private static LuaAdmissionStatus TryEnterProviderOperationWithOutcome(out LuaSt } LuaState acquired = new(l); - if (!TryStampOrVerify(acquired)) + int providerTop = acquired.Top; + ValidateProviderStackTop(providerTop); + + bool verified = TryStampOrVerify(acquired); + int verifiedTop = acquired.Top; + ValidateVerifiedStackTop(providerTop, verifiedTop); + + if (!verified) { ExitOperation(); state = default; diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Support/FakeExports.cs b/tests/CheatEngine.SDK.Hosting.Tests/Support/FakeExports.cs index 17527a2..54be848 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Support/FakeExports.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Support/FakeExports.cs @@ -28,6 +28,11 @@ internal static unsafe class FakeExports private static byte s_checkSynchronizeRawResult = 1; private static Action? s_checkSynchronizeHandlerForTests; + internal static Func? StateProviderForTests + { + get; set; + } + public static int GetLuaStateCalls => Volatile.Read(ref s_getLuaStateCalls); public static int ProcessMessagesCalls => Volatile.Read(ref s_processMessagesCalls); @@ -84,6 +89,7 @@ public static void Reset() s_lastPushedObject = 0; s_checkSynchronizeRawResult = 1; s_checkSynchronizeHandlerForTests = null; + StateProviderForTests = null; } /// Builds the record the host would pass, with every slot pointing at a double. @@ -106,7 +112,7 @@ public static ManagedExportedFunctions Create(int reportedSize = 0, bool withPus private static void* GetLuaState() { Interlocked.Increment(ref s_getLuaStateCalls); - return s_state; + return (void*) (StateProviderForTests?.Invoke() ?? (nint) s_state); } [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Threading/CeCoroutineDispatchTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Threading/CeCoroutineDispatchTests.cs new file mode 100644 index 0000000..3ab80c5 --- /dev/null +++ b/tests/CheatEngine.SDK.Hosting.Tests/Threading/CeCoroutineDispatchTests.cs @@ -0,0 +1,296 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Hosting.Tests.Support; +using CheatEngine.SDK.Hosting.Threading; +using CheatEngine.SDK.Lua.Callbacks; +using CheatEngine.SDK.Lua.Interop.Api; +using CheatEngine.SDK.Lua.Interop.Types; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Hosting.Tests.Threading; + +/// Models CE's rooted thread states and re-entry of the waiting worker coroutine. +public sealed unsafe class CeCoroutineDispatchTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + [Trait("Category", "NativeLua")] + public void Repeated_dispatch_reenters_the_worker_coroutine_and_restores_both_stacks(bool throwInWork) + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState root = new(); + using ThreadStates states = new(root); + using HostSimulator host = new(); + HostingTest.Enable(host, root); + FakeExports.StateProviderForTests = states.GetState; + nint mainState = states.GetState(); + int mainThread = Environment.CurrentManagedThreadId; + using SynchronizeStandIn synchronize = new(new LuaState(mainState)); + const int repetitions = 32; + ConcurrentQueue observations = new(); + Task worker = Task.Factory.StartNew(() => + { + nint workerState = states.GetState(); + for (int index = 0; index < repetitions && !synchronize.IsStopping; index++) + { + int before = LuaApi.lua_gettop((lua_State*) workerState); + nint callbackState = 0; + int callbackThread = 0; + Exception? failure = Record.Exception(() => MainThread.Invoke(_ => + { + callbackThread = Environment.CurrentManagedThreadId; + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + callbackState = (nint) operation.State.Pointer; + using LuaFrame frame = new(operation.State); + operation.State.PushInteger(123); + if (throwInWork) + { + throw new NotSupportedException("owned callback failure"); + } + }, 0)); + observations.Enqueue(new Observation(before, LuaApi.lua_gettop((lua_State*) workerState), + workerState, callbackState, callbackThread, failure)); + } + }, TestContext.Current.CancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default); + + try + { + for (int index = 0; index < repetitions; index++) + { + Assert.True(synchronize.PumpOne(TimeSpan.FromSeconds(5)), "Worker did not queue the expected dispatch."); + } + } + finally + { + synchronize.StopAndDrain(); + Join(worker); + } + Assert.Equal(repetitions, observations.Count); + Assert.Equal(repetitions, synchronize.PumpedCalls); + VerifyObservations(observations, mainState, mainThread, throwInWork); + Assert.Equal(0, LuaApi.lua_gettop((lua_State*) mainState)); + Assert.Equal(0, LuaApi.lua_gettop(root.L)); + } + + private static void VerifyObservations(IEnumerable observations, nint mainState, int mainThread, + bool throwInWork) + { + Assert.All(observations, observation => + { + Assert.Equal(0, observation.Before); + Assert.Equal(0, observation.After); + Assert.NotEqual(mainState, observation.WorkerState); + Assert.Equal(mainState, observation.CallbackState); + Assert.Equal(mainThread, observation.CallbackThread); + if (throwInWork) + { + Assert.Equal("owned callback failure", Assert.IsType(observation.Failure).Message); + } + else + { + Assert.Null(observation.Failure); + } + }); + } + + private static void Join(Task worker) + { + // Never release Lua while a worker still owns it. The isolated test process has an external deadline; + // a broken dispatcher that cannot drain is terminated there, without disposing a live state here. + worker.GetAwaiter().GetResult(); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Queued_stop_drains_the_worker_without_running_work_or_releasing_a_live_coroutine() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState root = new(); + using ThreadStates states = new(root); + using HostSimulator host = new(); + HostingTest.Enable(host, root); + FakeExports.StateProviderForTests = states.GetState; + nint mainState = states.GetState(); + using SynchronizeStandIn synchronize = new(new LuaState(mainState)); + StrongBox workerState = new(); + StrongBox failure = new(); + int[] executions = [0]; + Task worker = Task.Factory.StartNew(() => + { + workerState.Value = states.GetState(); + failure.Value = Record.Exception(() => MainThread.Invoke(static box => box[0]++, executions)); + }, TestContext.Current.CancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default); + try + { + Assert.True(synchronize.WaitForPending(TimeSpan.FromSeconds(5))); + } + finally + { + synchronize.StopAndDrain(); + Join(worker); + } + + Assert.Contains("returned without running the dispatched work", + Assert.IsType(failure.Value).Message, StringComparison.Ordinal); + Assert.Equal(0, executions[0]); + Assert.Equal(0, synchronize.PumpedCalls); + Assert.NotEqual(0, workerState.Value); + Assert.Equal(0, LuaApi.lua_gettop((lua_State*) workerState.Value)); + Assert.Equal(0, LuaApi.lua_gettop((lua_State*) mainState)); + Assert.Equal(0, LuaApi.lua_gettop(root.L)); + } + + private sealed record Observation(int Before, int After, nint WorkerState, nint CallbackState, + int CallbackThread, Exception? Failure); + + private sealed class ThreadStates(NativeLuaState root) : IDisposable + { + private readonly Lock _gate = new(); + private readonly Dictionary _states = []; + + public nint GetState() + { + lock (_gate) + { + int thread = Environment.CurrentManagedThreadId; + if (_states.TryGetValue(thread, out (nint State, int Reference) existing)) + { + return existing.State; + } + + lua_State* coroutine = LuaApi.lua_newthread(root.L); + int reference = LuaApi.luaL_ref(root.L, LuaApi.LUA_REGISTRYINDEX); + _states.Add(thread, ((nint) coroutine, reference)); + return (nint) coroutine; + } + } + + public void Dispose() + { + foreach ((nint _, int reference) in _states.Values) + { + LuaApi.luaL_unref(root.L, LuaApi.LUA_REGISTRYINDEX, reference); + } + } + } + + // CE's public 7.5 host implementation saves the function from the worker state; TLuaCaller.synchronize + // invokes it on that same state with one nil argument and one result. The installed 7.7 contract is + // modeled here; its private implementation remains to be observed on the real host. + private sealed class SynchronizeStandIn : IDisposable + { + private static SynchronizeStandIn? _active; + private readonly AutoResetEvent _queued = new(initialState: false); + private readonly ConcurrentQueue _pending = new(); + private readonly Lock _gate = new(); + private bool _stopping; + + public SynchronizeStandIn(LuaState main) + { + _active = this; + Assert.True(main.TryPushFunction(new LuaNativeFunction(&Synchronize)).IsOk); + Assert.True(main.TrySetGlobal("synchronize"u8).IsOk); + } + + public int PumpedCalls + { + get; private set; + } + + public bool IsStopping => Volatile.Read(ref _stopping); + + public bool WaitForPending(TimeSpan timeout) + { + return _queued.WaitOne(timeout); + } + + public bool PumpOne(TimeSpan timeout) + { + if (!_queued.WaitOne(timeout) || !_pending.TryDequeue(out PendingCall? call)) + { + return false; + } + + int status = -1; + try + { + lua_State* state = (lua_State*) call.State; + LuaApi.lua_rawgeti(state, LuaApi.LUA_REGISTRYINDEX, call.Reference); + LuaApi.lua_pushnil(state); + status = LuaApi.lua_pcall(state, 1, 1, 0); + PumpedCalls++; + } + finally + { + call.Completed.SetResult(status); + } + + Assert.Equal(0, status); + return true; + } + + public void StopAndDrain() + { + lock (_gate) + { + Volatile.Write(ref _stopping, true); + while (_pending.TryDequeue(out PendingCall? call)) + { + // Cancel without touching its Lua state. Only the awakened worker releases the reference. + call.Completed.SetResult(-1); + } + } + } + + public void Dispose() + { + StopAndDrain(); + _active = null; + _queued.Dispose(); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int Synchronize(nint pointer) + { + try + { + lua_State* state = (lua_State*) pointer; + LuaApi.lua_pushvalue(state, 1); + PendingCall call = new(pointer, LuaApi.luaL_ref(state, LuaApi.LUA_REGISTRYINDEX)); + SynchronizeStandIn active = _active!; + lock (active._gate) + { + if (active.IsStopping) + { + LuaApi.luaL_unref(state, LuaApi.LUA_REGISTRYINDEX, call.Reference); + return 0; + } + active._pending.Enqueue(call); + active._queued.Set(); + } + int status = call.Completed.Task.GetAwaiter().GetResult(); + LuaApi.luaL_unref(state, LuaApi.LUA_REGISTRYINDEX, call.Reference); + return status == 0 ? 1 : 0; + } + catch + { + // Exceptions must never escape an unmanaged callback. + return 0; + } + } + + private sealed class PendingCall(nint state, int reference) + { + public nint State { get; } = state; + public int Reference { get; } = reference; + public TaskCompletionSource Completed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + } +} diff --git a/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaProviderStackGuardTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaProviderStackGuardTests.cs new file mode 100644 index 0000000..a47e3cc --- /dev/null +++ b/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaProviderStackGuardTests.cs @@ -0,0 +1,74 @@ +using System.Globalization; + +using CheatEngine.SDK.Lua.Runtime; + +namespace CheatEngine.SDK.Lua.Tests.Runtime; + +/// Validates invalid stack observations without constructing an invalid native state. +public sealed class LuaProviderStackGuardTests +{ + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(int.MaxValue)] + public void Provider_top_accepts_empty_and_nonempty_stacks(int top) + { + Assert.Null(Record.Exception(() => LuaRuntime.ValidateProviderStackTop(top))); + } + + [Theory] + [InlineData(-1)] + [InlineData(int.MinValue)] + public void Provider_top_rejects_negative_observations(int top) + { + InvalidOperationException error = Assert.Throws(() => + LuaRuntime.ValidateProviderStackTop(top)); + Assert.Equal("The host returned a Lua state with a negative stack top (" + + top.ToString(CultureInfo.InvariantCulture) + ").", error.Message); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(int.MaxValue)] + public void Stamp_check_accepts_unchanged_stack_top(int top) + { + Assert.Null(Record.Exception(() => LuaRuntime.ValidateVerifiedStackTop(top, top))); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(1, 0)] + [InlineData(1, -1)] + [InlineData(int.MaxValue, 0)] + public void Stamp_check_rejects_growth_shrinkage_and_negative_observations(int before, int after) + { + InvalidOperationException error = Assert.Throws(() => + LuaRuntime.ValidateVerifiedStackTop(before, after)); + Assert.Equal("The Lua stack changed during the host-state check (before " + + before.ToString(CultureInfo.InvariantCulture) + ", after " + + after.ToString(CultureInfo.InvariantCulture) + ").", error.Message); + } + + [Fact] + public void Rejection_diagnostics_use_invariant_numbers() + { + CultureInfo original = CultureInfo.CurrentCulture; + CultureInfo custom = (CultureInfo) CultureInfo.InvariantCulture.Clone(); + custom.NumberFormat.NegativeSign = "~"; + try + { + CultureInfo.CurrentCulture = custom; + InvalidOperationException provider = Assert.Throws(() => + LuaRuntime.ValidateProviderStackTop(-1)); + Assert.Equal("The host returned a Lua state with a negative stack top (-1).", provider.Message); + InvalidOperationException stamp = Assert.Throws(() => + LuaRuntime.ValidateVerifiedStackTop(1, -1)); + Assert.Equal("The Lua stack changed during the host-state check (before 1, after -1).", stamp.Message); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } +} diff --git a/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaProviderStackTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaProviderStackTests.cs new file mode 100644 index 0000000..bf95a89 --- /dev/null +++ b/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaProviderStackTests.cs @@ -0,0 +1,50 @@ +using CheatEngine.SDK.Lua.Interop.Api; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.Lua.Tests.Support; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Lua.Tests.Runtime; + +/// Acquiring a provider state must preserve values the caller already owns. +[Trait("Category", "NativeLua")] +public sealed unsafe class LuaProviderStackTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Acquire_preserves_existing_stack_values_for_first_stamp_and_verification(bool deferredStamp) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(openLibraries: false); + LuaRuntime.Detach(); + LuaState view = new(handle: state.Pointer); + view.PushInteger(123); + view.PushBoolean(true); + LuaHostBinding binding = HostDouble.CreateBinding(deferredStamp ? null : state.L); + LuaRuntime.Attach(binding); + HostDouble.SetStateForCurrentThread(state.Pointer); + try + { + for (int index = 0; index < 3; index++) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + Assert.Equal(state.Pointer, operation.State.Handle); + Assert.Equal(2, operation.State.Top); + Assert.True(operation.State.TryReadInteger(1, out long value)); + Assert.Equal(123, value); + Assert.True(operation.State.ToBoolean(2)); + } + + // A transition drains admissions only after all three operation leases have been released. + using (LuaRuntime.BeginStateReset()) + { + } + Assert.Equal(2, LuaApi.lua_gettop(state.L)); + } + finally + { + LuaRuntime.Detach(); + } + } +}