From efbf98a9eaf4beb44f59331a62e8596995521c9e Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:05:53 +0900 Subject: [PATCH] test: make async GC lifetime assertions deterministic Task completion can wake a blocking consumer while the dispatched async callback still holds its state machine and captured payload. A controlled .NET 8/10 reproduction confirms that Lua frames and stack are already empty, and releasing the callback makes the payload collectible. Use a one-shot manual suspension in exception and VM-context lifetime tests so the resumed continuation returns before collection. Preserve actual async suspension and immediate weak-reference assertions, and remove the VM-context test's polling assertion. Production code is unchanged. Validation: 42 focused cases and all 2,585 runtime tests pass on each of .NET 8 and .NET 10 in Release. CSharpier and git diff --check pass. --- tests/Lua.Tests/ExceptionLifetimeTests.cs | 32 +++++++++++-------- .../ExecutionContextLifetimeTests.cs | 24 ++++++-------- tests/Lua.Tests/ManualSuspension.cs | 25 +++++++++++++++ 3 files changed, 54 insertions(+), 27 deletions(-) create mode 100644 tests/Lua.Tests/ManualSuspension.cs diff --git a/tests/Lua.Tests/ExceptionLifetimeTests.cs b/tests/Lua.Tests/ExceptionLifetimeTests.cs index c023db53..0c659bff 100644 --- a/tests/Lua.Tests/ExceptionLifetimeTests.cs +++ b/tests/Lua.Tests/ExceptionLifetimeTests.cs @@ -30,23 +30,25 @@ public void DiscardedExecutionErrorDoesNotRootPayload(bool asynchronous, bool re [MethodImpl(MethodImplOptions.NoInlining)] static WeakReference FailExecution(LuaState state, bool asynchronous, bool readMessage) { + var suspension = new ManualSuspension(); var payload = new object(); state.Environment["payload"] = LuaValue.FromObject(payload); state.Environment["pause"] = new LuaFunction( async (context, _) => { if (asynchronous) - await Task.Yield(); + await suspension; return context.Return(); } ); try { - state + var operation = state .DoStringAsync("local value=payload;payload=nil;pause();error(value)") - .AsTask() - .GetAwaiter() - .GetResult(); + .AsTask(); + if (asynchronous) + suspension.Resume(); + operation.GetAwaiter().GetResult(); Assert.Fail("Expected a Lua error."); } catch (LuaRuntimeException exception) @@ -68,22 +70,22 @@ public void DiscardedDirectCallErrorDoesNotRootPayload(bool asynchronous) [MethodImpl(MethodImplOptions.NoInlining)] static WeakReference FailDirectCall(LuaState state, bool asynchronous) { + var suspension = new ManualSuspension(); var payload = new object(); var function = new LuaFunction( async (context, _) => { if (asynchronous) - await Task.Yield(); + await suspension; throw new LuaRuntimeException(context.State, context.GetArgument(0)); } ); try { - state - .CallAsync(function, [LuaValue.FromObject(payload)]) - .AsTask() - .GetAwaiter() - .GetResult(); + var operation = state.CallAsync(function, [LuaValue.FromObject(payload)]).AsTask(); + if (asynchronous) + suspension.Resume(); + operation.GetAwaiter().GetResult(); Assert.Fail("Expected a Lua error."); } catch (LuaRuntimeException) { } @@ -101,6 +103,7 @@ public void DiscardedMetamethodErrorDoesNotRootCapturedPayload(bool asynchronous [MethodImpl(MethodImplOptions.NoInlining)] static WeakReference FailMetamethod(LuaState state, bool asynchronous) { + var suspension = new ManualSuspension(); var payload = new object(); var table = new LuaTable { @@ -110,7 +113,7 @@ static WeakReference FailMetamethod(LuaState state, bool asynchronous) async (context, _) => { if (asynchronous) - await Task.Yield(); + await suspension; GC.KeepAlive(payload); throw new LuaRuntimeException(context.State, "failure"); } @@ -119,7 +122,10 @@ static WeakReference FailMetamethod(LuaState state, bool asynchronous) }; try { - state.AddAsync(table, 1).AsTask().GetAwaiter().GetResult(); + var operation = state.AddAsync(table, 1).AsTask(); + if (asynchronous) + suspension.Resume(); + operation.GetAwaiter().GetResult(); Assert.Fail("Expected a Lua error."); } catch (LuaRuntimeException) { } diff --git a/tests/Lua.Tests/ExecutionContextLifetimeTests.cs b/tests/Lua.Tests/ExecutionContextLifetimeTests.cs index d9a6fa65..a5cd2906 100644 --- a/tests/Lua.Tests/ExecutionContextLifetimeTests.cs +++ b/tests/Lua.Tests/ExecutionContextLifetimeTests.cs @@ -11,29 +11,22 @@ public class ExecutionContextLifetimeTests public void CompletedExecutionDoesNotRootDisposedState(bool asynchronous, bool failure) { var reference = RunAndDispose(asynchronous, failure); - // A completed async continuation may still be unwinding on another thread. - Assert.That( - () => - { - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - return reference.IsAlive; - }, - Is.False.After(1000, 10), - "A pooled VM context retained its Lua state." - ); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + Assert.That(reference.IsAlive, Is.False, "A pooled VM context retained its Lua state."); } [MethodImpl(MethodImplOptions.NoInlining)] static WeakReference RunAndDispose(bool asynchronous, bool failure) { using var state = LuaState.Create(); + var suspension = new ManualSuspension(); state.Environment["callback"] = new LuaFunction( async (context, _) => { if (asynchronous) - await Task.Yield(); + await suspension; if (failure) throw new LuaRuntimeException(context.State, "failure"); return context.Return(42); @@ -41,7 +34,10 @@ static WeakReference RunAndDispose(bool asynchronous, bool failure) ); try { - state.DoStringAsync("return callback()").AsTask().GetAwaiter().GetResult(); + var operation = state.DoStringAsync("return callback()").AsTask(); + if (asynchronous) + suspension.Resume(); + operation.GetAwaiter().GetResult(); Assert.That(failure, Is.False); } catch (LuaRuntimeException) when (failure) { } diff --git a/tests/Lua.Tests/ManualSuspension.cs b/tests/Lua.Tests/ManualSuspension.cs new file mode 100644 index 00000000..1a1c33b7 --- /dev/null +++ b/tests/Lua.Tests/ManualSuspension.cs @@ -0,0 +1,25 @@ +using System.Runtime.CompilerServices; + +namespace Lua.Tests; + +sealed class ManualSuspension : INotifyCompletion +{ + Action? continuation; + + public ManualSuspension GetAwaiter() => this; + + public bool IsCompleted => false; + + public void GetResult() { } + + public void OnCompleted(Action continuation) => this.continuation = continuation; + + // Return from the continuation before callers assert collectibility. + [MethodImpl(MethodImplOptions.NoInlining)] + public void Resume() + { + var callback = continuation!; + continuation = null; + callback(); + } +}