From c934aec483c6c5cfe7a24112d9c3218ba4a1629b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=96=A4=20ShadowNine=20=F0=9F=92=9B?= Date: Mon, 19 Oct 2026 18:06:07 +0200 Subject: [PATCH] Release synchronized callbacks on the admitted Lua state --- .../Threading/MainThreadDispatcher.cs | 9 +- .../Threading/CeCoroutineDispatchTests.cs | 155 ++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/libs/CheatEngine.SDK.Hosting/Threading/MainThreadDispatcher.cs b/libs/CheatEngine.SDK.Hosting/Threading/MainThreadDispatcher.cs index e4d4c5ca..931bacd8 100644 --- a/libs/CheatEngine.SDK.Hosting/Threading/MainThreadDispatcher.cs +++ b/libs/CheatEngine.SDK.Hosting/Threading/MainThreadDispatcher.cs @@ -121,7 +121,7 @@ internal static void Dispatch(MainThreadWorkItem item) LuaError.FromStack(l, status).Message); } - using (callback) + try { if (!callback!.TryPush(l)) { @@ -130,6 +130,13 @@ internal static void Dispatch(MainThreadWorkItem item) status = l.TryCall(1, 0); } + finally + { + // The callback belongs to this admitted operation's Lua universe. Releasing it through Dispose would ask + // the host for a second thread state after synchronize returned; keep cleanup on the state whose frame and + // lifecycle lease are still active instead. + callback!.Release(l); + } if (!status.IsOk) { diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Threading/CeCoroutineDispatchTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Threading/CeCoroutineDispatchTests.cs index 3ab80c59..2d216308 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Threading/CeCoroutineDispatchTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Threading/CeCoroutineDispatchTests.cs @@ -78,6 +78,144 @@ public void Repeated_dispatch_reenters_the_worker_coroutine_and_restores_both_st Assert.Equal(0, LuaApi.lua_gettop(root.L)); } + [Theory] + [InlineData(false)] + [InlineData(true)] + [Trait("Category", "NativeLua")] + public void Dispatch_releases_its_callback_on_the_original_worker_state_after_success_or_managed_failure( + bool throwInWork) + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState root = new(); + using ThreadStates states = new(root); + using HostSimulator host = new(); + HostingTest.Enable(host, root); + nint mainState = states.GetState(); + int mainThread = Environment.CurrentManagedThreadId; + using SynchronizeStandIn synchronize = new(new LuaState(mainState)); + OneShotStateProvider provider = new(states); + FakeExports.StateProviderForTests = provider.GetState; + int exportCallsBefore = FakeExports.GetLuaStateCalls; + StrongBox observation = new(); + const long sentinel = 0x53388; + + Task worker = Task.Factory.StartNew(() => + { + nint workerState = states.GetState(); + LuaState workerLua = new(workerState); + workerLua.PushInteger(sentinel); + int before = workerLua.Top; + StrongBox callbackThread = new(); + StrongBox executions = new(); + Exception? failure = Record.Exception(() => MainThread.Invoke(_ => + { + callbackThread.Value = Environment.CurrentManagedThreadId; + executions.Value++; + if (throwInWork) + { + throw new NotSupportedException("managed dispatch failure"); + } + }, 0)); + int after = workerLua.Top; + bool readSentinel = workerLua.TryReadInteger(1, out long observedSentinel); + workerLua.SetTop(0); + observation.Value = new OriginalStateReleaseObservation(workerState, before, after, readSentinel, + observedSentinel, callbackThread.Value, executions.Value, failure); + }, TestContext.Current.CancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default); + + try + { + Assert.True(synchronize.PumpOne(TimeSpan.FromSeconds(5)), "Worker did not queue the expected dispatch."); + } + finally + { + synchronize.StopAndDrain(); + Join(worker); + } + + OriginalStateReleaseObservation result = Assert.IsType(observation.Value); + VerifyOriginalStateRelease(result, provider, exportCallsBefore, sentinel, mainState, root); + Assert.Equal(mainThread, result.CallbackThread); + Assert.Equal(1, result.Executions); + VerifyManagedDispatchFailure(result.Failure, throwInWork); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Synchronize_Lua_error_survives_same_state_callback_release_and_restores_the_worker_stack() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState root = new(); + using ThreadStates states = new(root); + using HostSimulator host = new(); + HostingTest.Enable(host, root); + nint mainState = states.GetState(); + LuaState setupState = new(mainState); + using (LuaFrame frame = new(setupState)) + { + Assert.True(setupState.TryExecute( + "function synchronize(f, ...) error('same-state synchronize failure', 0) end"u8, 0).IsOk); + } + + OneShotStateProvider provider = new(states); + FakeExports.StateProviderForTests = provider.GetState; + int exportCallsBefore = FakeExports.GetLuaStateCalls; + StrongBox observation = new(); + StrongBox executions = new(); + const long sentinel = 0xC511; + + Task worker = Task.Factory.StartNew(() => + { + nint workerState = states.GetState(); + LuaState workerLua = new(workerState); + workerLua.PushInteger(sentinel); + int before = workerLua.Top; + Exception? failure = Record.Exception(() => MainThread.Invoke(static box => box.Value++, executions)); + int after = workerLua.Top; + bool readSentinel = workerLua.TryReadInteger(1, out long observedSentinel); + workerLua.SetTop(0); + observation.Value = new OriginalStateReleaseObservation(workerState, before, after, readSentinel, + observedSentinel, 0, executions.Value, failure); + }, TestContext.Current.CancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default); + + Join(worker); + + OriginalStateReleaseObservation result = Assert.IsType(observation.Value); + InvalidOperationException exception = Assert.IsType(result.Failure); + Assert.Equal("The host's 'synchronize' call failed: same-state synchronize failure", exception.Message); + VerifyOriginalStateRelease(result, provider, exportCallsBefore, sentinel, mainState, root); + Assert.Equal(0, result.Executions); + } + + private static void VerifyOriginalStateRelease(OriginalStateReleaseObservation result, OneShotStateProvider provider, + int exportCallsBefore, long sentinel, nint mainState, NativeLuaState root) + { + Assert.Equal(1, provider.Calls); + Assert.Equal(1, FakeExports.GetLuaStateCalls - exportCallsBefore); + Assert.Equal(0, LuaCallbackRegistry.Count); + Assert.Equal(1, result.Before); + Assert.Equal(result.Before, result.After); + Assert.True(result.ReadSentinel); + Assert.Equal(sentinel, result.Sentinel); + Assert.Equal(0, LuaApi.lua_gettop((lua_State*) result.WorkerState)); + Assert.Equal(0, LuaApi.lua_gettop((lua_State*) mainState)); + Assert.Equal(0, LuaApi.lua_gettop(root.L)); + } + + private static void VerifyManagedDispatchFailure(Exception? failure, bool expected) + { + if (expected) + { + Assert.Equal("managed dispatch failure", Assert.IsType(failure).Message); + } + else + { + Assert.Null(failure); + } + } + private static void VerifyObservations(IEnumerable observations, nint mainState, int mainThread, bool throwInWork) { @@ -150,6 +288,23 @@ public void Queued_stop_drains_the_worker_without_running_work_or_releasing_a_li private sealed record Observation(int Before, int After, nint WorkerState, nint CallbackState, int CallbackThread, Exception? Failure); + private sealed record OriginalStateReleaseObservation(nint WorkerState, int Before, int After, + bool ReadSentinel, long Sentinel, int CallbackThread, int Executions, Exception? Failure); + + private sealed class OneShotStateProvider(ThreadStates states) + { + private int _calls; + + public int Calls => Volatile.Read(ref _calls); + + public nint GetState() + { + // Returning null after the first lookup makes a cleanup-time provider reacquisition deterministic without + // violating the host contract for the state admitted at the start of the operation. + return Interlocked.Increment(ref _calls) == 1 ? states.GetState() : 0; + } + } + private sealed class ThreadStates(NativeLuaState root) : IDisposable { private readonly Lock _gate = new();