Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 28 additions & 1 deletion libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,26 @@
}
}

// 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})."));

Check warning on line 900 in libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use string.Create instead of FormattableString

See more on https://sonarcloud.io/project/issues?id=CheatEngineNet_CheatEngine.SDK&issues=AaErws7gRCRia-k9r79f&open=AaErws7gRCRia-k9r79f&pullRequest=90
}
}

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})."));

Check warning on line 909 in libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use string.Create instead of FormattableString

See more on https://sonarcloud.io/project/issues?id=CheatEngineNet_CheatEngine.SDK&issues=AaErws7gRCRia-k9r79g&open=AaErws7gRCRia-k9r79g&pullRequest=90
}
}

// 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.
Expand Down Expand Up @@ -919,7 +939,14 @@
}

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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ internal static unsafe class FakeExports
private static byte s_checkSynchronizeRawResult = 1;
private static Action? s_checkSynchronizeHandlerForTests;

internal static Func<nint>? StateProviderForTests
{
get; set;
}

public static int GetLuaStateCalls => Volatile.Read(ref s_getLuaStateCalls);

public static int ProcessMessagesCalls => Volatile.Read(ref s_processMessagesCalls);
Expand Down Expand Up @@ -84,6 +89,7 @@ public static void Reset()
s_lastPushedObject = 0;
s_checkSynchronizeRawResult = 1;
s_checkSynchronizeHandlerForTests = null;
StateProviderForTests = null;
}

/// <summary>Builds the record the host would pass, with every slot pointing at a double.</summary>
Expand All @@ -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)])]
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Models CE's rooted thread states and re-entry of the waiting worker coroutine.</summary>
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<Observation> 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<Observation> 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<NotSupportedException>(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<nint> workerState = new();
StrongBox<Exception?> 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<InvalidOperationException>(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<int, (nint State, int Reference)> _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<PendingCall> _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<int> Completed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
}
}
}
Loading
Loading