From 09041b5bce072c06df76f2c327aad7a42192803d Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:57:18 +0900 Subject: [PATCH 01/32] fix: validate bytecode and release VM state safely --- .../Compilation/BytecodeVerifier.cs | 255 +++++++++++++++++ src/Lua/CodeAnalysis/Compilation/Dump.cs | 114 ++++---- src/Lua/LuaValue.cs | 7 +- src/Lua/Runtime/LuaVirtualMachine.cs | 12 +- tests/Lua.Tests/BytecodeValidationTests.cs | 256 ++++++++++++++++++ .../ExecutionContextLifetimeTests.cs | 50 ++++ tests/Lua.Tests/LuaValueTests.cs | 28 ++ tests/Lua.Tests/UpvalueUnwindingTests.cs | 113 ++++++++ 8 files changed, 785 insertions(+), 50 deletions(-) create mode 100644 src/Lua/CodeAnalysis/Compilation/BytecodeVerifier.cs create mode 100644 tests/Lua.Tests/BytecodeValidationTests.cs create mode 100644 tests/Lua.Tests/ExecutionContextLifetimeTests.cs create mode 100644 tests/Lua.Tests/UpvalueUnwindingTests.cs diff --git a/src/Lua/CodeAnalysis/Compilation/BytecodeVerifier.cs b/src/Lua/CodeAnalysis/Compilation/BytecodeVerifier.cs new file mode 100644 index 00000000..f0a96368 --- /dev/null +++ b/src/Lua/CodeAnalysis/Compilation/BytecodeVerifier.cs @@ -0,0 +1,255 @@ +using System.Diagnostics.CodeAnalysis; +using Lua.Runtime; + +namespace Lua.CodeAnalysis.Compilation; + +// Loaded chunks cannot share the compiler's operand/bounds guarantees. Verify +// once at the input boundary so the VM can keep its unchecked register accesses. +readonly ref struct BytecodeVerifier(Prototype prototype, ReadOnlySpan name) +{ + readonly ReadOnlySpan chunkName = name; + + public void Verify() + { + if (prototype.ParameterCount > prototype.MaxStackSize || prototype.Code.IsEmpty) + Invalid("function header"); + + var code = prototype.Code; + for (var pc = 0; pc < code.Length; pc++) + { + var instruction = code[pc]; + var a = instruction.A; + var b = instruction.B; + var c = instruction.C; + switch (instruction.OpCode) + { + case OpCode.Move: + case OpCode.Unm: + case OpCode.Not: + case OpCode.Len: + Register(a); + Register(b); + break; + case OpCode.LoadK: + Register(a); + Constant(instruction.Bx); + break; + case OpCode.LoadKX: + Register(a); + Constant(ExtraArgument(pc)); + Target(pc + 2); + continue; + case OpCode.LoadBool: + Register(a); + Target(pc + (c == 0 ? 1 : 2)); + continue; + case OpCode.LoadNil: + Registers(a, b + 1); + break; + case OpCode.GetUpVal: + case OpCode.SetUpVal: + Register(a); + UpValue(b); + break; + case OpCode.GetTabUp: + Register(a); + UpValue(b); + RegisterOrConstant(c); + break; + case OpCode.GetTable: + Register(a); + Register(b); + RegisterOrConstant(c); + break; + case OpCode.SetTabUp: + UpValue(a); + RegisterOrConstant(b); + RegisterOrConstant(c); + break; + case OpCode.SetTable: + Register(a); + RegisterOrConstant(b); + RegisterOrConstant(c); + break; + case OpCode.NewTable: + Register(a); + break; + case OpCode.Self: + Registers(a, 2); + Register(b); + RegisterOrConstant(c); + break; + case OpCode.Add: + case OpCode.Sub: + case OpCode.Mul: + case OpCode.Div: + case OpCode.Mod: + case OpCode.Pow: + Register(a); + RegisterOrConstant(b); + RegisterOrConstant(c); + break; + case OpCode.Concat: + Register(a); + if (b >= c) + Invalid("concatenation range"); + Registers(b, c - b + 1); + break; + case OpCode.Jmp: + if (a != 0) + Register(a - 1); + Target(pc + 1 + instruction.SBx); + continue; + case OpCode.Eq: + case OpCode.Lt: + case OpCode.Le: + Flag(a); + RegisterOrConstant(b); + RegisterOrConstant(c); + Target(pc + 2); + break; + case OpCode.Test: + Register(a); + Flag(c); + Target(pc + 2); + break; + case OpCode.TestSet: + Register(a); + Register(b); + Flag(c); + Target(pc + 2); + break; + case OpCode.Call: + case OpCode.TailCall: + Register(a); + if (b != 0) + Registers(a, b); + if (instruction.OpCode == OpCode.TailCall) + continue; + if (c != 0) + Registers(a, c - 1); + break; + case OpCode.Return: + Registers(a, b == 0 ? 0 : b - 1); + continue; + case OpCode.ForLoop: + case OpCode.ForPrep: + Registers(a, 4); + Target(pc + 1 + instruction.SBx); + if (instruction.OpCode == OpCode.ForPrep) + continue; + break; + case OpCode.TForCall: + Registers(a, 3 + c); + break; + case OpCode.TForLoop: + Registers(a, 2); + Target(pc + 1 + instruction.SBx); + break; + case OpCode.SetList: + Registers(a, b + 1); + var block = c == 0 ? ExtraArgument(pc) : c; + if (block == 0 || block > (int.MaxValue - LuaStack.MaxCapacity) / 50 + 1) + Invalid("table constructor block"); + if (c == 0) + { + Target(pc + 2); + continue; + } + break; + case OpCode.Closure: + Register(a); + if ((uint)instruction.Bx >= (uint)prototype.ChildPrototypes.Length) + Invalid("child prototype index"); + break; + case OpCode.VarArg: + if (!prototype.HasVariableArguments) + Invalid("vararg instruction in fixed-argument function"); + Registers(a, b == 0 ? 0 : b - 1); + break; + case OpCode.ExtraArg: + if ( + pc == 0 + || !( + code[pc - 1].OpCode == OpCode.LoadKX + || (code[pc - 1].OpCode == OpCode.SetList && code[pc - 1].C == 0) + ) + ) + Invalid("unexpected extra argument"); + continue; + default: + Invalid("opcode"); + break; + } + + Target(pc + 1); + } + + foreach (var child in prototype.ChildPrototypes) + { + foreach (var upValue in child.UpValues) + { + if (upValue.IsLocal) + Register(upValue.Index); + else + UpValue(upValue.Index); + } + } + } + + void Register(int index) => Registers(index, 1); + + void Registers(int start, int count) + { + if ( + (uint)start > (uint)prototype.MaxStackSize + || (uint)count > (uint)(prototype.MaxStackSize - start) + ) + Invalid("register range"); + } + + void Constant(int index) + { + if ((uint)index >= (uint)prototype.Constants.Length) + Invalid("constant index"); + } + + void UpValue(int index) + { + if ((uint)index >= (uint)prototype.UpValues.Length) + Invalid("upvalue index"); + } + + void RegisterOrConstant(int operand) + { + if (Instruction.IsConstant(operand)) + Constant(Instruction.ConstantIndex(operand)); + else + Register(operand); + } + + void Target(int pc) + { + if ((uint)pc >= (uint)prototype.Code.Length || prototype.Code[pc].OpCode == OpCode.ExtraArg) + Invalid("instruction target"); + } + + int ExtraArgument(int pc) + { + if (pc + 1 >= prototype.Code.Length || prototype.Code[pc + 1].OpCode != OpCode.ExtraArg) + Invalid("missing extra argument"); + return prototype.Code[pc + 1].Ax; + } + + void Flag(int value) + { + if (value > 1) + Invalid("boolean operand"); + } + + [DoesNotReturn] + void Invalid(string reason) => + throw new LuaUndumpException( + $"{chunkName.ToString()}: invalid {reason} in precompiled chunk" + ); +} diff --git a/src/Lua/CodeAnalysis/Compilation/Dump.cs b/src/Lua/CodeAnalysis/Compilation/Dump.cs index ed90dd2d..86f4163f 100644 --- a/src/Lua/CodeAnalysis/Compilation/Dump.cs +++ b/src/Lua/CodeAnalysis/Compilation/Dump.cs @@ -1,6 +1,5 @@ using System.Buffers; using System.Buffers.Binary; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -77,6 +76,7 @@ public void Validate(ReadOnlySpan name) || PointerSize is not (4 or 8) || InstructionSize != 4 || NumberSize != 8 + || Endianness > 1 ) { goto ErrIncompatible; @@ -303,6 +303,7 @@ StringInternPool internPool int pointerSize; readonly ReadOnlySpan name = name; + [DoesNotReturn] void Throw(string why) { throw new LuaUndumpException($"{name.ToString()}: {why} precompiled chunk"); @@ -341,16 +342,10 @@ byte ReadByte() bool ReadBool() { - if (0 < Unread.Length) - { - var b = Unread[0]; - Unread = Unread[1..]; - return b != 0; - } - - ThrowTooShort(); - - return false; + var value = ReadByte(); + if (value > 1) + Throw("invalid boolean in"); + return value != 0; } int ReadInt() @@ -367,6 +362,16 @@ int ReadInt() return i; } + // Check serialized lengths against the available input before allocating. + // Dividing avoids overflow when a corrupt count is close to int.MaxValue. + int ReadCount(int minimumElementSize) + { + var count = ReadInt(); + if ((uint)count > (uint)(Unread.Length / minimumElementSize)) + Throw("invalid length in"); + return count; + } + long ReadLong() { long i = 0; @@ -397,41 +402,42 @@ public Prototype Undump() h.Validate(name); otherEndian = BitConverter.IsLittleEndian ^ (h.Endianness == 1); pointerSize = h.PointerSize; - return UndumpFunction(); + return UndumpFunction(0); } - Prototype UndumpFunction() + Prototype UndumpFunction(int depth) { + if (depth >= Constants.MaxCallCount || !RuntimeHelpers.TryEnsureSufficientExecutionStack()) + Throw("excessive nesting in"); var lineDefined = ReadInt(); // 4 var lastLineDefined = ReadInt(); // 4 var parameterCount = ReadByte(); // 1 var maxStackSize = ReadByte(); // 1 - var isVarArg = ReadByte() == 1; // 1 - var codeLength = ReadInt(); + var isVarArg = ReadBool(); // 1 + var codeLength = ReadCount(sizeof(int)); var code = new Instruction[codeLength]; ReadInToIntSpan(MemoryMarshal.Cast(code)); var constants = ReadConstants(); - var prototypes = ReadPrototypes(); + var prototypes = ReadPrototypes(depth); var upValues = ReadUpValues(); // Debug var source = ReadString(); - var lineInfoLength = ReadInt(); + var lineInfoLength = ReadCount(sizeof(int)); + if (lineInfoLength != 0 && lineInfoLength != codeLength) + Throw("invalid line info in"); var lineInfo = new int[lineInfoLength]; ReadInToIntSpan(lineInfo.AsSpan()); var localVariables = ReadLocalVariables(); var upValueCount = ReadInt(); - Debug.Assert( - upValueCount == upValues.Length, - $"upvalue count mismatch: {upValueCount} != {upValues.Length}" - ); - foreach (ref var desc in upValues.AsSpan()) + if (upValueCount != 0 && upValueCount != upValues.Length) + Throw("invalid upvalue name count in"); + for (var i = 0; i < upValueCount; i++) { - var name = ReadString(); - desc.Name = name; + upValues[i].Name = ReadString(); } - return new( + var prototype = new Prototype( source, lineDefined, lastLineDefined, @@ -445,6 +451,8 @@ Prototype UndumpFunction() localVariables, upValues ); + new BytecodeVerifier(prototype, name).Verify(); + return prototype; } void ReadInToIntSpan(Span toWrite) @@ -457,32 +465,33 @@ void ReadInToIntSpan(Span toWrite) string ReadString() { - var len = pointerSize == 4 ? ReadInt() : (int)ReadLong(); - if (len == 0) + var serializedLength = pointerSize == 4 ? ReadInt() : ReadLong(); + if (serializedLength == 0) { return ""; } - len--; - var arrayPooled = ArrayPool.Shared.Rent(len); + if (serializedLength < 0 || serializedLength > Unread.Length) + Throw("invalid string length in"); + var len = (int)serializedLength; + if (Unread[len - 1] != 0) + Throw("unterminated string in"); + var bytes = Unread[..(len - 1)]; + Unread = Unread[len..]; char[]? charArrayPooled = null; try { - var span = arrayPooled.AsSpan(0, len); - Read(span); - - var l = ReadByte(); - Debug.Assert(l == 0); + // UTF-8 produces no more UTF-16 code units than input bytes. Decode + // directly from the chunk instead of copying to a second pooled array. var chars = len <= 128 - ? stackalloc char[len * 2] - : (charArrayPooled = ArrayPool.Shared.Rent(len * 2)); - var count = Encoding.UTF8.GetChars(span, chars); + ? stackalloc char[len] + : (charArrayPooled = ArrayPool.Shared.Rent(len)); + var count = Encoding.UTF8.GetChars(bytes, chars); return internPool.Intern(chars[..count]); } finally { - ArrayPool.Shared.Return(arrayPooled); if (charArrayPooled != null) { ArrayPool.Shared.Return(charArrayPooled); @@ -492,7 +501,7 @@ string ReadString() LuaValue[] ReadConstants() { - var count = ReadInt(); + var count = ReadCount(1); var constants = new LuaValue[count]; for (var i = 0; i < count; i++) { @@ -502,7 +511,7 @@ LuaValue[] ReadConstants() case LuaValueType.Nil: break; case LuaValueType.Boolean: - constants[i] = ReadByte() == 1; + constants[i] = ReadBool(); break; case LuaValueType.Number: constants[i] = ReadDouble(); @@ -510,19 +519,24 @@ LuaValue[] ReadConstants() case LuaValueType.String: constants[i] = ReadString(); break; + default: + Throw("invalid constant type in"); + break; } } return constants; } - Prototype[] ReadPrototypes() + Prototype[] ReadPrototypes(int depth) { - var count = ReadInt(); + // Two line numbers, three flags, seven section counts, and the source length. + const int fixedFunctionSize = 2 * sizeof(int) + 3 + 7 * sizeof(int); + var count = ReadCount(fixedFunctionSize + pointerSize); var prototypes = count != 0 ? new Prototype[count] : []; for (var i = 0; i < count; i++) { - prototypes[i] = UndumpFunction(); + prototypes[i] = UndumpFunction(depth + 1); } return prototypes; @@ -530,7 +544,7 @@ Prototype[] ReadPrototypes() LocalVariable[] ReadLocalVariables() { - var count = ReadInt(); + var count = ReadCount(pointerSize + 2 * sizeof(int)); var localVariables = new LocalVariable[count]; for (var i = 0; i < count; i++) { @@ -550,14 +564,20 @@ LocalVariable[] ReadLocalVariables() UpValueDesc[] ReadUpValues() { - var count = ReadInt(); - Debug.Assert(count < 100, $" too many upvalues :{count}"); + var count = ReadCount(2); + if (count > Constants.MaxUpValue) + Throw("too many upvalues in"); var upValues = new UpValueDesc[count]; for (var i = 0; i < count; i++) { var isLocal = ReadBool(); var index = ReadByte(); - upValues[i] = new() { IsLocal = isLocal, Index = index }; + upValues[i] = new() + { + IsLocal = isLocal, + Index = index, + Name = "", + }; } return upValues; diff --git a/src/Lua/LuaValue.cs b/src/Lua/LuaValue.cs index ae3e4e89..f534ce9a 100644 --- a/src/Lua/LuaValue.cs +++ b/src/Lua/LuaValue.cs @@ -153,12 +153,17 @@ public bool TryRead(out T result) break; } case LuaValueType.Function: - if (t == typeof(LuaFunction) || t.IsSubclassOf(typeof(LuaFunction))) + if (t == typeof(LuaFunction)) { var v = referenceValue!; result = Unsafe.As(ref v); return true; } + else if (t.IsSubclassOf(typeof(LuaFunction)) && referenceValue is T function) + { + result = function; + return true; + } else if (t == typeof(object)) { result = (T)referenceValue!; diff --git a/src/Lua/Runtime/LuaVirtualMachine.cs b/src/Lua/Runtime/LuaVirtualMachine.cs index 54b4a946..9c0d9460 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.cs @@ -36,6 +36,7 @@ void Init(LuaState state, in CallStackFrame frame, CancellationToken cancellatio State = state; LuaClosure = (LuaClosure)frame.Function; FrameBase = frame.Base; + InitialFrameBase = frame.Base; VariableArgumentCount = frame.VariableArgumentCount; CurrentReturnFrameBase = frame.ReturnBase; CancellationToken = cancellationToken; @@ -56,6 +57,7 @@ void Init(LuaState state, in CallStackFrame frame, CancellationToken cancellatio public Prototype Prototype => LuaClosure.Proto; public int FrameBase; + public int InitialFrameBase; public int VariableArgumentCount; public CancellationToken CancellationToken; public int Pc; @@ -307,9 +309,10 @@ is not (PostOperationType.TailCall or PostOperationType.DontPop) } catch (Exception e) { + // FrameBase follows nested calls; all discarded frames need closing. + State.CloseUpValues(InitialFrameBase); if (toCatchFlag) { - State.CloseUpValues(FrameBase); if (e is not (LuaRuntimeException or LuaCanceledException)) { Exception newException = @@ -327,6 +330,11 @@ e is OperationCanceledException } finally { + Stack = null!; + State = null!; + LuaClosure = null!; + Task = default; + CancellationToken = default; pool.TryPush(this); } } @@ -1093,7 +1101,7 @@ static void VarArg(VirtualMachineExecutionContext context) } catch (Exception e) { - context.State.CloseUpValues(context.FrameBase); + context.State.CloseUpValues(context.InitialFrameBase); if (e is not (LuaRuntimeException or LuaCanceledException)) { Exception newException = diff --git a/tests/Lua.Tests/BytecodeValidationTests.cs b/tests/Lua.Tests/BytecodeValidationTests.cs new file mode 100644 index 00000000..0c64fd66 --- /dev/null +++ b/tests/Lua.Tests/BytecodeValidationTests.cs @@ -0,0 +1,256 @@ +using System.Buffers.Binary; +using Lua.CodeAnalysis; +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +public class BytecodeValidationTests +{ + static Prototype Create( + Instruction[] code, + int stackSize = 2, + LuaValue[]? constants = null, + Prototype[]? children = null, + UpValueDesc[]? upValues = null, + bool varargs = false + ) => + new( + "bytecode-test", + 0, + 0, + 0, + stackSize, + varargs, + constants ?? [], + code, + children ?? [], + Enumerable.Repeat(1, code.Length).ToArray(), + [], + upValues ?? [] + ); + + static IEnumerable InvalidInstructions() + { + var instructions = new (string, Instruction)[] + { + ("read outside stack allocation", Instruction.Move(0, 511)), + ("write outside register window", Instruction.Move(255, 0)), + ("constant index", Instruction.LoadK(0, 1)), + ("missing extra argument", Instruction.LoadKX(0)), + ("nil register range", Instruction.LoadNil(1, 1)), + ("upvalue read", Instruction.GetUpVal(0, 0)), + ("upvalue write", Instruction.SetUpVal(0, 0)), + ("method receiver slot", Instruction.Self(1, 0, 0)), + ("arithmetic constant", Instruction.Add(0, 256, 0)), + ("arithmetic register", Instruction.Add(0, 0, 2)), + ("unary register", Instruction.Unm(0, 2)), + ("reversed concatenation", Instruction.Concat(0, 1, 0)), + ("backward jump", Instruction.Jmp(0, -2)), + ("forward jump", Instruction.Jmp(0, 1)), + ("call arguments", Instruction.Call(0, 3, 1)), + ("call results", Instruction.Call(0, 1, 4)), + ("return range", Instruction.Return(1, 3)), + ("numeric loop window", Instruction.ForPrep(0, 0)), + ("iterator window", Instruction.TForCall(0, 1)), + ("constructor values", Instruction.SetList(0, 2, 1)), + ("missing constructor block", Instruction.SetList(0, 1, 0)), + ("child prototype", Instruction.Closure(0, 0)), + ("vararg in fixed function", Instruction.VarArg(0, 2)), + ("unattached extra argument", Instruction.ExtraArg(0)), + ("unknown opcode", new Instruction(63)), + }; + foreach (var (name, instruction) in instructions) + yield return new TestCaseData(new[] { instruction, Instruction.Return(0, 1) }).SetName( + "Rejects_" + name.Replace(' ', '_') + ); + yield return new TestCaseData(new[] { Instruction.Move(0, 0) }).SetName( + "Rejects_fallthrough_past_code" + ); + yield return new TestCaseData( + new[] { Instruction.LoadBool(0, 1, 1), Instruction.Return(0, 1) } + ).SetName("Rejects_skip_past_code"); + yield return new TestCaseData( + new[] + { + Instruction.Jmp(0, 1), + Instruction.LoadKX(0), + Instruction.ExtraArg(0), + Instruction.Return(0, 1), + } + ).SetName("Rejects_jump_into_extra_argument"); + yield return new TestCaseData( + new[] { Instruction.LoadKX(0), Instruction.ExtraArg(0), Instruction.Return(0, 1) } + ).SetName("Rejects_extra_constant_index"); + } + + [TestCaseSource(nameof(InvalidInstructions))] + public void RejectsInvalidOperandsBeforeExecution(Instruction[] code) + { + var bytes = Create(code).ToBytecode(); + Assert.Throws(() => Prototype.FromBytecode(bytes, "invalid")); + using var state = LuaState.Create(); + Assert.Throws(() => state.Load(bytes)); + } + + [TestCase(true)] + [TestCase(false)] + public async Task ExtraArgumentsAndBothByteOrdersRoundTrip(bool littleEndian) + { + var prototype = Create( + [ + Instruction.NewTable(0, 0, 0), + Instruction.LoadKX(1), + Instruction.ExtraArg(0), + Instruction.SetList(0, 1, 0), + Instruction.ExtraArg(1), + Instruction.Return(0, 2), + ], + constants: [42] + ); + using var state = LuaState.Create(); + var values = await state.ExecuteAsync(state.Load(prototype.ToBytecode(littleEndian))); + Assert.That(values[0].Read()[1], Is.EqualTo(new LuaValue(42))); + } + + [TestCase(true)] + [TestCase(false)] + public void RejectsChildCapturesOutsideParent(bool local) + { + var child = Create( + [Instruction.Return(0, 1)], + upValues: + [ + new() + { + IsLocal = local, + Index = 2, + Name = "x", + }, + ] + ); + var bytes = Create([Instruction.Return(0, 1)], children: [child]).ToBytecode(); + Assert.Throws(() => Prototype.FromBytecode(bytes, "capture")); + } + + [TestCase(-1)] + [TestCase(int.MaxValue)] + public void RejectsInstructionCountsBeforeAllocating(int count) + { + var bytes = Create([Instruction.Return(0, 1)]).ToBytecode(); + // Header, two line numbers, three byte-sized function fields. + BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(29), count); + Assert.Throws(() => Prototype.FromBytecode(bytes, "count")); + } + + [TestCase(-1L)] + [TestCase(4294967296L)] + [TestCase(long.MaxValue)] + public void RejectsStringLengthsWithoutNarrowing(long length) + { + var bytes = Create([Instruction.Return(0, 1)]).ToBytecode(); + // Skip the instruction count/body and three empty sections to the source string. + const int sourceLengthOffset = 49; + if (IntPtr.Size == 8) + BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(sourceLengthOffset), length); + else + BinaryPrimitives.WriteInt32LittleEndian( + bytes.AsSpan(sourceLengthOffset), + unchecked((int)length) + ); + Assert.Throws(() => Prototype.FromBytecode(bytes, "string")); + } + + [Test] + public void RejectsUnknownConstantTags() + { + var bytes = Create([Instruction.Return(0, 1)], constants: [42]).ToBytecode(); + bytes[41] = 255; + Assert.Throws(() => Prototype.FromBytecode(bytes, "constant")); + } + + [Test] + public void RejectsExcessivelyNestedPrototypes() + { + var prototype = Create([Instruction.Return(0, 1)]); + for (var i = 0; i < 210; i++) + prototype = Create([Instruction.Return(0, 1)], children: [prototype]); + var bytes = prototype.ToBytecode(); + Assert.Throws(() => Prototype.FromBytecode(bytes, "nesting")); + } + + [Test] + public void RejectsEveryTruncatedPrefix() + { + var bytes = Create( + [Instruction.LoadK(0, 0), Instruction.Return(0, 2)], + constants: ["hello"] + ) + .ToBytecode(); + for (var length = 0; length < bytes.Length; length++) + { + var truncated = bytes[..length]; + Assert.Throws(() => Prototype.FromBytecode(truncated, "truncated")); + } + } + + [Test] + public void MutatedChunksDoNotLeakHostExceptions() + { + using var state = LuaState.Create(); + var bytes = state + .Load("local function f(x) return x+1 end; return f(41)", "mutation") + .Proto.ToBytecode(); + var random = new Random(42); + for (var i = 0; i < 1000; i++) + { + var mutated = (byte[])bytes.Clone(); + mutated[random.Next(mutated.Length)] ^= (byte)random.Next(1, 256); + try + { + Prototype.FromBytecode(mutated, "mutation"); + } + catch (LuaUndumpException) { } + } + } + + [Test] + public async Task LoadedChunkPreservesCallsClosuresAndLoops() + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + var closure = state.Load( + """ + local t = {total = 0} + function t:add(x) self.total = self.total + x end + local function run(offset, ...) + local n = offset + local function add(x) n = n + x end + for i, v in ipairs({...}) do + if i <= 3 and v ~= 0 then add(v) end + end + for i = 1, 10 do t:add(i) end + return n + t.total + end + return run(10, 1, 2, 3, 100) + """, + "workload" + ); + var loaded = state.Load(closure.Proto.ToBytecode()); + Assert.That((await state.ExecuteAsync(loaded))[0], Is.EqualTo(new LuaValue(71))); + } + + [TestCase("code.lua")] + [TestCase("closure.lua")] + [TestCase("constructs.lua")] + [TestCase("vararg.lua")] + [TestCase("goto.lua")] + [TestCase("nextvar.lua")] + public void CompilerFixturesPassVerification(string file) + { + using var state = LuaState.Create(); + var source = File.ReadAllText(FileHelper.GetAbsolutePath("tests-lua/" + file)); + var prototype = state.Load(source, file).Proto; + Assert.DoesNotThrow(() => Prototype.FromBytecode(prototype.ToBytecode(), file)); + } +} diff --git a/tests/Lua.Tests/ExecutionContextLifetimeTests.cs b/tests/Lua.Tests/ExecutionContextLifetimeTests.cs new file mode 100644 index 00000000..d9a6fa65 --- /dev/null +++ b/tests/Lua.Tests/ExecutionContextLifetimeTests.cs @@ -0,0 +1,50 @@ +using System.Runtime.CompilerServices; + +namespace Lua.Tests; + +public class ExecutionContextLifetimeTests +{ + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + 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." + ); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference RunAndDispose(bool asynchronous, bool failure) + { + using var state = LuaState.Create(); + state.Environment["callback"] = new LuaFunction( + async (context, _) => + { + if (asynchronous) + await Task.Yield(); + if (failure) + throw new LuaRuntimeException(context.State, "failure"); + return context.Return(42); + } + ); + try + { + state.DoStringAsync("return callback()").AsTask().GetAwaiter().GetResult(); + Assert.That(failure, Is.False); + } + catch (LuaRuntimeException) when (failure) { } + return new WeakReference(state); + } +} diff --git a/tests/Lua.Tests/LuaValueTests.cs b/tests/Lua.Tests/LuaValueTests.cs index 21c028c7..d263e0fb 100644 --- a/tests/Lua.Tests/LuaValueTests.cs +++ b/tests/Lua.Tests/LuaValueTests.cs @@ -1,7 +1,35 @@ +using Lua.Runtime; + namespace Lua.Tests; public class LuaValueTests { + [Test] + public void FunctionConversionsCheckTheStoredRuntimeType() + { + var function = new LuaFunction((context, _) => new(context.Return())); + LuaValue value = function; + Assert.That(value.Read(), Is.SameAs(function)); + Assert.That(value.TryRead(out var luaClosure), Is.False); + Assert.That(luaClosure, Is.Null); + Assert.That(value.TryRead(out var csClosure), Is.False); + Assert.That(csClosure, Is.Null); + Assert.Throws(() => value.Read()); + + using var state = LuaState.Create(); + var closure = state.Load("return 42", "conversion"); + value = closure; + Assert.That(value.Read(), Is.SameAs(closure)); + Assert.That(value.Read(), Is.SameAs(closure)); + Assert.That(value.TryRead(out _), Is.False); + + var managed = new CSharpClosure("managed", [], (context, _) => new(context.Return())); + value = managed; + Assert.That(value.Read(), Is.SameAs(managed)); + Assert.That(value.Read(), Is.SameAs(managed)); + Assert.That(value.TryRead(out _), Is.False); + } + [Test] public void TryRead_LuaValue_ReturnsOriginalValue() { diff --git a/tests/Lua.Tests/UpvalueUnwindingTests.cs b/tests/Lua.Tests/UpvalueUnwindingTests.cs new file mode 100644 index 00000000..fee37505 --- /dev/null +++ b/tests/Lua.Tests/UpvalueUnwindingTests.cs @@ -0,0 +1,113 @@ +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +public class UpvalueUnwindingTests +{ + [Test] + public async Task CancellationAfterSuccessfulAsyncCallbackClosesCaptures() + { + using var state = LuaState.Create(); + using var cancellation = new CancellationTokenSource(); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + state.Environment["pause"] = new LuaFunction( + async (context, _) => + { + await gate.Task; + return context.Return(); + } + ); + var execution = state + .DoStringAsync( + """ + local x = 123 + saved = function() return x end + pause() + x = 456 + """, + cancellationToken: cancellation.Token + ) + .AsTask(); + Assert.That(execution.IsCompleted, Is.False); + cancellation.Cancel(); + gate.SetResult(); + Assert.ThrowsAsync(async () => await execution); + var saved = state.Environment["saved"].Read(); + Assert.That(saved.UpValues[0].IsClosed, Is.True); + state.Stack.Clear(); + Assert.That((await state.ExecuteAsync(saved))[0], Is.EqualTo(new LuaValue(123))); + } + + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + public async Task NestedFailureClosesAllDiscardedFrames(bool asynchronous, bool protectedCall) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + state.Environment["fail"] = new LuaFunction( + async (context, _) => + { + if (asynchronous) + await Task.Yield(); + throw new InvalidOperationException("failure"); + } + ); + // A non-tail call leaves multiple Lua frames in a single VM invocation. + var source = """ + local function outer() + local x = 123 + savedOuter = function() return x end + local function middle() + local y = 456 + savedMiddle = function() return y end + local function inner() fail(); return 0 end + inner() + end + middle() + end + """; + if (protectedCall) + await state.DoStringAsync(source + "\npcall(outer)"); + else + Assert.ThrowsAsync(async () => + await state.DoStringAsync(source + "\nouter()") + ); + + var outer = state.Environment["savedOuter"].Read(); + var middle = state.Environment["savedMiddle"].Read(); + Assert.That(outer.UpValues[0].IsClosed, Is.True); + Assert.That(middle.UpValues[0].IsClosed, Is.True); + state.Stack.Clear(); + await state.DoStringAsync("local a,b,c,d=7,8,9,10; return a+b+c+d"); + var values = await state.DoStringAsync("return savedOuter(), savedMiddle()"); + Assert.That(values, Is.EqualTo(new LuaValue[] { 123, 456 })); + Assert.That(state.CallStackFrameCount, Is.Zero); + Assert.That(state.Stack.Count, Is.Zero); + } + + [Test] + public async Task ProtectedFailureKeepsTheSurvivingCallersCaptureOpen() + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + await state.DoStringAsync( + """ + local x = 123 + local function saved() return x end + local function outer() + local y = 456 + discarded = function() return y end + local function inner() error('failure') end + inner() + end + assert(not pcall(outer)) + x = 789 + assert(saved() == 789) + assert(discarded() == 456) + """ + ); + } +} From e6318a4ab279c61683c89f8ae198d830a724fea4 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:31:50 +0900 Subject: [PATCH 02/32] fix: separate loaded chunk initialization from closure capture --- src/Lua/Internal/LuaGlobalState.cs | 4 - src/Lua/LuaState.cs | 11 +-- src/Lua/Runtime/LuaClosure.cs | 92 ++++++------------ src/Lua/Runtime/LuaVirtualMachine.cs | 4 +- tests/Lua.Tests/ClosureBindingTests.cs | 118 ++++++++++++++++++++++++ tests/Lua.Tests/LoadEnvironmentTests.cs | 24 +++++ 6 files changed, 179 insertions(+), 74 deletions(-) create mode 100644 tests/Lua.Tests/ClosureBindingTests.cs diff --git a/src/Lua/Internal/LuaGlobalState.cs b/src/Lua/Internal/LuaGlobalState.cs index 34b1f799..98ce794f 100644 --- a/src/Lua/Internal/LuaGlobalState.cs +++ b/src/Lua/Internal/LuaGlobalState.cs @@ -2,7 +2,6 @@ using System.Runtime.CompilerServices; using Lua.Internal; using Lua.Platforms; -using Lua.Runtime; using Lua.Standard; namespace Lua; @@ -14,7 +13,6 @@ sealed class LuaGlobalState FastStackCore stateStack; readonly LuaTable environment; readonly LuaTable registry = new(); - readonly UpValue envUpValue; FastStackCore debugBufferPool; @@ -24,7 +22,6 @@ sealed class LuaGlobalState // Coroutines share this policy so Lua code cannot reset the limit by creating a new thread. internal int MaxCallDepth = 100_000; - internal UpValue EnvUpValue => envUpValue; internal ref FastStackCore ThreadStack => ref stateStack; internal ref FastStackCore DebugBufferPool => ref debugBufferPool; @@ -53,7 +50,6 @@ public static LuaGlobalState Create(LuaPlatform? platform = null) { mainState = new(this); environment = new(); - envUpValue = UpValue.Closed(environment); registry[ModuleLibrary.LoadedKeyForRegistry] = new LuaTable(0, 8); registry[ModuleLibrary.PreloadKeyForRegistry] = new LuaTable(0, 8); Platform = platform; diff --git a/src/Lua/LuaState.cs b/src/Lua/LuaState.cs index 884ee884..c6173c38 100644 --- a/src/Lua/LuaState.cs +++ b/src/Lua/LuaState.cs @@ -420,18 +420,17 @@ public LuaTable GetCurrentEnvironment() for (int i = callStack.Length - 1; i >= 0; i--) { var function = callStack[i].Function; - if (function is not LuaClosure closure || closure.Proto.UpValues.Length == 0) + if (function is not LuaClosure closure) { continue; } - if (closure.Proto.UpValues[0].Name != "_ENV") + var descriptions = closure.Proto.UpValues; + for (var index = 0; index < descriptions.Length; index++) { - continue; + if (descriptions[index].Name == "_ENV") + return closure.UpValues[index].GetValue().Read(); } - - var upValue = closure.UpValues[0]; - return upValue.GetValue().Read(); } return Environment; diff --git a/src/Lua/Runtime/LuaClosure.cs b/src/Lua/Runtime/LuaClosure.cs index 9d13d675..622de4bc 100644 --- a/src/Lua/Runtime/LuaClosure.cs +++ b/src/Lua/Runtime/LuaClosure.cs @@ -1,55 +1,56 @@ using System.Runtime.CompilerServices; -using Lua.CodeAnalysis; -using Lua.Internal; namespace Lua.Runtime; public sealed class LuaClosure : LuaFunction { - FastListCore upValues; + readonly UpValue[] upValues; - public LuaClosure(LuaState state, Prototype proto, LuaTable? environment = null) + LuaClosure(Prototype proto) : base( proto.ChunkName, static (context, ct) => LuaVirtualMachine.ExecuteClosureAsync(context.State, ct) ) { Proto = proto; - if (environment != null) - { - upValues.Add(UpValue.Closed(environment)); - return; - } + upValues = proto.UpValues.IsEmpty ? [] : new UpValue[proto.UpValues.Length]; + } - if (state.CallStackFrameCount == 0) - { - upValues.Add(state.GlobalState.EnvUpValue); + /// + /// Creates a loaded chunk with its first upvalue set to the environment + /// and any remaining upvalues set to nil. + /// + public LuaClosure(LuaState state, Prototype proto, LuaTable? environment = null) + : this(proto) + { + if (proto.UpValues.IsEmpty) return; - } - var baseIndex = state.GetCallStackFrames()[^1].Base; + upValues[0] = UpValue.Closed(environment ?? state.Environment); + for (var i = 1; i < proto.UpValues.Length; i++) + upValues[i] = UpValue.Closed(LuaValue.Nil); + } - // add upvalues - for (var i = 0; i < proto.UpValues.Length; i++) + internal LuaClosure(LuaState state, Prototype proto, LuaClosure parent, int frameBase) + : this(proto) + { + var descriptions = proto.UpValues; + for (var i = 0; i < descriptions.Length; i++) { - var description = proto.UpValues[i]; - var upValue = GetUpValueFromDescription( - state.GlobalState, - state, - description, - baseIndex - ); - upValues.Add(upValue); + var description = descriptions[i]; + upValues[i] = description.IsLocal + ? state.GetOrAddUpValue(frameBase + description.Index) + : parent.upValues[description.Index]; } } public Prototype Proto { get; } - public ReadOnlySpan UpValues => upValues.AsSpan(); + public ReadOnlySpan UpValues => upValues; internal Span GetUpValuesSpan() { - return upValues.AsSpan(); + return upValues; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -72,42 +73,7 @@ internal void SetUpValue(int index, LuaValue value) internal void SetEnvironment(LuaValue environment) { - if (Proto.UpValues.Length == 0 || Proto.UpValues[0].Name != "_ENV") - { - return; - } - - if (upValues.Length == 0) - { - upValues.Add(UpValue.Closed(environment)); - return; - } - - upValues[0] = UpValue.Closed(environment); - } - - static UpValue GetUpValueFromDescription( - LuaGlobalState globalState, - LuaState state, - UpValueDesc description, - int baseIndex = 0 - ) - { - if (description.IsLocal) - { - if (description is { Index: 0, Name: "_ENV" }) - { - return globalState.EnvUpValue; - } - - return state.GetOrAddUpValue(baseIndex + description.Index); - } - - if (state.GetCurrentFrame().Function is LuaClosure parentClosure) - { - return parentClosure.UpValues[description.Index]; - } - - throw new(); + if (upValues.Length != 0) + upValues[0].SetValue(environment); } } diff --git a/src/Lua/Runtime/LuaVirtualMachine.cs b/src/Lua/Runtime/LuaVirtualMachine.cs index 9c0d9460..ac4c8ef0 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.cs @@ -1050,7 +1050,9 @@ out doRestart stack.EnsureCapacity(ra1); stack.Get(ra1 - 1) = new LuaClosure( context.State, - context.Prototype.ChildPrototypes[instruction.Bx] + context.Prototype.ChildPrototypes[instruction.Bx], + context.LuaClosure, + frameBase ); stack.NotifyTop(ra1); diff --git a/tests/Lua.Tests/ClosureBindingTests.cs b/tests/Lua.Tests/ClosureBindingTests.cs new file mode 100644 index 00000000..aec88fe6 --- /dev/null +++ b/tests/Lua.Tests/ClosureBindingTests.cs @@ -0,0 +1,118 @@ +using Lua.Platforms; +using Lua.Runtime; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class ClosureBindingTests(LuaStringMode stringMode) +{ + LuaState CreateState() => + LuaState.Create( + stringMode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + + [TestCase(false)] + [TestCase(true)] + public async Task NestedClosureCapturesLocalEnvironmentAtRegisterZero(bool bytecode) + { + using var state = CreateState(); + state.Environment["value"] = 42; + var environment = new LuaTable { ["value"] = 99 }; + var chunk = state.Load( + "local _ENV={value=7}; return function() return value end", + "local-environment", + environment + ); + if (bytecode) + chunk = state.Load(chunk.Proto.ToBytecode(), environment: environment); + var nested = (await state.ExecuteAsync(chunk))[0].Read(); + Assert.That((await state.ExecuteAsync(nested))[0], Is.EqualTo(new LuaValue(7))); + Assert.That(state.Environment["value"], Is.EqualTo(new LuaValue(42))); + } + + [Test] + public async Task LoadedChunksKeepIndependentEnvironmentCells() + { + using var state = CreateState(); + state.Environment["value"] = 42; + var reader = state.Load("return value", "reader"); + var setter = state.Load("_ENV={value=7}; return value", "setter"); + Assert.That((await state.ExecuteAsync(setter))[0], Is.EqualTo(new LuaValue(7))); + Assert.That((await state.ExecuteAsync(reader))[0], Is.EqualTo(new LuaValue(42))); + Assert.That((await state.DoStringAsync("return value"))[0], Is.EqualTo(new LuaValue(42))); + } + + [Test] + public async Task CurrentEnvironmentCanFollowAnotherCapturedVariable() + { + using var state = CreateState(); + var environment = new LuaTable + { + ["read"] = new LuaFunction( + (context, _) => new(context.Return(context.State.GetCurrentEnvironment())) + ), + }; + var chunk = state.Load( + "local x=1; return function() local y=x; return y,read() end", + "current-environment", + environment + ); + var nested = (await state.ExecuteAsync(chunk))[0].Read(); + var results = await state.ExecuteAsync(nested); + Assert.That(results[0], Is.EqualTo(new LuaValue(1))); + Assert.That(results[1].Read(), Is.SameAs(environment)); + } + + [TestCase(false)] + [TestCase(true)] + public async Task ReloadedFunctionInitializesAllUpvaluesWithoutCapturingCaller(bool duringCall) + { + using var state = CreateState(); + var original = ( + await state.DoStringAsync( + "local first,second=11,22; return function() return first,second end" + ) + )[0] + .Read(); + var bytes = original.Proto.ToBytecode(); + var initializedClosed = false; + LuaClosure Load() + { + var result = state.Load(bytes); + initializedClosed = result.UpValues.ToArray().All(value => value.IsClosed); + return result; + } + + LuaClosure loaded; + if (duringCall) + { + state.Environment["reload"] = new LuaFunction( + (context, _) => new(context.Return(Load())) + ); + loaded = (await state.DoStringAsync("return reload()"))[0].Read(); + } + else + { + loaded = Load(); + } + + Assert.That(loaded.UpValues.Length, Is.EqualTo(2)); + Assert.That(initializedClosed, Is.True); + var results = await state.ExecuteAsync(loaded); + Assert.That(results[0].Read(), Is.SameAs(state.Environment)); + Assert.That(results[1], Is.EqualTo(LuaValue.Nil)); + Assert.That((await state.DoStringAsync("return 42"))[0], Is.EqualTo(new LuaValue(42))); + } + + [Test] + public async Task FunctionWithoutUpvaluesStaysWithoutUpvaluesWhenReloaded() + { + using var state = CreateState(); + var original = (await state.DoStringAsync("return function() return 42 end"))[0] + .Read(); + var loaded = state.Load(original.Proto.ToBytecode()); + Assert.That(loaded.UpValues.Length, Is.Zero); + Assert.That((await state.ExecuteAsync(loaded))[0], Is.EqualTo(new LuaValue(42))); + } +} diff --git a/tests/Lua.Tests/LoadEnvironmentTests.cs b/tests/Lua.Tests/LoadEnvironmentTests.cs index 6b732925..6be8c1dc 100644 --- a/tests/Lua.Tests/LoadEnvironmentTests.cs +++ b/tests/Lua.Tests/LoadEnvironmentTests.cs @@ -1,5 +1,6 @@ using System.Text; using Lua.IO; +using Lua.Runtime; using Lua.Standard; namespace Lua.Tests; @@ -57,4 +58,27 @@ await File.WriteAllBytesAsync( Assert.That(result, Has.Length.EqualTo(1)); Assert.That(result[0], Is.EqualTo(LuaValue.Nil)); } + + [Test] + public async Task LoadFileSetsFirstUpvalueRegardlessOfItsName() + { + using var state = LuaState.Create(); + state.Platform = state.Platform with { FileSystem = new FileSystem(testDirectory) }; + state.OpenStandardLibraries(); + var original = ( + await state.DoStringAsync( + "local first,second=11,22; return function() return first,second end" + ) + )[0] + .Read(); + await File.WriteAllBytesAsync( + Path.Combine(testDirectory, "captured.luac"), + original.Proto.ToBytecode() + ); + var results = await state.DoStringAsync( + "local f=assert(loadfile('captured.luac','b',{value=42})); return f()" + ); + Assert.That(results[0].Read()["value"], Is.EqualTo(new LuaValue(42))); + Assert.That(results[1], Is.EqualTo(LuaValue.Nil)); + } } From f4ae4adaa01d54f58a2697651e525ff327940cb3 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:31:50 +0900 Subject: [PATCH 03/32] fix: encode closure prototype indices as unsigned operands --- src/Lua/Runtime/Instruction.cs | 2 +- tests/Lua.Tests/InstructionTests.cs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Lua/Runtime/Instruction.cs b/src/Lua/Runtime/Instruction.cs index a1531e7c..4d2204ec 100644 --- a/src/Lua/Runtime/Instruction.cs +++ b/src/Lua/Runtime/Instruction.cs @@ -998,7 +998,7 @@ public static Instruction Closure(byte a, int sBx) { OpCode = OpCode.Closure, A = a, - SBx = sBx, + Bx = sBx, }; } diff --git a/tests/Lua.Tests/InstructionTests.cs b/tests/Lua.Tests/InstructionTests.cs index 591cfb0c..df585798 100644 --- a/tests/Lua.Tests/InstructionTests.cs +++ b/tests/Lua.Tests/InstructionTests.cs @@ -4,6 +4,19 @@ namespace Lua.Tests; public class InstructionTests { + [TestCase(0)] + [TestCase(17)] + [TestCase(Instruction.MaxArgBx)] + public void ClosureFactoryEncodesUnsignedPrototypeIndex(int prototypeIndex) + { + var instruction = Instruction.Closure(3, prototypeIndex); + Assert.That( + instruction.Value, + Is.EqualTo(Instruction.CreateABx(OpCode.Closure, 3, prototypeIndex)) + ); + Assert.That(instruction.Bx, Is.EqualTo(prototypeIndex)); + } + [Test] public void Test() { From c630fe5abf557a38e5139900a8bd148508f94398 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:20:26 +0900 Subject: [PATCH 04/32] fix: release retained runtime references and align Lua 5.2 loading Detach closed captures, clear reference-containing pooled buffers and released compiler sources, and share terminal cleanup for wrapped coroutine failures. Initialize loaded upvalues according to Lua 5.2 and name the closure factory operand bx. Add 20 regression cases. Validation: 1,686 runtime tests pass on each of .NET 8 and .NET 10; all four library targets build cleanly. Allocations are unchanged across ten measured workloads. --- .../Compilation/PrototypeBuilder.cs | 1 + src/Lua/Internal/CoroutineCore.cs | 38 +++--- src/Lua/Internal/PooledList.cs | 16 ++- src/Lua/Runtime/Instruction.cs | 4 +- src/Lua/Runtime/LuaClosure.cs | 10 +- src/Lua/Runtime/UpValue.cs | 3 +- tests/Lua.Tests/ClosureBindingTests.cs | 32 ++++- tests/Lua.Tests/CoroutineLifetimeTests.cs | 61 +++++++++ tests/Lua.Tests/LoadEnvironmentTests.cs | 17 ++- tests/Lua.Tests/MemoryLifetimeTests.cs | 127 ++++++++++++++++++ 10 files changed, 268 insertions(+), 41 deletions(-) create mode 100644 tests/Lua.Tests/CoroutineLifetimeTests.cs create mode 100644 tests/Lua.Tests/MemoryLifetimeTests.cs diff --git a/src/Lua/CodeAnalysis/Compilation/PrototypeBuilder.cs b/src/Lua/CodeAnalysis/Compilation/PrototypeBuilder.cs index 5ea73fcc..b3421253 100644 --- a/src/Lua/CodeAnalysis/Compilation/PrototypeBuilder.cs +++ b/src/Lua/CodeAnalysis/Compilation/PrototypeBuilder.cs @@ -65,6 +65,7 @@ internal static PrototypeBuilder Get(string source) internal void Release() { + Source = string.Empty; ConstantsList.Clear(); CodeList.Clear(); PrototypeList.Clear(); diff --git a/src/Lua/Internal/CoroutineCore.cs b/src/Lua/Internal/CoroutineCore.cs index e1c84cc7..dd421374 100644 --- a/src/Lua/Internal/CoroutineCore.cs +++ b/src/Lua/Internal/CoroutineCore.cs @@ -190,30 +190,26 @@ internal async ValueTask ResumeAsyncCore( } catch (Exception ex) when (ex is not OperationCanceledException) { - if (IsProtectedMode) + if (ex is ILuaTracebackBuildable tracebackBuildable) { - if (ex is ILuaTracebackBuildable tracebackBuildable) - { - traceback = tracebackBuildable.BuildOrGet(); - } - - Volatile.Write(ref status, (byte)LuaThreadStatus.Dead); - Thread.Dispose(); - stack.PopUntil(returnBase); - stack.Push(false); - // Wrapped C# exceptions carry a message rather than a Lua error object. - // Keep error(nil) intact when the exception originated in Lua. - stack.Push( - ex is LuaRuntimeException { InnerException: null } luaEx - ? luaEx.ErrorObject - : ex.Message - ); - return 2; + traceback = tracebackBuildable.BuildOrGet(); } - else - { + + Volatile.Write(ref status, (byte)LuaThreadStatus.Dead); + Thread.Dispose(); + if (!IsProtectedMode) throw; - } + + stack.PopUntil(returnBase); + stack.Push(false); + // Wrapped C# exceptions carry a message rather than a Lua error object. + // Keep error(nil) intact when the exception originated in Lua. + stack.Push( + ex is LuaRuntimeException { InnerException: null } luaEx + ? luaEx.ErrorObject + : ex.Message + ); + return 2; } finally { diff --git a/src/Lua/Internal/PooledList.cs b/src/Lua/Internal/PooledList.cs index 50aed4ea..208b231f 100644 --- a/src/Lua/Internal/PooledList.cs +++ b/src/Lua/Internal/PooledList.cs @@ -30,9 +30,12 @@ public void Add(in T item) else if (buffer.Length == tail) { var newArray = ArrayPool.Shared.Rent(tail * 2); - buffer.AsSpan().CopyTo(newArray); - ArrayPool.Shared.Return(buffer); + buffer.AsSpan(0, tail).CopyTo(newArray); + newArray[tail] = item; + ArrayPool.Shared.Return(buffer, RuntimeHelpers.IsReferenceOrContainsReferences()); buffer = newArray; + tail++; + return; } buffer[tail] = item; @@ -56,9 +59,12 @@ public void AddRange(scoped ReadOnlySpan items) } var newArray = ArrayPool.Shared.Rent(newSize); - buffer.AsSpan().CopyTo(newArray); - ArrayPool.Shared.Return(buffer); + buffer.AsSpan(0, tail).CopyTo(newArray); + items.CopyTo(newArray.AsSpan(tail)); + ArrayPool.Shared.Return(buffer, RuntimeHelpers.IsReferenceOrContainsReferences()); buffer = newArray; + tail += items.Length; + return; } items.CopyTo(buffer.AsSpan()[tail..]); @@ -107,7 +113,7 @@ public void Dispose() if (buffer != null) { - ArrayPool.Shared.Return(buffer); + ArrayPool.Shared.Return(buffer, RuntimeHelpers.IsReferenceOrContainsReferences()); buffer = null; } diff --git a/src/Lua/Runtime/Instruction.cs b/src/Lua/Runtime/Instruction.cs index 4d2204ec..566f9827 100644 --- a/src/Lua/Runtime/Instruction.cs +++ b/src/Lua/Runtime/Instruction.cs @@ -992,13 +992,13 @@ public static Instruction SetList(byte a, ushort b, ushort c) /// /// R(A) := closure(KPROTO[Bx]) /// - public static Instruction Closure(byte a, int sBx) + public static Instruction Closure(byte a, int bx) { return new() { OpCode = OpCode.Closure, A = a, - Bx = sBx, + Bx = bx, }; } diff --git a/src/Lua/Runtime/LuaClosure.cs b/src/Lua/Runtime/LuaClosure.cs index 622de4bc..55e7e08c 100644 --- a/src/Lua/Runtime/LuaClosure.cs +++ b/src/Lua/Runtime/LuaClosure.cs @@ -17,8 +17,9 @@ public sealed class LuaClosure : LuaFunction } /// - /// Creates a loaded chunk with its first upvalue set to the environment - /// and any remaining upvalues set to nil. + /// Creates a loaded chunk with closed upvalues initialized to nil. + /// An explicit environment initializes the first upvalue; otherwise the global + /// environment initializes it only when the chunk has a single upvalue. /// public LuaClosure(LuaState state, Prototype proto, LuaTable? environment = null) : this(proto) @@ -26,7 +27,10 @@ public LuaClosure(LuaState state, Prototype proto, LuaTable? environment = null) if (proto.UpValues.IsEmpty) return; - upValues[0] = UpValue.Closed(environment ?? state.Environment); + var firstUpValue = LuaValue.Nil; + if (environment != null || upValues.Length == 1) + firstUpValue = environment ?? state.Environment; + upValues[0] = UpValue.Closed(firstUpValue); for (var i = 1; i < proto.UpValues.Length; i++) upValues[i] = UpValue.Closed(LuaValue.Nil); } diff --git a/src/Lua/Runtime/UpValue.cs b/src/Lua/Runtime/UpValue.cs index 5050b912..96697c80 100644 --- a/src/Lua/Runtime/UpValue.cs +++ b/src/Lua/Runtime/UpValue.cs @@ -6,7 +6,7 @@ public sealed class UpValue { LuaValue value; - public LuaState? Thread { get; } + public LuaState? Thread { get; private set; } public bool IsClosed { get; private set; } public int RegisterIndex { get; private set; } @@ -70,6 +70,7 @@ public void Close() if (!IsClosed) { value = Thread!.Stack.Get(RegisterIndex); + Thread = null; } IsClosed = true; diff --git a/tests/Lua.Tests/ClosureBindingTests.cs b/tests/Lua.Tests/ClosureBindingTests.cs index aec88fe6..ccf9bf0d 100644 --- a/tests/Lua.Tests/ClosureBindingTests.cs +++ b/tests/Lua.Tests/ClosureBindingTests.cs @@ -64,9 +64,14 @@ public async Task CurrentEnvironmentCanFollowAnotherCapturedVariable() Assert.That(results[1].Read(), Is.SameAs(environment)); } - [TestCase(false)] - [TestCase(true)] - public async Task ReloadedFunctionInitializesAllUpvaluesWithoutCapturingCaller(bool duringCall) + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + public async Task ReloadedFunctionInitializesAllUpvaluesWithoutCapturingCaller( + bool duringCall, + bool explicitEnvironment + ) { using var state = CreateState(); var original = ( @@ -76,10 +81,11 @@ await state.DoStringAsync( )[0] .Read(); var bytes = original.Proto.ToBytecode(); + var environment = explicitEnvironment ? new LuaTable() : null; var initializedClosed = false; LuaClosure Load() { - var result = state.Load(bytes); + var result = state.Load(bytes, environment: environment); initializedClosed = result.UpValues.ToArray().All(value => value.IsClosed); return result; } @@ -100,11 +106,27 @@ LuaClosure Load() Assert.That(loaded.UpValues.Length, Is.EqualTo(2)); Assert.That(initializedClosed, Is.True); var results = await state.ExecuteAsync(loaded); - Assert.That(results[0].Read(), Is.SameAs(state.Environment)); + Assert.That( + results[0], + Is.EqualTo(environment is null ? LuaValue.Nil : new LuaValue(environment)) + ); Assert.That(results[1], Is.EqualTo(LuaValue.Nil)); Assert.That((await state.DoStringAsync("return 42"))[0], Is.EqualTo(new LuaValue(42))); } + [Test] + public async Task ReloadedFunctionWithOneUpvalueDefaultsToGlobalEnvironment() + { + using var state = CreateState(); + var original = ( + await state.DoStringAsync("local value=42; return function() return value end") + )[0] + .Read(); + var loaded = state.Load(original.Proto.ToBytecode()); + var results = await state.ExecuteAsync(loaded); + Assert.That(results[0].Read(), Is.SameAs(state.Environment)); + } + [Test] public async Task FunctionWithoutUpvaluesStaysWithoutUpvaluesWhenReloaded() { diff --git a/tests/Lua.Tests/CoroutineLifetimeTests.cs b/tests/Lua.Tests/CoroutineLifetimeTests.cs new file mode 100644 index 00000000..7d1f35f1 --- /dev/null +++ b/tests/Lua.Tests/CoroutineLifetimeTests.cs @@ -0,0 +1,61 @@ +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +public class CoroutineLifetimeTests +{ + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + public async Task FailedCoroutineReleasesStackAndPreservesError(bool wrap, bool yieldFirst) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + state.Environment["wrap"] = wrap; + state.Environment["yieldFirst"] = yieldFirst; + var results = await state.DoStringAsync( + """ + local thread + local failure={} + local read + local function body() + thread=coroutine.running() + local captured=42 + read=function() return captured end + if yieldFirst then coroutine.yield() end + error(failure) + end + local run + if wrap then + run=coroutine.wrap(body) + else + thread=coroutine.create(body) + run=function() return coroutine.resume(thread) end + end + local function resume() + if wrap then return pcall(run) end + return run() + end + if yieldFirst then resume() end + local ok,err=resume() + local again,why=resume() + return thread,ok,err==failure,coroutine.status(thread),again,why,read(),debug.traceback(thread) + """, + "coroutine-lifetime" + ); + var thread = results[0].Read(); + Assert.That(results[1], Is.EqualTo(new LuaValue(false))); + Assert.That(results[2], Is.EqualTo(new LuaValue(true))); + Assert.That(results[3].Read(), Is.EqualTo("dead")); + Assert.That(results[4], Is.EqualTo(new LuaValue(false))); + Assert.That(results[5].Read(), Does.Contain("cannot resume dead coroutine")); + Assert.That(results[6], Is.EqualTo(new LuaValue(42))); + Assert.That(results[7].Read(), Does.Contain("coroutine-lifetime")); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead)); + Assert.That(thread.GetStackValues().Length, Is.Zero); + Assert.That(thread.CallStackFrameCount, Is.Zero); + Assert.That((await state.DoStringAsync("return 42"))[0], Is.EqualTo(new LuaValue(42))); + } +} diff --git a/tests/Lua.Tests/LoadEnvironmentTests.cs b/tests/Lua.Tests/LoadEnvironmentTests.cs index 6be8c1dc..3cde6ae0 100644 --- a/tests/Lua.Tests/LoadEnvironmentTests.cs +++ b/tests/Lua.Tests/LoadEnvironmentTests.cs @@ -59,8 +59,11 @@ await File.WriteAllBytesAsync( Assert.That(result[0], Is.EqualTo(LuaValue.Nil)); } - [Test] - public async Task LoadFileSetsFirstUpvalueRegardlessOfItsName() + [TestCase("")] + [TestCase(",nil")] + [TestCase(",false")] + [TestCase(",{value=42}")] + public async Task LoadFileInitializesMultipleUpvalues(string environmentArgument) { using var state = LuaState.Create(); state.Platform = state.Platform with { FileSystem = new FileSystem(testDirectory) }; @@ -76,9 +79,15 @@ await File.WriteAllBytesAsync( original.Proto.ToBytecode() ); var results = await state.DoStringAsync( - "local f=assert(loadfile('captured.luac','b',{value=42})); return f()" + "local f=assert(loadfile('captured.luac','b'" + environmentArgument + ")); return f()" ); - Assert.That(results[0].Read()["value"], Is.EqualTo(new LuaValue(42))); + if (environmentArgument == ",{value=42}") + Assert.That(results[0].Read()["value"], Is.EqualTo(new LuaValue(42))); + else + Assert.That( + results[0], + Is.EqualTo(environmentArgument == ",false" ? new LuaValue(false) : LuaValue.Nil) + ); Assert.That(results[1], Is.EqualTo(LuaValue.Nil)); } } diff --git a/tests/Lua.Tests/MemoryLifetimeTests.cs b/tests/Lua.Tests/MemoryLifetimeTests.cs new file mode 100644 index 00000000..8cf25926 --- /dev/null +++ b/tests/Lua.Tests/MemoryLifetimeTests.cs @@ -0,0 +1,127 @@ +using System.Runtime.CompilerServices; +using Lua.Internal; +using Lua.Runtime; + +namespace Lua.Tests; + +public class MemoryLifetimeTests +{ + [Test] + public void ClosedCaptureDoesNotRootOriginatingState() + { + var (closure, reference) = CreateClosedCapture(); + var upValue = closure.UpValues[0]; + Assert.That(upValue.IsClosed, Is.True); + Assert.That(upValue.Thread, Is.Null); + AssertCollected(reference); + upValue.Close(); + using var state = LuaState.Create(); + Assert.That( + state.ExecuteAsync(closure).AsTask().GetAwaiter().GetResult()[0], + Is.EqualTo(new LuaValue(42)) + ); + upValue.SetValue(43); + Assert.That(upValue.GetValue(), Is.EqualTo(new LuaValue(43))); + GC.KeepAlive(closure); + } + + [Test] + public void DiscardedCompilationDoesNotRootSource() + { + AssertCollected(CompileAndDiscard()); + } + + [Test] + public void ClearedTableDoesNotRootMigratedValue() + { + AssertCollected(MigrateAndClearTable()); + } + + [TestCase(false)] + [TestCase(true)] + public void DisposedPooledListDoesNotRootValues(bool pop) + { + AssertCollected(AddAndDispose(pop)); + } + + [TestCase(false)] + [TestCase(true)] + public void PooledListGrowthPreservesAliasedInputAndReleasesOldBuffer(bool range) + { + AssertCollected(GrowAndClear(range)); + } + + static void AssertCollected(WeakReference reference) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + Assert.That(reference.IsAlive, Is.False); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static (LuaClosure, WeakReference) CreateClosedCapture() + { + using var state = LuaState.Create(); + var closure = state + .DoStringAsync("local x=42; return function() return x end") + .AsTask() + .GetAwaiter() + .GetResult()[0] + .Read(); + return (closure, new WeakReference(state)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference CompileAndDiscard() + { + var source = "--" + new string('x', 65536) + "\nreturn 42"; + using var state = LuaState.Create(); + state.DoStringAsync(source).AsTask().GetAwaiter().GetResult(); + return new WeakReference(source); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference MigrateAndClearTable() + { + var payload = new object(); + var table = new LuaTable(0, 0); + table[17] = LuaValue.FromObject(payload); + for (var i = 1; i <= 16; i++) + table[i] = i; + Assert.That(table[17].Read(), Is.SameAs(payload)); + table.Clear(); + return new WeakReference(payload); + } + + sealed class Payload; + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference AddAndDispose(bool pop) + { + using var list = new PooledList(16); + var payload = new Payload(); + list.Add(payload); + if (pop) + list.Pop(1); + return new WeakReference(payload); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference GrowAndClear(bool range) + { + using var list = new PooledList(16); + var payload = new Payload(); + for (var i = 0; i < 16; i++) + list.Add(payload); + if (range) + list.AddRange(list.AsSpan()); + else + list.Add(in list.AsSpan()[0]); + Assert.That(list.Count, Is.EqualTo(range ? 32 : 17)); + for (var i = 0; i < list.Count; i++) + Assert.That(list[i], Is.SameAs(payload)); + list.Clear(); + return new WeakReference(payload); + } +} From 5ea35a339d1a28c2d0ab4049b354fe5415e521f8 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:32:02 +0900 Subject: [PATCH 05/32] fix: release cancelled coroutines and repair runtime diagnostics Apply terminal cleanup before propagating coroutine cancellation, and release completed task references. Scope tail-call diagnostic references and program counters to each RunAsync invocation so nested executions restore their caller. Clear removed exception frames, preserve frame positions when growing reversed stacks, and correct the upvalue and traceback indices used by error reporting. Add 24 regression cases covering cancellation, retained values, mixed Lua/C# tracebacks, and captured environment errors in both string modes. All 1,710 runtime tests pass on each of .NET 8 and .NET 10, and all four library targets build without warnings. Eleven measured workloads stay within about 3% of baseline; allocations are unchanged except for 16 bytes of coroutine diagnostic bookkeeping. --- src/Lua/Exceptions.cs | 2 +- src/Lua/Internal/CoroutineCore.cs | 6 +- src/Lua/Internal/ReversedStack.cs | 20 +++-- src/Lua/LuaState.cs | 4 + src/Lua/Runtime/Tracebacks.cs | 2 +- tests/Lua.Tests/ClosureBindingTests.cs | 17 ++++ tests/Lua.Tests/CoroutineLifetimeTests.cs | 70 +++++++++++++++++ tests/Lua.Tests/MemoryLifetimeTests.cs | 95 +++++++++++++++++++++++ tests/Lua.Tests/TracebackTests.cs | 86 ++++++++++++++++++++ 9 files changed, 293 insertions(+), 9 deletions(-) create mode 100644 tests/Lua.Tests/TracebackTests.cs diff --git a/src/Lua/Exceptions.cs b/src/Lua/Exceptions.cs index 82555a12..864eacd5 100644 --- a/src/Lua/Exceptions.cs +++ b/src/Lua/Exceptions.cs @@ -328,7 +328,7 @@ internal static void AttemptInvalidOperationOnUpValues(LuaState state, string op var proto = closure.Proto; var upValue = proto.UpValues[reg]; - var luaValue = closure.UpValues[upValue.Index].GetValue(); + var luaValue = closure.UpValues[reg].GetValue(); var name = upValue.Name; throw new LuaRuntimeException( diff --git a/src/Lua/Internal/CoroutineCore.cs b/src/Lua/Internal/CoroutineCore.cs index dd421374..b00c9e6c 100644 --- a/src/Lua/Internal/CoroutineCore.cs +++ b/src/Lua/Internal/CoroutineCore.cs @@ -171,6 +171,7 @@ internal async ValueTask ResumeAsyncCore( if (functionTask.IsCompleted || (result0 = await resumeTask).IsDead) { _ = functionTask.Result; + functionTask = default; Volatile.Write(ref status, (byte)LuaThreadStatus.Dead); stack.PopUntil(returnBase); stack.Push(true); @@ -188,8 +189,9 @@ internal async ValueTask ResumeAsyncCore( return results.Length + 1; } } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (Exception ex) { + functionTask = default; if (ex is ILuaTracebackBuildable tracebackBuildable) { traceback = tracebackBuildable.BuildOrGet(); @@ -197,7 +199,7 @@ internal async ValueTask ResumeAsyncCore( Volatile.Write(ref status, (byte)LuaThreadStatus.Dead); Thread.Dispose(); - if (!IsProtectedMode) + if (!IsProtectedMode || ex is OperationCanceledException) throw; stack.PopUntil(returnBase); diff --git a/src/Lua/Internal/ReversedStack.cs b/src/Lua/Internal/ReversedStack.cs index 3f228288..f3347276 100644 --- a/src/Lua/Internal/ReversedStack.cs +++ b/src/Lua/Internal/ReversedStack.cs @@ -1,4 +1,6 @@ -namespace Lua.Internal; +using System.Runtime.CompilerServices; + +namespace Lua.Internal; sealed class ReversedStack(int capacity = 16) { @@ -42,7 +44,9 @@ static void ThrowEmpty() throw new InvalidOperationException("Cannot pop an empty stack"); } - return buffer[tail++]; + var element = buffer[tail]; + buffer[tail++] = default!; + return element; } public void Pop(int count) @@ -57,6 +61,8 @@ static void ThrowEmpty() throw new InvalidOperationException("Cannot pop more elements than exist in the stack"); } + if (RuntimeHelpers.IsReferenceOrContainsReferences()) + buffer.AsSpan(tail, count).Clear(); tail += count; } @@ -68,11 +74,14 @@ public void TryPop(out T? element) return; } - element = buffer[tail++]; + element = buffer[tail]; + buffer[tail++] = default!; } public void Clear() { + if (RuntimeHelpers.IsReferenceOrContainsReferences()) + AsSpan().Clear(); tail = buffer.Length; } @@ -111,8 +120,9 @@ void Resize(int requiredCapacity) } var newBuffer = new T[newCapacity]; - AsSpan().CopyTo(newBuffer.AsSpan(newCapacity - buffer.Length)); - tail = newCapacity - (buffer.Length - tail); + var newTail = newCapacity - Length; + AsSpan().CopyTo(newBuffer.AsSpan(newTail)); + tail = newTail; buffer = newBuffer; } } diff --git a/src/Lua/LuaState.cs b/src/Lua/LuaState.cs index c6173c38..bce86548 100644 --- a/src/Lua/LuaState.cs +++ b/src/Lua/LuaState.cs @@ -579,6 +579,8 @@ public async ValueTask RunAsync( ReturnFrameBase = returnBase, }; var callStackTop = state.CallStackFrameCount; + var previousCallerFunction = state.LastCallerFunction; + var previousPc = state.LastPc; try { if (CallOrReturnHookMask.Value != 0 && !IsInHook) @@ -591,6 +593,8 @@ public async ValueTask RunAsync( finally { PopCallStackFrameUntil(callStackTop - 1); + LastCallerFunction = previousCallerFunction; + LastPc = previousPc; } } diff --git a/src/Lua/Runtime/Tracebacks.cs b/src/Lua/Runtime/Tracebacks.cs index d0964c70..c3934d0e 100644 --- a/src/Lua/Runtime/Tracebacks.cs +++ b/src/Lua/Runtime/Tracebacks.cs @@ -98,7 +98,7 @@ public int FirstLine { var stackFrames = StackFrames; - for (var index = 1; index <= stackFrames.Length; index++) + for (var index = 1; index < stackFrames.Length; index++) { var lastFunc = stackFrames[index - 1].Function; var frame = stackFrames[index]; diff --git a/tests/Lua.Tests/ClosureBindingTests.cs b/tests/Lua.Tests/ClosureBindingTests.cs index ccf9bf0d..7efc7cb0 100644 --- a/tests/Lua.Tests/ClosureBindingTests.cs +++ b/tests/Lua.Tests/ClosureBindingTests.cs @@ -1,5 +1,6 @@ using Lua.Platforms; using Lua.Runtime; +using Lua.Standard; namespace Lua.Tests; @@ -12,6 +13,22 @@ LuaState CreateState() => stringMode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default ); + [TestCase(false)] + [TestCase(true)] + public async Task IndexingCapturedEnvironmentReportsLuaError(bool setter) + { + using var state = CreateState(); + state.OpenStandardLibraries(); + var results = await state.DoStringAsync( + "local protected=pcall; local a,b,c,d=1,2,3,4; local _ENV=1; local function f() " + + (setter ? "missing=42" : "return missing") + + " end; return protected(f)" + ); + Assert.That(results[0], Is.EqualTo(new LuaValue(false))); + Assert.That(results[1].Read(), Does.Contain("attempt to index a number value")); + Assert.That(results[1].Read(), Does.Contain("upvalue '_ENV'")); + } + [TestCase(false)] [TestCase(true)] public async Task NestedClosureCapturesLocalEnvironmentAtRegisterZero(bool bytecode) diff --git a/tests/Lua.Tests/CoroutineLifetimeTests.cs b/tests/Lua.Tests/CoroutineLifetimeTests.cs index 7d1f35f1..c172e717 100644 --- a/tests/Lua.Tests/CoroutineLifetimeTests.cs +++ b/tests/Lua.Tests/CoroutineLifetimeTests.cs @@ -5,6 +5,76 @@ namespace Lua.Tests; public class CoroutineLifetimeTests { + [Test] + public async Task CancelledCoroutineReleasesStackAndPreservesCancellation( + [Values] bool wrap, + [Values] bool yieldFirst, + [Values] bool cancellableCallback + ) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + using var cancellation = new CancellationTokenSource(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + state.Environment["pause"] = new LuaFunction( + async (context, token) => + { + entered.SetResult(); + if (cancellableCallback) + await release.Task.WaitAsync(token); + else + await release.Task; + return context.Return(); + } + ); + state.Environment["wrap"] = wrap; + state.Environment["yieldFirst"] = yieldFirst; + var execution = state + .DoStringAsync( + """ + local function body() + thread=coroutine.running() + local captured=42 + saved=function() return captured end + if yieldFirst then coroutine.yield() end + pause() + return captured + end + if wrap then + run=coroutine.wrap(body) + else + thread=coroutine.create(body) + run=function() return coroutine.resume(thread) end + end + if yieldFirst then run() end + return run() + """, + "coroutine-cancellation", + cancellation.Token + ) + .AsTask(); + await entered.Task; + cancellation.Cancel(); + release.TrySetResult(); + var exception = Assert.ThrowsAsync(async () => await execution)!; + Assert.That(exception.CancellationToken, Is.EqualTo(cancellation.Token)); + Assert.That(exception.LuaTraceback!.ToString(), Does.Contain("coroutine-cancellation")); + var thread = state.Environment["thread"].Read(); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead)); + Assert.That(thread.GetStackValues().Length, Is.Zero); + Assert.That(thread.CallStackFrameCount, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + Assert.That(state.GetStatus(), Is.EqualTo(LuaThreadStatus.Running)); + state.Stack.Clear(); + var results = await state.DoStringAsync( + "local ok,err; if wrap then ok,err=pcall(run) else ok,err=run() end; return ok,err,saved()" + ); + Assert.That(results[0], Is.EqualTo(new LuaValue(false))); + Assert.That(results[1].Read(), Does.Contain("cannot resume dead coroutine")); + Assert.That(results[2], Is.EqualTo(new LuaValue(42))); + } + [TestCase(false, false)] [TestCase(true, false)] [TestCase(false, true)] diff --git a/tests/Lua.Tests/MemoryLifetimeTests.cs b/tests/Lua.Tests/MemoryLifetimeTests.cs index 8cf25926..216cb742 100644 --- a/tests/Lua.Tests/MemoryLifetimeTests.cs +++ b/tests/Lua.Tests/MemoryLifetimeTests.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; using Lua.Internal; using Lua.Runtime; +using Lua.Standard; namespace Lua.Tests; @@ -51,6 +52,39 @@ public void PooledListGrowthPreservesAliasedInputAndReleasesOldBuffer(bool range AssertCollected(GrowAndClear(range)); } + [TestCase(false)] + [TestCase(true)] + public void CompletedTailCallDoesNotRootCapturedValue(bool asynchronous) + { + using var state = LuaState.Create(); + var reference = RunTailCall(state, asynchronous); + AssertCollected(reference); + state.DoStringAsync("return 42").AsTask().GetAwaiter().GetResult(); + GC.KeepAlive(state); + } + + [Test] + public void ClearedExceptionTraceDoesNotRootDiscardedFrames() + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + var reference = HandleError(state); + state.DoStringAsync("return 42").AsTask().GetAwaiter().GetResult(); + AssertCollected(reference); + GC.KeepAlive(state); + } + + [TestCase("clear")] + [TestCase("pop")] + [TestCase("pop-range")] + [TestCase("try-pop")] + public void RemovedReversedStackValuesAreCollectible(string operation) + { + var stack = new ReversedStack(); + AssertCollected(RemoveReversedStackValue(stack, operation)); + GC.KeepAlive(stack); + } + static void AssertCollected(WeakReference reference) { GC.Collect(); @@ -96,6 +130,67 @@ static WeakReference MigrateAndClearTable() sealed class Payload; + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference RunTailCall(LuaState state, bool asynchronous) + { + var payload = new object(); + state.Environment["payload"] = LuaValue.FromObject(payload); + state.Environment["sink"] = new LuaFunction( + async (context, _) => + { + if (asynchronous) + await Task.Yield(); + return context.Return(42); + } + ); + state + .DoStringAsync( + "local p=payload; payload=nil; local function f() return sink(p) end; return f()" + ) + .AsTask() + .GetAwaiter() + .GetResult(); + return new WeakReference(payload); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference HandleError(LuaState state) + { + var payload = new object(); + state.Environment["payload"] = LuaValue.FromObject(payload); + state + .DoStringAsync( + "local p=payload; payload=nil; local ok=pcall(function() error(p) end); return ok" + ) + .AsTask() + .GetAwaiter() + .GetResult(); + return new WeakReference(payload); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference RemoveReversedStackValue(ReversedStack stack, string operation) + { + var payload = new Payload(); + stack.Push(payload); + switch (operation) + { + case "clear": + stack.Clear(); + break; + case "pop": + stack.Pop(); + break; + case "pop-range": + stack.Pop(1); + break; + case "try-pop": + stack.TryPop(out _); + break; + } + return new WeakReference(payload); + } + [MethodImpl(MethodImplOptions.NoInlining)] static WeakReference AddAndDispose(bool pop) { diff --git a/tests/Lua.Tests/TracebackTests.cs b/tests/Lua.Tests/TracebackTests.cs new file mode 100644 index 00000000..b3057191 --- /dev/null +++ b/tests/Lua.Tests/TracebackTests.cs @@ -0,0 +1,86 @@ +using Lua.Internal; +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +public class TracebackTests +{ + [Test] + public void ReversedStackGrowthPreservesExistingFramesAndOrder() + { + var stack = new ReversedStack(4); + stack.Push(1); + stack.Push(2); + stack.Push(new[] { 3, 4, 5 }); + Assert.That(stack.AsSpan().ToArray(), Is.EqualTo(new[] { 3, 4, 5, 2, 1 })); + } + + [Test] + public async Task FirstLineIsZeroWithoutLuaCaller() + { + using var state = LuaState.Create(); + Assert.That(state.GetTraceback().FirstLine, Is.Zero); + await state.RunAsync( + new LuaFunction( + (context, _) => new(context.Return(context.State.GetTraceback().FirstLine)) + ) + ); + Assert.That(state.Stack.Pop(), Is.EqualTo(new LuaValue(0))); + } + + [TestCase(false)] + [TestCase(true)] + public async Task NestedFailurePreservesAllTracebackFrames(bool asynchronous) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + state.Environment["bounce"] = new LuaFunction( + async (context, token) => + { + if (asynchronous) + await Task.Yield(); + await context.State.DoStringAsync("error('failure')", "inner-chunk", token); + return context.Return(); + } + ); + var exception = Assert.ThrowsAsync(async () => + await state.DoStringAsync( + "local function f(n) if n==0 then bounce() else f(n-1) end end; f(20)", + "outer-chunk" + ) + )!; + var trace = exception.LuaTraceback!; + var frames = trace.StackFrames.ToArray(); + Assert.That(frames.All(frame => frame.Function is not null), Is.True); + Assert.That(trace.ToString(), Does.Contain("inner-chunk")); + Assert.That(trace.ToString(), Does.Contain("outer-chunk")); + state.Stack.Clear(); + await state.DoStringAsync("return 42"); + Assert.That(trace.StackFrames.ToArray(), Is.EqualTo(frames)); + } + + [Test] + public async Task NestedExecutionRestoresTailCallerForArgumentErrors() + { + using var state = LuaState.Create(); + state.Environment["inner"] = new LuaFunction((context, _) => new(context.Return())); + state.Environment["outer"] = new LuaFunction( + async (context, token) => + { + await context.State.DoStringAsync( + "local function f() return inner() end; return f()", + "inner-call", + token + ); + LuaRuntimeException.BadArgument(context.State, 1, "bad value"); + return context.Return(); + } + ); + var exception = Assert.ThrowsAsync(async () => + await state.DoStringAsync("return outer()", "outer-call") + )!; + Assert.That(exception.InnerException, Is.Null); + Assert.That(exception.ErrorObject.Read(), Does.Contain("to 'outer'")); + } +} From 5f7152fdaa77ef2444e58326046335bc44dcbea6 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:30:58 +0900 Subject: [PATCH 06/32] fix: own reader buffers and finish suspended coroutine cleanup Give each UTF-8 reader a pooled buffer for its full lifetime, release it on disposal, and bound BOM inspection by the bytes read. Reject unknown read formats with Lua errors while preserving host stack prefixes. Finalize cancelled coroutine execution even when no resume call is active, and coordinate completion with copying yielded values. Allow top-level managed coroutine bodies to yield while keeping the nested C# boundary restriction. Release handled pcall exception state while preserving Lua error values. Add 23 regression cases for buffer ownership, cross-thread disposal, pooled buffer bounds, idle cancellation, managed yields, handled-error lifetimes, and invalid read formats. All 1,733 runtime tests pass on each of .NET 8 and .NET 10; all four library targets build without warnings. Fourteen measured workloads show unchanged coroutine and normal stream-read allocations; overlapping readers save 1,048 bytes per cycle at about 76 ns extra for three in-memory readers, and handled-error allocations decrease. --- src/Lua/Exceptions.cs | 12 ++- src/Lua/Internal/CoroutineCore.cs | 74 ++++++++++--- src/Lua/Internal/Utf8Reader.cs | 41 +++----- src/Lua/Standard/BasicLibrary.cs | 13 +-- src/Lua/Standard/Internal/IOHelper.cs | 9 +- tests/Lua.Tests/CoroutineLifetimeTests.cs | 122 ++++++++++++++++++++++ tests/Lua.Tests/IoReadFormatTests.cs | 65 ++++++++++++ tests/Lua.Tests/MemoryLifetimeTests.cs | 14 ++- tests/Lua.Tests/StreamLifetimeTests.cs | 96 +++++++++++++++++ 9 files changed, 396 insertions(+), 50 deletions(-) create mode 100644 tests/Lua.Tests/IoReadFormatTests.cs create mode 100644 tests/Lua.Tests/StreamLifetimeTests.cs diff --git a/src/Lua/Exceptions.cs b/src/Lua/Exceptions.cs index 864eacd5..817f1ef0 100644 --- a/src/Lua/Exceptions.cs +++ b/src/Lua/Exceptions.cs @@ -349,7 +349,10 @@ internal static (string NameWhat, string Name) GetCurrentFunctionName(LuaState s } else { - var caller = state.GetCallStackFrames()[^2]; + var frames = state.GetCallStackFrames(); + if (frames.Length < 2) + return ("function", current.Function.Name); + var caller = frames[^2]; callerFunction = caller.Function; } @@ -487,7 +490,12 @@ static string CreateMessage(Traceback traceback, LuaValue errorObject, int level internal void Forget() { - State?.ExceptionTrace.Clear(); + var state = State ?? luaTraceback?.State; + if (state != null && ReferenceEquals(state.CurrentException, this)) + { + state.CurrentException = null; + state.ExceptionTrace.Clear(); + } State = null; } diff --git a/src/Lua/Internal/CoroutineCore.cs b/src/Lua/Internal/CoroutineCore.cs index b00c9e6c..c1ecacd9 100644 --- a/src/Lua/Internal/CoroutineCore.cs +++ b/src/Lua/Internal/CoroutineCore.cs @@ -24,6 +24,12 @@ readonly struct ResumeContext(LuaStack? stack, int argCount) bool isFirstCall = true; ValueTask functionTask; + const int ResumeIdle = 0; + const int ResumeWaiting = 1; + const int ResumeYielded = 2; + const int ResumeCompleted = 3; + int resumePhase; + ManualResetValueTaskSourceCore resume; ManualResetValueTaskSourceCore yield; Traceback? traceback; @@ -97,13 +103,17 @@ internal async ValueTask ResumeAsyncCore( switch ((LuaThreadStatus)Volatile.Read(ref status)) { case LuaThreadStatus.Suspended: + var phase = Interlocked.CompareExchange( + ref resumePhase, + ResumeWaiting, + ResumeIdle + ); + if (phase == ResumeCompleted) + goto case LuaThreadStatus.Dead; + if (phase != ResumeIdle) + goto case LuaThreadStatus.Running; Volatile.Write(ref status, (byte)LuaThreadStatus.Running); - if (!isFirstCall) - { - yield.SetResult(new(stack, argCount)); - } - break; case LuaThreadStatus.Normal: case LuaThreadStatus.Running: @@ -161,31 +171,33 @@ internal async ValueTask ResumeAsyncCore( if (!functionTask.IsCompleted) { // UnsafeOnCompleted: OnCompleted would capture SyncContext and deadlock on WebGL. - functionTask - .GetAwaiter() - .UnsafeOnCompleted(() => resume.SetResult(default)); + functionTask.GetAwaiter().UnsafeOnCompleted(OnFunctionCompleted); } } + else + { + yield.SetResult(new(stack, argCount)); + } ResumeContext result0; if (functionTask.IsCompleted || (result0 = await resumeTask).IsDead) { _ = functionTask.Result; functionTask = default; - Volatile.Write(ref status, (byte)LuaThreadStatus.Dead); stack.PopUntil(returnBase); stack.Push(true); stack.PushRange(Thread.Stack.AsSpan()); Thread.Dispose(); + Volatile.Write(ref status, (byte)LuaThreadStatus.Dead); return stack.Count - returnBase; } else { - Volatile.Write(ref status, (byte)LuaThreadStatus.Suspended); var results = result0.Results; stack.PopUntil(returnBase); stack.Push(true); stack.PushRange(results); + Volatile.Write(ref status, (byte)LuaThreadStatus.Suspended); return results.Length + 1; } } @@ -197,8 +209,8 @@ internal async ValueTask ResumeAsyncCore( traceback = tracebackBuildable.BuildOrGet(); } - Volatile.Write(ref status, (byte)LuaThreadStatus.Dead); Thread.Dispose(); + Volatile.Write(ref status, (byte)LuaThreadStatus.Dead); if (!IsProtectedMode || ex is OperationCanceledException) throw; @@ -217,6 +229,11 @@ internal async ValueTask ResumeAsyncCore( { registration.Dispose(); resume.Reset(); + var phase = Interlocked.CompareExchange(ref resumePhase, ResumeIdle, ResumeYielded); + if (phase == ResumeCompleted) + CompleteSuspendedFunction(); + else if (phase == ResumeWaiting) + Interlocked.CompareExchange(ref resumePhase, ResumeIdle, ResumeWaiting); } } finally @@ -229,6 +246,36 @@ internal async ValueTask ResumeAsyncCore( } } + void OnFunctionCompleted() + { + var phase = Interlocked.Exchange(ref resumePhase, ResumeCompleted); + if (phase == ResumeWaiting) + resume.SetResult(default); + else if (phase == ResumeIdle) + CompleteSuspendedFunction(); + } + + void CompleteSuspendedFunction() + { + if (Volatile.Read(ref status) != (byte)LuaThreadStatus.Suspended) + return; + try + { + functionTask.GetAwaiter().GetResult(); + } + catch (Exception ex) + { + if (ex is ILuaTracebackBuildable tracebackBuildable) + traceback = tracebackBuildable.BuildOrGet(); + } + finally + { + functionTask = default; + Thread.Dispose(); + Volatile.Write(ref status, (byte)LuaThreadStatus.Dead); + } + } + [AsyncMethodBuilder(typeof(LightAsyncValueTaskMethodBuilder<>))] internal async ValueTask YieldAsyncCore( LuaStack stack, @@ -245,7 +292,8 @@ internal async ValueTask YieldAsyncCore( if (baseThread != null) { - if (baseThread.GetCallStackFrames()[^2].Function is not LuaClosure) + var frames = baseThread.GetCallStackFrames(); + if (frames.Length > 1 && frames[^2].Function is not LuaClosure) { throw new LuaRuntimeException( baseThread, @@ -254,6 +302,8 @@ internal async ValueTask YieldAsyncCore( } } + // Yielded values remain owned by the resumer until its copy completes. + Volatile.Write(ref resumePhase, ResumeYielded); resume.SetResult(new(stack, argCount)); CancellationTokenRegistration registration = default; diff --git a/src/Lua/Internal/Utf8Reader.cs b/src/Lua/Internal/Utf8Reader.cs index ca3a78da..d10114a0 100644 --- a/src/Lua/Internal/Utf8Reader.cs +++ b/src/Lua/Internal/Utf8Reader.cs @@ -7,21 +7,13 @@ namespace Lua.Internal; sealed class Utf8Reader { - [ThreadStatic] - static byte[]? scratchBuffer; - - [ThreadStatic] - internal static bool scratchBufferUsed; - - readonly byte[] buffer; + byte[] buffer = ArrayPool.Shared.Rent(1024); readonly Encoding encoding; readonly bool byteStrings; int bufPos, bufLen; Decoder? decoder; - const int ThreadStaticBufferSize = 1024; - /// /// In byte mode, every byte is decoded to one char (0x00-0xFF) and a UTF-8 BOM is not skipped, /// like the reference C implementation. Otherwise the stream is decoded as UTF-8. @@ -30,17 +22,6 @@ public Utf8Reader(LuaStringMode stringMode = LuaStringMode.Utf16) { byteStrings = stringMode == LuaStringMode.Bytes; encoding = byteStrings ? LuaByteEncoding.Instance : Encoding.UTF8; - - if (scratchBufferUsed) - { - buffer = new byte[ThreadStaticBufferSize]; - return; - } - - scratchBuffer ??= new byte[ThreadStaticBufferSize]; - - buffer = scratchBuffer; - scratchBufferUsed = true; } public long Remain => bufLen - bufPos; @@ -56,7 +37,10 @@ public Utf8Reader(LuaStringMode stringMode = LuaStringMode.Utf16) if (bufPos >= bufLen) { bufLen = stream.Read(buffer, 0, buffer.Length); - bufPos = !byteStrings && buffer.AsSpan().StartsWith(BomUtility.BomUtf8) ? 3 : 0; + bufPos = + !byteStrings && buffer.AsSpan(0, bufLen).StartsWith(BomUtility.BomUtf8) + ? 3 + : 0; if (bufLen <= bufPos) { break; // EOF @@ -139,7 +123,10 @@ public string ReadToEnd(Stream stream) if (bufPos >= bufLen) { bufLen = stream.Read(buffer, 0, buffer.Length); - bufPos = !byteStrings && buffer.AsSpan().StartsWith(BomUtility.BomUtf8) ? 3 : 0; + bufPos = + !byteStrings && buffer.AsSpan(0, bufLen).StartsWith(BomUtility.BomUtf8) + ? 3 + : 0; if (bufLen <= bufPos) { break; // EOF @@ -222,7 +209,10 @@ public byte ReadByte(Stream stream) if (bufPos >= bufLen) { bufLen = stream.Read(buffer, 0, buffer.Length); - bufPos = !byteStrings && buffer.AsSpan().StartsWith(BomUtility.BomUtf8) ? 3 : 0; + bufPos = + !byteStrings && buffer.AsSpan(0, bufLen).StartsWith(BomUtility.BomUtf8) + ? 3 + : 0; if (bufLen <= bufPos) { break; // EOF @@ -476,6 +466,9 @@ int PeekByte(Stream stream, int offset = 0) public void Dispose() { - scratchBufferUsed = false; + var rented = buffer; + buffer = []; + bufPos = bufLen = 0; + ArrayPool.Shared.Return(rented); } } diff --git a/src/Lua/Standard/BasicLibrary.cs b/src/Lua/Standard/BasicLibrary.cs index 542ee7bd..72be5d6b 100644 --- a/src/Lua/Standard/BasicLibrary.cs +++ b/src/Lua/Standard/BasicLibrary.cs @@ -383,18 +383,13 @@ CancellationToken cancellationToken throw new LuaCanceledException(context.State, cancellationToken, ex); case LuaRuntimeException luaEx: { - if ( + LuaValue error = luaEx.InnerException == null && luaEx.ErrorObject.Type != LuaValueType.String - ) - { - return context.Return(false, luaEx.ErrorObject); - } - - using PooledList builder = new(); - var message = luaEx.MinimalMessage(context.State.Platform); + ? luaEx.ErrorObject + : luaEx.MinimalMessage(context.State.Platform); luaEx.Forget(); - return context.Return(false, message); + return context.Return(false, error); } default: return context.Return(false, context.State.Platform.GetErrorMessage(ex)); diff --git a/src/Lua/Standard/Internal/IOHelper.cs b/src/Lua/Standard/Internal/IOHelper.cs index 09e4488c..649ffb03 100644 --- a/src/Lua/Standard/Internal/IOHelper.cs +++ b/src/Lua/Standard/Internal/IOHelper.cs @@ -163,6 +163,13 @@ CancellationToken cancellationToken return i + 1; } break; + default: + LuaRuntimeException.BadArgument( + state, + startArgumentIndex + i + 1, + "invalid format" + ); + break; } } else if (format.TryRead(out var count)) @@ -182,7 +189,7 @@ CancellationToken cancellationToken { LuaRuntimeException.BadArgument( state, - i + 1, + startArgumentIndex + i + 1, ["string", "integer"], format.TypeToString() ); diff --git a/tests/Lua.Tests/CoroutineLifetimeTests.cs b/tests/Lua.Tests/CoroutineLifetimeTests.cs index c172e717..da3f79e9 100644 --- a/tests/Lua.Tests/CoroutineLifetimeTests.cs +++ b/tests/Lua.Tests/CoroutineLifetimeTests.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using Lua.Runtime; using Lua.Standard; @@ -5,6 +6,127 @@ namespace Lua.Tests; public class CoroutineLifetimeTests { + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + public void CancellingSuspendedCoroutineReleasesExecution(bool protectedMode, bool asynchronous) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + using var cancellation = new CancellationTokenSource(); + var (thread, reference) = StartSuspendedCoroutine( + state, + cancellation.Token, + protectedMode, + asynchronous + ); + using (thread) + { + cancellation.Cancel(); + Assert.That(() => thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead).After(1000, 10)); + Assert.That(thread.CanResume, Is.False); + Assert.That(thread.GetStackValues().Length, Is.Zero); + Assert.That(thread.CallStackFrameCount, Is.Zero); + Assert.That(thread.LuaTraceback!.ToString(), Does.Contain("suspended-cancellation")); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + Assert.That(reference.IsAlive, Is.False); + var results = new LuaStack(); + if (protectedMode) + { + thread.ResumeAsync(results).AsTask().GetAwaiter().GetResult(); + Assert.That(results[0], Is.EqualTo(new LuaValue(false))); + Assert.That( + results[1].Read(), + Does.Contain("cannot resume dead coroutine") + ); + } + else + { + Assert.ThrowsAsync(async () => + await thread.ResumeAsync(results) + ); + } + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static (LuaState, WeakReference) StartSuspendedCoroutine( + LuaState state, + CancellationToken token, + bool protectedMode, + bool asynchronous + ) + { + var payload = new object(); + state.Environment["payload"] = LuaValue.FromObject(payload); + state.Environment["pause"] = new LuaFunction( + async (context, _) => + { + if (asynchronous) + await Task.Yield(); + return context.Return(); + } + ); + var thread = state.CreateCoroutine( + state.Load( + "pause(); local p=payload; payload=nil; coroutine.yield(42); return p", + "suspended-cancellation" + ), + protectedMode + ); + var stack = new LuaStack(); + thread.ResumeAsync(stack, token).AsTask().GetAwaiter().GetResult(); + Assert.That(stack.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 42 })); + return (thread, new WeakReference(payload)); + } + + [TestCase(false)] + [TestCase(true)] + public async Task ManagedCoroutineBodyCanYieldAndReceiveResumeArguments(bool asynchronous) + { + using var state = LuaState.Create(); + using var thread = state.CreateCoroutine( + new LuaFunction( + async (context, token) => + { + if (asynchronous) + await Task.Yield(); + return await context.State.YieldAsync(context, token); + } + ) + ); + var stack = new LuaStack(); + stack.Push(7); + await thread.ResumeAsync(stack); + Assert.That(stack.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 7 })); + stack.Clear(); + stack.Push(42); + await thread.ResumeAsync(stack); + Assert.That(stack.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 42 })); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead)); + } + + [Test] + public async Task YieldAcrossNestedManagedCallStillReportsLuaError() + { + using var state = LuaState.Create(); + var nested = new LuaFunction((context, token) => context.State.YieldAsync(context, token)); + using var thread = state.CreateCoroutine( + new LuaFunction((context, token) => context.State.RunAsync(nested, token)), + true + ); + var stack = new LuaStack(); + await thread.ResumeAsync(stack); + Assert.That(stack[0], Is.EqualTo(new LuaValue(false))); + Assert.That( + stack[1].Read(), + Does.Contain("attempt to yield across a C#-call boundary") + ); + } + [Test] public async Task CancelledCoroutineReleasesStackAndPreservesCancellation( [Values] bool wrap, diff --git a/tests/Lua.Tests/IoReadFormatTests.cs b/tests/Lua.Tests/IoReadFormatTests.cs new file mode 100644 index 00000000..109202d7 --- /dev/null +++ b/tests/Lua.Tests/IoReadFormatTests.cs @@ -0,0 +1,65 @@ +using System.Text; +using Lua.IO; +using Lua.Standard; + +namespace Lua.Tests; + +public class IoReadFormatTests +{ + [TestCase("*bad", false)] + [TestCase("*bad", true)] + [TestCase("", false)] + [TestCase("", true)] + public async Task UnknownFormatRaisesLuaErrorWithoutReadingInput( + string format, + bool defaultInput + ) + { + using var stream = new LuaStream( + LuaFileOpenMode.Read, + new MemoryStream(Encoding.UTF8.GetBytes("abc")) + ); + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + state.Environment["file"] = new LuaValue(new FileHandle(stream)); + state.Environment["format"] = format; + await state.DoStringAsync("io.input(file)"); + var reader = defaultInput ? "io.read(format)" : "file:read(format)"; + var result = await state.DoStringAsync( + "local ok,err=pcall(function() return " + reader + " end); return ok,err,file:read(1)" + ); + Assert.That(result[0], Is.EqualTo(new LuaValue(false))); + Assert.That(result[1].Read(), Does.Contain("invalid format")); + Assert.That(result[2], Is.EqualTo(new LuaValue("a"))); + } + + [TestCase(false)] + [TestCase(true)] + public async Task UnknownFormatFromHostPreservesStackPrefix(bool defaultInput) + { + using var stream = new LuaStream( + LuaFileOpenMode.Read, + new MemoryStream(Encoding.UTF8.GetBytes("abc")) + ); + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + var file = new LuaValue(new FileHandle(stream)); + state.Environment["file"] = file; + await state.DoStringAsync("io.input(file)"); + var read = ( + await state.DoStringAsync(defaultInput ? "return io.read" : "return file.read") + )[0] + .Read(); + state.Push(42); + if (!defaultInput) + state.Push(file); + state.Push("*bad"); + var exception = Assert.ThrowsAsync(async () => + await state.RunAsync(read, defaultInput ? 1 : 2) + )!; + Assert.That(exception.InnerException, Is.Null); + Assert.That(exception.ErrorObject.Read(), Does.Contain("invalid format")); + Assert.That(state.Stack.Count, Is.EqualTo(1)); + Assert.That(state.Stack.Pop(), Is.EqualTo(new LuaValue(42))); + } +} diff --git a/tests/Lua.Tests/MemoryLifetimeTests.cs b/tests/Lua.Tests/MemoryLifetimeTests.cs index 216cb742..ab23381f 100644 --- a/tests/Lua.Tests/MemoryLifetimeTests.cs +++ b/tests/Lua.Tests/MemoryLifetimeTests.cs @@ -74,6 +74,16 @@ public void ClearedExceptionTraceDoesNotRootDiscardedFrames() GC.KeepAlive(state); } + [TestCase(false)] + [TestCase(true)] + public void HandledErrorDoesNotRootPayloadWhileStateIsIdle(bool text) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + AssertCollected(HandleError(state, text)); + GC.KeepAlive(state); + } + [TestCase("clear")] [TestCase("pop")] [TestCase("pop-range")] @@ -154,9 +164,9 @@ static WeakReference RunTailCall(LuaState state, bool asynchronous) } [MethodImpl(MethodImplOptions.NoInlining)] - static WeakReference HandleError(LuaState state) + static WeakReference HandleError(LuaState state, bool text = false) { - var payload = new object(); + object payload = text ? new string('x', 65536) : new object(); state.Environment["payload"] = LuaValue.FromObject(payload); state .DoStringAsync( diff --git a/tests/Lua.Tests/StreamLifetimeTests.cs b/tests/Lua.Tests/StreamLifetimeTests.cs new file mode 100644 index 00000000..f04e3db9 --- /dev/null +++ b/tests/Lua.Tests/StreamLifetimeTests.cs @@ -0,0 +1,96 @@ +using System.Buffers; +using System.Text; +using Lua.IO; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class StreamLifetimeTests(LuaStringMode stringMode) +{ + LuaStream CreateStream(string text) => + new(LuaFileOpenMode.Read, new MemoryStream(Encoding.UTF8.GetBytes(text)), stringMode); + + [TestCase(false)] + [TestCase(true)] + public async Task BomDetectionDoesNotReadUnwrittenBufferTail(bool readAll) + { + await Task.Factory.StartNew( + () => + { + var rented = ArrayPool.Shared.Rent(1024); + new byte[] { 0xEF, 0xBB, 0xBF }.CopyTo(rented, 0); + ArrayPool.Shared.Return(rented); + using var stream = new LuaStream( + LuaFileOpenMode.Read, + new MemoryStream(new byte[] { 0xEF }), + stringMode + ); + var text = readAll + ? stream.ReadAllAsync(default).Result + : stream.ReadLineAsync(false, default).Result; + Assert.That( + text, + Is.EqualTo(stringMode == LuaStringMode.Bytes ? "\u00EF" : "\uFFFD") + ); + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default + ); + } + + [Test] + public async Task ClosingOneReaderDoesNotReleaseAnotherReadersBuffer() + { + await Task.Factory.StartNew( + () => + { + using var first = CreateStream("a\nA-left\n"); + using var second = CreateStream("b\nB-left\n"); + using var third = CreateStream("c\nC-left\n"); + Assert.That(first.ReadLineAsync(false, default).Result, Is.EqualTo("a")); + Assert.That(second.ReadLineAsync(false, default).Result, Is.EqualTo("b")); + second.Dispose(); + Assert.That(third.ReadLineAsync(false, default).Result, Is.EqualTo("c")); + Assert.That(first.ReadLineAsync(false, default).Result, Is.EqualTo("A-left")); + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default + ); + } + + [Test] + public async Task DisposingOnAnotherThreadPreservesThatThreadsReader() + { + var first = await Task.Factory.StartNew( + () => + { + var stream = CreateStream("a\nA-left\n"); + Assert.That(stream.ReadLineAsync(false, default).Result, Is.EqualTo("a")); + return stream; + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default + ); + await Task.Factory.StartNew( + () => + { + using (first) + { + using var second = CreateStream("b\nB-left\n"); + using var third = CreateStream("c\nC-left\n"); + Assert.That(second.ReadLineAsync(false, default).Result, Is.EqualTo("b")); + first.Dispose(); + Assert.That(third.ReadLineAsync(false, default).Result, Is.EqualTo("c")); + Assert.That(second.ReadLineAsync(false, default).Result, Is.EqualTo("B-left")); + } + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default + ); + } +} From 417e159371f974d8ff4cac5eb6f93ca77a254053 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:14:38 +0900 Subject: [PATCH 07/32] fix: unwind failed calls and preserve buffered stream reads Clear failed RunAsync calls back to their return base, including cancelled calls and argument/frame setup failures. Preserve caller stack values and release discarded locals without adding checks to the VM execution loop. Drain the UTF-8 reader before raw byte and bulk reads, and report EOF without accessing stale buffer contents. Keep fresh raw reads allocation-free apart from their existing stream/writer allocations and inline byte reads. Return nil for parameter names missing from stripped debug metadata while preserving ordinary Lua 5.2 parameter lookup. Add 24 regression cases covering synchronous/asynchronous failures, cancellation, caller arguments, retained locals, mixed buffered/raw reads, EOF, and stripped parameter metadata in both string modes. Validation: 1,757 tests pass on each of net8.0 and net10.0; final stream regressions pass on both; all four library targets build without warnings. Seventeen before/after workloads retain identical allocations. Lua/text workloads remain within 4%; raw reads add about 0.24 ns per byte and 47 ns per bulk memory-stream read in the local probe. --- src/Lua/IO/LuaStream.cs | 11 +- src/Lua/Internal/Utf8Reader.cs | 45 +++---- src/Lua/LuaState.cs | 85 +++++++------- src/Lua/Standard/DebugLibrary.cs | 5 +- tests/Lua.Tests/DebugLocalMetadataTests.cs | 59 ++++++++++ tests/Lua.Tests/ExecutionFailureTests.cs | 129 +++++++++++++++++++++ tests/Lua.Tests/MemoryLifetimeTests.cs | 38 ++++++ tests/Lua.Tests/StreamLifetimeTests.cs | 56 +++++++++ 8 files changed, 354 insertions(+), 74 deletions(-) create mode 100644 tests/Lua.Tests/DebugLocalMetadataTests.cs create mode 100644 tests/Lua.Tests/ExecutionFailureTests.cs diff --git a/src/Lua/IO/LuaStream.cs b/src/Lua/IO/LuaStream.cs index 7bdbfabc..dfe158c6 100644 --- a/src/Lua/IO/LuaStream.cs +++ b/src/Lua/IO/LuaStream.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Runtime.CompilerServices; using System.Text; using Lua.Internal; @@ -39,6 +40,11 @@ public ValueTask ReadAllAsync(CancellationToken cancellationToken) public ValueTask ReadBytesAsync(IBufferWriter writer, CancellationToken cancellationToken) { mode.ThrowIfNotReadable(); + if (reader != null) + { + cancellationToken.ThrowIfCancellationRequested(); + reader.CopyBufferedBytes(writer); + } while (true) { cancellationToken.ThrowIfCancellationRequested(); @@ -53,10 +59,13 @@ public ValueTask ReadBytesAsync(IBufferWriter writer, CancellationToken ca } } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask ReadByteAsync(CancellationToken cancellationToken) { mode.ThrowIfNotReadable(); - return new(innerStream.ReadByte()); + if (reader == null) + return new(innerStream.ReadByte()); + return new(reader.ReadByte(innerStream)); } public ValueTask ReadAsync(int count, CancellationToken cancellationToken) diff --git a/src/Lua/Internal/Utf8Reader.cs b/src/Lua/Internal/Utf8Reader.cs index d10114a0..07ef21ba 100644 --- a/src/Lua/Internal/Utf8Reader.cs +++ b/src/Lua/Internal/Utf8Reader.cs @@ -151,39 +151,22 @@ public string ReadToEnd(Stream stream) } } - public byte ReadByte(Stream stream) + public int ReadByte(Stream stream) { - if (buffer.Length == 0) - { - return 0; - } - - var len = 0; - while (len < 1) - { - if (bufPos >= bufLen) - { - bufLen = stream.Read(buffer, 0, buffer.Length); - bufPos = 0; - if (bufLen == 0) - { - break; // EOF - } - } - - var bytesToRead = Math.Min(1, bufLen - bufPos); - if (bytesToRead == 0) - { - break; - } - - if (bytesToRead > 0) - { - len += bytesToRead; - } - } + var value = PeekByte(stream); + if (value >= 0) + bufPos++; + return value; + } - return buffer[bufPos++]; + public void CopyBufferedBytes(IBufferWriter writer) + { + var count = bufLen - bufPos; + if (count == 0) + return; + buffer.AsSpan(bufPos, count).CopyTo(writer.GetSpan(count)); + writer.Advance(count); + bufPos = bufLen; } public string? Read(Stream stream, int charCount) diff --git a/src/Lua/LuaState.cs b/src/Lua/LuaState.cs index bce86548..1634de52 100644 --- a/src/Lua/LuaState.cs +++ b/src/Lua/LuaState.cs @@ -536,53 +536,53 @@ public async ValueTask RunAsync( throw new ArgumentNullException(nameof(function)); } - this.ThrowIfCancellationRequested(cancellationToken); var state = this; - var varArgumentCount = function.GetVariableArgumentCount(argumentCount); - if (varArgumentCount != 0) + var callStackTop = state.CallStackFrameCount; + var previousCallerFunction = state.LastCallerFunction; + var previousPc = state.LastPc; + try { - if (varArgumentCount < 0) + state.ThrowIfCancellationRequested(cancellationToken); + var varArgumentCount = function.GetVariableArgumentCount(argumentCount); + if (varArgumentCount != 0) { - state.Stack.SetTop(state.Stack.Count - varArgumentCount); + if (varArgumentCount < 0) + { + state.Stack.SetTop(state.Stack.Count - varArgumentCount); + argumentCount -= varArgumentCount; + varArgumentCount = 0; + } + else + { + LuaVirtualMachine.PrepareVariableArgument( + state.Stack, + argumentCount, + varArgumentCount + ); + } + argumentCount -= varArgumentCount; - varArgumentCount = 0; } - else + + CallStackFrame frame = new() { - LuaVirtualMachine.PrepareVariableArgument( - state.Stack, - argumentCount, - varArgumentCount - ); + Base = state.Stack.Count - argumentCount, + VariableArgumentCount = varArgumentCount, + Function = function, + ReturnBase = returnBase, + }; + if (state.IsInHook) + { + frame.Flags |= CallStackFrameFlags.InHook; } - argumentCount -= varArgumentCount; - } - - CallStackFrame frame = new() - { - Base = state.Stack.Count - argumentCount, - VariableArgumentCount = varArgumentCount, - Function = function, - ReturnBase = returnBase, - }; - if (state.IsInHook) - { - frame.Flags |= CallStackFrameFlags.InHook; - } - - state.PushCallStackFrame(frame); - LuaFunctionExecutionContext context = new() - { - State = state, - ArgumentCount = argumentCount, - ReturnFrameBase = returnBase, - }; - var callStackTop = state.CallStackFrameCount; - var previousCallerFunction = state.LastCallerFunction; - var previousPc = state.LastPc; - try - { + state.PushCallStackFrame(frame); + LuaFunctionExecutionContext context = new() + { + State = state, + ArgumentCount = argumentCount, + ReturnFrameBase = returnBase, + }; if (CallOrReturnHookMask.Value != 0 && !IsInHook) { return await LuaVirtualMachine.ExecuteCallHook(context, cancellationToken); @@ -590,9 +590,14 @@ public async ValueTask RunAsync( return await function.Func(context, cancellationToken); } + catch + { + Stack.PopUntil(returnBase); + throw; + } finally { - PopCallStackFrameUntil(callStackTop - 1); + PopCallStackFrameUntil(callStackTop); LastCallerFunction = previousCallerFunction; LastPc = previousPc; } diff --git a/src/Lua/Standard/DebugLibrary.cs b/src/Lua/Standard/DebugLibrary.cs index 811753b7..46d97f13 100644 --- a/src/Lua/Standard/DebugLibrary.cs +++ b/src/Lua/Standard/DebugLibrary.cs @@ -143,10 +143,11 @@ static LuaValue GetParam(LuaFunction function, int index) { if (function is LuaClosure closure) { - var paramCount = closure.Proto.ParameterCount; + var locals = closure.Proto.LocalVariables; + var paramCount = Math.Min(closure.Proto.ParameterCount, locals.Length); if (0 <= index && index < paramCount) { - return closure.Proto.LocalVariables[index].Name; + return locals[index].Name; } } diff --git a/tests/Lua.Tests/DebugLocalMetadataTests.cs b/tests/Lua.Tests/DebugLocalMetadataTests.cs new file mode 100644 index 00000000..6338a3c7 --- /dev/null +++ b/tests/Lua.Tests/DebugLocalMetadataTests.cs @@ -0,0 +1,59 @@ +using Lua.Platforms; +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class DebugLocalMetadataTests(LuaStringMode mode) +{ + [TestCase(false)] + [TestCase(true)] + public async Task ParameterLookupHandlesStrippedDebugLocals(bool stripped) + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + var original = ( + await state.DoStringAsync("return function(a,b) local c=a+b; return c end") + )[0] + .Read(); + var proto = original.Proto; + if (stripped) + { + proto = new Prototype( + proto.ChunkName, + proto.LineDefined, + proto.LastLineDefined, + proto.ParameterCount, + proto.MaxStackSize, + proto.HasVariableArguments, + proto.Constants.ToArray(), + proto.Code.ToArray(), + proto.ChildPrototypes.ToArray(), + [], + [], + proto.UpValues.ToArray() + ); + } + state.Environment["f"] = new LuaClosure(state, proto); + var results = await state.DoStringAsync( + "return debug.getlocal(f,1),debug.getlocal(f,2),debug.getlocal(f,3),debug.getlocal(f,0),debug.getlocal(f,-1)" + ); + Assert.That( + results, + Is.EqualTo( + new LuaValue[] + { + stripped ? LuaValue.Nil : new LuaValue("a"), + stripped ? LuaValue.Nil : new LuaValue("b"), + LuaValue.Nil, + LuaValue.Nil, + LuaValue.Nil, + } + ) + ); + } +} diff --git a/tests/Lua.Tests/ExecutionFailureTests.cs b/tests/Lua.Tests/ExecutionFailureTests.cs new file mode 100644 index 00000000..b91909e5 --- /dev/null +++ b/tests/Lua.Tests/ExecutionFailureTests.cs @@ -0,0 +1,129 @@ +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +public class ExecutionFailureTests +{ + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + public void FailedInvocationRestoresCallerPrefix(bool asynchronous, bool cancellation) + { + using var state = LuaState.Create(); + using var source = new CancellationTokenSource(); + var function = new LuaFunction( + async (context, _) => + { + context.State.Stack.Push("scratch"); + if (asynchronous) + await Task.Yield(); + if (cancellation) + { + source.Cancel(); + throw new OperationCanceledException(source.Token); + } + throw new InvalidOperationException("failure"); + } + ); + state.Push(42); + state.Push(99); + if (cancellation) + Assert.ThrowsAsync(async () => + await state.RunAsync(function, 1, source.Token) + ); + else + Assert.ThrowsAsync(async () => + await state.RunAsync(function, 1) + ); + Assert.That(state.CallStackFrameCount, Is.Zero); + Assert.That(state.Stack.Count, Is.EqualTo(1)); + Assert.That(state.Pop(), Is.EqualTo(new LuaValue(42))); + } + + [TestCase(false)] + [TestCase(true)] + public async Task NestedFailurePreservesManagedCallbackArguments(bool asynchronous) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + state.Environment["fail"] = new LuaFunction( + async (context, _) => + { + if (asynchronous) + await Task.Yield(); + throw new LuaRuntimeException(context.State, "failure"); + } + ); + state.Environment["callback"] = new LuaFunction( + async (context, token) => + { + try + { + await context.State.DoStringAsync( + "local a,b,c=1,2,3; fail()", + "nested-failure", + token + ); + } + catch (LuaRuntimeException) { } + return context.Return(context.GetArgument(0)); + } + ); + var result = await state.DoStringAsync("return callback(42)"); + Assert.That(result, Is.EqualTo(new LuaValue[] { 42 })); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + [Test] + public async Task FrameSetupFailureRestoresCallerPrefix() + { + using var state = LuaState.Create(); + var function = (await state.DoStringAsync("return function(a,b,...) return a,b end"))[0] + .Read(); + var caller = new LuaFunction((context, _) => new(context.Return())); + state.MaxCallDepth = 1; + state.Push(42); + state.PushCallStackFrame( + new() + { + Base = 0, + ReturnBase = 0, + Function = caller, + VariableArgumentCount = 0, + } + ); + try + { + Assert.ThrowsAsync(async () => + await state.RunAsync(function) + ); + Assert.That(state.CallStackFrameCount, Is.EqualTo(1)); + Assert.That(state.Stack.Count, Is.EqualTo(1)); + Assert.That(state.Pop(), Is.EqualTo(new LuaValue(42))); + } + finally + { + state.PopCallStackFrame(); + } + } + + [Test] + public void AlreadyCancelledInvocationRestoresCallerPrefix() + { + using var state = LuaState.Create(); + using var source = new CancellationTokenSource(); + source.Cancel(); + var function = new LuaFunction((context, _) => new(context.Return())); + state.Push(42); + state.Push(99); + Assert.ThrowsAsync(async () => + await state.RunAsync(function, 1, source.Token) + ); + Assert.That(state.CallStackFrameCount, Is.Zero); + Assert.That(state.Stack.Count, Is.EqualTo(1)); + Assert.That(state.Pop(), Is.EqualTo(new LuaValue(42))); + } +} diff --git a/tests/Lua.Tests/MemoryLifetimeTests.cs b/tests/Lua.Tests/MemoryLifetimeTests.cs index ab23381f..086a23d3 100644 --- a/tests/Lua.Tests/MemoryLifetimeTests.cs +++ b/tests/Lua.Tests/MemoryLifetimeTests.cs @@ -84,6 +84,44 @@ public void HandledErrorDoesNotRootPayloadWhileStateIsIdle(bool text) GC.KeepAlive(state); } + [TestCase(false)] + [TestCase(true)] + public void FailedExecutionDoesNotRootDiscardedLocals(bool asynchronous) + { + using var state = LuaState.Create(); + var reference = FailAndDiscard(state, asynchronous); + AssertCollected(reference); + Assert.That(state.Stack.Count, Is.Zero); + state.DoStringAsync("return 42").AsTask().GetAwaiter().GetResult(); + Assert.That(state.Stack.Count, Is.Zero); + GC.KeepAlive(state); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference FailAndDiscard(LuaState state, bool asynchronous) + { + var payload = new object(); + state.Environment["payload"] = LuaValue.FromObject(payload); + state.Environment["fail"] = new LuaFunction( + async (context, _) => + { + if (asynchronous) + await Task.Yield(); + throw new LuaRuntimeException(context.State, "failure"); + } + ); + try + { + state + .DoStringAsync("local p=payload; payload=nil; fail()") + .AsTask() + .GetAwaiter() + .GetResult(); + } + catch (LuaRuntimeException) { } + return new WeakReference(payload); + } + [TestCase("clear")] [TestCase("pop")] [TestCase("pop-range")] diff --git a/tests/Lua.Tests/StreamLifetimeTests.cs b/tests/Lua.Tests/StreamLifetimeTests.cs index f04e3db9..a8ed5605 100644 --- a/tests/Lua.Tests/StreamLifetimeTests.cs +++ b/tests/Lua.Tests/StreamLifetimeTests.cs @@ -11,6 +11,62 @@ public class StreamLifetimeTests(LuaStringMode stringMode) LuaStream CreateStream(string text) => new(LuaFileOpenMode.Read, new MemoryStream(Encoding.UTF8.GetBytes(text)), stringMode); + [TestCase(3)] + [TestCase(2048)] + public async Task RawByteThenBulkReadConsumeBufferedBytes(int length) + { + var payload = Enumerable.Range(0, length).Select(i => (byte)(255 - i % 256)).ToArray(); + var data = Encoding.ASCII.GetBytes("abc\n").Concat(payload).ToArray(); + using var stream = new LuaStream(LuaFileOpenMode.Read, new MemoryStream(data), stringMode); + Assert.That(await stream.ReadLineAsync(false, default), Is.EqualTo("abc")); + Assert.That(await stream.ReadByteAsync(default), Is.EqualTo(255)); + var writer = new ArrayBufferWriter(); + writer.GetSpan(1)[0] = 42; + writer.Advance(1); + await stream.ReadBytesAsync(writer, default); + Assert.That( + writer.WrittenSpan.ToArray(), + Is.EqualTo( + new byte[] { 42 } + .Concat(payload.Skip(1)) + .ToArray() + ) + ); + Assert.That(await stream.ReadByteAsync(default), Is.EqualTo(-1)); + Assert.That(await stream.ReadAllAsync(default), Is.Empty); + } + + [TestCase(3)] + [TestCase(2048)] + public async Task RawBulkReadConsumesBufferedBytes(int length) + { + var payload = Enumerable.Range(0, length).Select(i => (byte)(255 - i % 256)).ToArray(); + var data = Encoding.ASCII.GetBytes("abc\n").Concat(payload).ToArray(); + using var stream = new LuaStream(LuaFileOpenMode.Read, new MemoryStream(data), stringMode); + Assert.That(await stream.ReadLineAsync(false, default), Is.EqualTo("abc")); + var writer = new ArrayBufferWriter(); + await stream.ReadBytesAsync(writer, default); + Assert.That(writer.WrittenSpan.ToArray(), Is.EqualTo(payload)); + Assert.That(await stream.ReadByteAsync(default), Is.EqualTo(-1)); + Assert.That(await stream.ReadAllAsync(default), Is.Empty); + } + + [Test] + public async Task CancelledRawBulkReadDoesNotConsumeBufferedBytes() + { + using var stream = CreateStream("abc\ndef"); + Assert.That(await stream.ReadLineAsync(false, default), Is.EqualTo("abc")); + using var source = new CancellationTokenSource(); + source.Cancel(); + var writer = new ArrayBufferWriter(); + Assert.Throws(() => + { + _ = stream.ReadBytesAsync(writer, source.Token); + }); + Assert.That(writer.WrittenCount, Is.Zero); + Assert.That(await stream.ReadAllAsync(default), Is.EqualTo("def")); + } + [TestCase(false)] [TestCase(true)] public async Task BomDetectionDoesNotReadUnwrittenBufferTail(bool readAll) From 44c24a31753fcf651e220791d21e60e278a8bdb9 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:00:55 +0900 Subject: [PATCH 08/32] fix: reclaim deleted keys and complete runtime cleanup safely Release deleted hash keys through weak records while preserving Lua 5.2 next(t, key). Reclaim empty hashes, compact deleted entries in place, and reuse a bounded set of cleared weak records. Keep normal key lookup intact and find live or deleted iteration keys in one hash lookup. Always dispose the underlying stream when flushing fails, and release the reader buffer even if underlying disposal also fails. Handle missing debug line metadata and function-only getinfo queries, and compare local indices against frame size without overflowing an addition. Give pending yields ownership of cancellation registration and arbitrate resume/cancellation completion. Respect later resume tokens, link distinct tokens only when required, and reject cancelled yields before publishing. Add 40 regression cases covering key collection and iteration, compaction, cleanup failures, stripped metadata, oversized local indices, and coroutine cancellation at the first and subsequent yields. Validation: 1,797 tests pass on each of net8.0 and net10.0. All four library targets build without warnings, and formatting checks pass. Local 21-workload comparisons show hash churn about 3% faster with 28% less allocation and cancellable coroutines allocating 64 bytes less. The normal coroutine workload adds 24 bytes. Deleting during iteration costs about 95 ns more per key (45%) to preserve weak identity without retaining keys. --- src/Lua/IO/LuaStream.cs | 11 +- src/Lua/Internal/CoroutineCore.cs | 75 ++++---- src/Lua/Internal/LuaDebug.cs | 8 +- src/Lua/Internal/LuaValueDictionary.cs | 132 +++++++++++--- src/Lua/LuaTable.cs | 9 + src/Lua/Runtime/LuaVirtualMachine.cs | 26 +-- src/Lua/Standard/DebugLibrary.cs | 4 +- tests/Lua.Tests/CoroutineLifetimeTests.cs | 99 +++++++++++ tests/Lua.Tests/DebugLocalMetadataTests.cs | 79 +++++++-- tests/Lua.Tests/StreamLifetimeTests.cs | 43 +++++ tests/Lua.Tests/TableKeyLifetimeTests.cs | 197 +++++++++++++++++++++ 11 files changed, 591 insertions(+), 92 deletions(-) create mode 100644 tests/Lua.Tests/TableKeyLifetimeTests.cs diff --git a/src/Lua/IO/LuaStream.cs b/src/Lua/IO/LuaStream.cs index dfe158c6..110e9199 100644 --- a/src/Lua/IO/LuaStream.cs +++ b/src/Lua/IO/LuaStream.cs @@ -218,12 +218,17 @@ public void Dispose() { innerStream.Flush(); } - - innerStream.Dispose(); } finally { - reader?.Dispose(); + try + { + innerStream.Dispose(); + } + finally + { + reader?.Dispose(); + } } } } diff --git a/src/Lua/Internal/CoroutineCore.cs b/src/Lua/Internal/CoroutineCore.cs index c1ecacd9..0a0b5bce 100644 --- a/src/Lua/Internal/CoroutineCore.cs +++ b/src/Lua/Internal/CoroutineCore.cs @@ -23,6 +23,8 @@ readonly struct ResumeContext(LuaStack? stack, int argCount) internal byte status = (byte)LuaThreadStatus.Suspended; bool isFirstCall = true; ValueTask functionTask; + CancellationToken resumeCancellationToken; + int yieldCompleted; const int ResumeIdle = 0; const int ResumeWaiting = 1; @@ -146,19 +148,7 @@ internal async ValueTask ResumeAsyncCore( } ValueTask resumeTask = new(this, resume.Version); - - CancellationTokenRegistration registration = default; - if (cancellationToken.CanBeCanceled) - { - registration = cancellationToken.UnsafeRegister( - static x => - { - var coroutine = (CoroutineCore)x!; - coroutine.yield.SetException(new OperationCanceledException()); - }, - this - ); - } + resumeCancellationToken = cancellationToken; try { @@ -176,7 +166,8 @@ internal async ValueTask ResumeAsyncCore( } else { - yield.SetResult(new(stack, argCount)); + if (Interlocked.CompareExchange(ref yieldCompleted, 1, 0) == 0) + yield.SetResult(new(stack, argCount)); } ResumeContext result0; @@ -184,6 +175,7 @@ internal async ValueTask ResumeAsyncCore( { _ = functionTask.Result; functionTask = default; + resumeCancellationToken = default; stack.PopUntil(returnBase); stack.Push(true); stack.PushRange(Thread.Stack.AsSpan()); @@ -204,6 +196,7 @@ internal async ValueTask ResumeAsyncCore( catch (Exception ex) { functionTask = default; + resumeCancellationToken = default; if (ex is ILuaTracebackBuildable tracebackBuildable) { traceback = tracebackBuildable.BuildOrGet(); @@ -227,7 +220,6 @@ internal async ValueTask ResumeAsyncCore( } finally { - registration.Dispose(); resume.Reset(); var phase = Interlocked.CompareExchange(ref resumePhase, ResumeIdle, ResumeYielded); if (phase == ResumeCompleted) @@ -248,6 +240,7 @@ internal async ValueTask ResumeAsyncCore( void OnFunctionCompleted() { + resumeCancellationToken = default; var phase = Interlocked.Exchange(ref resumePhase, ResumeCompleted); if (phase == ResumeWaiting) resume.SetResult(default); @@ -255,6 +248,12 @@ void OnFunctionCompleted() CompleteSuspendedFunction(); } + void CancelYield() + { + if (Interlocked.CompareExchange(ref yieldCompleted, 1, 0) == 0) + yield.SetException(new OperationCanceledException()); + } + void CompleteSuspendedFunction() { if (Volatile.Read(ref status) != (byte)LuaThreadStatus.Suspended) @@ -302,40 +301,46 @@ internal async ValueTask YieldAsyncCore( } } - // Yielded values remain owned by the resumer until its copy completes. - Volatile.Write(ref resumePhase, ResumeYielded); - resume.SetResult(new(stack, argCount)); - - CancellationTokenRegistration registration = default; - if (cancellationToken.CanBeCanceled) + CancellationTokenSource? linkedSource = null; + var resumeToken = resumeCancellationToken; + if (resumeToken.CanBeCanceled && resumeToken != cancellationToken) { - registration = cancellationToken.UnsafeRegister( - static x => - { - var coroutine = (CoroutineCore)x!; - coroutine.yield.SetException(new OperationCanceledException()); - }, - this - ); + if (cancellationToken.CanBeCanceled) + { + linkedSource = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + resumeToken + ); + cancellationToken = linkedSource.Token; + } + else + cancellationToken = resumeToken; } - RETRY: + CancellationTokenRegistration registration = default; try { + cancellationToken.ThrowIfCancellationRequested(); + // Yielded values remain owned by the resumer until its copy completes. + Volatile.Write(ref resumePhase, ResumeYielded); + resume.SetResult(new(stack, argCount)); + if (cancellationToken.CanBeCanceled) + registration = cancellationToken.UnsafeRegister( + static x => ((CoroutineCore)x!).CancelYield(), + this + ); var result = await new ValueTask(this, yield.Version); + resumeCancellationToken.ThrowIfCancellationRequested(); stack.PopUntil(returnBase); stack.PushRange(result.Results); return result.Results.Length; } - catch (Exception ex) when (ex is not OperationCanceledException) - { - yield.Reset(); - goto RETRY; - } finally { registration.Dispose(); + linkedSource?.Dispose(); yield.Reset(); + Volatile.Write(ref yieldCompleted, 0); } } } diff --git a/src/Lua/Internal/LuaDebug.cs b/src/Lua/Internal/LuaDebug.cs index 63552f33..19dadd5b 100644 --- a/src/Lua/Internal/LuaDebug.cs +++ b/src/Lua/Internal/LuaDebug.cs @@ -233,7 +233,13 @@ ReadOnlySpan what case 'l': { CurrentLine = - pc >= 0 && closure is not null ? closure.Proto.LineInfo[pc] : -1; + frame.HasValue && closure is not null + ? ( + (uint)pc < (uint)closure.Proto.LineInfo.Length + ? closure.Proto.LineInfo[pc] + : 0 + ) + : -1; break; } case 'u': diff --git a/src/Lua/Internal/LuaValueDictionary.cs b/src/Lua/Internal/LuaValueDictionary.cs index e57449e8..ad012b43 100644 --- a/src/Lua/Internal/LuaValueDictionary.cs +++ b/src/Lua/Internal/LuaValueDictionary.cs @@ -22,6 +22,9 @@ sealed class LuaValueDictionary int _nilCount; + [ThreadStatic] + static FastStackCore> deletedKeyPool; + public LuaValueDictionary(int capacity) { if (capacity < 0) @@ -69,6 +72,17 @@ public void Clear() _count = 0; _freeList = -1; _freeCount = 0; + if (_nilCount != 0) + { + for (var i = 0; i < count; i++) + { + if ( + _entries[i].keyType == LuaValueType.Nil + && _entries[i].keyObject is WeakReference deleted + ) + ReturnDeletedKey(deleted); + } + } Array.Clear(_entries, 0, count); _nilCount = 0; } @@ -189,7 +203,8 @@ void Insert(LuaValue key, LuaValue value) { if (value.Type is LuaValueType.Nil) { - _nilCount++; + Delete(key); + return; } if (_buckets == null) @@ -260,15 +275,13 @@ void Insert(LuaValue key, LuaValue value) } else { - var count = _count; - if (count == entries.Length) + if (_count == entries.Length) { Resize(); bucket = ref GetBucket(hashCode); } - index = count; - _count = count + 1; + index = _count++; entries = _entries; } @@ -287,7 +300,8 @@ void Insert(LuaValue key, LuaValue value) void Resize() { - Resize(_entries!.Length * 2); + var size = _entries!.Length; + Resize(_count - _nilCount <= size / 2 ? size : size * 2); } void Resize(int newSize) @@ -296,25 +310,61 @@ void Resize(int newSize) Debug.Assert(_entries != null, "_entries should be non-null"); Debug.Assert(newSize >= _entries.Length); - var entries = new Entry[newSize]; + var reuse = newSize == _entries.Length; + var entries = reuse ? _entries : new Entry[newSize]; + var buckets = reuse ? _buckets! : new int[newSize]; + if (reuse) + Array.Clear(buckets, 0, buckets.Length); - var count = _count; - Array.Copy(_entries, entries, count); - - // Assign member variables after both arrays allocated to guard against corruption from OOM if second fails - _buckets = new int[newSize]; - - for (var i = 0; i < count; i++) + var count = 0; + for (var i = 0; i < _count; i++) { - if (entries[i].next >= -1) + ref var entry = ref _entries[i]; + if (entry.next >= -1 && entry.value.Type != LuaValueType.Nil) { - ref var bucket = ref GetBucket(entries[i].hashCode); - entries[i].next = bucket - 1; // Value in _buckets is 1-based - bucket = i + 1; + entries[count] = entry; + ref var bucket = ref buckets[entry.hashCode & (newSize - 1)]; + entries[count].next = bucket - 1; + bucket = ++count; } + else if ( + entry.keyType == LuaValueType.Nil + && entry.keyObject is WeakReference deleted + ) + ReturnDeletedKey(deleted); } + if (reuse) + Array.Clear(entries, count, _count - count); + _buckets = buckets; _entries = entries; + _count = count; + _nilCount = 0; + _freeCount = 0; + _freeList = -1; + } + + void Delete(LuaValue key) + { + ref var value = ref FindValue(key, out var index); + if (Unsafe.IsNullRef(ref value) || value.Type == LuaValueType.Nil) + return; + + if (_nilCount + 1 == Count) + { + Clear(); + return; + } + + ref var entry = ref _entries![index]; + if (entry.keyObject is { } keyObject) + { + // Retain the position for next(t, key) without keeping a deleted key alive. + entry.keyObject = CreateDeletedKey(keyObject); + entry.keyType = LuaValueType.Nil; + } + entry.value = default; + _nilCount++; } public bool Remove(LuaValue key) @@ -413,8 +463,8 @@ public bool TryGetValue(LuaValue key, out LuaValue value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool TryGetNext(LuaValue key, out KeyValuePair pair) { - ref var valRef = ref FindValue(key, out var index); - if (!Unsafe.IsNullRef(ref valRef)) + var index = FindIterationIndex(key); + if (index >= 0) { var entries = _entries!; while (++index < _count) @@ -432,6 +482,33 @@ public bool TryGetNext(LuaValue key, out KeyValuePair pair) return false; } + int FindIterationIndex(LuaValue key) + { + if (_buckets == null) + return -1; + var hashCode = (uint)key.GetHashCode(); + var entries = _entries!; + for (var i = GetBucket(hashCode) - 1; i >= 0; i = entries[i].next) + { + ref var entry = ref entries[i]; + if (entry.hashCode != hashCode) + continue; + if (new LuaValue(entry.keyType, entry.keyNumber, entry.keyObject).EqualsForDict(key)) + return i; + if ( + entry.keyType == LuaValueType.Nil + && ((WeakReference)entry.keyObject!).TryGetTarget(out var target) + && ( + key.Type == LuaValueType.String + ? target is string text && text == key.UnsafeReadString() + : target == key.UnsafeReadObject() + ) + ) + return i; + } + return -1; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] ref int GetBucket(uint hashCode) { @@ -439,6 +516,21 @@ ref int GetBucket(uint hashCode) return ref buckets[hashCode & (buckets.Length - 1)]; } + static WeakReference CreateDeletedKey(object target) + { + if (!deletedKeyPool.TryPop(out var key)) + return new(target); + key.SetTarget(target); + return key; + } + + static void ReturnDeletedKey(WeakReference key) + { + key.SetTarget(null!); + if (deletedKeyPool.Count < 64) + deletedKeyPool.Push(key); + } + struct Entry { public uint hashCode; diff --git a/src/Lua/LuaTable.cs b/src/Lua/LuaTable.cs index d2585e5d..453ebaab 100644 --- a/src/Lua/LuaTable.cs +++ b/src/Lua/LuaTable.cs @@ -193,6 +193,15 @@ public bool ContainsKey(LuaValue key) return dictionary.TryGetValue(key, out var value) && value.Type is not LuaValueType.Nil; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void SetExistingValue(LuaValue key, ref LuaValue existing, LuaValue value) + { + if (value.Type == LuaValueType.Nil) + this[key] = value; + else + existing = value; + } + public LuaValue RemoveAt(int index) { var arrayIndex = index - 1; diff --git a/src/Lua/Runtime/LuaVirtualMachine.cs b/src/Lua/Runtime/LuaVirtualMachine.cs index ac4c8ef0..cdb6b6b0 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.cs @@ -549,7 +549,11 @@ out doRestart && valueRef.Type != LuaValueType.Nil ) { - valueRef = RKC(ref stackHead, ref constHead, instruction); + luaTable.SetExistingValue( + vb, + ref valueRef, + RKC(ref stackHead, ref constHead, instruction) + ); continue; } } @@ -2200,20 +2204,14 @@ out bool doRestart skip = false; if (!Unsafe.IsNullRef(ref valueRef) && valueRef.Type != LuaValueType.Nil) { - valueRef = value; + luaTable.SetExistingValue(key, ref valueRef, value); return true; } var metatable = luaTable.Metatable; if (metatable == null || !metatable.TryGetValue(Metamethods.NewIndex, out table)) { - if (Unsafe.IsNullRef(ref valueRef)) - { - luaTable[key] = value; - return true; - } - - valueRef = value; + luaTable[key] = value; return true; } @@ -2357,20 +2355,14 @@ CancellationToken ct skip = false; if (!Unsafe.IsNullRef(ref valueRef) && valueRef.Type != LuaValueType.Nil) { - valueRef = value; + luaTable.SetExistingValue(key, ref valueRef, value); return default; } var metatable = luaTable.Metatable; if (metatable == null || !metatable.TryGetValue(Metamethods.NewIndex, out table)) { - if (Unsafe.IsNullRef(ref valueRef)) - { - luaTable[key] = value; - return default; - } - - valueRef = value; + luaTable[key] = value; return default; } diff --git a/src/Lua/Standard/DebugLibrary.cs b/src/Lua/Standard/DebugLibrary.cs index 46d97f13..983b3e91 100644 --- a/src/Lua/Standard/DebugLibrary.cs +++ b/src/Lua/Standard/DebugLibrary.cs @@ -91,7 +91,7 @@ is OpCode.Call or OpCode.TailCall ? nextFrame.Base - 1 : nextFrame.Base; - if (nextFrameBase - 1 < frameBase + index) + if (index >= nextFrameBase - frameBase) { name = null; return ref Unsafe.NullRef(); @@ -123,7 +123,7 @@ or OpCode.TailCall { var nextFrameBase = level != 0 ? callStack[^level].Base : state.Stack.Count; - if (nextFrameBase - 1 < frameBase + index) + if (index >= nextFrameBase - frameBase) { name = null; return ref Unsafe.NullRef(); diff --git a/tests/Lua.Tests/CoroutineLifetimeTests.cs b/tests/Lua.Tests/CoroutineLifetimeTests.cs index da3f79e9..241275ae 100644 --- a/tests/Lua.Tests/CoroutineLifetimeTests.cs +++ b/tests/Lua.Tests/CoroutineLifetimeTests.cs @@ -6,6 +6,105 @@ namespace Lua.Tests; public class CoroutineLifetimeTests { + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + public void CancellationBeforeYieldPropagatesAndReleasesExecution( + bool protectedMode, + bool asynchronous + ) + { + using var state = LuaState.Create(); + using var source = new CancellationTokenSource(); + using var thread = state.CreateCoroutine( + new LuaFunction( + async (context, token) => + { + if (asynchronous) + await Task.Yield(); + source.Cancel(); + return await context.State.YieldAsync(context, token); + } + ), + protectedMode + ); + Assert.CatchAsync(async () => + await thread.ResumeAsync(new LuaStack(), source.Token) + ); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead)); + Assert.That(thread.CallStackFrameCount, Is.Zero); + Assert.That(thread.GetStackValues().Length, Is.Zero); + } + + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + public async Task LaterResumeTokenCancelsTheNextYield(bool protectedMode, bool alreadyCancelled) + { + using var state = LuaState.Create(); + using var source = new CancellationTokenSource(); + using var thread = state.CreateCoroutine( + new LuaFunction( + async (context, token) => + { + context.State.Stack.Push(1); + await context.State.YieldAsync(context with { ArgumentCount = 1 }, token); + context.State.Stack.Push(2); + return await context.State.YieldAsync( + context with + { + ArgumentCount = 1, + }, + token + ); + } + ), + protectedMode + ); + var stack = new LuaStack(); + await thread.ResumeAsync(stack); + Assert.That(stack.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 1 })); + stack.Clear(); + if (alreadyCancelled) + { + source.Cancel(); + Assert.CatchAsync(async () => + await thread.ResumeAsync(stack, source.Token) + ); + } + else + { + await thread.ResumeAsync(stack, source.Token); + Assert.That(stack.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 2 })); + source.Cancel(); + } + Assert.That(() => thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead).After(1000, 10)); + Assert.That(thread.CallStackFrameCount, Is.Zero); + Assert.That(thread.GetStackValues().Length, Is.Zero); + } + + [TestCase(false)] + [TestCase(true)] + public async Task EitherDistinctCancellationTokenCanCancelSuspendedYield(bool cancelResume) + { + using var state = LuaState.Create(); + using var resumeSource = new CancellationTokenSource(); + using var yieldSource = new CancellationTokenSource(); + using var thread = state.CreateCoroutine( + new LuaFunction((context, _) => context.State.YieldAsync(context, yieldSource.Token)) + ); + var stack = new LuaStack(); + stack.Push(42); + await thread.ResumeAsync(stack, resumeSource.Token); + Assert.That(stack.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 42 })); + (cancelResume ? resumeSource : yieldSource).Cancel(); + Assert.That(() => thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead).After(1000, 10)); + Assert.That(thread.CallStackFrameCount, Is.Zero); + Assert.That(thread.GetStackValues().Length, Is.Zero); + } + [TestCase(false, false)] [TestCase(true, false)] [TestCase(false, true)] diff --git a/tests/Lua.Tests/DebugLocalMetadataTests.cs b/tests/Lua.Tests/DebugLocalMetadataTests.cs index 6338a3c7..59b88646 100644 --- a/tests/Lua.Tests/DebugLocalMetadataTests.cs +++ b/tests/Lua.Tests/DebugLocalMetadataTests.cs @@ -8,6 +8,70 @@ namespace Lua.Tests; [TestFixture(LuaStringMode.Bytes)] public class DebugLocalMetadataTests(LuaStringMode mode) { + [TestCase(false)] + [TestCase(true)] + public async Task LineLookupHandlesFunctionQueriesAndStrippedActiveFrames(bool stripped) + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + var original = ( + await state.DoStringAsync( + "return function() local info=debug.getinfo(1,'l'); return info.currentline end" + ) + )[0] + .Read(); + state.Environment["f"] = stripped + ? new LuaClosure(state, StripDebug(original.Proto)) + : original; + var result = await state.DoStringAsync( + "local info=debug.getinfo(f); local line=f(); return info.currentline,line" + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { -1, stripped ? 0 : 1 })); + } + + [TestCase(0)] + [TestCase(1)] + public async Task LargeLocalIndicesReturnNilWithoutChangingLocals(int level) + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + state.Environment["level"] = level; + var result = await state.DoStringAsync( + """ + local function f() + local x=42 + local name=debug.getlocal(level,2147483647) + local changed=debug.setlocal(level,2147483647,99) + return name,changed,x + end + local pad=0 + local a,b,c=f() + return a,b,c + """ + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { LuaValue.Nil, LuaValue.Nil, 42 })); + } + + static Prototype StripDebug(Prototype proto) => + new( + proto.ChunkName, + proto.LineDefined, + proto.LastLineDefined, + proto.ParameterCount, + proto.MaxStackSize, + proto.HasVariableArguments, + proto.Constants.ToArray(), + proto.Code.ToArray(), + proto.ChildPrototypes.ToArray(), + [], + [], + proto.UpValues.ToArray() + ); + [TestCase(false)] [TestCase(true)] public async Task ParameterLookupHandlesStrippedDebugLocals(bool stripped) @@ -23,20 +87,7 @@ public async Task ParameterLookupHandlesStrippedDebugLocals(bool stripped) var proto = original.Proto; if (stripped) { - proto = new Prototype( - proto.ChunkName, - proto.LineDefined, - proto.LastLineDefined, - proto.ParameterCount, - proto.MaxStackSize, - proto.HasVariableArguments, - proto.Constants.ToArray(), - proto.Code.ToArray(), - proto.ChildPrototypes.ToArray(), - [], - [], - proto.UpValues.ToArray() - ); + proto = StripDebug(proto); } state.Environment["f"] = new LuaClosure(state, proto); var results = await state.DoStringAsync( diff --git a/tests/Lua.Tests/StreamLifetimeTests.cs b/tests/Lua.Tests/StreamLifetimeTests.cs index a8ed5605..35632c3e 100644 --- a/tests/Lua.Tests/StreamLifetimeTests.cs +++ b/tests/Lua.Tests/StreamLifetimeTests.cs @@ -11,6 +11,49 @@ public class StreamLifetimeTests(LuaStringMode stringMode) LuaStream CreateStream(string text) => new(LuaFileOpenMode.Read, new MemoryStream(Encoding.UTF8.GetBytes(text)), stringMode); + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + public async Task DisposalClosesUnderlyingStreamWhenCleanupThrows( + bool flushFailure, + bool disposeFailure + ) + { + var inner = new CleanupStream(flushFailure, disposeFailure); + var stream = new LuaStream(LuaFileOpenMode.ReadUpdate, inner, stringMode); + Assert.That(await stream.ReadLineAsync(false, default), Is.EqualTo("line")); + if (flushFailure || disposeFailure) + Assert.Throws(stream.Dispose); + else + stream.Dispose(); + Assert.That(inner.DisposeCount, Is.EqualTo(1)); + Assert.That(inner.CanRead, Is.False); + Assert.That(stream.IsOpen, Is.False); + stream.Dispose(); + Assert.That(inner.DisposeCount, Is.EqualTo(1)); + } + + sealed class CleanupStream(bool flushFailure, bool disposeFailure) + : MemoryStream("line\n"u8.ToArray()) + { + public int DisposeCount { get; private set; } + + public override void Flush() + { + if (flushFailure) + throw new IOException("flush failed"); + } + + protected override void Dispose(bool disposing) + { + DisposeCount++; + base.Dispose(disposing); + if (disposeFailure) + throw new IOException("dispose failed"); + } + } + [TestCase(3)] [TestCase(2048)] public async Task RawByteThenBulkReadConsumeBufferedBytes(int length) diff --git a/tests/Lua.Tests/TableKeyLifetimeTests.cs b/tests/Lua.Tests/TableKeyLifetimeTests.cs new file mode 100644 index 00000000..3ae0676c --- /dev/null +++ b/tests/Lua.Tests/TableKeyLifetimeTests.cs @@ -0,0 +1,197 @@ +using System.Runtime.CompilerServices; +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +public class TableKeyLifetimeTests +{ + [TestCase("table")] + [TestCase("function")] + [TestCase("string")] + [TestCase("thread")] + [TestCase("userdata")] + [TestCase("object")] + public void DeletedReferenceKeyIsCollectible(string kind) + { + var table = new LuaTable(); + var reference = DeleteKey(table, kind); + Collect(reference); + Assert.That(table.HashMapCount, Is.EqualTo(1)); + GC.KeepAlive(table); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference DeleteKey(LuaTable table, string kind) + { + LuaValue key = kind switch + { + "table" => new LuaTable(), + "function" => new LuaFunction((context, _) => new(context.Return())), + "string" => new string('k', 1024), + "thread" => LuaState.Create(), + "userdata" => new LuaValue(new KeyUserData()), + _ => LuaValue.FromObject(new object()), + }; + table["anchor"] = 1; + table[key] = 42; + table[key] = LuaValue.Nil; + if (kind == "thread") + key.Read().Dispose(); + return new WeakReference(key.UnsafeRead()); + } + + [TestCase("t[k]=nil")] + [TestCase("rawset(t,k,nil)")] + [TestCase("local proxy=setmetatable({}, {__newindex=t}); proxy[k]=nil")] + public void LuaDeletionReleasesKey(string deletion) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + var reference = DeleteLuaKey(state, deletion); + Collect(reference); + GC.KeepAlive(state); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference DeleteLuaKey(LuaState state, string deletion) + { + var key = new LuaTable(); + state.Environment["k"] = key; + state + .DoStringAsync("t={anchor=1}; t[k]=42; " + deletion + "; k=nil") + .AsTask() + .GetAwaiter() + .GetResult(); + return new WeakReference(key); + } + + [Test] + public async Task NextContinuesAfterDeletingAndCollectingCurrentKeys() + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + state.Environment["collect"] = new LuaFunction( + (context, _) => + { + var other = new LuaTable(); + other["anchor"] = 1; + for (var i = 0; i < 8; i++) + { + var key = new LuaTable(); + other[key] = 1; + other[key] = LuaValue.Nil; + } + other.Clear(); + GC.Collect(); + GC.WaitForPendingFinalizers(); + return new(context.Return()); + } + ); + var result = await state.DoStringAsync( + """ + local t={[-1]=1,[false]=2,[string.rep('k',100)]=3,[{}]=4,[function() end]=5} + local k=next(t) + local count=0 + while k~=nil do + t[k]=nil + collect() + k=next(t,k) + count=count+1 + end + return count,next(t) + """ + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { 5, LuaValue.Nil })); + } + + [Test] + public void DeletedEntriesAreReclaimedDuringInsertion() + { + var table = new LuaTable(0, 8); + table["anchor"] = 1; + for (var i = 0; i < 1000; i++) + { + var key = new LuaTable(); + table[key] = i; + table[key] = LuaValue.Nil; + } + table["live"] = 42; + Assert.That(table.Dictionary.Count, Is.LessThanOrEqualTo(8)); + Assert.That(table.HashMapCount, Is.EqualTo(2)); + Assert.That(table["live"], Is.EqualTo(new LuaValue(42))); + } + + [Test] + public void AssigningNilToMissingKeysDoesNotCreateEntries() + { + var table = new LuaTable(0, 0); + for (var i = 0; i < 32; i++) + table[new LuaTable()] = LuaValue.Nil; + Assert.That(table.Dictionary.Count, Is.Zero); + } + + [Test] + public void CompactionDoesNotRetainCopiesOfMovedValues() + { + var table = new LuaTable(0, 8); + Collect(CompactAndDeleteValue(table)); + Assert.That(table["trigger"], Is.EqualTo(new LuaValue(1))); + GC.KeepAlive(table); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference CompactAndDeleteValue(LuaTable table) + { + table["anchor"] = 1; + for (var i = 0; i < 6; i++) + { + var key = new LuaTable(); + table[key] = i; + table[key] = LuaValue.Nil; + } + var payload = new object(); + table["live"] = LuaValue.FromObject(payload); + table["trigger"] = 1; + table["live"] = LuaValue.Nil; + return new WeakReference(payload); + } + + [Test] + public async Task ReinsertingDeletedKeysPreservesValuesAndCounts() + { + using var state = LuaState.Create(); + var table = new LuaTable(0, 8); + state.Environment["t"] = table; + await state.DoStringAsync( + """ + local keys={-1,false,'name',{}} + for i=1,4 do + local k=keys[i] + t[k]=i + t[k]=nil + t[k]=nil + t[k]=i+10 + end + """ + ); + Assert.That(table.HashMapCount, Is.EqualTo(4)); + Assert.That( + table.Select(pair => pair.Value.Read()).Order(), + Is.EqualTo(new[] { 11, 12, 13, 14 }) + ); + } + + static void Collect(WeakReference reference) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + Assert.That(reference.IsAlive, Is.False); + } + + sealed class KeyUserData : ILuaUserData + { + public LuaTable? Metatable { get; set; } + } +} From 4dbc1d7a696d300ae97c6820f5ab6ca3d79949ab Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:58:56 +0900 Subject: [PATCH 09/32] fix: fill omitted parameters and release failed host calls Pad missing Lua parameters at call boundaries, including fixed-parameter functions and tail calls. Clear discarded tail-call slots before padding, retain native callback argument counts, and keep exact-arity calls fast. Route SetTableAsync writes through the existing deletion bookkeeping so reference keys can be collected and hash counts and iteration remain valid. Restore the array-returning CallAsync stack region on errors, cancellation, and frame-setup failure. Append the callable and arguments with one capacity reservation before mutation, copying borrowed arguments before their source can be overwritten. Preserve buffered unread bytes when a seek fails and invalidate the reader only after successful positioning. Add 51 regression cases for missing arguments in both string modes and with hooks, host deletion and key collection, failed and nested calls, borrowed arguments, and successful and failed seeks. Validation: 1,848 tests pass on each of net8.0 and net10.0. All four library targets build without warnings; formatting and diff checks pass. Eighteen before/after workloads were measured. Normal call allocations are unchanged. A paired check shows host-call time unchanged and the tail-call probe about 1.5% slower. Correct host deletion churn costs about 30 ns more per key (41%) while allocating about 7% less than the broken path. --- src/Lua/IO/LuaStream.cs | 3 +- src/Lua/LuaState.cs | 4 +- src/Lua/LuaStateExtensions.cs | 18 +- src/Lua/Runtime/LuaStack.cs | 9 + src/Lua/Runtime/LuaValueRuntimeExtensions.cs | 9 +- src/Lua/Runtime/LuaVirtualMachine.cs | 14 +- tests/Lua.Tests/ArgumentAdjustmentTests.cs | 79 +++++++++ tests/Lua.Tests/ExecutionFailureTests.cs | 165 ++++++++++++++++++- tests/Lua.Tests/StreamLifetimeTests.cs | 44 +++++ tests/Lua.Tests/TableKeyLifetimeTests.cs | 58 +++++-- 10 files changed, 366 insertions(+), 37 deletions(-) create mode 100644 tests/Lua.Tests/ArgumentAdjustmentTests.cs diff --git a/src/Lua/IO/LuaStream.cs b/src/Lua/IO/LuaStream.cs index 110e9199..db676ad9 100644 --- a/src/Lua/IO/LuaStream.cs +++ b/src/Lua/IO/LuaStream.cs @@ -199,8 +199,9 @@ public long Seek(SeekOrigin origin, long offset) offset -= reader.Remain; } + var position = innerStream.Seek(offset, origin); reader?.Clear(); - return innerStream.Seek(offset, origin); + return position; } public void Dispose() diff --git a/src/Lua/LuaState.cs b/src/Lua/LuaState.cs index 1634de52..e66073be 100644 --- a/src/Lua/LuaState.cs +++ b/src/Lua/LuaState.cs @@ -289,7 +289,7 @@ int callerInstructionIndex ) { var state = this; - var varArgumentCount = function.GetVariableArgumentCount(argumentCount); + var varArgumentCount = function.GetArgumentAdjustment(argumentCount); if (varArgumentCount != 0) { if (varArgumentCount < 0) @@ -543,7 +543,7 @@ public async ValueTask RunAsync( try { state.ThrowIfCancellationRequested(cancellationToken); - var varArgumentCount = function.GetVariableArgumentCount(argumentCount); + var varArgumentCount = function.GetArgumentAdjustment(argumentCount); if (varArgumentCount != 0) { if (varArgumentCount < 0) diff --git a/src/Lua/LuaStateExtensions.cs b/src/Lua/LuaStateExtensions.cs index 5206b5b5..f1639ada 100644 --- a/src/Lua/LuaStateExtensions.cs +++ b/src/Lua/LuaStateExtensions.cs @@ -492,7 +492,7 @@ public static ValueTask SetTableAsync( ref var valueRef = ref luaTable.FindValue(key); if (!Unsafe.IsNullRef(ref valueRef) && valueRef.Type != LuaValueType.Nil) { - valueRef = value; + luaTable.SetExistingValue(key, ref valueRef, value); return default; } } @@ -552,8 +552,7 @@ public static ValueTask CallAsync( ) { var funcIndex = state.Stack.Count; - state.Stack.Push(function); - state.Stack.PushRange(arguments); + state.Stack.PushRange(function, arguments); return Impl(state, funcIndex, cancellationToken); [AsyncMethodBuilder(typeof(LightAsyncValueTaskMethodBuilder<>))] @@ -563,10 +562,15 @@ static async ValueTask Impl( CancellationToken cancellationToken ) { - await LuaVirtualMachine.Call(state, funcIndex, funcIndex, cancellationToken); - var count = state.Stack.Count - funcIndex; - using var results = state.ReadStack(count); - return results.AsSpan().ToArray(); + try + { + await LuaVirtualMachine.Call(state, funcIndex, funcIndex, cancellationToken); + return state.Stack.AsSpan()[funcIndex..].ToArray(); + } + finally + { + state.Stack.PopUntil(funcIndex); + } } } } diff --git a/src/Lua/Runtime/LuaStack.cs b/src/Lua/Runtime/LuaStack.cs index 5a60a3d9..758ec0df 100644 --- a/src/Lua/Runtime/LuaStack.cs +++ b/src/Lua/Runtime/LuaStack.cs @@ -74,6 +74,15 @@ public void PushRange(params ReadOnlySpan values) top += values.Length; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void PushRange(LuaValue first, ReadOnlySpan values) + { + EnsureCapacity(top + 1 + values.Length); + values.CopyTo(array.AsSpan(top + 1)); + array[top] = first; + top += 1 + values.Length; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public LuaValue Pop() { diff --git a/src/Lua/Runtime/LuaValueRuntimeExtensions.cs b/src/Lua/Runtime/LuaValueRuntimeExtensions.cs index da97068f..bc31f39d 100644 --- a/src/Lua/Runtime/LuaValueRuntimeExtensions.cs +++ b/src/Lua/Runtime/LuaValueRuntimeExtensions.cs @@ -18,10 +18,11 @@ out LuaValue result } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int GetVariableArgumentCount(this LuaFunction function, int argumentCount) + public static int GetArgumentAdjustment(this LuaFunction function, int argumentCount) { - return function is LuaClosure { Proto.HasVariableArguments: true } luaClosure - ? argumentCount - luaClosure.Proto.ParameterCount - : 0; + if (function is not LuaClosure closure) + return 0; + var adjustment = argumentCount - closure.Proto.ParameterCount; + return adjustment <= 0 || closure.Proto.HasVariableArguments ? adjustment : 0; } } diff --git a/src/Lua/Runtime/LuaVirtualMachine.cs b/src/Lua/Runtime/LuaVirtualMachine.cs index cdb6b6b0..945b96d3 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.cs @@ -3110,7 +3110,7 @@ bool isMetamethod state.Stack.SetTop(newBase + argumentCount); } - var variableArgumentCount = function.GetVariableArgumentCount(argumentCount); + var variableArgumentCount = function.GetArgumentAdjustment(argumentCount); if (variableArgumentCount < 0) { @@ -3136,7 +3136,7 @@ int newBase { var argumentCount = (int)(state.Stack.Count - newBase); - var variableArgumentCount = function.GetVariableArgumentCount(argumentCount); + var variableArgumentCount = function.GetArgumentAdjustment(argumentCount); if (variableArgumentCount < 0) { @@ -3194,10 +3194,16 @@ bool isMetamethod newBase = currentBase; } - var variableArgumentCount = function.GetVariableArgumentCount(argumentCount); + var variableArgumentCount = function.GetArgumentAdjustment(argumentCount); - if (variableArgumentCount <= 0) + if (variableArgumentCount == 0) + return (argumentCount, 0); + + if (variableArgumentCount < 0) { + stack.PopUntil(newBase + argumentCount); + argumentCount -= variableArgumentCount; + stack.SetTop(newBase + argumentCount); return (argumentCount, 0); } diff --git a/tests/Lua.Tests/ArgumentAdjustmentTests.cs b/tests/Lua.Tests/ArgumentAdjustmentTests.cs new file mode 100644 index 00000000..4c6e6c9f --- /dev/null +++ b/tests/Lua.Tests/ArgumentAdjustmentTests.cs @@ -0,0 +1,79 @@ +using Lua.Platforms; +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class ArgumentAdjustmentTests(LuaStringMode mode) +{ + LuaState CreateState(bool hooks) + { + var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + if (hooks) + state.SetHook(new LuaFunction((context, _) => new(context.Return())), "cr"); + return state; + } + + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public async Task TailCallsFillMissingParameters(bool varargs, bool hooks) + { + using var state = CreateState(hooks); + var parameters = varargs ? "a,b,..." : "a,b"; + foreach (var result in new[] { "a", "b" }) + { + var values = await state.DoStringAsync( + "local function f(" + + parameters + + ") return " + + result + + " end; local stale={}; return f()" + ); + Assert.That(values, Is.EqualTo(new LuaValue[] { LuaValue.Nil })); + } + var partial = await state.DoStringAsync( + "local function f(" + parameters + ") return a,b end; local stale={}; return f(7)" + ); + Assert.That(partial, Is.EqualTo(new LuaValue[] { 7, LuaValue.Nil })); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public async Task RunAsyncFillsMissingParametersAndPreservesPrefix(bool varargs, bool hooks) + { + using var state = CreateState(hooks); + var parameters = varargs ? "a,b,..." : "a,b"; + var function = ( + await state.DoStringAsync("return function(" + parameters + ") return b end") + )[0] + .Read(); + state.Push(42); + var count = await state.RunAsync(function); + using (var results = state.ReadStack(count)) + Assert.That(results.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { LuaValue.Nil })); + Assert.That(state.Pop(), Is.EqualTo(new LuaValue(42))); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + [Test] + public async Task NativeCallbacksKeepTheirActualArgumentCount() + { + using var state = CreateState(false); + state.Environment["native"] = new LuaFunction( + (context, _) => new(context.Return(context.ArgumentCount)) + ); + var result = await state.DoStringAsync("return native(),native(1,nil,3)"); + Assert.That(result, Is.EqualTo(new LuaValue[] { 0, 3 })); + } +} diff --git a/tests/Lua.Tests/ExecutionFailureTests.cs b/tests/Lua.Tests/ExecutionFailureTests.cs index b91909e5..b55b6b05 100644 --- a/tests/Lua.Tests/ExecutionFailureTests.cs +++ b/tests/Lua.Tests/ExecutionFailureTests.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using Lua.Runtime; using Lua.Standard; @@ -5,6 +6,140 @@ namespace Lua.Tests; public class ExecutionFailureTests { + [Test] + public async Task CallApiPreservesArgumentsBorrowedFromStackBuffer() + { + using var state = LuaState.Create(); + var function = new LuaFunction( + (context, _) => + new(context.Return(context.GetArgument(0) + context.GetArgument(1))) + ); + state.Stack.GetBuffer()[0] = 42; + state.Stack.GetBuffer()[1] = 43; + var values = await state.CallAsync(function, state.Stack.GetBuffer()[..2]); + Assert.That(values, Is.EqualTo(new LuaValue[] { 85 })); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + [TestCase(false)] + [TestCase(true)] + public async Task NestedCallApiFailurePreservesManagedCallbackArguments(bool asynchronous) + { + using var state = LuaState.Create(); + var failure = new LuaFunction( + async (_, _) => + { + if (asynchronous) + await Task.Yield(); + throw new InvalidOperationException("failure"); + } + ); + state.Environment["callback"] = new LuaFunction( + async (context, token) => + { + try + { + await context.State.CallAsync(failure, [13, 14], token); + } + catch (InvalidOperationException) { } + return context.Return(context.GetArgument(0)); + } + ); + var values = await state.DoStringAsync("return callback(42)"); + Assert.That(values, Is.EqualTo(new LuaValue[] { 42 })); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + public void FailedCallApiRestoresCallerPrefix(bool asynchronous, bool cancellation) + { + using var state = LuaState.Create(); + using var source = new CancellationTokenSource(); + var function = new LuaFunction( + async (context, _) => + { + context.State.Stack.Push("scratch"); + if (asynchronous) + await Task.Yield(); + if (cancellation) + { + source.Cancel(); + throw new OperationCanceledException(source.Token); + } + throw new InvalidOperationException("failure"); + } + ); + state.Push(42); + if (cancellation) + Assert.CatchAsync(async () => + await state.CallAsync(function, [99], source.Token) + ); + else + Assert.ThrowsAsync(async () => + await state.CallAsync(function, [99]) + ); + Assert.That(state.CallStackFrameCount, Is.Zero); + Assert.That(state.Stack.Count, Is.EqualTo(1)); + Assert.That(state.Pop(), Is.EqualTo(new LuaValue(42))); + } + + [TestCase(false)] + [TestCase(true)] + public void FailedCallApiDoesNotRootArguments(bool asynchronous) + { + using var state = LuaState.Create(); + var reference = FailCallApi(state, asynchronous); + state.DoStringAsync("return 42").AsTask().GetAwaiter().GetResult(); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + Assert.That(reference.IsAlive, Is.False); + Assert.That(state.Stack.Count, Is.Zero); + GC.KeepAlive(state); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference FailCallApi(LuaState state, bool asynchronous) + { + var payload = new object(); + var function = new LuaFunction( + async (_, _) => + { + if (asynchronous) + await Task.Yield(); + throw new InvalidOperationException("failure"); + } + ); + try + { + state + .CallAsync(function, [LuaValue.FromObject(payload)]) + .AsTask() + .GetAwaiter() + .GetResult(); + } + catch (InvalidOperationException) { } + return new WeakReference(payload); + } + + [Test] + public void NonCallableCallApiRestoresCallerPrefix() + { + using var state = LuaState.Create(); + state.Push(42); + Assert.ThrowsAsync(async () => + await state.CallAsync(LuaValue.Nil, [99]) + ); + Assert.That(state.Stack.Count, Is.EqualTo(1)); + Assert.That(state.Pop(), Is.EqualTo(new LuaValue(42))); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + [TestCase(false, false)] [TestCase(true, false)] [TestCase(false, true)] @@ -77,8 +212,9 @@ await context.State.DoStringAsync( Assert.That(state.CallStackFrameCount, Is.Zero); } - [Test] - public async Task FrameSetupFailureRestoresCallerPrefix() + [TestCase(false)] + [TestCase(true)] + public async Task FrameSetupFailureRestoresCallerPrefix(bool callApi) { using var state = LuaState.Create(); var function = (await state.DoStringAsync("return function(a,b,...) return a,b end"))[0] @@ -98,8 +234,12 @@ public async Task FrameSetupFailureRestoresCallerPrefix() try { Assert.ThrowsAsync(async () => - await state.RunAsync(function) - ); + { + if (callApi) + await state.CallAsync(function, []); + else + await state.RunAsync(function); + }); Assert.That(state.CallStackFrameCount, Is.EqualTo(1)); Assert.That(state.Stack.Count, Is.EqualTo(1)); Assert.That(state.Pop(), Is.EqualTo(new LuaValue(42))); @@ -110,18 +250,25 @@ await state.RunAsync(function) } } - [Test] - public void AlreadyCancelledInvocationRestoresCallerPrefix() + [TestCase(false)] + [TestCase(true)] + public void AlreadyCancelledInvocationRestoresCallerPrefix(bool callApi) { using var state = LuaState.Create(); using var source = new CancellationTokenSource(); source.Cancel(); var function = new LuaFunction((context, _) => new(context.Return())); state.Push(42); - state.Push(99); Assert.ThrowsAsync(async () => - await state.RunAsync(function, 1, source.Token) - ); + { + if (callApi) + await state.CallAsync(function, [99], source.Token); + else + { + state.Push(99); + await state.RunAsync(function, 1, source.Token); + } + }); Assert.That(state.CallStackFrameCount, Is.Zero); Assert.That(state.Stack.Count, Is.EqualTo(1)); Assert.That(state.Pop(), Is.EqualTo(new LuaValue(42))); diff --git a/tests/Lua.Tests/StreamLifetimeTests.cs b/tests/Lua.Tests/StreamLifetimeTests.cs index 35632c3e..cefe608b 100644 --- a/tests/Lua.Tests/StreamLifetimeTests.cs +++ b/tests/Lua.Tests/StreamLifetimeTests.cs @@ -1,6 +1,8 @@ using System.Buffers; using System.Text; using Lua.IO; +using Lua.Platforms; +using Lua.Standard; namespace Lua.Tests; @@ -11,6 +13,48 @@ public class StreamLifetimeTests(LuaStringMode stringMode) LuaStream CreateStream(string text) => new(LuaFileOpenMode.Read, new MemoryStream(Encoding.UTF8.GetBytes(text)), stringMode); + [TestCase(SeekOrigin.Begin, -1)] + [TestCase(SeekOrigin.Current, -8)] + [TestCase(SeekOrigin.End, -8)] + public async Task FailedSeekPreservesUnreadBytes(SeekOrigin origin, long offset) + { + using var stream = CreateStream("abc\ndef"); + Assert.That(await stream.ReadLineAsync(false, default), Is.EqualTo("abc")); + Assert.Throws(() => stream.Seek(origin, offset)); + Assert.That(await stream.ReadAllAsync(default), Is.EqualTo("def")); + } + + [TestCase(SeekOrigin.Begin, 0, 0, "abc\ndef")] + [TestCase(SeekOrigin.Current, 0, 4, "def")] + [TestCase(SeekOrigin.End, -3, 4, "def")] + public async Task SuccessfulSeekUsesTheLogicalReadPosition( + SeekOrigin origin, + long offset, + long position, + string remaining + ) + { + using var stream = CreateStream("abc\ndef"); + Assert.That(await stream.ReadLineAsync(false, default), Is.EqualTo("abc")); + Assert.That(stream.Seek(origin, offset), Is.EqualTo(position)); + Assert.That(await stream.ReadAllAsync(default), Is.EqualTo(remaining)); + } + + [Test] + public async Task LuaFileReadContinuesAfterFailedSeek() + { + using var state = LuaState.Create( + stringMode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + using var stream = CreateStream("abc\ndef"); + state.Environment["file"] = new LuaValue(new FileHandle(stream)); + var values = await state.DoStringAsync( + "local line=file:read('*l'); local pos=file:seek('set',-1); return line,pos,file:read('*a')" + ); + Assert.That(values, Is.EqualTo(new LuaValue[] { "abc", LuaValue.Nil, "def" })); + } + [TestCase(false, false)] [TestCase(true, false)] [TestCase(false, true)] diff --git a/tests/Lua.Tests/TableKeyLifetimeTests.cs b/tests/Lua.Tests/TableKeyLifetimeTests.cs index 3ae0676c..11846ef5 100644 --- a/tests/Lua.Tests/TableKeyLifetimeTests.cs +++ b/tests/Lua.Tests/TableKeyLifetimeTests.cs @@ -6,23 +6,29 @@ namespace Lua.Tests; public class TableKeyLifetimeTests { - [TestCase("table")] - [TestCase("function")] - [TestCase("string")] - [TestCase("thread")] - [TestCase("userdata")] - [TestCase("object")] - public void DeletedReferenceKeyIsCollectible(string kind) + [TestCase("table", false)] + [TestCase("function", false)] + [TestCase("string", false)] + [TestCase("thread", false)] + [TestCase("userdata", false)] + [TestCase("object", false)] + [TestCase("table", true)] + [TestCase("function", true)] + [TestCase("string", true)] + [TestCase("thread", true)] + [TestCase("userdata", true)] + [TestCase("object", true)] + public void DeletedReferenceKeyIsCollectible(string kind, bool hostApi) { var table = new LuaTable(); - var reference = DeleteKey(table, kind); + var reference = DeleteKey(table, kind, hostApi); Collect(reference); Assert.That(table.HashMapCount, Is.EqualTo(1)); GC.KeepAlive(table); } [MethodImpl(MethodImplOptions.NoInlining)] - static WeakReference DeleteKey(LuaTable table, string kind) + static WeakReference DeleteKey(LuaTable table, string kind, bool hostApi) { LuaValue key = kind switch { @@ -35,7 +41,13 @@ static WeakReference DeleteKey(LuaTable table, string kind) }; table["anchor"] = 1; table[key] = 42; - table[key] = LuaValue.Nil; + if (hostApi) + { + using var state = LuaState.Create(); + state.SetTableAsync(table, key, LuaValue.Nil).GetAwaiter().GetResult(); + } + else + table[key] = LuaValue.Nil; if (kind == "thread") key.Read().Dispose(); return new WeakReference(key.UnsafeRead()); @@ -182,6 +194,32 @@ await state.DoStringAsync( ); } + [Test] + public async Task HostDeletionPreservesCountsReinsertionAndIteration() + { + using var state = LuaState.Create(); + var table = new LuaTable(0, 8); + LuaValue[] keys = [-1, false, "name", new LuaTable()]; + foreach (var key in keys) + { + await state.SetTableAsync(table, key, 42); + await state.SetTableAsync(table, key, LuaValue.Nil); + await state.SetTableAsync(table, key, 99); + } + Assert.That(table.HashMapCount, Is.EqualTo(keys.Length)); + LuaValue previous = LuaValue.Nil; + var removed = 0; + while (table.TryGetNext(previous, out var pair)) + { + previous = pair.Key; + Assert.That(pair.Value, Is.EqualTo(new LuaValue(99))); + await state.SetTableAsync(table, previous, LuaValue.Nil); + removed++; + } + Assert.That(removed, Is.EqualTo(keys.Length)); + Assert.That(table.HashMapCount, Is.Zero); + } + static void Collect(WeakReference reference) { GC.Collect(); From 656d5d0e5e5474661ea2694ca27609c94bdb7fd0 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:18:19 +0900 Subject: [PATCH 10/32] fix: reclaim tail-call arguments and correct hook frames Align hook frame bases after vararg rearrangement and treat stripped line metadata as unknown. Reuse the outgoing vararg area for tail calls and close captures from the original argument base when execution fails. Retain caller prototypes for diagnostics without rooting old closures. Add 62 regression cases covering both string modes, hook signatures and events, stripped chunks, constant-space tail calls, suspended lifetimes, and capture unwinding. Validation: 1,910 tests pass on each of net8.0 and net10.0; all four library targets build without warnings; CSharpier and diff checks pass. Paired performance runs show unchanged allocations and timings within 3%. --- src/Lua/Exceptions.cs | 10 +- src/Lua/LuaState.cs | 8 +- src/Lua/Runtime/LuaVirtualMachine.Debug.cs | 4 +- src/Lua/Runtime/LuaVirtualMachine.cs | 18 +- tests/Lua.Tests/DebugLocalMetadataTests.cs | 34 ++++ tests/Lua.Tests/HookArgumentTests.cs | 106 +++++++++++ tests/Lua.Tests/TailCallLifetimeTests.cs | 193 +++++++++++++++++++++ 7 files changed, 354 insertions(+), 19 deletions(-) create mode 100644 tests/Lua.Tests/HookArgumentTests.cs create mode 100644 tests/Lua.Tests/TailCallLifetimeTests.cs diff --git a/src/Lua/Exceptions.cs b/src/Lua/Exceptions.cs index 817f1ef0..cb319f20 100644 --- a/src/Lua/Exceptions.cs +++ b/src/Lua/Exceptions.cs @@ -341,11 +341,11 @@ internal static (string NameWhat, string Name) GetCurrentFunctionName(LuaState s { var current = state.GetCurrentFrame(); var pc = current.CallerInstructionIndex; - LuaFunction callerFunction; + Prototype? callerPrototype; if (current.IsTailCall) { pc = state.LastPc; - callerFunction = state.LastCallerFunction!; + callerPrototype = state.LastCallerPrototype; } else { @@ -353,16 +353,16 @@ internal static (string NameWhat, string Name) GetCurrentFunctionName(LuaState s if (frames.Length < 2) return ("function", current.Function.Name); var caller = frames[^2]; - callerFunction = caller.Function; + callerPrototype = (caller.Function as LuaClosure)?.Proto; } - if (callerFunction is not LuaClosure callerClosure) + if (callerPrototype is null) { return ("function", current.Function.Name); } return ( - LuaDebug.GetFuncName(callerClosure.Proto, pc, out var name) ?? "", + LuaDebug.GetFuncName(callerPrototype, pc, out var name) ?? "", name ?? current.Function.Name ); } diff --git a/src/Lua/LuaState.cs b/src/Lua/LuaState.cs index e66073be..801f9dbd 100644 --- a/src/Lua/LuaState.cs +++ b/src/Lua/LuaState.cs @@ -200,7 +200,7 @@ public void Release() internal ILuaTracebackBuildable? CurrentException; internal readonly ReversedStack ExceptionTrace = new(); - internal LuaFunction? LastCallerFunction; + internal Prototype? LastCallerPrototype; internal ref FastListCore OpenUpValues => ref openUpValues; public bool IsRunning => CallStackFrameCount != 0; @@ -306,6 +306,8 @@ int callerInstructionIndex varArgumentCount ); } + + argumentCount -= varArgumentCount; } CallStackFrame frame = new() @@ -538,7 +540,7 @@ public async ValueTask RunAsync( var state = this; var callStackTop = state.CallStackFrameCount; - var previousCallerFunction = state.LastCallerFunction; + var previousCallerPrototype = state.LastCallerPrototype; var previousPc = state.LastPc; try { @@ -598,7 +600,7 @@ public async ValueTask RunAsync( finally { PopCallStackFrameUntil(callStackTop); - LastCallerFunction = previousCallerFunction; + LastCallerPrototype = previousCallerPrototype; LastPc = previousPc; } } diff --git a/src/Lua/Runtime/LuaVirtualMachine.Debug.cs b/src/Lua/Runtime/LuaVirtualMachine.Debug.cs index 6e55ef4b..8474ef9f 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.Debug.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.Debug.cs @@ -66,14 +66,14 @@ static async ValueTask Impl(VirtualMachineExecutionContext context) if (context.State.IsLineHookEnabled) { var sourcePositions = prototype.LineInfo; - var line = sourcePositions[pc]; + var line = sourcePositions.IsEmpty ? 0 : sourcePositions[pc]; if ( countHookIsDone || pc == 0 || context.State.LastPc < 0 || pc <= context.State.LastPc - || sourcePositions[context.State.LastPc] != line + || (!sourcePositions.IsEmpty && sourcePositions[context.State.LastPc] != line) ) { if (countHookIsDone) diff --git a/src/Lua/Runtime/LuaVirtualMachine.cs b/src/Lua/Runtime/LuaVirtualMachine.cs index 945b96d3..945baee4 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.cs @@ -36,7 +36,7 @@ void Init(LuaState state, in CallStackFrame frame, CancellationToken cancellatio State = state; LuaClosure = (LuaClosure)frame.Function; FrameBase = frame.Base; - InitialFrameBase = frame.Base; + InitialArgumentBase = frame.Base - frame.VariableArgumentCount; VariableArgumentCount = frame.VariableArgumentCount; CurrentReturnFrameBase = frame.ReturnBase; CancellationToken = cancellationToken; @@ -57,7 +57,7 @@ void Init(LuaState state, in CallStackFrame frame, CancellationToken cancellatio public Prototype Prototype => LuaClosure.Proto; public int FrameBase; - public int InitialFrameBase; + public int InitialArgumentBase; public int VariableArgumentCount; public CancellationToken CancellationToken; public int Pc; @@ -310,7 +310,7 @@ is not (PostOperationType.TailCall or PostOperationType.DontPop) catch (Exception e) { // FrameBase follows nested calls; all discarded frames need closing. - State.CloseUpValues(InitialFrameBase); + State.CloseUpValues(InitialArgumentBase); if (toCatchFlag) { if (e is not (LuaRuntimeException or LuaCanceledException)) @@ -1107,7 +1107,7 @@ static void VarArg(VirtualMachineExecutionContext context) } catch (Exception e) { - context.State.CloseUpValues(context.InitialFrameBase); + context.State.CloseUpValues(context.InitialArgumentBase); if (e is not (LuaRuntimeException or LuaCanceledException)) { Exception newException = @@ -1669,12 +1669,12 @@ static bool TailCall(VirtualMachineExecutionContext context, out bool doRestart) newBase, isMetamethod ); - newBase = context.FrameBase + variableArgumentCount; + newBase = context.FrameBase - context.VariableArgumentCount + variableArgumentCount; stack.PopUntil(newBase + argumentCount); var lastFrame = state.GetCurrentFrame(); state.LastPc = context.Pc; - state.LastCallerFunction = lastFrame.Function; + state.LastCallerPrototype = context.Prototype; var newFrame = func.CreateNewTailCallFrame( context, newBase, @@ -3179,9 +3179,9 @@ bool isMetamethod state.Stack.SetTop(newBase + argumentCount); } - // In the case of tailcall, the local variables of the caller are immediately discarded, so there is no need to retain them. - // Therefore, a call can be made without allocating new registers. - var currentBase = state.GetCurrentFrame().Base; + // Reuse the caller's entire argument area, including its vararg prefix. + ref readonly var currentFrame = ref state.GetCurrentFrame(); + var currentBase = currentFrame.Base - currentFrame.VariableArgumentCount; { var stackBuffer = stack.GetBuffer(); if (argumentCount > 0) diff --git a/tests/Lua.Tests/DebugLocalMetadataTests.cs b/tests/Lua.Tests/DebugLocalMetadataTests.cs index 59b88646..535a6831 100644 --- a/tests/Lua.Tests/DebugLocalMetadataTests.cs +++ b/tests/Lua.Tests/DebugLocalMetadataTests.cs @@ -8,6 +8,40 @@ namespace Lua.Tests; [TestFixture(LuaStringMode.Bytes)] public class DebugLocalMetadataTests(LuaStringMode mode) { + [TestCase(false)] + [TestCase(true)] + public async Task LineHooksHandleStrippedChunks(bool asynchronous) + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + var prototype = state + .Load("local total=0;for i=1,3 do total=total+i end;return total", "lines") + .Proto; + var stripped = state.Load(StripDebug(prototype).ToBytecode(), "stripped-lines"); + var lines = new List(); + state.SetHook( + new LuaFunction( + async (context, _) => + { + if (asynchronous) + await Task.Yield(); + Assert.That(context.GetArgument(0), Is.EqualTo("line")); + lines.Add(context.GetArgument(1)); + return context.Return(); + } + ), + "l" + ); + state.Stack.PushRange([101, 102]); + var result = await state.ExecuteAsync(stripped); + Assert.That(result, Is.EqualTo(new LuaValue[] { 6 })); + Assert.That(lines.Count, Is.GreaterThan(1)); + Assert.That(lines, Is.All.EqualTo(0)); + Assert.That(state.GetStackValues().ToArray(), Is.EqualTo(new LuaValue[] { 101, 102 })); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + [TestCase(false)] [TestCase(true)] public async Task LineLookupHandlesFunctionQueriesAndStrippedActiveFrames(bool stripped) diff --git a/tests/Lua.Tests/HookArgumentTests.cs b/tests/Lua.Tests/HookArgumentTests.cs new file mode 100644 index 00000000..a56dc392 --- /dev/null +++ b/tests/Lua.Tests/HookArgumentTests.cs @@ -0,0 +1,106 @@ +using Lua.Platforms; +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class HookArgumentTests(LuaStringMode mode) +{ + LuaState CreateState() + { + var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + state.Stack.PushRange([101, 102, 103, 104]); + return state; + } + + [Test] + [Combinatorial] + public async Task LuaHooksReceiveFixedAndVariableArguments( + [Values(0, 1, 2, 3)] int fixedParameters, + [Values("c", "r", "l", "")] string mask + ) + { + using var state = CreateState(); + var calls = new List(); + state.Environment["observe"] = new LuaFunction( + (context, _) => + { + calls.Add(context.Arguments.ToArray()); + return new(context.Return()); + } + ); + var parameters = fixedParameters switch + { + 0 => "...", + 1 => "event,...", + 2 => "event,line,...", + _ => "event,line,extra,...", + }; + var hook = ( + await state.DoStringAsync( + "return function(" + parameters + ") observe(" + parameters + ");return 99 end" + ) + )[0] + .Read(); + state.SetHook(hook, mask, mask.Length == 0 ? 1 : 0); + + var result = await state.DoStringAsync( + "local function f(...) return ... end; local function g(...) return f(...) end; local x=g(42); return x" + ); + state.SetHook(null, ""); + + Assert.That(result, Is.EqualTo(new LuaValue[] { 42 })); + Assert.That(calls, Is.Not.Empty); + foreach (var call in calls) + { + Assert.That(call.Length, Is.EqualTo(Math.Max(2, fixedParameters))); + Assert.That( + call[0].Read(), + mask switch + { + "c" => Is.EqualTo("call").Or.EqualTo("tail call"), + "r" => Is.EqualTo("return"), + "l" => Is.EqualTo("line"), + _ => Is.EqualTo("count"), + } + ); + if (mask == "l") + Assert.That(call[1].Read(), Is.GreaterThan(0)); + else + Assert.That(call[1], Is.EqualTo(LuaValue.Nil)); + if (fixedParameters == 3) + Assert.That(call[2], Is.EqualTo(LuaValue.Nil)); + } + Assert.That( + state.GetStackValues().ToArray(), + Is.EqualTo(new LuaValue[] { 101, 102, 103, 104 }) + ); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + [TestCase(false)] + [TestCase(true)] + public async Task LuaInstalledReturnHookReceivesVarargs(bool fixedEvent) + { + using var state = CreateState(); + var parameters = fixedEvent ? "event,..." : "..."; + var result = await state.DoStringAsync( + "local seen; debug.sethook(function(" + + parameters + + ") debug.sethook();seen=table.pack(" + + parameters + + ") end,'r');return seen.n,seen[1],seen[2]" + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { 2, "return", LuaValue.Nil })); + Assert.That( + state.GetStackValues().ToArray(), + Is.EqualTo(new LuaValue[] { 101, 102, 103, 104 }) + ); + Assert.That(state.CallStackFrameCount, Is.Zero); + } +} diff --git a/tests/Lua.Tests/TailCallLifetimeTests.cs b/tests/Lua.Tests/TailCallLifetimeTests.cs new file mode 100644 index 00000000..a88ab70a --- /dev/null +++ b/tests/Lua.Tests/TailCallLifetimeTests.cs @@ -0,0 +1,193 @@ +using System.Runtime.CompilerServices; +using Lua.Platforms; +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class TailCallLifetimeTests(LuaStringMode mode) +{ + LuaState CreateState(bool hooks = false) + { + var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + if (hooks) + state.SetHook(new LuaFunction((context, _) => new(context.Return())), "cr"); + state.Stack.PushRange([101, 102]); + return state; + } + + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + public async Task VarargTailCallsUseConstantStackSpace(bool hooks, bool callable) + { + using var state = CreateState(hooks); + var stackSizes = new HashSet(); + var calls = 0; + state.Environment["observe"] = new LuaFunction( + (context, _) => + { + calls++; + stackSizes.Add(context.State.Stack.Count); + return new(context.Return()); + } + ); + var source = callable + ? """ + local step + step=setmetatable({}, {__call=function(self,n,...) + if n==0 then return ... end + observe() + return step(n-1,...) + end}) + return step(256,7,nil,35) + """ + : """ + local function step(n,...) + if n==0 then return ... end + observe() + return step(n-1,...) + end + return step(256,7,nil,35) + """; + var result = await state.DoStringAsync(source); + Assert.That(result, Is.EqualTo(new LuaValue[] { 7, LuaValue.Nil, 35 })); + Assert.That(calls, Is.EqualTo(256)); + Assert.That(stackSizes.Count, Is.EqualTo(1)); + Assert.That(state.GetStackValues().ToArray(), Is.EqualTo(new LuaValue[] { 101, 102 })); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + [TestCase(false)] + [TestCase(true)] + public async Task TailCallsDiscardOldVarargsBeforeFillingMissingParameters(bool hooks) + { + using var state = CreateState(hooks); + var result = await state.DoStringAsync( + """ + local function result(a,b,c,...) return a,b,c,select('#',...),... end + local function forward(...) return result(42) end + return forward(7,nil,35) + """ + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { 42, LuaValue.Nil, LuaValue.Nil, 0 })); + Assert.That(state.GetStackValues().ToArray(), Is.EqualTo(new LuaValue[] { 101, 102 })); + } + + [TestCase("arguments")] + [TestCase("closure")] + [TestCase("completed-closure")] + public async Task DiscardedTailCallerValuesCanBeCollectedWhileExecutionIsSuspended(string kind) + { + using var state = CreateState(); + var reference = InstallPayload(state); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + state.Environment["pause"] = new LuaFunction( + async (context, _) => + { + await gate.Task; + return context.Return(); + } + ); + var source = kind switch + { + "arguments" => """ + local function worker() pause();return 42 end + local function forward(...) return worker() end + local value=payload + payload=nil + return forward(value) + """, + "closure" => """ + local function worker() pause();return 42 end + local function make(value) + return function() if value then return worker() end end + end + local f=make(payload) + payload=nil + return f() + """, + _ => """ + local function sink() return 42 end + local function make(value) + return function() if value then return sink() end end + end + local f=make(payload) + payload=nil + f() + f=nil + pause() + return 42 + """, + }; + var execution = state.DoStringAsync(source).AsTask(); + try + { + Assert.That(execution.IsCompleted, Is.False); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + Assert.That(reference.IsAlive, Is.False); + } + finally + { + gate.SetResult(); + await execution; + } + Assert.That(execution.Result, Is.EqualTo(new LuaValue[] { 42 })); + Assert.That(state.GetStackValues().ToArray(), Is.EqualTo(new LuaValue[] { 101, 102 })); + GC.KeepAlive(state); + } + + [TestCase(false)] + [TestCase(true)] + public async Task FailureClosesCapturesInReusedArgumentArea(bool asynchronous) + { + using var state = CreateState(); + state.Environment["fail"] = new LuaFunction( + async (_, _) => + { + if (asynchronous) + await Task.Yield(); + throw new InvalidOperationException("failure"); + } + ); + var function = ( + await state.DoStringAsync( + """ + return function(...) + local function worker() + local x=42 + saved=function() return x end + fail() + end + return worker() + end + """ + ) + )[0].Read(); + Assert.ThrowsAsync(async () => + await state.CallAsync(function, [11, 12, 13]) + ); + var saved = state.Environment["saved"].Read(); + Assert.That(saved.UpValues[0].IsClosed, Is.True); + await state.DoStringAsync("local a,b,c,d=1,2,3,4;return a+b+c+d"); + Assert.That(await state.ExecuteAsync(saved), Is.EqualTo(new LuaValue[] { 42 })); + Assert.That(state.GetStackValues().ToArray(), Is.EqualTo(new LuaValue[] { 101, 102 })); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference InstallPayload(LuaState state) + { + var payload = new object(); + state.Environment["payload"] = LuaValue.FromObject(payload); + return new WeakReference(payload); + } +} From a6e817b164f9e6176d36689eac346fc9955dff18 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:24:42 +0900 Subject: [PATCH 11/32] fix: bound debug locals and release discarded call references Use the original call boundary for caller-local access across vararg and callable frames. Reuse saved callable slots on tail calls and release the outgoing VM closure before a hook or native callee can suspend. Trim Lua arguments outside the register window at frame entry, preserving open results and native callback argument counts. Release coroutine entry functions on completion and retain the normal dead-resume errors for wrapped coroutines. Document CoroutineFunction's completed-state behavior. Add 46 regression cases covering both string modes, managed and Lua caller boundaries, async tail calls with hooks, surplus arguments, open results, and completed coroutine captures. Validation: all 1,956 Lua tests pass on each of net8.0 and net10.0; all four library targets build without warnings; formatting and diff checks pass. Paired runs of ten workloads show unchanged allocations and no measured performance regression. --- src/Lua/Internal/CoroutineCore.cs | 12 ++- src/Lua/LuaState.cs | 1 + src/Lua/Runtime/LuaVirtualMachine.Debug.cs | 28 +++--- src/Lua/Runtime/LuaVirtualMachine.cs | 15 +-- src/Lua/Standard/CoroutineLibrary.cs | 5 +- src/Lua/Standard/DebugLibrary.cs | 9 +- tests/Lua.Tests/ArgumentAdjustmentTests.cs | 89 +++++++++++++++++ .../Lua.Tests/CoroutineEntryLifetimeTests.cs | 90 +++++++++++++++++ tests/Lua.Tests/DebugLocalMetadataTests.cs | 99 +++++++++++++++++++ tests/Lua.Tests/TailCallLifetimeTests.cs | 27 ++++- 10 files changed, 348 insertions(+), 27 deletions(-) create mode 100644 tests/Lua.Tests/CoroutineEntryLifetimeTests.cs diff --git a/src/Lua/Internal/CoroutineCore.cs b/src/Lua/Internal/CoroutineCore.cs index 0a0b5bce..701a810c 100644 --- a/src/Lua/Internal/CoroutineCore.cs +++ b/src/Lua/Internal/CoroutineCore.cs @@ -37,7 +37,7 @@ readonly struct ResumeContext(LuaStack? stack, int argCount) Traceback? traceback; public bool IsProtectedMode { get; private set; } = isProtectedMode; - public LuaFunction Function { get; private set; } = function; + public LuaFunction? Function { get; private set; } = function; public LuaState Thread { get; private set; } = state; public Traceback? Traceback => traceback; @@ -155,8 +155,11 @@ internal async ValueTask ResumeAsyncCore( if (isFirstCall) { Thread.Stack.PushRange(stack.AsSpan()[^argCount..]); - // functionTask = Function.InvokeAsync(new() { Access = this.CurrentAccess, ArgumentCount = Stack.Count, ReturnFrameBase = 0 }, cancellationToken); - functionTask = Thread.RunAsync(Function, Thread.Stack.Count, cancellationToken); + functionTask = Thread.RunAsync( + Function!, + Thread.Stack.Count, + cancellationToken + ); Volatile.Write(ref isFirstCall, false); if (!functionTask.IsCompleted) { @@ -180,6 +183,7 @@ internal async ValueTask ResumeAsyncCore( stack.Push(true); stack.PushRange(Thread.Stack.AsSpan()); Thread.Dispose(); + Function = null; Volatile.Write(ref status, (byte)LuaThreadStatus.Dead); return stack.Count - returnBase; } @@ -203,6 +207,7 @@ internal async ValueTask ResumeAsyncCore( } Thread.Dispose(); + Function = null; Volatile.Write(ref status, (byte)LuaThreadStatus.Dead); if (!IsProtectedMode || ex is OperationCanceledException) throw; @@ -271,6 +276,7 @@ void CompleteSuspendedFunction() { functionTask = default; Thread.Dispose(); + Function = null; Volatile.Write(ref status, (byte)LuaThreadStatus.Dead); } } diff --git a/src/Lua/LuaState.cs b/src/Lua/LuaState.cs index 801f9dbd..982d43fe 100644 --- a/src/Lua/LuaState.cs +++ b/src/Lua/LuaState.cs @@ -209,6 +209,7 @@ public void Release() internal bool IsLuaDebugHook; internal byte HookMask; + /// The entry function, or null for non-coroutines and completed coroutines. public LuaFunction? CoroutineFunction => coroutine?.Function; public bool CanResume => GetStatus() == LuaThreadStatus.Suspended; public LuaStack Stack => CoreData!.Stack; diff --git a/src/Lua/Runtime/LuaVirtualMachine.Debug.cs b/src/Lua/Runtime/LuaVirtualMachine.Debug.cs index 8474ef9f..b2853037 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.Debug.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.Debug.cs @@ -150,6 +150,11 @@ internal static async ValueTask ExecuteCallHook( var argCount = context.ArgumentCount; var hook = context.State.Hook!; var stack = context.State.Stack; + { + ref readonly var frame = ref context.State.GetCurrentFrame(); + if (frame.Function is LuaClosure closure) + stack.PopUntil(frame.Base + closure.Proto.MaxStackSize); + } if (context.State.IsCallHookEnabled) { var top = stack.Count; @@ -185,16 +190,17 @@ internal static async ValueTask ExecuteCallHook( context.State.ThrowIfCancellationRequested(cancellationToken); { - var frame = context.State.GetCurrentFrame(); - var task = frame.Function.Func( - new() - { - State = context.State, - ArgumentCount = argCount, - ReturnFrameBase = frame.ReturnBase, - }, - cancellationToken - ); + var task = context + .State.GetCurrentFrame() + .Function.Func( + new() + { + State = context.State, + ArgumentCount = argCount, + ReturnFrameBase = context.ReturnFrameBase, + }, + cancellationToken + ); var r = await task; if (isTailCall || !context.State.IsReturnHookEnabled) { @@ -209,7 +215,7 @@ internal static async ValueTask ExecuteCallHook( var framePushed = false; try { - frame = context.State.CreateCallStackFrame(hook, 2, top, 0); + var frame = context.State.CreateCallStackFrame(hook, 2, top, 0); context.State.PushCallStackFrame(frame); framePushed = true; LuaFunctionExecutionContext funcContext = new() diff --git a/src/Lua/Runtime/LuaVirtualMachine.cs b/src/Lua/Runtime/LuaVirtualMachine.cs index 945baee4..782e4ac4 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.cs @@ -34,13 +34,9 @@ void Init(LuaState state, in CallStackFrame frame, CancellationToken cancellatio { Stack = state.Stack; State = state; - LuaClosure = (LuaClosure)frame.Function; - FrameBase = frame.Base; + Push(frame); InitialArgumentBase = frame.Base - frame.VariableArgumentCount; - VariableArgumentCount = frame.VariableArgumentCount; - CurrentReturnFrameBase = frame.ReturnBase; CancellationToken = cancellationToken; - Pc = -1; Instruction = default; PostOperation = PostOperationType.None; BaseCallStackCount = state.CallStackFrameCount; @@ -204,6 +200,7 @@ public void Push(in CallStackFrame frame) FrameBase = frame.Base; CurrentReturnFrameBase = frame.ReturnBase; VariableArgumentCount = frame.VariableArgumentCount; + Stack.PopUntil(FrameBase + LuaClosure.Proto.MaxStackSize); } public void PopOnTopCallStackFrames() @@ -1669,10 +1666,14 @@ static bool TailCall(VirtualMachineExecutionContext context, out bool doRestart) newBase, isMetamethod ); - newBase = context.FrameBase - context.VariableArgumentCount + variableArgumentCount; + var argumentBase = context.FrameBase - context.VariableArgumentCount; + newBase = argumentBase + variableArgumentCount; stack.PopUntil(newBase + argumentCount); var lastFrame = state.GetCurrentFrame(); + // Ordinary calls reserve a callable/result slot immediately before their arguments. + if (lastFrame.ReturnBase == argumentBase - 1) + stack.Get(lastFrame.ReturnBase) = func; state.LastPc = context.Pc; state.LastCallerPrototype = context.Prototype; var newFrame = func.CreateNewTailCallFrame( @@ -1685,6 +1686,8 @@ static bool TailCall(VirtualMachineExecutionContext context, out bool doRestart) newFrame.CallerInstructionIndex = lastFrame.CallerInstructionIndex; newFrame.Version = lastFrame.Version; state.ReplaceCallStackFrame(newFrame); + // Release the replaced closure before a hook or native tail callee can suspend. + context.LuaClosure = null!; if (state.CallOrReturnHookMask.Value != 0 && !context.State.IsInHook) { diff --git a/src/Lua/Standard/CoroutineLibrary.cs b/src/Lua/Standard/CoroutineLibrary.cs index d54cb41f..33b45081 100644 --- a/src/Lua/Standard/CoroutineLibrary.cs +++ b/src/Lua/Standard/CoroutineLibrary.cs @@ -93,7 +93,8 @@ CancellationToken cancellationToken ) { var state = context.GetCsClosure()!.UpValues[0].Read(); - if (!state.IsCoroutine) + var function = state.CoroutineFunction; + if (function is null) { return await state.ResumeAsync(context, cancellationToken); } @@ -106,7 +107,7 @@ CancellationToken cancellationToken Base = frameBase, ReturnBase = context.ReturnFrameBase, VariableArgumentCount = 0, - Function = state.CoroutineFunction!, + Function = function, } ); try diff --git a/src/Lua/Standard/DebugLibrary.cs b/src/Lua/Standard/DebugLibrary.cs index 983b3e91..ab866365 100644 --- a/src/Lua/Standard/DebugLibrary.cs +++ b/src/Lua/Standard/DebugLibrary.cs @@ -89,8 +89,8 @@ static ref LuaValue FindLocal(LuaState state, int level, int index, out string? var nextFrameBase = closure.Proto.Code[currentPc].OpCode is OpCode.Call or OpCode.TailCall - ? nextFrame.Base - 1 - : nextFrame.Base; + ? nextFrame.ReturnBase + : nextFrame.Base - nextFrame.VariableArgumentCount; if (index >= nextFrameBase - frameBase) { name = null; @@ -121,7 +121,10 @@ or OpCode.TailCall } else { - var nextFrameBase = level != 0 ? callStack[^level].Base : state.Stack.Count; + var nextFrameBase = + level != 0 + ? callStack[^level].Base - callStack[^level].VariableArgumentCount + : state.Stack.Count; if (index >= nextFrameBase - frameBase) { diff --git a/tests/Lua.Tests/ArgumentAdjustmentTests.cs b/tests/Lua.Tests/ArgumentAdjustmentTests.cs index 4c6e6c9f..e3d47db4 100644 --- a/tests/Lua.Tests/ArgumentAdjustmentTests.cs +++ b/tests/Lua.Tests/ArgumentAdjustmentTests.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using Lua.Platforms; using Lua.Runtime; using Lua.Standard; @@ -8,6 +9,94 @@ namespace Lua.Tests; [TestFixture(LuaStringMode.Bytes)] public class ArgumentAdjustmentTests(LuaStringMode mode) { + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + public async Task SurplusArgumentsOutsideLuaRegisterWindowCanBeCollected( + bool tailCall, + bool callHook + ) + { + using var state = CreateState(false); + var worker = ( + await state.DoStringAsync( + "return function() local sum=0;for i=1,1000 do sum=sum+i end;return sum end" + ) + )[0] + .Read(); + state.Environment["worker"] = worker; + var reference = InstallPayload(state); + var observed = false; + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + state.SetHook( + new LuaFunction( + async (context, _) => + { + var caller = context.State.GetCallStackFrames()[^2].Function; + if (caller == worker && !observed) + { + observed = true; + await gate.Task; + } + return context.Return(); + } + ), + callHook ? "c" : "", + callHook ? 0 : 128 + ); + var call = "worker(1,2,3,4,5,6,7,8,9,take())"; + var source = """ + local function take() + local value=payload + payload=nil + return value + end + """; + source += tailCall ? "\nreturn " + call : "\nlocal result=" + call + ";return result"; + var execution = state.DoStringAsync(source).AsTask(); + try + { + Assert.That(observed, Is.True); + Assert.That(execution.IsCompleted, Is.False); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + Assert.That(reference.IsAlive, Is.False); + } + finally + { + gate.SetResult(); + await execution; + } + Assert.That(execution.Result, Is.EqualTo(new LuaValue[] { 500500 })); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + GC.KeepAlive(state); + } + + [TestCase(false)] + [TestCase(true)] + public async Task VariableResultsCanExtendPastTheCallersRegisterWindow(bool hooks) + { + using var state = CreateState(hooks); + var result = await state.DoStringAsync( + "local function many() return 1,2,3,4,5,6,7,8,9,10 end;return 0,many()" + ); + Assert.That( + result, + Is.EqualTo(Enumerable.Range(0, 11).Select(value => new LuaValue(value)).ToArray()) + ); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference InstallPayload(LuaState state) + { + var payload = new object(); + state.Environment["payload"] = LuaValue.FromObject(payload); + return new WeakReference(payload); + } + LuaState CreateState(bool hooks) { var state = LuaState.Create( diff --git a/tests/Lua.Tests/CoroutineEntryLifetimeTests.cs b/tests/Lua.Tests/CoroutineEntryLifetimeTests.cs new file mode 100644 index 00000000..a6028d78 --- /dev/null +++ b/tests/Lua.Tests/CoroutineEntryLifetimeTests.cs @@ -0,0 +1,90 @@ +using System.Runtime.CompilerServices; +using Lua.Platforms; +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class CoroutineEntryLifetimeTests(LuaStringMode mode) +{ + [Test] + [Combinatorial] + public async Task CompletedCoroutineReleasesEntryCaptures( + [Values(false, true)] bool wrap, + [Values(false, true)] bool asynchronous, + [Values(false, true)] bool yieldFirst + ) + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + var reference = InstallPayload(state); + state.Environment["wrap"] = wrap; + state.Environment["yieldFirst"] = yieldFirst; + state.Environment["pause"] = new LuaFunction( + async (context, _) => + { + if (asynchronous) + await Task.Yield(); + return context.Return(); + } + ); + var result = await state.DoStringAsync( + """ + local function make(value) + return function() + thread=coroutine.running() + pause() + if yieldFirst then coroutine.yield(7) end + return value and 42 + end + end + local entry=make(payload) + payload=nil + if wrap then + run=coroutine.wrap(entry) + else + local co=coroutine.create(entry) + run=function() return coroutine.resume(co) end + end + entry=nil + if yieldFirst then run() end + return run() + """ + ); + await Task.Yield(); + Assert.That(result, Is.EqualTo(wrap ? new LuaValue[] { 42 } : new LuaValue[] { true, 42 })); + var thread = state.Environment["thread"].Read(); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead)); + Assert.That(() => IsAliveAfterCollection(reference), Is.False.After(1000, 10)); + + var again = await state.DoStringAsync(wrap ? "return pcall(run)" : "return run()"); + Assert.That(again[0], Is.EqualTo(new LuaValue(false))); + Assert.That(again[1].Read(), Does.Contain("cannot resume dead coroutine")); + Assert.That(thread.GetStackValues().Length, Is.Zero); + Assert.That(thread.CallStackFrameCount, Is.Zero); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + GC.KeepAlive(thread); + GC.KeepAlive(state); + } + + static bool IsAliveAfterCollection(WeakReference reference) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + return reference.IsAlive; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference InstallPayload(LuaState state) + { + var payload = new object(); + state.Environment["payload"] = LuaValue.FromObject(payload); + return new WeakReference(payload); + } +} diff --git a/tests/Lua.Tests/DebugLocalMetadataTests.cs b/tests/Lua.Tests/DebugLocalMetadataTests.cs index 535a6831..a13bf5e9 100644 --- a/tests/Lua.Tests/DebugLocalMetadataTests.cs +++ b/tests/Lua.Tests/DebugLocalMetadataTests.cs @@ -8,6 +8,105 @@ namespace Lua.Tests; [TestFixture(LuaStringMode.Bytes)] public class DebugLocalMetadataTests(LuaStringMode mode) { + [Test] + public async Task NativeCallerLocalLookupStopsBeforeLuaVarargs() + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + var probe = ( + await state.DoStringAsync( + """ + return function(...) + local name,value=debug.getlocal(2,2) + local changed=debug.setlocal(2,2,99) + local validName,validValue=debug.getlocal(2,1) + return name,value,changed,validName,validValue,... + end + """ + ) + )[0].Read(); + state.Environment["bridge"] = new LuaFunction( + async (context, token) => + { + context.State.Stack.PushRange([10, 20, 30]); + var count = await context.State.RunAsync(probe, 3, token); + LuaValue[] values; + using (var results = context.State.ReadStack(count)) + values = results.AsSpan().ToArray(); + return context.Return(values); + } + ); + var result = await state.DoStringAsync("return bridge(42)"); + Assert.That( + result, + Is.EqualTo( + new LuaValue[] + { + LuaValue.Nil, + LuaValue.Nil, + LuaValue.Nil, + "(*temporary)", + 42, + 10, + 20, + 30, + } + ) + ); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + public async Task CallerLocalLookupStopsBeforeCalleeArguments(bool callable, bool tailCall) + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + var parameters = callable ? "self,..." : "..."; + var forward = tailCall + ? "local original=probe;probe=function(" + + parameters + + ") return original(" + + parameters + + ") end" + : ""; + var target = callable ? "setmetatable({}, {__call=probe})" : "probe"; + var result = await state.DoStringAsync( + $$""" + local target + do + local function probe({{parameters}}) + local name,value=debug.getlocal(2,4) + local changed=debug.setlocal(2,4,99) + local validName,validValue=debug.getlocal(2,2) + debug.setlocal(2,2,43) + return name,value,changed,validName,validValue,... + end + {{forward}} + target={{target}} + end + local a=42 + local name,value,changed,validName,validValue,x,y,z=target(10,20,30) + return name,value,changed,validName,validValue,x,y,z,a + """ + ); + Assert.That( + result, + Is.EqualTo( + new LuaValue[] { LuaValue.Nil, LuaValue.Nil, LuaValue.Nil, "a", 42, 10, 20, 30, 43 } + ) + ); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + [TestCase(false)] [TestCase(true)] public async Task LineHooksHandleStrippedChunks(bool asynchronous) diff --git a/tests/Lua.Tests/TailCallLifetimeTests.cs b/tests/Lua.Tests/TailCallLifetimeTests.cs index a88ab70a..4fe90c2b 100644 --- a/tests/Lua.Tests/TailCallLifetimeTests.cs +++ b/tests/Lua.Tests/TailCallLifetimeTests.cs @@ -83,16 +83,22 @@ public async Task TailCallsDiscardOldVarargsBeforeFillingMissingParameters(bool [TestCase("arguments")] [TestCase("closure")] [TestCase("completed-closure")] + [TestCase("callable-slot")] + [TestCase("callable-slot-hooks")] + [TestCase("callable-slot-native")] + [TestCase("callable-slot-native-hooks")] public async Task DiscardedTailCallerValuesCanBeCollectedWhileExecutionIsSuspended(string kind) { - using var state = CreateState(); + using var state = CreateState( + kind is "callable-slot-hooks" or "callable-slot-native-hooks" + ); var reference = InstallPayload(state); var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); state.Environment["pause"] = new LuaFunction( async (context, _) => { await gate.Task; - return context.Return(); + return context.Return(42); } ); var source = kind switch @@ -113,6 +119,23 @@ local function make(value) payload=nil return f() """, + "callable-slot" or "callable-slot-hooks" => """ + local function worker() pause();return 42 end + local function make(value) + payload=nil + return function() if value then return worker() end end + end + local result=make(payload)() + return result + """, + "callable-slot-native" or "callable-slot-native-hooks" => """ + local function make(value) + payload=nil + return function() if value then return pause() end end + end + local result=make(payload)() + return result + """, _ => """ local function sink() return 42 end local function make(value) From f20f405b6d1f5f292040463d5cabdc05d2580dcc Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:06:39 +0900 Subject: [PATCH 12/32] fix: release abandoned coroutine captures and correct wrap frames Detach captured registers into their existing upvalue cells while a coroutine is yielded, weakly reference the suspended owner, and restore registers before execution resumes. Escaped closures retain their shared values without retaining the abandoned thread and its unrelated locals. Route debug-local access through the same cells and preserve explicit close, cancellation, and stack-growth behavior. Remove the synthetic Lua frame around coroutine.wrap so parent-frame inspection sees the real native wrapper and cannot index past its frames. Add 30 regression cases covering both string modes, synchronous and async wrappers, abandoned self-referencing coroutines, and capture updates. Validation: all 1,986 tests pass on both net8.0 and net10.0; all four library targets build without warnings; formatting and diff checks pass. Paired Release measurements keep ordinary capture allocations unchanged. The captured-yield workload costs about 5.7% and 32 bytes more per coroutine; capture-free coroutines add 8 bytes with approximately unchanged timing. --- src/Lua/Internal/CoroutineCore.cs | 2 + src/Lua/LuaState.cs | 30 +++ src/Lua/Runtime/UpValue.cs | 78 ++++-- src/Lua/Standard/CoroutineLibrary.cs | 33 +-- src/Lua/Standard/DebugLibrary.cs | 6 +- .../CoroutineCaptureLifetimeTests.cs | 250 ++++++++++++++++++ tests/Lua.Tests/DebugLocalMetadataTests.cs | 39 +++ 7 files changed, 386 insertions(+), 52 deletions(-) create mode 100644 tests/Lua.Tests/CoroutineCaptureLifetimeTests.cs diff --git a/src/Lua/Internal/CoroutineCore.cs b/src/Lua/Internal/CoroutineCore.cs index 701a810c..274c47c3 100644 --- a/src/Lua/Internal/CoroutineCore.cs +++ b/src/Lua/Internal/CoroutineCore.cs @@ -327,6 +327,7 @@ internal async ValueTask YieldAsyncCore( try { cancellationToken.ThrowIfCancellationRequested(); + Thread.SuspendUpValues(); // Yielded values remain owned by the resumer until its copy completes. Volatile.Write(ref resumePhase, ResumeYielded); resume.SetResult(new(stack, argCount)); @@ -337,6 +338,7 @@ internal async ValueTask YieldAsyncCore( ); var result = await new ValueTask(this, yield.Version); resumeCancellationToken.ThrowIfCancellationRequested(); + Thread.ResumeUpValues(); stack.PopUntil(returnBase); stack.PushRange(result.Results); return result.Results.Length; diff --git a/src/Lua/LuaState.cs b/src/Lua/LuaState.cs index 982d43fe..ff9b9d67 100644 --- a/src/Lua/LuaState.cs +++ b/src/Lua/LuaState.cs @@ -187,6 +187,7 @@ public void Release() } FastListCore openUpValues; + WeakReference? suspendedReference; internal int CallCount; internal LuaGlobalState GlobalState { get; } ThreadCoreData? CoreData; @@ -762,6 +763,35 @@ internal void CloseUpValues(int frameBase) } } + internal void SuspendUpValues() + { + if (openUpValues.Length == 0) + return; + + var reference = suspendedReference ??= new(this); + foreach (var upValue in openUpValues.AsSpan()) + upValue.Suspend(reference); + } + + internal void ResumeUpValues() + { + for (var i = 0; i < openUpValues.Length; i++) + { + if (!openUpValues[i].Resume(this)) + openUpValues.RemoveAtSwapBack(i--); + } + } + + internal ref LuaValue GetLocalReference(int registerIndex) + { + foreach (var upValue in openUpValues.AsSpan()) + { + if (upValue.RegisterIndex == registerIndex && !upValue.IsClosed) + return ref upValue.GetValueRef(); + } + return ref Stack.Get(registerIndex); + } + public void Dispose() { if (CoreData == null) diff --git a/src/Lua/Runtime/UpValue.cs b/src/Lua/Runtime/UpValue.cs index 96697c80..be3d3fc2 100644 --- a/src/Lua/Runtime/UpValue.cs +++ b/src/Lua/Runtime/UpValue.cs @@ -6,73 +6,107 @@ public sealed class UpValue { LuaValue value; - public LuaState? Thread { get; private set; } - public bool IsClosed { get; private set; } + // A running thread, a weak reference to a suspended thread, or null after closing. + object? owner; + + public LuaState? Thread => + owner is LuaState thread ? thread + : owner is WeakReference reference && reference.TryGetTarget(out var suspended) + ? suspended + : null; + public bool IsClosed => + owner is null + || owner is WeakReference reference && !reference.TryGetTarget(out _); public int RegisterIndex { get; private set; } - UpValue(LuaState? state) - { - Thread = state; - } + UpValue() { } public static UpValue Open(LuaState state, int registerIndex) { - return new(state) { RegisterIndex = registerIndex }; + return new() { owner = state, RegisterIndex = registerIndex }; } public static UpValue Closed(LuaValue value) { - return new(null) { IsClosed = true, value = value }; + return new() { value = value }; } [MethodImpl(MethodImplOptions.AggressiveInlining)] public LuaValue GetValue() { - if (IsClosed) + if (owner is LuaState thread) { - return value; + return thread.Stack.Get(RegisterIndex); } else { - return Thread!.Stack.Get(RegisterIndex); + return value; } } [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal ref readonly LuaValue GetValueRef() + internal ref LuaValue GetValueRef() { - if (IsClosed) + if (owner is LuaState thread) { - return ref value; + return ref thread.Stack.Get(RegisterIndex); } else { - return ref Thread!.Stack.Get(RegisterIndex); + return ref value; } } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetValue(LuaValue value) { - if (IsClosed) + if (owner is LuaState thread) { - this.value = value; + thread.Stack.Get(RegisterIndex) = value; } else { - Thread!.Stack.Get(RegisterIndex) = value; + this.value = value; } } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Close() { - if (!IsClosed) + if (owner is LuaState thread) { - value = Thread!.Stack.Get(RegisterIndex); - Thread = null; + value = thread.Stack.Get(RegisterIndex); } + else if ( + owner is WeakReference reference + && reference.TryGetTarget(out var suspended) + ) + { + // Explicitly closing a suspended capture leaves its former local intact. + suspended.Stack.Get(RegisterIndex) = value; + } + owner = null; + } + + internal void Suspend(WeakReference reference) + { + if (owner is LuaState thread) + { + ref var register = ref thread.Stack.Get(RegisterIndex); + value = register; + register = default; + owner = reference; + } + } + + internal bool Resume(LuaState thread) + { + if (owner is not WeakReference) + return false; - IsClosed = true; + thread.Stack.Get(RegisterIndex) = value; + value = default; + owner = thread; + return true; } } diff --git a/src/Lua/Standard/CoroutineLibrary.cs b/src/Lua/Standard/CoroutineLibrary.cs index 33b45081..1601844f 100644 --- a/src/Lua/Standard/CoroutineLibrary.cs +++ b/src/Lua/Standard/CoroutineLibrary.cs @@ -93,37 +93,16 @@ CancellationToken cancellationToken ) { var state = context.GetCsClosure()!.UpValues[0].Read(); - var function = state.CoroutineFunction; - if (function is null) + if (!state.IsCoroutine) { return await state.ResumeAsync(context, cancellationToken); } - var stack = context.State.Stack; - var frameBase = stack.Count; - context.State.PushCallStackFrame( - new() - { - Base = frameBase, - ReturnBase = context.ReturnFrameBase, - VariableArgumentCount = 0, - Function = function, - } - ); - try - { - await state.ResumeAsync(context, cancellationToken); - var result = context.GetReturnBuffer( - context.State.Stack.Count - context.ReturnFrameBase - ); - result[1..].CopyTo(result); - context.State.Stack.Pop(); - return result.Length - 1; - } - finally - { - context.State.PopCallStackFrame(); - } + await state.ResumeAsync(context, cancellationToken); + var result = context.GetReturnBuffer(context.State.Stack.Count - context.ReturnFrameBase); + result[1..].CopyTo(result); + context.State.Stack.Pop(); + return result.Length - 1; } public ValueTask Yield( diff --git a/src/Lua/Standard/DebugLibrary.cs b/src/Lua/Standard/DebugLibrary.cs index ab866365..0b2470fa 100644 --- a/src/Lua/Standard/DebugLibrary.cs +++ b/src/Lua/Standard/DebugLibrary.cs @@ -69,7 +69,7 @@ static ref LuaValue FindLocal(LuaState state, int level, int index, out string? if (frameVariableArgumentCount > 0 && index < frameVariableArgumentCount) { name = "(*vararg)"; - return ref state.Stack.Get(frame.Base - frameVariableArgumentCount + index); + return ref state.GetLocalReference(frame.Base - frameVariableArgumentCount + index); } name = null; @@ -115,7 +115,7 @@ or OpCode.TailCall if (localId == 0) { name = l.Name; - return ref state.Stack.Get(frameBase + index); + return ref state.GetLocalReference(frameBase + index); } } } @@ -134,7 +134,7 @@ or OpCode.TailCall } name = "(*temporary)"; - return ref state.Stack.Get(frameBase + index); + return ref state.GetLocalReference(frameBase + index); } public ValueTask GetLocal( diff --git a/tests/Lua.Tests/CoroutineCaptureLifetimeTests.cs b/tests/Lua.Tests/CoroutineCaptureLifetimeTests.cs new file mode 100644 index 00000000..5fd9edca --- /dev/null +++ b/tests/Lua.Tests/CoroutineCaptureLifetimeTests.cs @@ -0,0 +1,250 @@ +using System.Runtime.CompilerServices; +using Lua.Platforms; +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class CoroutineCaptureLifetimeTests(LuaStringMode mode) +{ + LuaState CreateState() + { + var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + return state; + } + + [Test] + [Combinatorial] + public async Task AbandonedCoroutineDoesNotKeepUnrelatedLocalsAlive( + [Values(false, true)] bool wrap, + [Values(false, true)] bool selfReference, + [Values(false, true)] bool yieldClosure + ) + { + using var state = CreateState(); + var payload = InstallPayload(state); + WeakReference? thread = null; + state.Environment["observe"] = new LuaFunction( + (context, _) => + { + thread = new WeakReference(context.State); + return new(context.Return()); + } + ); + state.Environment["wrap"] = wrap; + state.Environment["selfReference"] = selfReference; + state.Environment["yieldClosure"] = yieldClosure; + var closure = ( + await state.DoStringAsync( + """ + local function body() + observe() + local keep=42 + local junk=payload + payload=nil + local self=selfReference and coroutine.running() + local capture=function() return keep end + if yieldClosure then + coroutine.yield(capture) + else + saved=capture + coroutine.yield() + end + return junk,self + end + local run + if wrap then + run=coroutine.wrap(body) + else + local co=coroutine.create(body) + run=function() + local ok,value=coroutine.resume(co) + assert(ok) + return value + end + end + local yielded=run() + return yieldClosure and yielded or saved + """ + ) + )[0].Read(); + Collect(); + Assert.That(thread, Is.Not.Null); + Assert.That(thread!.IsAlive, Is.False); + Assert.That(payload.IsAlive, Is.False); + Assert.That(closure.UpValues[0].IsClosed, Is.True); + Assert.That(closure.UpValues[0].Thread, Is.Null); + Assert.That(await state.ExecuteAsync(closure), Is.EqualTo(new LuaValue[] { 42 })); + closure.UpValues[0].SetValue(43); + Assert.That(await state.ExecuteAsync(closure), Is.EqualTo(new LuaValue[] { 43 })); + GC.KeepAlive(closure); + GC.KeepAlive(state); + } + + [Test] + public async Task SuspendedCapturesShareUpdatesWithDebugLocalsAndResumedRegisters() + { + using var state = CreateState(); + var result = await state.DoStringAsync( + """ + local co=coroutine.create(function() + local keep=1 + get=function() return keep end + set=function(value) keep=value end + coroutine.yield() + keep=keep+10 + coroutine.yield(keep) + return keep + end) + assert(coroutine.resume(co)) + local identity=debug.upvalueid(get,1) + set(2) + local name,before=debug.getlocal(co,1,1) + assert(debug.setlocal(co,1,1,3)==name) + local fromClosure=get() + local ok,yielded=coroutine.resume(co) + assert(ok) + set(20) + local again,done=coroutine.resume(co) + assert(again) + return before,fromClosure,yielded,done,get(),identity==debug.upvalueid(get,1) + """ + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { 2, 3, 13, 20, 20, true })); + } + + [Test] + public async Task UpdatingSuspendedCaptureReleasesItsPreviousValue() + { + using var state = CreateState(); + var payload = InstallPayload(state); + await state.DoStringAsync( + """ + co=coroutine.create(function() + local keep=payload + payload=nil + get=function() return keep end + set=function(value) keep=value end + coroutine.yield() + return keep + end) + assert(coroutine.resume(co)) + set(42) + """ + ); + Collect(); + Assert.That(payload.IsAlive, Is.False); + Assert.That( + await state.DoStringAsync("return coroutine.resume(co)"), + Is.EqualTo(new LuaValue[] { true, 42 }) + ); + GC.KeepAlive(state); + } + + [Test] + public async Task ExplicitlyClosedSuspendedCaptureDoesNotReplaceItsFormerLocal() + { + using var state = CreateState(); + var closure = ( + await state.DoStringAsync( + """ + co=coroutine.create(function() + local keep=1 + get=function() return keep end + coroutine.yield() + keep=keep+10 + newer=function() return keep end + return keep + end) + assert(coroutine.resume(co)) + return get + """ + ) + )[0].Read(); + closure.UpValues[0].Close(); + closure.UpValues[0].SetValue(999); + var result = await state.DoStringAsync( + "local name,value=debug.getlocal(co,1,1);local ok,done=coroutine.resume(co);return value,ok,done,get(),newer()" + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { 1, true, 11, 999, 11 })); + } + + [Test] + public async Task ResumedCapturesSurviveStackGrowth() + { + using var state = CreateState(); + var thread = ( + await state.DoStringAsync( + """ + local co=coroutine.create(function() + local keep=1 + get=function() return keep end + set=function(value) keep=value end + local values={coroutine.yield()} + keep=keep+values[300] + return keep + end) + assert(coroutine.resume(co)) + set(3) + return co + """ + ) + )[0].Read(); + var arguments = new LuaStack(); + for (var i = 1; i <= 400; i++) + arguments.Push(i); + await thread.ResumeAsync(arguments); + Assert.That(arguments.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 303 })); + Assert.That(await state.DoStringAsync("return get()"), Is.EqualTo(new LuaValue[] { 303 })); + } + + [Test] + public async Task CancellationClosesTheLatestSuspendedValue() + { + using var state = CreateState(); + using var cancellation = new CancellationTokenSource(); + var closure = ( + await state.DoStringAsync( + """ + co=coroutine.create(function() + local keep=1 + get=function() return keep end + set=function(value) keep=value end + coroutine.yield() + return keep + end) + assert(coroutine.resume(co)) + return get + """, + cancellationToken: cancellation.Token + ) + )[0].Read(); + await state.DoStringAsync("set(7)"); + cancellation.Cancel(); + var thread = state.Environment["co"].Read(); + Assert.That(() => thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead).After(1000, 10)); + Assert.That(closure.UpValues[0].IsClosed, Is.True); + Assert.That(await state.ExecuteAsync(closure), Is.EqualTo(new LuaValue[] { 7 })); + Assert.That(thread.GetStackValues().Length, Is.Zero); + } + + static void Collect() + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference InstallPayload(LuaState state) + { + var value = new object(); + state.Environment["payload"] = LuaValue.FromObject(value); + return new WeakReference(value); + } +} diff --git a/tests/Lua.Tests/DebugLocalMetadataTests.cs b/tests/Lua.Tests/DebugLocalMetadataTests.cs index a13bf5e9..297df40e 100644 --- a/tests/Lua.Tests/DebugLocalMetadataTests.cs +++ b/tests/Lua.Tests/DebugLocalMetadataTests.cs @@ -8,6 +8,45 @@ namespace Lua.Tests; [TestFixture(LuaStringMode.Bytes)] public class DebugLocalMetadataTests(LuaStringMode mode) { + [TestCase(false)] + [TestCase(true)] + public async Task WrappedCoroutineCanInspectItsParentsNativeFrame(bool asynchronous) + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + state.Environment["pause"] = new LuaFunction( + async (context, _) => + { + if (asynchronous) + await Task.Yield(); + return context.Return(); + } + ); + var result = await state.DoStringAsync( + """ + local main=coroutine.running() + local run=coroutine.wrap(function(value) + pause() + local name,before=debug.getlocal(main,0,1) + local changed=debug.setlocal(main,0,1,99) + local info=debug.getinfo(main,0,'S') + coroutine.yield(name,before,changed,info.what,value) + return value + end) + local name,before,changed,what,value=run(42) + return name,before,changed,what,value,run() + """ + ); + Assert.That( + result, + Is.EqualTo(new LuaValue[] { "(*temporary)", 42, "(*temporary)", "C#", 42, 42 }) + ); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + [Test] public async Task NativeCallerLocalLookupStopsBeforeLuaVarargs() { From 0ea579294fd3f57388b31adf5a58893d350efb48 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:35:57 +0900 Subject: [PATCH 13/32] fix: preserve return-hook frames and isolate file metatables Dispatch Lua return hooks before moving results and reclaiming registers. Preserve locals, varargs, and open results across synchronous and async hooks, including stack growth and hooks enabled during execution. Keep hook bookkeeping outside the main dispatch method and preserve existing host-hook exception propagation. Own the shared file metatable per Lua global state and store replacements per handle. Independent states and discarded metatable values no longer remain reachable through a process-wide static table. Reuse the state-owned table for every standard-library file creation path. Replace explicitly closed upvalues when a local is captured again, while keeping existing closed closures independent. Reject nil table keys at the write boundary so rawset cannot retain an inaccessible value. Add 26 regression cases across both string modes. The full suite passes 2,012 tests on each of net8.0 and net10.0; 115 focused hook cases also pass after the final dispatch refactor. All four library targets build without warnings, and formatting and diff checks pass. Paired Release measurements leave ordinary capture timings within about 2% of baseline with unchanged execution allocations. Return-hook-heavy calls cost about 6.6% more; opening standard libraries adds 664 bytes per state for isolated file metatables and per-handle ownership. --- src/Lua/Internal/LuaGlobalState.cs | 3 + src/Lua/LuaState.cs | 4 +- src/Lua/LuaTable.cs | 5 + src/Lua/Runtime/LuaVirtualMachine.Debug.cs | 86 ++++++++----- src/Lua/Runtime/LuaVirtualMachine.cs | 26 +++- src/Lua/Standard/FileHandle.cs | 21 ++-- src/Lua/Standard/IOLibrary.cs | 7 +- src/Lua/Standard/Internal/IOHelper.cs | 2 +- src/Lua/Standard/OpenLibsExtensions.cs | 7 +- tests/Lua.Tests/ClosedUpValueTests.cs | 46 +++++++ tests/Lua.Tests/FileHandleMetatableTests.cs | 97 +++++++++++++++ tests/Lua.Tests/NilTableKeyTests.cs | 54 ++++++++ tests/Lua.Tests/ReturnHookLocalTests.cs | 129 ++++++++++++++++++++ 13 files changed, 437 insertions(+), 50 deletions(-) create mode 100644 tests/Lua.Tests/ClosedUpValueTests.cs create mode 100644 tests/Lua.Tests/FileHandleMetatableTests.cs create mode 100644 tests/Lua.Tests/NilTableKeyTests.cs create mode 100644 tests/Lua.Tests/ReturnHookLocalTests.cs diff --git a/src/Lua/Internal/LuaGlobalState.cs b/src/Lua/Internal/LuaGlobalState.cs index 98ce794f..c3983ebc 100644 --- a/src/Lua/Internal/LuaGlobalState.cs +++ b/src/Lua/Internal/LuaGlobalState.cs @@ -39,6 +39,9 @@ sealed class LuaGlobalState LuaTable? booleanMetatable; LuaTable? functionMetatable; LuaTable? stateMetatable; + LuaTable? fileHandleMetatable; + + internal LuaTable FileHandleMetatable => fileHandleMetatable ??= FileHandle.CreateMetatable(); public static LuaGlobalState Create(LuaPlatform? platform = null) { diff --git a/src/Lua/LuaState.cs b/src/Lua/LuaState.cs index ff9b9d67..c1fe574c 100644 --- a/src/Lua/LuaState.cs +++ b/src/Lua/LuaState.cs @@ -735,10 +735,12 @@ static bool AllowsMode(string mode, char chunkMode) internal UpValue GetOrAddUpValue(int registerIndex) { - foreach (var upValue in openUpValues.AsSpan()) + foreach (ref var upValue in openUpValues.AsSpan()) { if (upValue.RegisterIndex == registerIndex) { + if (upValue.IsClosed) + upValue = UpValue.Open(this, registerIndex); return upValue; } } diff --git a/src/Lua/LuaTable.cs b/src/Lua/LuaTable.cs index 453ebaab..7a27f4df 100644 --- a/src/Lua/LuaTable.cs +++ b/src/Lua/LuaTable.cs @@ -61,6 +61,11 @@ public LuaValue this[LuaValue key] [MethodImpl(MethodImplOptions.AggressiveInlining)] set { + if (key.Type is LuaValueType.Nil) + { + ThrowIndexIsNil(); + } + if (key.TryReadNumber(out var d)) { if (double.IsNaN(d)) diff --git a/src/Lua/Runtime/LuaVirtualMachine.Debug.cs b/src/Lua/Runtime/LuaVirtualMachine.Debug.cs index b2853037..2229b339 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.Debug.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.Debug.cs @@ -202,40 +202,70 @@ internal static async ValueTask ExecuteCallHook( cancellationToken ); var r = await task; - if (isTailCall || !context.State.IsReturnHookEnabled) + if ( + isTailCall + || context.State.GetCurrentFrame().Function is LuaClosure + || !context.State.IsReturnHookEnabled + ) { return r; } context.State.ThrowIfCancellationRequested(cancellationToken); - var top = stack.Count; - stack.Push("return"); - stack.Push(LuaValue.Nil); - context.State.IsInHook = true; - var framePushed = false; - try - { - var frame = context.State.CreateCallStackFrame(hook, 2, top, 0); - context.State.PushCallStackFrame(frame); - framePushed = true; - LuaFunctionExecutionContext funcContext = new() - { - State = context.State, - ArgumentCount = stack.Count - frame.Base, - ReturnFrameBase = frame.ReturnBase, - }; - await hook.Func(funcContext, cancellationToken); - } - finally - { - context.State.IsInHook = false; - if (framePushed) - { - context.State.PopCallStackFrameWithStackPop(); - } - } - + await ExecuteReturnHook(context.State, 0, cancellationToken); return r; } } + + [MethodImpl(MethodImplOptions.NoInlining)] + static bool ExecuteReturnHook(VirtualMachineExecutionContext context) + { + // Return hooks inspect the callee before its registers are reclaimed. + context.PostOperation = PostOperationType.Return; + var task = ExecuteReturnHook(context.State, context.Pc, context.CancellationToken); + if (!task.IsCompleted) + { + context.Task = task; + return true; + } + task.GetAwaiter().GetResult(); + context.PostOperation = PostOperationType.None; + return false; + } + + [AsyncMethodBuilder(typeof(LightAsyncValueTaskMethodBuilder<>))] + static async ValueTask ExecuteReturnHook( + LuaState state, + int pc, + CancellationToken cancellationToken + ) + { + var hook = state.Hook!; + var stack = state.Stack; + var top = stack.Count; + stack.Push("return"); + stack.Push(LuaValue.Nil); + state.IsInHook = true; + var framePushed = false; + try + { + var frame = state.CreateCallStackFrame(hook, 2, top, pc); + state.PushCallStackFrame(frame); + framePushed = true; + LuaFunctionExecutionContext context = new() + { + State = state, + ArgumentCount = stack.Count - frame.Base, + ReturnFrameBase = frame.ReturnBase, + }; + await hook.Func(context, cancellationToken); + } + finally + { + state.IsInHook = false; + if (framePushed) + state.PopCallStackFrameWithStackPop(); + } + return 0; + } } diff --git a/src/Lua/Runtime/LuaVirtualMachine.cs b/src/Lua/Runtime/LuaVirtualMachine.cs index 782e4ac4..bcdde0ac 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.cs @@ -257,6 +257,9 @@ CallStackFrameFlags flags } break; + case PostOperationType.Return: + PostOperation = PostOperationType.None; + return Pop(Instruction, FrameBase); case PostOperationType.Self: SelfPostOperation(this, lastTop, resultsSpan); break; @@ -286,7 +289,11 @@ public async ValueTask ExecuteClosureAsyncImpl() var frameFlags = frame.Flags; if ( PostOperation - is not (PostOperationType.TailCall or PostOperationType.DontPop) + is not ( + PostOperationType.TailCall + or PostOperationType.Return + or PostOperationType.DontPop + ) ) { State.PopCallStackFrame(); @@ -310,7 +317,10 @@ is not (PostOperationType.TailCall or PostOperationType.DontPop) State.CloseUpValues(InitialArgumentBase); if (toCatchFlag) { - if (e is not (LuaRuntimeException or LuaCanceledException)) + if ( + e is not (LuaRuntimeException or LuaCanceledException) + && PostOperation != PostOperationType.Return + ) { Exception newException = e is OperationCanceledException @@ -362,6 +372,7 @@ enum PostOperationType TForCall, Call, TailCall, + Return, Self, Compare, DontPop, @@ -960,6 +971,12 @@ out doRestart case OpCode.Return: Markers.Return(); context.State.CloseUpValues(frameBase); + if ( + context.State.IsReturnHookEnabled + && !context.State.IsInHook + && ExecuteReturnHook(context) + ) + return true; if (context.Pop(instruction, frameBase)) { goto Restart; @@ -1105,7 +1122,10 @@ static void VarArg(VirtualMachineExecutionContext context) catch (Exception e) { context.State.CloseUpValues(context.InitialArgumentBase); - if (e is not (LuaRuntimeException or LuaCanceledException)) + if ( + e is not (LuaRuntimeException or LuaCanceledException) + && context.PostOperation != PostOperationType.Return + ) { Exception newException = e is OperationCanceledException diff --git a/src/Lua/Standard/FileHandle.cs b/src/Lua/Standard/FileHandle.cs index 4ed4aa7e..8ecf3b12 100644 --- a/src/Lua/Standard/FileHandle.cs +++ b/src/Lua/Standard/FileHandle.cs @@ -43,27 +43,26 @@ public sealed class FileHandle : ILuaUserData public bool IsOpen => stream?.IsOpen ?? false; - LuaTable? ILuaUserData.Metatable - { - get => fileHandleMetatable; - set => fileHandleMetatable = value; - } + LuaTable? ILuaUserData.Metatable { get; set; } - static LuaTable? fileHandleMetatable; - - static FileHandle() + internal static LuaTable CreateMetatable() { - fileHandleMetatable = new(0, 1); - fileHandleMetatable[Metamethods.Index] = IndexMetamethod; - fileHandleMetatable["__tostring"] = ToStringFunction; + LuaTable metatable = new(0, 2); + metatable[Metamethods.Index] = IndexMetamethod; + metatable["__tostring"] = ToStringFunction; + return metatable; } public FileHandle(Stream stream, LuaFileOpenMode mode) : this(ILuaStream.CreateFromStream(stream, mode)) { } public FileHandle(ILuaStream stream) + : this(stream, CreateMetatable()) { } + + internal FileHandle(ILuaStream stream, LuaTable metatable) { this.stream = stream; + ((ILuaUserData)this).Metatable = metatable; } public ValueTask ReadNumberAsync(CancellationToken cancellationToken) diff --git a/src/Lua/Standard/IOLibrary.cs b/src/Lua/Standard/IOLibrary.cs index 9a0b66d7..478d83c3 100644 --- a/src/Lua/Standard/IOLibrary.cs +++ b/src/Lua/Standard/IOLibrary.cs @@ -99,7 +99,7 @@ CancellationToken cancellationToken LuaFileOpenMode.Read, cancellationToken ); - FileHandle handle = new(stream); + FileHandle handle = new(stream, context.GlobalState.FileHandleMetatable); registry["_IO_input"] = new(handle); return context.Return(new LuaValue(handle)); } @@ -267,7 +267,7 @@ CancellationToken cancellationToken LuaFileOpenMode.Write, cancellationToken ); - FileHandle handle = new(stream); + FileHandle handle = new(stream, context.GlobalState.FileHandleMetatable); io["_IO_output"] = new(handle); return context.Return(new LuaValue(handle)); } @@ -331,7 +331,8 @@ CancellationToken cancellationToken new FileHandle( await context.GlobalState.Platform.FileSystem.OpenTempFileStream( cancellationToken - ) + ), + context.GlobalState.FileHandleMetatable ) ) ); diff --git a/src/Lua/Standard/Internal/IOHelper.cs b/src/Lua/Standard/Internal/IOHelper.cs index 649ffb03..af51ed20 100644 --- a/src/Lua/Standard/Internal/IOHelper.cs +++ b/src/Lua/Standard/Internal/IOHelper.cs @@ -28,7 +28,7 @@ CancellationToken cancellationToken cancellationToken ); - state.Stack.Push(new(new FileHandle(stream))); + state.Stack.Push(new(new FileHandle(stream, state.GlobalState.FileHandleMetatable))); return 1; } catch (IOException ex) diff --git a/src/Lua/Standard/OpenLibsExtensions.cs b/src/Lua/Standard/OpenLibsExtensions.cs index e498edda..73947b1b 100644 --- a/src/Lua/Standard/OpenLibsExtensions.cs +++ b/src/Lua/Standard/OpenLibsExtensions.cs @@ -52,9 +52,10 @@ public static void OpenIOLibrary(this LuaState state) var registry = globalState.Registry; var standardIO = globalState.Platform.StandardIO; - LuaValue stdin = new(new FileHandle(standardIO.Input)); - LuaValue stdout = new(new FileHandle(standardIO.Output)); - LuaValue stderr = new(new FileHandle(standardIO.Error)); + var metatable = globalState.FileHandleMetatable; + LuaValue stdin = new(new FileHandle(standardIO.Input, metatable)); + LuaValue stdout = new(new FileHandle(standardIO.Output, metatable)); + LuaValue stderr = new(new FileHandle(standardIO.Error, metatable)); registry["_IO_input"] = stdin; registry["_IO_output"] = stdout; io["stdin"] = stdin; diff --git a/tests/Lua.Tests/ClosedUpValueTests.cs b/tests/Lua.Tests/ClosedUpValueTests.cs new file mode 100644 index 00000000..0b2f71eb --- /dev/null +++ b/tests/Lua.Tests/ClosedUpValueTests.cs @@ -0,0 +1,46 @@ +using Lua.Platforms; +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class ClosedUpValueTests(LuaStringMode mode) +{ + [TestCase(false)] + [TestCase(true)] + public async Task NewCapturesUseTheLocalAfterAnEarlierCaptureWasClosed(bool asynchronous) + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + state.Environment["close"] = new LuaFunction( + async (context, _) => + { + if (asynchronous) + await Task.Yield(); + context.GetArgument(0).UpValues[0].Close(); + return context.Return(); + } + ); + var result = await state.DoStringAsync( + """ + local n=1 + local old=function() return n end + close(old) + n=2 + local newer=function() return n end + local set=function(value) n=value end + local before=newer() + set(3) + return old(),before,newer(),debug.upvalueid(old,1)==debug.upvalueid(newer,1), + debug.upvalueid(newer,1)==debug.upvalueid(set,1) + """ + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { 1, 2, 3, false, true })); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + } +} diff --git a/tests/Lua.Tests/FileHandleMetatableTests.cs b/tests/Lua.Tests/FileHandleMetatableTests.cs new file mode 100644 index 00000000..1903df08 --- /dev/null +++ b/tests/Lua.Tests/FileHandleMetatableTests.cs @@ -0,0 +1,97 @@ +using System.Runtime.CompilerServices; +using Lua.Platforms; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class FileHandleMetatableTests(LuaStringMode mode) +{ + LuaState CreateState() + { + var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + return state; + } + + [Test] + public async Task IndependentStatesHaveIndependentFileMetatables() + { + using var first = CreateState(); + using var second = CreateState(); + await first.DoStringAsync("getmetatable(io.stdout).tag=42"); + var result = await second.DoStringAsync("return getmetatable(io.stdout).tag"); + Assert.That(result, Is.EqualTo(new LuaValue[] { LuaValue.Nil })); + } + + [Test] + public void DiscardedStateAndItsFileMetatableValuesAreCollectible() + { + var (state, payload) = CreateAndDiscard(); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + Assert.That(state.IsAlive, Is.False); + Assert.That(payload.IsAlive, Is.False); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + (WeakReference State, WeakReference Payload) CreateAndDiscard() + { + using var state = CreateState(); + var payload = new object(); + state.Environment["payload"] = LuaValue.FromObject(payload); + state + .DoStringAsync( + "local mt=getmetatable(io.stdout);mt.payload=payload;mt.owner=coroutine.running();payload=nil" + ) + .GetAwaiter() + .GetResult(); + return (new WeakReference(state), new WeakReference(payload)); + } + + [Test] + public async Task StateOwnedHandlesShareAMetatableAndReplacementAffectsOnlyOneHandle() + { + using var state = CreateState(); + var path = Path.GetTempFileName(); + try + { + File.WriteAllText(path, "line\n"); + state.Environment["path"] = state.Platform.EncodeText(path); + var result = await state.DoStringAsync( + """ + local shared=getmetatable(io.stdout) + assert(shared==getmetatable(io.stdin) and shared==getmetatable(io.stderr)) + local a=assert(io.open(path,'r')) + local b=assert(io.tmpfile()) + io.input(path) + local c=io.input() + io.output(path..'.out') + local d=io.output() + local lines=io.lines(path) + local _,e=debug.getupvalue(lines,1) + local files={a,b,c,d,e} + for _,file in ipairs(files) do assert(getmetatable(file)==shared) end + shared.tag=42 + local replacement={} + debug.setmetatable(a,replacement) + local isolated=getmetatable(a)==replacement and getmetatable(b)==shared + local tag=getmetatable(b).tag + debug.setmetatable(a,shared) + for _,file in ipairs(files) do file:close() end + return isolated,tag + """ + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { true, 42 })); + } + finally + { + File.Delete(path); + File.Delete(path + ".out"); + } + } +} diff --git a/tests/Lua.Tests/NilTableKeyTests.cs b/tests/Lua.Tests/NilTableKeyTests.cs new file mode 100644 index 00000000..f65dfde0 --- /dev/null +++ b/tests/Lua.Tests/NilTableKeyTests.cs @@ -0,0 +1,54 @@ +using System.Runtime.CompilerServices; +using Lua.Platforms; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class NilTableKeyTests(LuaStringMode mode) +{ + [TestCase(false)] + [TestCase(true)] + public async Task RawSetRejectsNilKeysWithoutChangingTheTable(bool nilValue) + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + state.Environment["nilValue"] = nilValue; + var result = await state.DoStringAsync( + """ + local t={anchor=42} + local value + if not nilValue then value={} end + local ok,message=pcall(rawset,t,nil,value) + local count=0 + for k,v in pairs(t) do count=count+1 end + return ok,type(message),t.anchor,count + """ + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { false, "string", 42, 1 })); + } + + [Test] + public void RejectedHostWriteDoesNotKeepItsValueAlive() + { + var table = new LuaTable(); + var payload = RejectWrite(table); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + Assert.That(payload.IsAlive, Is.False); + Assert.That(table.HashMapCount, Is.Zero); + GC.KeepAlive(table); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference RejectWrite(LuaTable table) + { + var payload = new object(); + Assert.Throws(() => table[LuaValue.Nil] = LuaValue.FromObject(payload)); + return new WeakReference(payload); + } +} diff --git a/tests/Lua.Tests/ReturnHookLocalTests.cs b/tests/Lua.Tests/ReturnHookLocalTests.cs new file mode 100644 index 00000000..ebca7f55 --- /dev/null +++ b/tests/Lua.Tests/ReturnHookLocalTests.cs @@ -0,0 +1,129 @@ +using Lua.Platforms; +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class ReturnHookLocalTests(LuaStringMode mode) +{ + LuaState CreateState() + { + var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + state.Stack.PushRange([101, 102]); + return state; + } + + [TestCase(false)] + [TestCase(true)] + public async Task ReturnHookInspectsAndUpdatesTheCalleesLocals(bool asynchronous) + { + using var state = CreateState(); + state.Environment["pause"] = new LuaFunction( + async (context, _) => + { + if (asynchronous) + await Task.Yield(); + return context.Return(); + } + ); + var result = await state.DoStringAsync( + """ + local seen + local function target(a,...) + local marker=123 + return a,marker + end + debug.sethook(function(event) + if event=='return' and debug.getinfo(2,'f').func==target then + pause() + local name,a=debug.getlocal(2,1) + local localName,marker=debug.getlocal(2,2) + local varName,before=debug.getlocal(2,-1) + assert(debug.setlocal(2,-1,777)==varName) + local _,after=debug.getlocal(2,-1) + seen={name,a,localName,marker,varName,before,after,event} + end + end,'r') + local a,marker=target(42,99) + debug.sethook() + return a,marker,table.unpack(seen) + """ + ); + Assert.That( + result, + Is.EqualTo( + new LuaValue[] { 42, 123, "a", 42, "marker", 123, "(*vararg)", 99, 777, "return" } + ) + ); + Assert.That(state.GetStackValues().ToArray(), Is.EqualTo(new LuaValue[] { 101, 102 })); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + [TestCase(false)] + [TestCase(true)] + public async Task ReturnHookPreservesOpenResultsWhenItsStackGrows(bool asynchronous) + { + using var state = CreateState(); + var values = Enumerable.Range(1, 400).Select(i => new LuaValue(i)).ToArray(); + state.Environment["expand"] = new LuaFunction((context, _) => new(context.Return(values))); + state.Environment["pause"] = new LuaFunction( + async (context, _) => + { + if (asynchronous) + await Task.Yield(); + return context.Return(); + } + ); + var result = await state.DoStringAsync( + """ + local calls=0 + local function target(...) return ... end + debug.sethook(function(event) + if event=='return' and debug.getinfo(2,'f').func==target then + local extra={expand()} + assert(extra[400]==400) + pause() + calls=calls+1 + end + end,'r') + local results={target(expand())} + debug.sethook() + assert(calls==1) + return table.unpack(results) + """ + ); + Assert.That(result, Is.EqualTo(values)); + Assert.That(state.GetStackValues().ToArray(), Is.EqualTo(new LuaValue[] { 101, 102 })); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + [Test] + public async Task HookEnabledInsideAFunctionRunsBeforeThatFunctionReturns() + { + using var state = CreateState(); + var result = await state.DoStringAsync( + """ + local seen + local target + target=function(a) + debug.sethook(function(event) + if event=='return' and debug.getinfo(2,'f').func==target then + local name,value=debug.getlocal(2,1) + seen={name,value} + end + end,'r') + return a + end + local value=target(42) + debug.sethook() + return value,seen and seen[1],seen and seen[2] + """ + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { 42, "a", 42 })); + } +} From abebb7177b988aed2435710388fcec65ead20382 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:16:49 +0900 Subject: [PATCH 14/32] fix: use stable Lua identities and release closed resources Hash reference-valued Lua keys by identity, matching their existing reference equality. Mutable host GetHashCode overrides can no longer make userdata entries inaccessible to lookup and deletion. Return stable opaque upvalue IDs through a weak-key table. Retaining an ID no longer retains the upvalue or captured payload. Keep the mapping on the debug API path so ordinary captures gain no field or allocation; preserve shared and joined upvalue identity across collection. Release a file handle's stream when close reports an error after actually closing it. Keep still-open streams available for retry after cancellation or failure. Clear reference-containing pooled arrays on return while leaving primitive buffers unchanged. Add 28 regression cases covering both string modes, full and light userdata, closed and suspended captures, cleanup failures and retries, and pooled object/LuaValue buffers. All 2,040 tests pass on each of net8.0 and net10.0; all four library targets build without warnings. Formatting and diff checks pass. Paired Release samples keep ordinary execution allocations unchanged. Isolated close and pooled-byte timings stay near baseline. Debug-ID lookup workloads cost about 6% more; the first-ID microbenchmark adds about 0.6 us and 51 allocated bytes per operation for the token and weak mapping. --- src/Lua/Internal/PooledArray.cs | 2 +- src/Lua/LuaValue.cs | 2 +- src/Lua/Standard/DebugLibrary.cs | 4 +- src/Lua/Standard/FileHandle.cs | 11 +- tests/Lua.Tests/FileCloseLifetimeTests.cs | 117 +++++++++++++ tests/Lua.Tests/LuaIdentityLifetimeTests.cs | 172 ++++++++++++++++++++ tests/Lua.Tests/PooledArrayLifetimeTests.cs | 35 ++++ 7 files changed, 339 insertions(+), 4 deletions(-) create mode 100644 tests/Lua.Tests/FileCloseLifetimeTests.cs create mode 100644 tests/Lua.Tests/LuaIdentityLifetimeTests.cs create mode 100644 tests/Lua.Tests/PooledArrayLifetimeTests.cs diff --git a/src/Lua/Internal/PooledArray.cs b/src/Lua/Internal/PooledArray.cs index 26d8bed3..de6e47fc 100644 --- a/src/Lua/Internal/PooledArray.cs +++ b/src/Lua/Internal/PooledArray.cs @@ -42,7 +42,7 @@ public Memory AsMemory() public void Dispose() { ThrowIfDisposed(); - ArrayPool.Shared.Return(array!); + ArrayPool.Shared.Return(array!, RuntimeHelpers.IsReferenceOrContainsReferences()); array = null; } diff --git a/src/Lua/LuaValue.cs b/src/Lua/LuaValue.cs index f534ce9a..077b77dc 100644 --- a/src/Lua/LuaValue.cs +++ b/src/Lua/LuaValue.cs @@ -613,7 +613,7 @@ public override int GetHashCode() LuaValueType.Nil => 0, LuaValueType.Boolean or LuaValueType.Number => value.GetHashCode(), LuaValueType.String => Unsafe.As(referenceValue)!.GetHashCode(), - _ => referenceValue!.GetHashCode(), + _ => RuntimeHelpers.GetHashCode(referenceValue!), }; } diff --git a/src/Lua/Standard/DebugLibrary.cs b/src/Lua/Standard/DebugLibrary.cs index 0b2470fa..e2f5184a 100644 --- a/src/Lua/Standard/DebugLibrary.cs +++ b/src/Lua/Standard/DebugLibrary.cs @@ -8,6 +8,7 @@ namespace Lua.Standard; public sealed class DebugLibrary { public static readonly DebugLibrary Instance = new(); + static readonly ConditionalWeakTable upValueIds = new(); public DebugLibrary() { @@ -434,7 +435,8 @@ CancellationToken cancellationToken return new(context.Return(LuaValue.Nil)); } - return new(context.Return(LuaValue.FromObject(upValues[n1 - 1]))); + var identity = upValueIds.GetValue(upValues[n1 - 1], static _ => new object()); + return new(context.Return(LuaValue.FromObject(identity))); } public ValueTask UpValueJoin( diff --git a/src/Lua/Standard/FileHandle.cs b/src/Lua/Standard/FileHandle.cs index 8ecf3b12..296496e8 100644 --- a/src/Lua/Standard/FileHandle.cs +++ b/src/Lua/Standard/FileHandle.cs @@ -128,7 +128,16 @@ public async ValueTask Close(CancellationToken cancellationToken) if (stream == null || !stream.IsOpen) throw new ObjectDisposedException(nameof(FileHandle)); - await stream.CloseAsync(cancellationToken); + try + { + await stream.CloseAsync(cancellationToken); + } + catch + { + if (stream is { IsOpen: false }) + stream = null!; + throw; + } stream = null!; } diff --git a/tests/Lua.Tests/FileCloseLifetimeTests.cs b/tests/Lua.Tests/FileCloseLifetimeTests.cs new file mode 100644 index 00000000..dd2333db --- /dev/null +++ b/tests/Lua.Tests/FileCloseLifetimeTests.cs @@ -0,0 +1,117 @@ +using System.Runtime.CompilerServices; +using Lua.IO; +using Lua.Platforms; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class FileCloseLifetimeTests(LuaStringMode mode) +{ + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + public void ClosedHandlesReleaseTheirStreamWhenCleanupReportsAnError( + bool flushFailure, + bool disposeFailure + ) + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + var stream = InstallAndClose(state, mode, flushFailure, disposeFailure); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + Assert.That(stream.IsAlive, Is.False); + Assert.That(state.Environment["file"].Read().IsOpen, Is.False); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + GC.KeepAlive(state); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference InstallAndClose( + LuaState state, + LuaStringMode mode, + bool flushFailure, + bool disposeFailure + ) + { + var stream = new CleanupStream(flushFailure, disposeFailure); + state.Environment["file"] = LuaValue.FromUserData( + new FileHandle(new LuaStream(LuaFileOpenMode.Write, stream, mode)) + ); + var result = state.DoStringAsync("return file:close()").GetAwaiter().GetResult(); + Assert.That( + result[0], + Is.EqualTo(flushFailure || disposeFailure ? LuaValue.Nil : new LuaValue(true)) + ); + Assert.That(stream.CanWrite, Is.False); + Assert.That(stream.DisposeCount, Is.EqualTo(1)); + return new WeakReference(stream); + } + + [TestCase(false)] + [TestCase(true)] + public async Task FailedCloseKeepsAnOpenStreamAvailableForRetry(bool cancel) + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var stream = new RetryStream(); + var file = new FileHandle(stream); + if (cancel) + Assert.CatchAsync(async () => + await file.Close(cancellation.Token) + ); + else + Assert.ThrowsAsync(async () => await file.Close(default)); + Assert.That(file.IsOpen, Is.True); + await file.Close(default); + Assert.That(file.IsOpen, Is.False); + Assert.That(stream.CloseCount, Is.EqualTo(2)); + } + + sealed class CleanupStream(bool flushFailure, bool disposeFailure) : MemoryStream + { + public int DisposeCount { get; private set; } + + public override void Flush() + { + if (flushFailure) + throw new IOException("flush failed"); + } + + protected override void Dispose(bool disposing) + { + DisposeCount++; + base.Dispose(disposing); + if (disposeFailure) + throw new IOException("dispose failed"); + } + } + + sealed class RetryStream : ILuaStream + { + public LuaFileOpenMode Mode => LuaFileOpenMode.Write; + public bool IsOpen { get; private set; } = true; + public int CloseCount { get; private set; } + + public async ValueTask CloseAsync(CancellationToken cancellationToken) + { + CloseCount++; + await Task.Yield(); + if (CloseCount == 1) + { + cancellationToken.ThrowIfCancellationRequested(); + throw new IOException("close failed while open"); + } + Dispose(); + } + + public void Dispose() => IsOpen = false; + } +} diff --git a/tests/Lua.Tests/LuaIdentityLifetimeTests.cs b/tests/Lua.Tests/LuaIdentityLifetimeTests.cs new file mode 100644 index 00000000..cba602d3 --- /dev/null +++ b/tests/Lua.Tests/LuaIdentityLifetimeTests.cs @@ -0,0 +1,172 @@ +using System.Runtime.CompilerServices; +using Lua.Platforms; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class LuaIdentityLifetimeTests(LuaStringMode mode) +{ + LuaState CreateState() + { + var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + return state; + } + + [TestCase(false)] + [TestCase(true)] + public async Task ReferenceKeysSurviveMutableHostHashCodes(bool fullUserData) + { + using var state = CreateState(); + var key = new MutableKey(); + state.Environment["key"] = KeyValue(key, fullUserData); + state.Environment["other"] = KeyValue(new MutableKey(), fullUserData); + state.Environment["mutate"] = new LuaFunction( + (context, _) => + { + key.Code = 2; + return new(context.Return()); + } + ); + var result = await state.DoStringAsync( + """ + local t={[key]=42,[other]=99} + mutate() + local value=t[key] + t[key]=nil + local count=0 + for k,v in pairs(t) do count=count+1 end + return value,t[other],count,key==other + """ + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { 42, 99, 1, false })); + } + + [TestCase(false)] + [TestCase(true)] + public void DeletedReferenceKeysAndValuesAreCollectibleAfterHostMutation(bool fullUserData) + { + using var state = CreateState(); + var (key, payload) = InsertMutateAndDelete(state, fullUserData); + Collect(); + Assert.That(key.IsAlive, Is.False); + Assert.That(payload.IsAlive, Is.False); + Assert.That(state.Environment["kept"].Read().HashMapCount, Is.Zero); + GC.KeepAlive(state); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static (WeakReference Key, WeakReference Payload) InsertMutateAndDelete( + LuaState state, + bool fullUserData + ) + { + var key = new MutableKey(); + var payload = new object(); + state.Environment["key"] = KeyValue(key, fullUserData); + state.Environment["payload"] = LuaValue.FromObject(payload); + state.Environment["mutate"] = new LuaFunction( + (context, _) => + { + key.Code = 2; + return new(context.Return()); + } + ); + state + .DoStringAsync( + "kept={};kept[key]=payload;mutate();kept[key]=nil;key=nil;payload=nil;mutate=nil" + ) + .GetAwaiter() + .GetResult(); + return (new WeakReference(key), new WeakReference(payload)); + } + + [TestCase(false)] + [TestCase(true)] + public void UpValueIdsDoNotKeepDiscardedCapturesAlive(bool coroutine) + { + using var state = CreateState(); + var payload = SaveCaptureId(state, coroutine); + Collect(); + Assert.That(payload.IsAlive, Is.False); + Assert.That(state.Environment["identity"].Type, Is.EqualTo(LuaValueType.LightUserData)); + GC.KeepAlive(state); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference SaveCaptureId(LuaState state, bool coroutine) + { + var payload = new object(); + state.Environment["payload"] = LuaValue.FromObject(payload); + var body = """ + local keep=payload + payload=nil + local f=function() return keep end + identity=debug.upvalueid(f,1) + """; + state + .DoStringAsync( + coroutine + ? "local co=coroutine.create(function() " + + body + + ";coroutine.yield();return keep end);assert(coroutine.resume(co))" + : body + ) + .GetAwaiter() + .GetResult(); + return new WeakReference(payload); + } + + [Test] + public async Task UpValueIdsRemainStableForSharedAndJoinedCaptures() + { + using var state = CreateState(); + state.Environment["collect"] = new LuaFunction( + (context, _) => + { + Collect(); + return new(context.Return()); + } + ); + var result = await state.DoStringAsync( + """ + local n,m=1,2 + local f=function() return n end + local g=function() return n end + local h=function() return m end + local identity=debug.upvalueid(f,1) + local before=debug.upvalueid(h,1) + collect() + local stable=identity==debug.upvalueid(f,1) + local shared=identity==debug.upvalueid(g,1) + debug.upvaluejoin(h,1,f,1) + collect() + n=3 + return stable,shared,before~=identity,debug.upvalueid(h,1)==identity,f(),g(),h() + """ + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { true, true, true, true, 3, 3, 3 })); + } + + static LuaValue KeyValue(MutableKey key, bool fullUserData) => + fullUserData ? LuaValue.FromUserData(key) : LuaValue.FromObject(key); + + static void Collect() + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + + sealed class MutableKey : ILuaUserData + { + public int Code = 1; + public LuaTable? Metatable { get; set; } + + public override int GetHashCode() => Code; + } +} diff --git a/tests/Lua.Tests/PooledArrayLifetimeTests.cs b/tests/Lua.Tests/PooledArrayLifetimeTests.cs new file mode 100644 index 00000000..18bcdbbd --- /dev/null +++ b/tests/Lua.Tests/PooledArrayLifetimeTests.cs @@ -0,0 +1,35 @@ +using System.Runtime.CompilerServices; +using Lua.Internal; + +namespace Lua.Tests; + +public class PooledArrayLifetimeTests +{ + [TestCase(false)] + [TestCase(true)] + public void DisposedArraysDoNotKeepReferenceValuesAlive(bool luaValues) + { + var payload = AllocateAndDispose(luaValues); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + Assert.That(payload.IsAlive, Is.False); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference AllocateAndDispose(bool luaValues) + { + var payload = new object(); + if (luaValues) + { + using var values = new PooledArray(16); + values.AsSpan()[^1] = LuaValue.FromObject(payload); + } + else + { + using var values = new PooledArray(16); + values[0] = payload; + } + return new WeakReference(payload); + } +} From 29546d8141fee962ac4d0fd2d2ae3c3d0f615491 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:35:59 +0900 Subject: [PATCH 15/32] fix: release pending exceptions after outermost unwind Preserve the completed traceback before detaching the state's pending exception and clearing residual frames. Keep collecting frames across nested invocations. Add lifetime regressions for execution errors, direct calls, metamethod closures, cancellation, and early traceback reads in both string modes. Verify retained exceptions preserve their error objects and nested traces across later executions. Validation: 2,066 tests passed on each of .NET 8 and .NET 10; Release library build passed for all four target frameworks with no warnings or errors. --- src/Lua/LuaState.cs | 16 ++ tests/Lua.Tests/ExceptionLifetimeTests.cs | 245 ++++++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 tests/Lua.Tests/ExceptionLifetimeTests.cs diff --git a/src/Lua/LuaState.cs b/src/Lua/LuaState.cs index c1fe574c..48902c23 100644 --- a/src/Lua/LuaState.cs +++ b/src/Lua/LuaState.cs @@ -372,6 +372,7 @@ internal void PopCallStackFrameWithStackPop() if (CurrentException != null) { ExceptionTrace.Push(popFrame); + CompleteExceptionTrace(callStack.Count); } coreData.Stack.PopUntil(popFrame.ReturnBase); @@ -386,6 +387,7 @@ internal void PopCallStackFrameWithStackPop(int frameBase) if (CurrentException != null) { ExceptionTrace.Push(popFrame); + CompleteExceptionTrace(callStack.Count); } { @@ -402,6 +404,7 @@ internal void PopCallStackFrame() if (CurrentException != null) { ExceptionTrace.Push(popFrame); + CompleteExceptionTrace(callStack.Count); } } @@ -413,11 +416,24 @@ internal void PopCallStackFrameUntil(int top) if (CurrentException != null) { ExceptionTrace.Push(callStack.AsSpan()[top..]); + CompleteExceptionTrace(top); } callStack.PopUntil(top); } + [MethodImpl(MethodImplOptions.NoInlining)] + void CompleteExceptionTrace(int remainingFrameCount) + { + // Nested calls must keep collecting frames until the outermost call unwinds. + if (remainingFrameCount != 0) + return; + + CurrentException!.BuildOrGet(); + CurrentException = null; + ExceptionTrace.Clear(); + } + public LuaTable GetCurrentEnvironment() { var callStack = GetCallStackFrames(); diff --git a/tests/Lua.Tests/ExceptionLifetimeTests.cs b/tests/Lua.Tests/ExceptionLifetimeTests.cs new file mode 100644 index 00000000..d8437af3 --- /dev/null +++ b/tests/Lua.Tests/ExceptionLifetimeTests.cs @@ -0,0 +1,245 @@ +using System.Runtime.CompilerServices; +using Lua.Platforms; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class ExceptionLifetimeTests(LuaStringMode mode) +{ + LuaState CreateState() + { + var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + return state; + } + + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public void DiscardedExecutionErrorDoesNotRootPayload(bool asynchronous, bool readMessage) + { + using var state = CreateState(); + AssertReleased(state, FailExecution(state, asynchronous, readMessage)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference FailExecution(LuaState state, bool asynchronous, bool readMessage) + { + var payload = new object(); + state.Environment["payload"] = LuaValue.FromObject(payload); + state.Environment["pause"] = new LuaFunction( + async (context, _) => + { + if (asynchronous) + await Task.Yield(); + return context.Return(); + } + ); + try + { + state + .DoStringAsync("local value=payload;payload=nil;pause();error(value)") + .AsTask() + .GetAwaiter() + .GetResult(); + Assert.Fail("Expected a Lua error."); + } + catch (LuaRuntimeException exception) + { + if (readMessage) + GC.KeepAlive(exception.Message); + } + return new WeakReference(payload); + } + + [TestCase(false)] + [TestCase(true)] + public void DiscardedDirectCallErrorDoesNotRootPayload(bool asynchronous) + { + using var state = CreateState(); + AssertReleased(state, FailDirectCall(state, asynchronous)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference FailDirectCall(LuaState state, bool asynchronous) + { + var payload = new object(); + var function = new LuaFunction( + async (context, _) => + { + if (asynchronous) + await Task.Yield(); + throw new LuaRuntimeException(context.State, context.GetArgument(0)); + } + ); + try + { + state + .CallAsync(function, [LuaValue.FromObject(payload)]) + .AsTask() + .GetAwaiter() + .GetResult(); + Assert.Fail("Expected a Lua error."); + } + catch (LuaRuntimeException) { } + return new WeakReference(payload); + } + + [TestCase(false)] + [TestCase(true)] + public void DiscardedMetamethodErrorDoesNotRootCapturedPayload(bool asynchronous) + { + using var state = CreateState(); + AssertReleased(state, FailMetamethod(state, asynchronous)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference FailMetamethod(LuaState state, bool asynchronous) + { + var payload = new object(); + var table = new LuaTable + { + Metatable = new LuaTable + { + ["__add"] = new LuaFunction( + async (context, _) => + { + if (asynchronous) + await Task.Yield(); + GC.KeepAlive(payload); + throw new LuaRuntimeException(context.State, "failure"); + } + ), + }, + }; + try + { + state.AddAsync(table, 1).AsTask().GetAwaiter().GetResult(); + Assert.Fail("Expected a Lua error."); + } + catch (LuaRuntimeException) { } + return new WeakReference(payload); + } + + [TestCase(false)] + [TestCase(true)] + public void DiscardedCancellationDoesNotRootInnerException(bool readTraceback) + { + using var state = CreateState(); + AssertReleased(state, CancelDirectCall(state, readTraceback)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference CancelDirectCall(LuaState state, bool readTraceback) + { + var payload = new object(); + var cancellation = new OperationCanceledException(); + cancellation.Data["payload"] = payload; + var function = new LuaFunction((_, _) => throw cancellation); + try + { + state.CallAsync(function, []).AsTask().GetAwaiter().GetResult(); + Assert.Fail("Expected cancellation."); + } + catch (LuaCanceledException exception) + { + if (readTraceback) + GC.KeepAlive(exception.LuaTraceback); + } + return new WeakReference(payload); + } + + [Test] + public void EarlyTracebackReadDoesNotRootLaterUnwoundClosures() + { + using var state = CreateState(); + AssertReleased(state, ReadTracebackAndRethrow(state)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference ReadTracebackAndRethrow(LuaState state) + { + var payload = new object(); + state.Environment["payload"] = LuaValue.FromObject(payload); + state.Environment["bounce"] = new LuaFunction( + async (context, token) => + { + try + { + await context.State.DoStringAsync("error('failure')", "inner-chunk", token); + } + catch (LuaRuntimeException exception) + { + GC.KeepAlive(exception.Message); + throw; + } + return context.Return(); + } + ); + try + { + state + .DoStringAsync( + "local p=payload;payload=nil;local function f() if p then bounce() end end;f()" + ) + .AsTask() + .GetAwaiter() + .GetResult(); + Assert.Fail("Expected a Lua error."); + } + catch (LuaRuntimeException) { } + return new WeakReference(payload); + } + + [TestCase(false)] + [TestCase(true)] + public async Task RetainedErrorPreservesPayloadAndNestedTraceback(bool asynchronous) + { + using var state = CreateState(); + var payload = new object(); + state.Environment["payload"] = LuaValue.FromObject(payload); + state.Environment["bounce"] = new LuaFunction( + async (context, token) => + { + if (asynchronous) + await Task.Yield(); + await context.State.DoStringAsync( + "local value=payload;payload=nil;error(value)", + "inner-chunk", + token + ); + return context.Return(); + } + ); + var exception = Assert.ThrowsAsync(async () => + await state.DoStringAsync("bounce()", "outer-chunk") + )!; + Assert.ThrowsAsync(async () => + await state.DoStringAsync("error('another failure')", "other-chunk") + ); + await state.DoStringAsync("return 42"); + + Assert.That(exception.ErrorObject.Read(), Is.SameAs(payload)); + Assert.That(exception.LuaTraceback!.StackFrames.Length, Is.EqualTo(4)); + var trace = exception.LuaTraceback.ToString(); + Assert.That(trace, Does.Contain("inner-chunk")); + Assert.That(trace, Does.Contain("outer-chunk")); + Assert.That(trace, Does.Not.Contain("other-chunk")); + } + + static void AssertReleased(LuaState state, WeakReference reference) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + Assert.That(reference.IsAlive, Is.False); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + GC.KeepAlive(state); + } +} From 1b8657c37d54cb201d90abb891add7b41b70fa03 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:56:45 +0900 Subject: [PATCH 16/32] fix: release failed concat operands and pre-frame exceptions Restore the original operand base when host concatenation fails, including after partial reductions and asynchronous metamethods. Keep its result on the stack after success and leave invalid-count validation outside cleanup. Register pending exceptions only when the state has call frames to unwind. This releases pre-frame cancellation sources while preserving nested caller tracebacks and the original exception payloads and tokens. Add regression coverage for both string modes, synchronous and asynchronous concatenation, pre-frame errors and cancellation, and retained diagnostics. Validation: 2,092 tests passed on each of .NET 8 and .NET 10. Release library build passed for all four target frameworks with no warnings or errors. --- src/Lua/Exceptions.cs | 21 ++-- src/Lua/Runtime/LuaVirtualMachine.cs | 41 +++++--- tests/Lua.Tests/ConcatLifetimeTests.cs | 122 ++++++++++++++++++++++ tests/Lua.Tests/ExceptionLifetimeTests.cs | 97 +++++++++++++++++ 4 files changed, 255 insertions(+), 26 deletions(-) create mode 100644 tests/Lua.Tests/ConcatLifetimeTests.cs diff --git a/src/Lua/Exceptions.cs b/src/Lua/Exceptions.cs index cb319f20..c3214ecd 100644 --- a/src/Lua/Exceptions.cs +++ b/src/Lua/Exceptions.cs @@ -182,27 +182,25 @@ public class LuaRuntimeException : Exception, ILuaTracebackBuildable public LuaRuntimeException(LuaState? state, Exception innerException) : base(innerException.Message, innerException) { - if (state != null) + if (state is { IsRunning: true }) { state.CurrentException?.BuildOrGet(); state.ExceptionTrace.Clear(); state.CurrentException = this; + State = state; } - - State = state; } public LuaRuntimeException(LuaState? state, LuaValue errorObject, int level = 1) { - if (state != null) + if (state is { IsRunning: true }) { state.CurrentException?.BuildOrGet(); state.ExceptionTrace.Clear(); state.CurrentException = this; + State = state; } - State = state; - ErrorObject = errorObject; this.level = level; } @@ -637,10 +635,13 @@ internal LuaCanceledException( cancellationToken ) { - state.CurrentException?.BuildOrGet(); - state.ExceptionTrace.Clear(); - state.CurrentException = this; - State = state; + if (state.IsRunning) + { + state.CurrentException?.BuildOrGet(); + state.ExceptionTrace.Clear(); + state.CurrentException = this; + State = state; + } } [MethodImpl(MethodImplOptions.NoInlining)] diff --git a/src/Lua/Runtime/LuaVirtualMachine.cs b/src/Lua/Runtime/LuaVirtualMachine.cs index bcdde0ac..1b7e7541 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.cs @@ -1260,26 +1260,35 @@ CancellationToken ct var stack = state.Stack; if ((uint)total > (uint)stack.Count) throw new ArgumentOutOfRangeException(nameof(total)); - if (total == 0) + var returnBase = stack.Count - total; + try { - stack.Push(""); - return ""; + if (total == 0) + { + stack.Push(""); + return ""; + } + while ((total = ConcatStrings(stack, total)) > 1) + { + var top = stack.Count; + var value = await ExecuteBinaryOperationMetaMethod( + state, + stack.Get(top - 2), + stack.Get(top - 1), + OpCode.Concat, + ct + ); + stack.Get(top - 2) = value; + stack.PopUntil(top - 1); + total--; + } + return stack.AsSpan()[^1]; } - while ((total = ConcatStrings(stack, total)) > 1) + catch { - var top = stack.Count; - var value = await ExecuteBinaryOperationMetaMethod( - state, - stack.Get(top - 2), - stack.Get(top - 1), - OpCode.Concat, - ct - ); - stack.Get(top - 2) = value; - stack.PopUntil(top - 1); - total--; + stack.PopUntil(returnBase); + throw; } - return stack.AsSpan()[^1]; } // Reduce the primitive suffix synchronously. A remaining pair needs __concat. diff --git a/tests/Lua.Tests/ConcatLifetimeTests.cs b/tests/Lua.Tests/ConcatLifetimeTests.cs new file mode 100644 index 00000000..129d2213 --- /dev/null +++ b/tests/Lua.Tests/ConcatLifetimeTests.cs @@ -0,0 +1,122 @@ +using System.Runtime.CompilerServices; +using Lua.Platforms; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class ConcatLifetimeTests(LuaStringMode mode) +{ + LuaState CreateState() => + LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public void FailedConcatenationReleasesOperandsAndPreservesPrefix( + bool asynchronous, + bool stackOperands + ) + { + using var state = CreateState(); + state.Stack.Push(12345); + var payload = FailConcatenation(state, asynchronous, stackOperands); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + Assert.That(payload.IsAlive, Is.False); + Assert.That(state.Stack.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { 12345 })); + Assert.That(state.CallStackFrameCount, Is.Zero); + GC.KeepAlive(state); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference FailConcatenation(LuaState state, bool asynchronous, bool stackOperands) + { + var payload = new object(); + var value = new LuaTable + { + ["payload"] = LuaValue.FromObject(payload), + Metatable = new LuaTable + { + ["__concat"] = new LuaFunction( + async (context, _) => + { + if (asynchronous) + await Task.Yield(); + throw new LuaRuntimeException(context.State, "failure"); + } + ), + }, + }; + try + { + if (stackOperands) + { + state.Stack.PushRange([value, "suffix", 42]); + state.ConcatAsync(3).AsTask().GetAwaiter().GetResult(); + } + else + state.ConcatAsync([value, "suffix", 42]).AsTask().GetAwaiter().GetResult(); + Assert.Fail("Expected a Lua error."); + } + catch (LuaRuntimeException exception) + { + Assert.That(exception.ErrorObject, Is.EqualTo(new LuaValue("failure"))); + } + return new(payload); + } + + [Test] + public void CancellationBeforeConcatenationRestoresPrefixWithoutInvokingMetamethod() + { + using var state = CreateState(); + state.Stack.Push(12345); + using var source = new CancellationTokenSource(); + source.Cancel(); + var value = new LuaTable + { + Metatable = new LuaTable + { + ["__concat"] = new LuaFunction( + (_, _) => throw new AssertionException("Unexpected call.") + ), + }, + }; + var exception = Assert.ThrowsAsync(async () => + await state.ConcatAsync([value, "suffix"], source.Token) + )!; + Assert.That(exception.CancellationToken, Is.EqualTo(source.Token)); + Assert.That(state.Stack.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { 12345 })); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + [TestCase(false)] + [TestCase(true)] + public async Task SuccessfulConcatenationKeepsOneResultAfterPrefix(bool asynchronous) + { + using var state = CreateState(); + state.Stack.Push(12345); + var value = new LuaTable + { + Metatable = new LuaTable + { + ["__concat"] = new LuaFunction( + async (context, _) => + { + if (asynchronous) + await Task.Yield(); + return context.Return("joined"); + } + ), + }, + }; + var result = await state.ConcatAsync(["start", value, "end", 42]); + Assert.That(result, Is.EqualTo(new LuaValue("startjoined"))); + Assert.That(state.Stack.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { 12345, result })); + Assert.That(state.CallStackFrameCount, Is.Zero); + } +} diff --git a/tests/Lua.Tests/ExceptionLifetimeTests.cs b/tests/Lua.Tests/ExceptionLifetimeTests.cs index d8437af3..c023db53 100644 --- a/tests/Lua.Tests/ExceptionLifetimeTests.cs +++ b/tests/Lua.Tests/ExceptionLifetimeTests.cs @@ -154,6 +154,103 @@ static WeakReference CancelDirectCall(LuaState state, bool readTraceback) return new WeakReference(payload); } + [TestCase("call")] + [TestCase("unary")] + [TestCase("binary")] + public void CancellationBeforeFirstFrameDoesNotRootExceptionOrTokenSource(string operation) + { + using var state = CreateState(); + var (exception, source) = CancelBeforeFirstFrame(state, operation); + AssertReleased(state, exception); + Assert.That(source.IsAlive, Is.False); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static (WeakReference Exception, WeakReference Source) CancelBeforeFirstFrame( + LuaState state, + string operation + ) + { + using var source = new CancellationTokenSource(); + source.Cancel(); + var function = new LuaFunction((_, _) => throw new AssertionException("Unexpected call.")); + var table = new LuaTable + { + Metatable = new LuaTable { ["__add"] = function, ["__unm"] = function }, + }; + try + { + switch (operation) + { + case "call": + state.CallAsync(function, [], source.Token).AsTask().GetAwaiter().GetResult(); + break; + case "unary": + state.UnmAsync(table, source.Token).AsTask().GetAwaiter().GetResult(); + break; + case "binary": + state.AddAsync(table, table, source.Token).AsTask().GetAwaiter().GetResult(); + break; + } + } + catch (LuaCanceledException exception) + { + Assert.That(exception.CancellationToken, Is.EqualTo(source.Token)); + Assert.That(exception.LuaTraceback, Is.Null); + return (new(exception), new(source)); + } + throw new AssertionException("Expected cancellation."); + } + + [TestCase(false)] + [TestCase(true)] + public void ErrorsCreatedWithoutCallFramesDoNotRootPayloads(bool wrapped) + { + using var state = CreateState(); + AssertReleased(state, CreateIdleError(state, wrapped)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference CreateIdleError(LuaState state, bool wrapped) + { + var payload = new object(); + if (wrapped) + { + var inner = new InvalidOperationException("failure"); + inner.Data["payload"] = payload; + GC.KeepAlive(new LuaRuntimeException(state, inner)); + } + else + GC.KeepAlive(new LuaRuntimeException(state, LuaValue.FromObject(payload))); + return new(payload); + } + + [Test] + public async Task CancellationBeforeNestedCallPreservesCallerTraceback() + { + using var state = CreateState(); + using var source = new CancellationTokenSource(); + source.Cancel(); + state.Environment["cancel"] = new LuaFunction( + async (context, _) => + { + await context.State.CallAsync( + new LuaFunction((_, _) => throw new AssertionException("Unexpected call.")), + [], + source.Token + ); + return context.Return(); + } + ); + var exception = Assert.ThrowsAsync(async () => + await state.DoStringAsync("cancel()", "cancel-caller") + )!; + await state.DoStringAsync("return 42"); + Assert.That(exception.CancellationToken, Is.EqualTo(source.Token)); + Assert.That(exception.LuaTraceback!.StackFrames.Length, Is.EqualTo(2)); + Assert.That(exception.LuaTraceback.ToString(), Does.Contain("cancel-caller")); + } + [Test] public void EarlyTracebackReadDoesNotRootLaterUnwoundClosures() { From 4b4b0480c13cb1c5cc7dfe308e2dd1d3546e696f Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:03:14 +0900 Subject: [PATCH 17/32] fix: reclaim comparison results and scope generated metatables Discard native comparison results after converting them to booleans so loops use constant stack space and release returned objects during execution. Cache generated default metatables with weak main-state keys. Threads share their defaults while independent states do not retain or modify each other's Lua values. Store explicit replacements with weak userdata keys, including nil removal, without adding instance fields or changing record equality. Add regressions for comparison stack growth, collection during execution, generated metatable lifetimes and isolation, coroutine sharing, per-object replacement, nil removal, concurrent initialization, and record equality. Validation: 2,122 runtime tests passed on each of .NET 8 and .NET 10; all 69 source-generator tests passed. Release builds passed for all four runtime targets without warnings or errors. Steady-state allocations and generated object size were unchanged in the before/after benchmarks. State isolation adds about 7-16% in the isolated generated-userdata lookup loops; numeric loops and normal calls were near baseline. --- .../LuaObjectGenerator.Emit.cs | 95 +++++++---- src/Lua/Internal/LuaGlobalState.cs | 2 +- src/Lua/LuaUserData.cs | 3 + src/Lua/Runtime/LuaVirtualMachine.cs | 2 +- tests/Lua.Tests/ComparisonLifetimeTests.cs | 119 ++++++++++++++ .../GeneratedMetatableLifetimeTests.cs | 137 ++++++++++++++++ .../Lua.Tests/MetatableInitializationTests.cs | 152 ++++++------------ 7 files changed, 370 insertions(+), 140 deletions(-) create mode 100644 tests/Lua.Tests/ComparisonLifetimeTests.cs create mode 100644 tests/Lua.Tests/GeneratedMetatableLifetimeTests.cs diff --git a/src/Lua.SourceGenerator/LuaObjectGenerator.Emit.cs b/src/Lua.SourceGenerator/LuaObjectGenerator.Emit.cs index 567b89b3..0be625ce 100644 --- a/src/Lua.SourceGenerator/LuaObjectGenerator.Emit.cs +++ b/src/Lua.SourceGenerator/LuaObjectGenerator.Emit.cs @@ -1351,51 +1351,80 @@ in SourceProductionContext context builder.AppendLine("global::Lua.LuaTable? global::Lua.ILuaUserData.Metatable"); using (builder.BeginBlockScope()) { - builder.AppendLine("get => Metatable;"); - builder.AppendLine("set => Metatable = value;"); - } - builder.AppendLine("static global::Lua.LuaTable? Metatable"); - using (builder.BeginBlockScope()) - { - builder.AppendLine("get"); + builder.AppendLine( + "get => __GetMetatableOverrides().GetValue(this, static _ => new __MetatableOverride(__CreateMetatable())).Value;" + ); + builder.AppendLine("set"); using (builder.BeginBlockScope()) { builder.AppendLine( - "var metatable = global::System.Threading.Volatile.Read(ref __metatable);" - ); - builder.AppendLine("if (metatable != null) return metatable;"); - builder.AppendLine(); - builder.AppendLine("metatable = new();"); - foreach (var metamethod in metamethods) - { - var metaMethodName = metamethod.ToString(); - var lowerName = metaMethodName.ToLower(); - builder.AppendLine( - $"metatable[global::Lua.Runtime.Metamethods.{metaMethodName}] = __metamethod_{lowerName};" - ); - } - - builder.AppendLine( - "// Publish only a complete table. Concurrent readers share the winning instance," - ); - builder.AppendLine( - "// including a custom metatable installed while initialization was in progress." - ); - builder.AppendLine( - "return global::System.Threading.Interlocked.CompareExchange(ref __metatable, metatable, null) ?? metatable;" + "__GetMetatableOverrides().GetValue(this, _ => new __MetatableOverride(value)).Value = value;" ); } + } - builder.AppendLine("set"); - using (builder.BeginBlockScope()) + builder.AppendLine( + "global::Lua.LuaTable? global::Lua.ILuaUserData.GetMetatable(global::Lua.LuaState state)" + ); + using (builder.BeginBlockScope()) + { + builder.AppendLine( + "var overrides = global::System.Threading.Volatile.Read(ref __metatableOverrides);" + ); + builder.AppendLine( + "if (overrides != null && overrides.TryGetValue(this, out var entry)) return entry.Value;" + ); + builder.AppendLine("var mainThread = state.MainThread;"); + builder.AppendLine( + "if (__metatables.TryGetValue(mainThread, out var metatable)) return metatable;" + ); + builder.AppendLine( + "return __metatables.GetValue(mainThread, static _ => __CreateMetatable());" + ); + } + builder.AppendLine("static global::Lua.LuaTable __CreateMetatable()"); + using (builder.BeginBlockScope()) + { + builder.AppendLine("var metatable = new global::Lua.LuaTable();"); + foreach (var metamethod in metamethods) { + var metaMethodName = metamethod.ToString(); + var lowerName = metaMethodName.ToLower(); builder.AppendLine( - "global::System.Threading.Volatile.Write(ref __metatable, value);" + $"metatable[global::Lua.Runtime.Metamethods.{metaMethodName}] = __metamethod_{lowerName};" ); } + builder.AppendLine("return metatable;"); } - builder.AppendLine("static global::Lua.LuaTable? __metatable;"); + builder.AppendLine("sealed class __MetatableOverride"); + using (builder.BeginBlockScope()) + { + builder.AppendLine("public volatile global::Lua.LuaTable? Value;"); + builder.AppendLine( + "public __MetatableOverride(global::Lua.LuaTable? value) => Value = value;" + ); + } + builder.AppendLine( + "static global::System.Runtime.CompilerServices.ConditionalWeakTable? __metatableOverrides;" + ); + builder.AppendLine( + "static global::System.Runtime.CompilerServices.ConditionalWeakTable __GetMetatableOverrides()" + ); + using (builder.BeginBlockScope()) + { + builder.AppendLine( + "var overrides = global::System.Threading.Volatile.Read(ref __metatableOverrides);" + ); + builder.AppendLine("if (overrides != null) return overrides;"); + builder.AppendLine("overrides = new();"); + builder.AppendLine( + "return global::System.Threading.Interlocked.CompareExchange(ref __metatableOverrides, overrides, null) ?? overrides;" + ); + } + builder.AppendLine( + "static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable __metatables = new();" + ); builder.AppendLine(); return true; diff --git a/src/Lua/Internal/LuaGlobalState.cs b/src/Lua/Internal/LuaGlobalState.cs index c3983ebc..e9602e52 100644 --- a/src/Lua/Internal/LuaGlobalState.cs +++ b/src/Lua/Internal/LuaGlobalState.cs @@ -69,7 +69,7 @@ internal bool TryGetMetatable(LuaValue value, [NotNullWhen(true)] out LuaTable? LuaValueType.Number => numberMetatable, LuaValueType.Function => functionMetatable, LuaValueType.Thread => stateMetatable, - LuaValueType.UserData => value.UnsafeRead().Metatable, + LuaValueType.UserData => value.UnsafeRead().GetMetatable(mainState), LuaValueType.Table => value.UnsafeRead().Metatable, _ => null, }; diff --git a/src/Lua/LuaUserData.cs b/src/Lua/LuaUserData.cs index 8ca3fba0..8da903a1 100644 --- a/src/Lua/LuaUserData.cs +++ b/src/Lua/LuaUserData.cs @@ -18,6 +18,9 @@ public interface ILuaUserData { LuaTable? Metatable { get; set; } + /// Gets the metatable associated with this state's globals. Defaults to . + LuaTable? GetMetatable(LuaState state) => Metatable; + // We use span for compatibility with lua5.4. Span UserValues => default; } diff --git a/src/Lua/Runtime/LuaVirtualMachine.cs b/src/Lua/Runtime/LuaVirtualMachine.cs index 1b7e7541..2f94c4ff 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.cs @@ -2973,7 +2973,7 @@ out metamethod context.Pc++; } - stack.PopUntil(newFrame.Base - newFrame.VariableArgumentCount + 1); + stack.PopUntil(newFrame.Base - newFrame.VariableArgumentCount); context.State.PopCallStackFrame(); return true; diff --git a/tests/Lua.Tests/ComparisonLifetimeTests.cs b/tests/Lua.Tests/ComparisonLifetimeTests.cs new file mode 100644 index 00000000..04242603 --- /dev/null +++ b/tests/Lua.Tests/ComparisonLifetimeTests.cs @@ -0,0 +1,119 @@ +using System.Runtime.CompilerServices; +using Lua.Platforms; +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class ComparisonLifetimeTests(LuaStringMode mode) +{ + LuaState CreateState() => + LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + + [TestCase("lt", false)] + [TestCase("lt", true)] + [TestCase("le", false)] + [TestCase("le", true)] + [TestCase("eq", false)] + [TestCase("eq", true)] + [TestCase("reverse-le", false)] + [TestCase("reverse-le", true)] + public async Task NativeComparisonsUseConstantStackSpace(string operation, bool asynchronous) + { + using var state = CreateState(); + var positions = new HashSet(); + var calls = 0; + var method = new LuaFunction( + async (context, _) => + { + positions.Add(context.ReturnFrameBase); + calls++; + if (asynchronous) + await Task.Yield(); + return context.Return(operation != "reverse-le"); + } + ); + var metatable = new LuaTable + { + [operation == "reverse-le" ? "__lt" : "__" + operation] = method, + }; + state.Environment["left"] = new LuaTable { Metatable = metatable }; + state.Environment["right"] = new LuaTable { Metatable = metatable }; + var symbol = operation switch + { + "lt" => "<", + "eq" => "==", + _ => "<=", + }; + var result = await state.DoStringAsync( + "local a,b=left,right;local n=0;for i=1,1000 do if a" + + symbol + + "b then n=n+1 end end;return n" + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { 1000 })); + Assert.That(calls, Is.EqualTo(1000)); + Assert.That(positions.Count, Is.EqualTo(1)); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + [Test] + public async Task DiscardedComparisonResultsAreCollectibleDuringExecution() + { + using var state = CreateState(); + var payloads = new List(); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var paused = false; + var method = new LuaFunction((context, _) => new(ReturnPayload(context, payloads))); + var metatable = new LuaTable { ["__lt"] = method }; + state.Environment["left"] = new LuaTable { Metatable = metatable }; + state.Environment["right"] = new LuaTable { Metatable = metatable }; + state.SetHook( + new LuaFunction( + async (context, _) => + { + if (payloads.Count >= 100 && !paused) + { + paused = true; + await gate.Task; + } + return context.Return(); + } + ), + "", + 64 + ); + var execution = state + .DoStringAsync( + "local a,b=left,right;local n=0;for i=1,1000 do if a payload.IsAlive), Is.False); + } + finally + { + gate.SetResult(); + await execution; + } + Assert.That(execution.Result, Is.EqualTo(new LuaValue[] { 1000 })); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static int ReturnPayload(LuaFunctionExecutionContext context, List payloads) + { + var payload = new object(); + payloads.Add(new(payload)); + return context.Return(LuaValue.FromObject(payload)); + } +} diff --git a/tests/Lua.Tests/GeneratedMetatableLifetimeTests.cs b/tests/Lua.Tests/GeneratedMetatableLifetimeTests.cs new file mode 100644 index 00000000..6fdcff35 --- /dev/null +++ b/tests/Lua.Tests/GeneratedMetatableLifetimeTests.cs @@ -0,0 +1,137 @@ +using System.Runtime.CompilerServices; +using Lua.Platforms; +using Lua.Standard; + +namespace Lua.Tests; + +[LuaObject] +public partial class GeneratedMetatableTestObject +{ + [LuaMember] + public int Value { get; set; } + + [LuaMember] + public int Read() => Value; +} + +[LuaObject] +public partial record GeneratedMetatableTestRecord +{ + [LuaMember] + public int Value { get; init; } +} + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class GeneratedMetatableLifetimeTests(LuaStringMode mode) +{ + LuaState CreateState() + { + var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + return state; + } + + [TestCase(false)] + [TestCase(true)] + public void DiscardedMetatablesDoNotRootStatesOrPayloads(bool replacement) + { + var (state, payload) = CreateAndDiscard(replacement); + Collect(); + Assert.That(state.IsAlive, Is.False); + Assert.That(payload.IsAlive, Is.False); + } + + [Test] + public void LiveHostUserDataDoesNotRootDiscardedStateDefaults() + { + var userData = new GeneratedMetatableTestObject(); + var (state, payload) = CreateAndDiscard(false, userData); + Collect(); + Assert.That(state.IsAlive, Is.False); + Assert.That(payload.IsAlive, Is.False); + GC.KeepAlive(userData); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + (WeakReference State, WeakReference Payload) CreateAndDiscard( + bool replacement, + GeneratedMetatableTestObject? userData = null + ) + { + using var state = CreateState(); + var payload = new object(); + state.Environment["obj"] = userData ?? new GeneratedMetatableTestObject(); + state.Environment["payload"] = LuaValue.FromObject(payload); + state + .DoStringAsync( + replacement + ? "debug.setmetatable(obj,{owner=coroutine.running(),payload=payload});obj=nil;payload=nil" + : "local mt=getmetatable(obj);mt.owner=coroutine.running();mt.payload=payload;obj=nil;payload=nil" + ) + .AsTask() + .GetAwaiter() + .GetResult(); + return (new(state), new(payload)); + } + + [Test] + public async Task IndependentStatesHaveIndependentDefaults() + { + using var first = CreateState(); + using var second = CreateState(); + first.Environment["obj"] = new GeneratedMetatableTestObject(); + second.Environment["obj"] = new GeneratedMetatableTestObject(); + await first.DoStringAsync("getmetatable(obj).tag=42"); + var result = await second.DoStringAsync("return getmetatable(obj).tag"); + Assert.That(result, Is.EqualTo(new LuaValue[] { LuaValue.Nil })); + } + + [Test] + public async Task ThreadsShareDefaultsAndReplacementAffectsOnlyOneUserData() + { + using var state = CreateState(); + state.Environment["first"] = new GeneratedMetatableTestObject { Value = 7 }; + state.Environment["second"] = new GeneratedMetatableTestObject { Value = 8 }; + var result = await state.DoStringAsync( + """ + local shared=getmetatable(first) + assert(shared==getmetatable(second)) + shared.tag=42 + local co=coroutine.create(function() + return getmetatable(second)==shared,second:Read() + end) + local ok,same,value=coroutine.resume(co) + local replacement={__index=function() return 99 end} + debug.setmetatable(first,replacement) + local isolated=getmetatable(first)==replacement and getmetatable(second)==shared + local changed=first.Value + debug.setmetatable(first,nil) + local removed=getmetatable(first)==nil + debug.setmetatable(first,shared) + return ok,same,value,isolated,changed,removed,first:Read(),getmetatable(second).tag + """ + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { true, true, 8, true, 99, true, 7, 42 })); + } + + [Test] + public void MetatableOverridesDoNotChangeRecordEquality() + { + var first = new GeneratedMetatableTestRecord { Value = 7 }; + var second = new GeneratedMetatableTestRecord { Value = 7 }; + ILuaUserData userData = first; + userData.Metatable = new LuaTable(); + Assert.That(first, Is.EqualTo(second)); + Assert.That(first.GetHashCode(), Is.EqualTo(second.GetHashCode())); + } + + static void Collect() + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } +} diff --git a/tests/Lua.Tests/MetatableInitializationTests.cs b/tests/Lua.Tests/MetatableInitializationTests.cs index 98c834ae..41eede95 100644 --- a/tests/Lua.Tests/MetatableInitializationTests.cs +++ b/tests/Lua.Tests/MetatableInitializationTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using Lua.Runtime; using Lua.Standard; @@ -30,130 +29,73 @@ public static int Add(ConcurrentMetatableTestObject a, ConcurrentMetatableTestOb public class MetatableInitializationTests { [Test] - public void ParallelFirstAccess_PublishesOneCompleteMetatable() + public async Task ParallelStatesInitializeCompleteIndependentMetatables() { const int workerCount = 8; - const int rounds = 500; using var barrier = new Barrier(workerCount); - var failures = new ConcurrentQueue(); - var tables = new LuaTable?[workerCount]; - ILuaUserData resetter = new ConcurrentMetatableTestObject(); var workers = Enumerable .Range(0, workerCount) - .Select(worker => new Thread(() => - { - try - { - using var state = LuaState.Create(); - state.OpenStandardLibraries(); - var obj = new ConcurrentMetatableTestObject(); - ILuaUserData userData = obj; - state.Environment["obj"] = obj; - var script = state.Load( - "obj.Value = 42; return obj:Read(), #obj, obj(), obj + obj, tostring(obj)", - "parallel-metatable.lua" - ); - for (var round = 0; round < rounds; round++) + .Select(worker => + Task.Factory.StartNew( + () => { - if (worker == 0) - { - // Reset only between rounds, after all readers have finished. - resetter.Metatable = null; - Array.Clear(tables); - } - barrier.SignalAndWait(TimeSpan.FromSeconds(10)); - try - { - var table = userData.Metatable!; - tables[worker] = table; - foreach ( - var key in new[] - { - "__index", - "__newindex", - "__add", - "__len", - "__call", - "__tostring", - } - ) + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + state.Environment["obj"] = new ConcurrentMetatableTestObject(); + var script = state.Load( + "obj.Value=42;return obj:Read(),#obj,obj(),obj+obj,tostring(obj),getmetatable(obj)", + "parallel-metatable.lua" + ); + Assert.That(barrier.SignalAndWait(TimeSpan.FromSeconds(10)), Is.True); + var results = state.ExecuteAsync(script).GetAwaiter().GetResult(); + Assert.That( + results[..5], + Is.EqualTo(new LuaValue[] { 42, 42, 42, 84, "42" }) + ); + var metatable = results[5].Read(); + foreach ( + var key in new[] { - if (table[key].Type != LuaValueType.Function) - failures.Enqueue( - $"Round {round}, worker {worker}: incomplete {key}" - ); + "__index", + "__newindex", + "__add", + "__len", + "__call", + "__tostring", } - var results = state.ExecuteAsync(script).GetAwaiter().GetResult(); - if ( - results.Length != 5 - || results[0].Read() != 42 - || results[1].Read() != 42 - || results[2].Read() != 42 - || results[3].Read() != 84 - || results[4].Read() != "42" - ) - failures.Enqueue( - $"Round {round}, worker {worker}: incorrect Lua result" - ); - } - catch (Exception ex) - { - failures.Enqueue($"Round {round}, worker {worker}: {ex.Message}"); - } - barrier.SignalAndWait(TimeSpan.FromSeconds(10)); - if (worker == 0 && tables.Any(table => !ReferenceEquals(table, tables[0]))) - failures.Enqueue($"Round {round}: readers used different metatables"); - } - } - catch (Exception ex) - { - failures.Enqueue($"Worker {worker}: {ex.Message}"); - } - }) - { - IsBackground = true, - }) + ) + Assert.That(metatable[key].Type, Is.EqualTo(LuaValueType.Function)); + return metatable; + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default + ) + ) .ToArray(); - foreach (var worker in workers) - worker.Start(); - foreach (var worker in workers) - Assert.That(worker.Join(TimeSpan.FromSeconds(30)), Is.True, "Worker did not finish"); - Assert.That(failures, Is.Empty, string.Join("\n", failures.Take(10))); + var tables = await Task.WhenAll(workers); + Assert.That(tables.Distinct().Count(), Is.EqualTo(workerCount)); } [Test] - public async Task ReplacementAndReset_PreserveSharedMetatableBehavior() + public async Task HostReplacementAndRemovalAffectOnlyOneUserData() { using var state = LuaState.Create(); var obj = new ConcurrentMetatableTestObject { Value = 7 }; ILuaUserData first = obj; ILuaUserData second = new ConcurrentMetatableTestObject(); - first.Metatable = null; var original = first.Metatable!; - Assert.That(second.Metatable, Is.SameAs(original)); + Assert.That(second.Metatable, Is.Not.SameAs(original)); var replacement = new LuaTable(); replacement[Metamethods.Index] = new LuaFunction((context, ct) => new(context.Return(99))); first.Metatable = replacement; - try - { - Assert.That(second.Metatable, Is.SameAs(replacement)); - state.Environment["obj"] = obj; - Assert.That( - (await state.DoStringAsync("return obj.Value"))[0].Read(), - Is.EqualTo(99) - ); - second.Metatable = null; - var restored = first.Metatable!; - Assert.That(restored, Is.Not.SameAs(original)); - Assert.That(second.Metatable, Is.SameAs(restored)); - Assert.That( - (await state.DoStringAsync("return obj:Read()"))[0].Read(), - Is.EqualTo(7) - ); - } - finally - { - first.Metatable = null; - } + Assert.That(second.Metatable, Is.Not.SameAs(replacement)); + state.Environment["obj"] = obj; + Assert.That((await state.DoStringAsync("return obj.Value"))[0].Read(), Is.EqualTo(99)); + first.Metatable = null; + Assert.That(first.Metatable, Is.Null); + Assert.That(first.GetMetatable(state), Is.Null); + first.Metatable = original; + Assert.That((await state.DoStringAsync("return obj:Read()"))[0].Read(), Is.EqualTo(7)); } } From 72868a2603a9c148e4785c8bc7e89d4a08deb7a9 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:50:56 +0900 Subject: [PATCH 18/32] fix: freeze generated userdata metatables Share one frozen default metatable per generated C# type and use direct userdata metatable dispatch. Keep explicit per-object replacements weakly held and mutable without changing record equality or instance size. Reject mutations at table writes and writable-view boundaries, detach retained array views when freezing, and read module searchers through a read-only view. Cover raw, VM, host and table-library writes, collection, sharing, replacement and ordinary Lua behavior with regression tests. Add reusable generated-userdata benchmarks and document the contract. Validation: 2,164 runtime tests pass on each of .NET 8 and .NET 10; all 69 source-generator tests pass. Release builds pass for all four library targets and the benchmark project. Pinned .NET 10 comparisons against the previous commit show 15% faster field reads, 14% faster method calls and 22% faster userdata comparisons with unchanged managed allocations; n-body, hash writes and host setters remain within 3% of the baseline. --- README.md | 2 + sandbox/Benchmark/Benchmark.csproj | 2 + .../Benchmark/GeneratedUserDataBenchmark.cs | 71 +++++++ sandbox/Benchmark/Program.cs | 1 + sandbox/Benchmark/README.md | 2 + .../LuaObjectGenerator.Emit.cs | 35 ++-- src/Lua/Internal/LuaGlobalState.cs | 2 +- src/Lua/LuaStateExtensions.cs | 2 +- src/Lua/LuaTable.cs | 54 +++++- src/Lua/LuaUserData.cs | 3 - src/Lua/Runtime/LuaVirtualMachine.cs | 6 +- src/Lua/Standard/ModuleLibrary.cs | 4 +- .../GeneratedMetatableLifetimeTests.cs | 45 ++++- .../Lua.Tests/MetatableInitializationTests.cs | 13 +- tests/Lua.Tests/ReadOnlyTableTests.cs | 174 ++++++++++++++++++ 15 files changed, 369 insertions(+), 47 deletions(-) create mode 100644 sandbox/Benchmark/GeneratedUserDataBenchmark.cs create mode 100644 tests/Lua.Tests/ReadOnlyTableTests.cs diff --git a/README.md b/README.md index fd3035e2..32b1297b 100644 --- a/README.md +++ b/README.md @@ -537,6 +537,8 @@ print(v1 - v2) -- <-1, -1, -1> > [!NOTE] > `__index` and `__newindex` cannot be set as they are used internally by the code generated by `[LuaObject]`. +Generated default metatables are immutable and shared by all instances of the same C# type, across Lua states. You can still assign writable C# members from Lua. To customize an individual object's metatable, replace `ILuaUserData.Metatable` in C# or use `debug.setmetatable` in Lua; replacement tables remain mutable. Ordinary Lua tables and metatables remain mutable. + ## Module Loading In Lua, you can load modules using the `require` function. In regular Lua, modules are managed by searchers within the `package.searchers` function list. In addition to this, Lua-CSharp provides `ILuaModuleLoader` as a module loading mechanism. diff --git a/sandbox/Benchmark/Benchmark.csproj b/sandbox/Benchmark/Benchmark.csproj index 8ace8446..5fb5bbf2 100644 --- a/sandbox/Benchmark/Benchmark.csproj +++ b/sandbox/Benchmark/Benchmark.csproj @@ -15,6 +15,8 @@ + + diff --git a/sandbox/Benchmark/GeneratedUserDataBenchmark.cs b/sandbox/Benchmark/GeneratedUserDataBenchmark.cs new file mode 100644 index 00000000..27fccf23 --- /dev/null +++ b/sandbox/Benchmark/GeneratedUserDataBenchmark.cs @@ -0,0 +1,71 @@ +using BenchmarkDotNet.Attributes; +using Lua; +using Lua.Runtime; + +[Config(typeof(ComparisonConfig))] +public class GeneratedUserDataBenchmark +{ + [Params("field", "method", "comparison", "write")] + public string Case { get; set; } = "field"; + + [Params(false, true)] + public bool OverrideMetatable { get; set; } + + LuaState state = default!; + LuaClosure closure = default!; + + [GlobalSetup] + public void Setup() + { + state = LuaState.Create(); + var first = new GeneratedBenchmarkObject { Value = 7 }; + var second = new GeneratedBenchmarkObject { Value = 8 }; + if (OverrideMetatable) + { + ILuaUserData left = first; + ILuaUserData right = second; + left.Metatable = left.Metatable; + right.Metatable = right.Metatable; + } + state.Environment["first"] = first; + state.Environment["second"] = second; + var (source, expected) = Case switch + { + "field" => ("local v=first;local n=0;for i=1,1000 do n=n+v.Value end;return n", 7000), + "method" => ("local v=first;local n=0;for i=1,1000 do n=n+v:Add(1) end;return n", 8000), + "comparison" => ( + "local a,b=first,second;local n=0;for i=1,1000 do if a ("local v=first;for i=1,1000 do v.Value=i end;return v.Value", 1000), + _ => throw new ArgumentOutOfRangeException(nameof(Case)), + }; + closure = state.Load(source, Case); + if (Run() != expected) + throw new InvalidOperationException($"Unexpected result for {Case}"); + } + + [Benchmark(OperationsPerInvoke = 1000)] + public int Run() + { + state.RunAsync(closure).GetAwaiter().GetResult(); + return state.Stack.Pop().Read(); + } + + [GlobalCleanup] + public void Cleanup() => state.Dispose(); +} + +[LuaObject] +public partial class GeneratedBenchmarkObject +{ + [LuaMember] + public int Value { get; set; } + + [LuaMember] + public int Add(int value) => Value + value; + + [LuaMetamethod(LuaObjectMetamethod.Lt)] + public static bool Less(GeneratedBenchmarkObject left, GeneratedBenchmarkObject right) => + left.Value < right.Value; +} diff --git a/sandbox/Benchmark/Program.cs b/sandbox/Benchmark/Program.cs index 5999c35f..b8a5a94c 100644 --- a/sandbox/Benchmark/Program.cs +++ b/sandbox/Benchmark/Program.cs @@ -29,6 +29,7 @@ typeof(InterpreterComparison), typeof(CompilationComparison), typeof(StartupComparison), + typeof(GeneratedUserDataBenchmark), ]) .Run(args); diff --git a/sandbox/Benchmark/README.md b/sandbox/Benchmark/README.md index d45b9fab..410d8b3c 100644 --- a/sandbox/Benchmark/README.md +++ b/sandbox/Benchmark/README.md @@ -25,6 +25,7 @@ measure Unicode encoding or arbitrary binary strings. | `InterpreterComparison` | Execute a retained compiled chunk and read one numeric result | Numeric loops, Lua calls/varargs, table access/sort, metamethods, math, strings, patterns, coroutines, host callbacks, n-body | | `CompilationComparison` | Compile source in an existing state; no execution | Small loop, n-body, control flow, identifiers, plain strings, escaped strings | | `StartupComparison` | Create a state, open libraries, dispose where supported | One lifecycle per engine | +| `GeneratedUserDataBenchmark` | Execute 1,000 generated C# member accesses or comparisons; report time per operation | Property reads/writes, method calls, comparisons, with default and explicitly replaced metatables | Only the engine being measured is initialized for a benchmark. Execution excludes source compilation and state setup. Scripts rebuild their mutable @@ -52,6 +53,7 @@ equivalent deterministic disposal API. dotnet bin/Release/net10.0/Benchmark.dll --filter '*InterpreterComparison*host-calls*' dotnet bin/Release/net10.0/Benchmark.dll --filter '*CompilationComparison*' dotnet bin/Release/net10.0/Benchmark.dll --filter '*StartupComparison*' +dotnet bin/Release/net10.0/Benchmark.dll --filter '*GeneratedUserDataBenchmark*' dotnet bin/Release/net10.0/Benchmark.dll --legacy --filter '*GSubBenchmark*' ``` diff --git a/src/Lua.SourceGenerator/LuaObjectGenerator.Emit.cs b/src/Lua.SourceGenerator/LuaObjectGenerator.Emit.cs index 0be625ce..9b979644 100644 --- a/src/Lua.SourceGenerator/LuaObjectGenerator.Emit.cs +++ b/src/Lua.SourceGenerator/LuaObjectGenerator.Emit.cs @@ -1351,9 +1351,16 @@ in SourceProductionContext context builder.AppendLine("global::Lua.LuaTable? global::Lua.ILuaUserData.Metatable"); using (builder.BeginBlockScope()) { - builder.AppendLine( - "get => __GetMetatableOverrides().GetValue(this, static _ => new __MetatableOverride(__CreateMetatable())).Value;" - ); + builder.AppendLine("get"); + using (builder.BeginBlockScope()) + { + builder.AppendLine( + "var overrides = global::System.Threading.Volatile.Read(ref __metatableOverrides);" + ); + builder.AppendLine( + "return overrides != null && overrides.TryGetValue(this, out var entry) ? entry.Value : __metatable;" + ); + } builder.AppendLine("set"); using (builder.BeginBlockScope()) { @@ -1363,25 +1370,6 @@ in SourceProductionContext context } } - builder.AppendLine( - "global::Lua.LuaTable? global::Lua.ILuaUserData.GetMetatable(global::Lua.LuaState state)" - ); - using (builder.BeginBlockScope()) - { - builder.AppendLine( - "var overrides = global::System.Threading.Volatile.Read(ref __metatableOverrides);" - ); - builder.AppendLine( - "if (overrides != null && overrides.TryGetValue(this, out var entry)) return entry.Value;" - ); - builder.AppendLine("var mainThread = state.MainThread;"); - builder.AppendLine( - "if (__metatables.TryGetValue(mainThread, out var metatable)) return metatable;" - ); - builder.AppendLine( - "return __metatables.GetValue(mainThread, static _ => __CreateMetatable());" - ); - } builder.AppendLine("static global::Lua.LuaTable __CreateMetatable()"); using (builder.BeginBlockScope()) { @@ -1394,6 +1382,7 @@ in SourceProductionContext context $"metatable[global::Lua.Runtime.Metamethods.{metaMethodName}] = __metamethod_{lowerName};" ); } + builder.AppendLine("metatable.Freeze();"); builder.AppendLine("return metatable;"); } @@ -1423,7 +1412,7 @@ in SourceProductionContext context ); } builder.AppendLine( - "static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable __metatables = new();" + "static readonly global::Lua.LuaTable __metatable = __CreateMetatable();" ); builder.AppendLine(); diff --git a/src/Lua/Internal/LuaGlobalState.cs b/src/Lua/Internal/LuaGlobalState.cs index e9602e52..c3983ebc 100644 --- a/src/Lua/Internal/LuaGlobalState.cs +++ b/src/Lua/Internal/LuaGlobalState.cs @@ -69,7 +69,7 @@ internal bool TryGetMetatable(LuaValue value, [NotNullWhen(true)] out LuaTable? LuaValueType.Number => numberMetatable, LuaValueType.Function => functionMetatable, LuaValueType.Thread => stateMetatable, - LuaValueType.UserData => value.UnsafeRead().GetMetatable(mainState), + LuaValueType.UserData => value.UnsafeRead().Metatable, LuaValueType.Table => value.UnsafeRead().Metatable, _ => null, }; diff --git a/src/Lua/LuaStateExtensions.cs b/src/Lua/LuaStateExtensions.cs index f1639ada..cf9836b4 100644 --- a/src/Lua/LuaStateExtensions.cs +++ b/src/Lua/LuaStateExtensions.cs @@ -489,7 +489,7 @@ public static ValueTask SetTableAsync( if (table.TryReadTable(out var luaTable)) { - ref var valueRef = ref luaTable.FindValue(key); + ref var valueRef = ref luaTable.FindValueForWrite(key); if (!Unsafe.IsNullRef(ref valueRef) && valueRef.Type != LuaValueType.Nil) { luaTable.SetExistingValue(key, ref valueRef, value); diff --git a/src/Lua/LuaTable.cs b/src/Lua/LuaTable.cs index 7a27f4df..146f9eeb 100644 --- a/src/Lua/LuaTable.cs +++ b/src/Lua/LuaTable.cs @@ -61,6 +61,7 @@ public LuaValue this[LuaValue key] [MethodImpl(MethodImplOptions.AggressiveInlining)] set { + ThrowIfReadOnly(); if (key.Type is LuaValueType.Nil) { ThrowIndexIsNil(); @@ -139,7 +140,30 @@ public int ArrayLength public LuaTable? Metatable { get => metatable; - set => metatable = value; + set + { + ThrowIfReadOnly(); + metatable = value; + } + } + + /// Whether this table's entries and metatable can no longer be changed. + public bool IsReadOnly { get; private set; } + + /// + /// Permanently prevents changes to this table's entries and metatable. + /// Referenced tables and userdata are not frozen. + /// + public void Freeze() + { + if (hasWritableArrayReferences) + { + // Detach writable views obtained before freezing. + array = (LuaValue[])array.Clone(); + knownArrayPrefixLength = 0; + hasWritableArrayReferences = false; + } + IsReadOnly = true; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -164,8 +188,9 @@ public bool TryGetValue(LuaValue key, out LuaValue value) } [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal ref LuaValue FindValue(LuaValue key) + internal ref LuaValue FindValueForWrite(LuaValue key) { + ThrowIfReadOnly(); if (key.Type is LuaValueType.Nil) { ThrowIndexIsNil(); @@ -209,6 +234,7 @@ internal void SetExistingValue(LuaValue key, ref LuaValue existing, LuaValue val public LuaValue RemoveAt(int index) { + ThrowIfReadOnly(); var arrayIndex = index - 1; var value = array[arrayIndex]; @@ -225,6 +251,7 @@ public LuaValue RemoveAt(int index) public void Insert(int index, LuaValue value) { + ThrowIfReadOnly(); var luaArrayLength = ArrayLength; if (index <= 0 || index > luaArrayLength + 1) { @@ -280,6 +307,7 @@ internal void SwapIntegerValues(int i, int j) { if (i > 0 && j > 0 && i <= array.Length && j <= array.Length) { + ThrowIfReadOnly(); (array[i - 1], array[j - 1]) = (array[j - 1], array[i - 1]); if (array[i - 1].Type is LuaValueType.Nil) { @@ -305,6 +333,7 @@ internal bool TryInsertWithinArray(int index, int length, LuaValue value) return false; } + ThrowIfReadOnly(); if (length + 1 >= array.Length) { EnsureArrayCapacity(length + 1); @@ -330,6 +359,7 @@ internal bool TryRemoveWithinArray(int index, int length, out LuaValue value) return false; } + ThrowIfReadOnly(); var arrayIndex = index - 1; value = array[arrayIndex]; array.AsSpan(index, length - index).CopyTo(array.AsSpan(arrayIndex)); @@ -389,6 +419,7 @@ public bool TryGetNext(LuaValue key, out KeyValuePair pair) public void Clear() { + ThrowIfReadOnly(); array.AsSpan().Clear(); dictionary.Clear(); knownArrayPrefixLength = 0; @@ -397,16 +428,20 @@ public void Clear() public Memory GetArrayMemory() { + ThrowIfReadOnly(); hasWritableArrayReferences = true; return array.AsMemory(); } public Span GetArraySpan() { + ThrowIfReadOnly(); hasWritableArrayReferences = true; return array.AsSpan(); } + public ReadOnlySpan GetArrayReadOnlySpan() => array; + internal void EnsureArrayCapacity(int newCapacity) { newCapacity = Math.Min(Math.Max(array.Length, newCapacity), MaxArraySize); @@ -423,6 +458,7 @@ internal void EnsureArrayCapacity(int newCapacity) return; } + ThrowIfReadOnly(); var prevLength = array.Length; var newLength = newCapacity <= 8 ? 8 : MathEx.NextPowerOfTwo(newCapacity); @@ -479,6 +515,20 @@ static void ThrowIndexIsNaN() throw new ArgumentException("the table index is NaN"); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + void ThrowIfReadOnly() + { + if (IsReadOnly) + { + ThrowReadOnly(); + } + } + + static void ThrowReadOnly() + { + throw new InvalidOperationException("attempt to modify a read-only table"); + } + public LuaTableEnumerator GetEnumerator() { return new(this); diff --git a/src/Lua/LuaUserData.cs b/src/Lua/LuaUserData.cs index 8da903a1..8ca3fba0 100644 --- a/src/Lua/LuaUserData.cs +++ b/src/Lua/LuaUserData.cs @@ -18,9 +18,6 @@ public interface ILuaUserData { LuaTable? Metatable { get; set; } - /// Gets the metatable associated with this state's globals. Defaults to . - LuaTable? GetMetatable(LuaState state) => Metatable; - // We use span for compatibility with lua5.4. Span UserValues => default; } diff --git a/src/Lua/Runtime/LuaVirtualMachine.cs b/src/Lua/Runtime/LuaVirtualMachine.cs index 2f94c4ff..6e806366 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.cs @@ -551,7 +551,7 @@ out doRestart if (table.TryReadTable(out luaTable)) { - ref var valueRef = ref luaTable.FindValue(vb); + ref var valueRef = ref luaTable.FindValueForWrite(vb); if ( !Unsafe.IsNullRef(ref valueRef) && valueRef.Type != LuaValueType.Nil @@ -2232,7 +2232,7 @@ out bool doRestart { ref var valueRef = ref skip ? ref Unsafe.NullRef() - : ref luaTable.FindValue(key); + : ref luaTable.FindValueForWrite(key); skip = false; if (!Unsafe.IsNullRef(ref valueRef) && valueRef.Type != LuaValueType.Nil) { @@ -2383,7 +2383,7 @@ CancellationToken ct { ref var valueRef = ref skip ? ref Unsafe.NullRef() - : ref luaTable.FindValue(key); + : ref luaTable.FindValueForWrite(key); skip = false; if (!Unsafe.IsNullRef(ref valueRef) && valueRef.Type != LuaValueType.Nil) { diff --git a/src/Lua/Standard/ModuleLibrary.cs b/src/Lua/Standard/ModuleLibrary.cs index 8fc1c584..18c7d3a5 100644 --- a/src/Lua/Standard/ModuleLibrary.cs +++ b/src/Lua/Standard/ModuleLibrary.cs @@ -136,9 +136,9 @@ CancellationToken cancellationToken { var package = state.GlobalState.Environment["package"].Read(); var searchers = package["searchers"].Read(); - for (var i = 0; i < searchers.GetArraySpan().Length; i++) + for (var i = 0; i < searchers.GetArrayReadOnlySpan().Length; i++) { - var searcher = searchers.GetArraySpan()[i]; + var searcher = searchers.GetArrayReadOnlySpan()[i]; if (searcher.Type == LuaValueType.Nil) { continue; diff --git a/tests/Lua.Tests/GeneratedMetatableLifetimeTests.cs b/tests/Lua.Tests/GeneratedMetatableLifetimeTests.cs index 6fdcff35..fcea027a 100644 --- a/tests/Lua.Tests/GeneratedMetatableLifetimeTests.cs +++ b/tests/Lua.Tests/GeneratedMetatableLifetimeTests.cs @@ -34,6 +34,25 @@ LuaState CreateState() return state; } + [TestCase("mt.__index=function() return 99 end")] + [TestCase("mt.__index=nil")] + [TestCase("mt.owner=coroutine.running()")] + [TestCase("rawset(mt,'__index',nil)")] + [TestCase("rawset(mt,'payload',{})")] + [TestCase("setmetatable(mt,{})")] + [TestCase("debug.setmetatable(mt,{})")] + public async Task DefaultMetatableRejectsMutation(string mutation) + { + using var state = CreateState(); + state.Environment["obj"] = new GeneratedMetatableTestObject { Value = 7 }; + var result = await state.DoStringAsync( + "local mt=getmetatable(obj);local ok,err=pcall(function() " + + mutation + + " end);return ok,type(err),obj:Read()" + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { false, "string", 7 })); + } + [TestCase(false)] [TestCase(true)] public void DiscardedMetatablesDoNotRootStatesOrPayloads(bool replacement) @@ -69,7 +88,12 @@ public void LiveHostUserDataDoesNotRootDiscardedStateDefaults() .DoStringAsync( replacement ? "debug.setmetatable(obj,{owner=coroutine.running(),payload=payload});obj=nil;payload=nil" - : "local mt=getmetatable(obj);mt.owner=coroutine.running();mt.payload=payload;obj=nil;payload=nil" + : """ + local mt=getmetatable(obj) + assert(not pcall(function() mt.owner=coroutine.running() end)) + assert(not pcall(function() rawset(mt,'payload',payload) end)) + obj=nil;payload=nil + """ ) .AsTask() .GetAwaiter() @@ -78,15 +102,18 @@ public void LiveHostUserDataDoesNotRootDiscardedStateDefaults() } [Test] - public async Task IndependentStatesHaveIndependentDefaults() + public async Task IndependentStatesShareFrozenDefaults() { using var first = CreateState(); using var second = CreateState(); first.Environment["obj"] = new GeneratedMetatableTestObject(); second.Environment["obj"] = new GeneratedMetatableTestObject(); - await first.DoStringAsync("getmetatable(obj).tag=42"); - var result = await second.DoStringAsync("return getmetatable(obj).tag"); - Assert.That(result, Is.EqualTo(new LuaValue[] { LuaValue.Nil })); + var firstTable = (await first.DoStringAsync("return getmetatable(obj)"))[0] + .Read(); + var secondTable = (await second.DoStringAsync("return getmetatable(obj)"))[0] + .Read(); + Assert.That(firstTable, Is.SameAs(secondTable)); + Assert.That(firstTable.IsReadOnly, Is.True); } [Test] @@ -99,7 +126,6 @@ public async Task ThreadsShareDefaultsAndReplacementAffectsOnlyOneUserData() """ local shared=getmetatable(first) assert(shared==getmetatable(second)) - shared.tag=42 local co=coroutine.create(function() return getmetatable(second)==shared,second:Read() end) @@ -107,14 +133,17 @@ public async Task ThreadsShareDefaultsAndReplacementAffectsOnlyOneUserData() local replacement={__index=function() return 99 end} debug.setmetatable(first,replacement) local isolated=getmetatable(first)==replacement and getmetatable(second)==shared + replacement.tag=42 + rawset(replacement,'__index',function() return replacement.tag end) local changed=first.Value debug.setmetatable(first,nil) local removed=getmetatable(first)==nil debug.setmetatable(first,shared) - return ok,same,value,isolated,changed,removed,first:Read(),getmetatable(second).tag + first.Value=9 + return ok,same,value,isolated,changed,removed,first:Read(),second.Value """ ); - Assert.That(result, Is.EqualTo(new LuaValue[] { true, true, 8, true, 99, true, 7, 42 })); + Assert.That(result, Is.EqualTo(new LuaValue[] { true, true, 8, true, 42, true, 9, 8 })); } [Test] diff --git a/tests/Lua.Tests/MetatableInitializationTests.cs b/tests/Lua.Tests/MetatableInitializationTests.cs index 41eede95..c5674c90 100644 --- a/tests/Lua.Tests/MetatableInitializationTests.cs +++ b/tests/Lua.Tests/MetatableInitializationTests.cs @@ -29,7 +29,7 @@ public static int Add(ConcurrentMetatableTestObject a, ConcurrentMetatableTestOb public class MetatableInitializationTests { [Test] - public async Task ParallelStatesInitializeCompleteIndependentMetatables() + public async Task ParallelStatesShareCompleteFrozenMetatable() { const int workerCount = 8; using var barrier = new Barrier(workerCount); @@ -53,6 +53,7 @@ public async Task ParallelStatesInitializeCompleteIndependentMetatables() Is.EqualTo(new LuaValue[] { 42, 42, 42, 84, "42" }) ); var metatable = results[5].Read(); + Assert.That(metatable.IsReadOnly, Is.True); foreach ( var key in new[] { @@ -74,18 +75,19 @@ var key in new[] ) .ToArray(); var tables = await Task.WhenAll(workers); - Assert.That(tables.Distinct().Count(), Is.EqualTo(workerCount)); + Assert.That(tables.Distinct().Count(), Is.EqualTo(1)); } [Test] public async Task HostReplacementAndRemovalAffectOnlyOneUserData() { using var state = LuaState.Create(); + state.OpenStandardLibraries(); var obj = new ConcurrentMetatableTestObject { Value = 7 }; ILuaUserData first = obj; ILuaUserData second = new ConcurrentMetatableTestObject(); var original = first.Metatable!; - Assert.That(second.Metatable, Is.Not.SameAs(original)); + Assert.That(second.Metatable, Is.SameAs(original)); var replacement = new LuaTable(); replacement[Metamethods.Index] = new LuaFunction((context, ct) => new(context.Return(99))); first.Metatable = replacement; @@ -94,7 +96,10 @@ public async Task HostReplacementAndRemovalAffectOnlyOneUserData() Assert.That((await state.DoStringAsync("return obj.Value"))[0].Read(), Is.EqualTo(99)); first.Metatable = null; Assert.That(first.Metatable, Is.Null); - Assert.That(first.GetMetatable(state), Is.Null); + Assert.That( + (await state.DoStringAsync("return debug.getmetatable(obj)"))[0], + Is.EqualTo(LuaValue.Nil) + ); first.Metatable = original; Assert.That((await state.DoStringAsync("return obj:Read()"))[0].Read(), Is.EqualTo(7)); } diff --git a/tests/Lua.Tests/ReadOnlyTableTests.cs b/tests/Lua.Tests/ReadOnlyTableTests.cs new file mode 100644 index 00000000..525f934f --- /dev/null +++ b/tests/Lua.Tests/ReadOnlyTableTests.cs @@ -0,0 +1,174 @@ +using Lua.Platforms; +using Lua.Standard; + +namespace Lua.Tests; + +public class ReadOnlyTableTests +{ + [Test] + public void FreezeRejectsHostWritesAndWritableViews() + { + var table = new LuaTable + { + [1] = 3, + [2] = 1, + [3] = 2, + ["key"] = 7, + }; + table.Freeze(); + TestDelegate[] writes = + [ + () => table[1] = 9, + () => table["key"] = LuaValue.Nil, + () => table["new"] = 9, + () => table.Insert(1, 9), + () => table.RemoveAt(1), + () => table.Clear(), + () => table.Metatable = new LuaTable(), + () => table.GetArraySpan(), + () => table.GetArrayMemory(), + ]; + foreach (var write in writes) + { + Assert.That(write, Throws.InvalidOperationException.With.Message.Contains("read-only")); + } + Assert.That( + table.GetArrayReadOnlySpan()[..3].ToArray(), + Is.EqualTo(new LuaValue[] { 3, 1, 2 }) + ); + Assert.That(table["key"], Is.EqualTo(new LuaValue(7))); + Assert.That(table.ContainsKey("new"), Is.False); + Assert.That(table.Metatable, Is.Null); + } + + [Test] + public void FreezeDetachesPreviouslyExposedArrayAndRecomputesLength() + { + var table = new LuaTable + { + [1] = 3, + [2] = 1, + [3] = 2, + }; + Assert.That(table.ArrayLength, Is.EqualTo(3)); + var span = table.GetArraySpan(); + var memory = table.GetArrayMemory(); + span[1] = LuaValue.Nil; + table.Freeze(); + span[0] = 99; + memory.Span[1] = 42; + table.Freeze(); + Assert.That(table.IsReadOnly, Is.True); + Assert.That(table.ArrayLength, Is.EqualTo(1)); + Assert.That( + table.GetArrayReadOnlySpan()[..3].ToArray(), + Is.EqualTo(new LuaValue[] { 3, LuaValue.Nil, 2 }) + ); + } +} + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class ReadOnlyTableLuaTests(LuaStringMode mode) +{ + LuaState CreateState() + { + var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + return state; + } + + [TestCase("t[1]=9")] + [TestCase("t.key=9")] + [TestCase("t[1]=nil")] + [TestCase("t.new=9")] + [TestCase("rawset(t,'key',nil)")] + [TestCase("table.insert(t,2,9)")] + [TestCase("table.remove(t,1)")] + [TestCase("table.sort(t)")] + public async Task LuaWritesCannotChangeFrozenTable(string mutation) + { + using var state = CreateState(); + var table = new LuaTable + { + [1] = 3, + [2] = 1, + [3] = 2, + ["key"] = 7, + }; + table.Freeze(); + state.Environment["t"] = table; + var result = await state.DoStringAsync( + "local ok,err=pcall(function() " + mutation + " end);return ok,tostring(err)" + ); + Assert.That(result[0], Is.EqualTo(new LuaValue(false))); + Assert.That(result[1].Read(), Does.Contain("read-only")); + Assert.That( + table.GetArrayReadOnlySpan()[..3].ToArray(), + Is.EqualTo(new LuaValue[] { 3, 1, 2 }) + ); + Assert.That(table["key"], Is.EqualTo(new LuaValue(7))); + Assert.That(table.ContainsKey("new"), Is.False); + } + + [TestCase(1)] + [TestCase("key")] + [TestCase("new")] + public void HostSetTableCannotChangeFrozenTable(object key) + { + using var state = CreateState(); + var table = new LuaTable { [1] = 3, ["key"] = 7 }; + table.Freeze(); + Assert.ThrowsAsync(async () => + await state.SetTableAsync(table, LuaValue.FromObject(key), 9) + ); + Assert.That(table[1], Is.EqualTo(new LuaValue(3))); + Assert.That(table["key"], Is.EqualTo(new LuaValue(7))); + Assert.That(table.ContainsKey("new"), Is.False); + } + + [Test] + public async Task FrozenTablesSupportReadsAndModuleSearchers() + { + using var state = CreateState(); + var table = new LuaTable + { + [1] = 3, + [2] = 1, + [3] = 2, + }; + table.Freeze(); + state.Environment["t"] = table; + await state.DoStringAsync("package.preload.frozen=function() return 42 end"); + state.Environment["package"].Read()["searchers"].Read().Freeze(); + var result = await state.DoStringAsync( + """ + local sum=0 + for k,v in pairs(t) do sum=sum+v end + return #t,rawget(t,1),table.concat(t,','),sum,require('frozen'),table.unpack(t) + """ + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { 3, 3, "3,1,2", 6, 42, 3, 1, 2 })); + } + + [Test] + public async Task OrdinaryLuaTablesAndMetatablesRemainMutable() + { + using var state = CreateState(); + var result = await state.DoStringAsync( + """ + local t=setmetatable({3,1,2},{tag=1}) + getmetatable(t).tag=42 + rawset(t,'key',7) + table.insert(t,4) + local removed=table.remove(t,1) + table.sort(t) + debug.setmetatable(t,{__index=function() return 9 end}) + return removed,table.concat(t,','),t.key,t.missing + """ + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { 3, "1,2,4", 7, 9 })); + } +} From 36c9d9231a5825bf05aa124cfa74f303fd949617 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:27:46 +0900 Subject: [PATCH 19/32] fix: reclaim variadic metamethod arguments Restore the original argument base when Lua metamethods return so their vararg prefixes cannot accumulate on the caller's stack or retain discarded operands. Preserve the reversed <= comparison flag when tail calls replace a metamethod frame. Use existing frame metadata without adding checks. Cover every affected arithmetic, unary, comparison, index and assignment operation, including global accesses, fixed parameters, suspension, tail calls and discarded extra results. Add GC regressions for operand lifetime and controls for method lookup, concatenation and callable tables in both string modes. Validation: all 2,402 runtime tests pass on each of .NET 8 and .NET 10; all four Release library targets build successfully. Pinned .NET 10 benchmarks preserve allocations, with ordinary workloads within 2% of baseline and no slowdown in the focused variadic comparison rerun. --- src/Lua/Runtime/LuaVirtualMachine.cs | 10 +- .../VariadicMetamethodLifetimeTests.cs | 226 ++++++++++++++++++ 2 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 tests/Lua.Tests/VariadicMetamethodLifetimeTests.cs diff --git a/src/Lua/Runtime/LuaVirtualMachine.cs b/src/Lua/Runtime/LuaVirtualMachine.cs index 6e806366..322ea7ce 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.cs @@ -122,6 +122,7 @@ public bool PopFromBuffer(int src, int srcCount) FrameBase = lastFrame.Base; VariableArgumentCount = lastFrame.VariableArgumentCount; + var argumentBase = frame.Base - frame.VariableArgumentCount; var opCode = callInstruction.OpCode; if (opCode is OpCode.Eq or OpCode.Lt or OpCode.Le) { @@ -136,7 +137,7 @@ public bool PopFromBuffer(int src, int srcCount) Pc++; } - State.PopCallStackFrameWithStackPop(); + State.PopCallStackFrameWithStackPop(argumentBase); return true; } @@ -163,15 +164,13 @@ public bool PopFromBuffer(int src, int srcCount) State.PopCallStackFrameWithStackPop(target + 2); return true; case OpCode.SetTable or OpCode.SetTabUp: - target = frame.Base; + target = argumentBase; targetCount = 0; break; // Other opcodes has one result default: Stack.Get(target) = result.Length == 0 ? LuaValue.Nil : result[0]; - State.PopCallStackFrameWithStackPop( - Math.Max(target + 1, CurrentReturnFrameBase) - ); + State.PopCallStackFrameWithStackPop(Math.Max(target + 1, argumentBase)); return true; } @@ -1714,6 +1713,7 @@ static bool TailCall(VirtualMachineExecutionContext context, out bool doRestart) newFrame.CallerInstructionIndex = lastFrame.CallerInstructionIndex; newFrame.Version = lastFrame.Version; + newFrame.Flags |= lastFrame.Flags & CallStackFrameFlags.ReversedLe; state.ReplaceCallStackFrame(newFrame); // Release the replaced closure before a hook or native tail callee can suspend. context.LuaClosure = null!; diff --git a/tests/Lua.Tests/VariadicMetamethodLifetimeTests.cs b/tests/Lua.Tests/VariadicMetamethodLifetimeTests.cs new file mode 100644 index 00000000..fee39c0d --- /dev/null +++ b/tests/Lua.Tests/VariadicMetamethodLifetimeTests.cs @@ -0,0 +1,226 @@ +using System.Runtime.CompilerServices; +using Lua.Platforms; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class VariadicMetamethodLifetimeTests(LuaStringMode mode) +{ + const int Iterations = 64; + + LuaState CreateState() + { + var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + return state; + } + + public static IEnumerable Operations() + { + (string Name, string Statement)[] operations = + [ + ("__add", "n=n+(a+b)"), + ("__sub", "n=n+(a-b)"), + ("__mul", "n=n+(a*b)"), + ("__div", "n=n+(a/b)"), + ("__mod", "n=n+(a%b)"), + ("__pow", "n=n+(a^b)"), + ("__unm", "n=n+(-a)"), + ("__len", "n=n+#a"), + ("__eq", "if a==b then n=n+1 end"), + ("__lt", "if a(); + var calls = 0; + state.Environment["observe"] = new LuaFunction( + async (context, _) => + { + positions.Add(context.ReturnFrameBase); + calls++; + if (suspend) + await Task.Yield(); + + var isGlobal = operation.StartsWith("global-"); + LuaValue first = + isGlobal ? state.Environment + : operation == "reverse-le" ? right + : left; + LuaValue second = operation switch + { + "__index" or "global-index" or "self" or "__newindex" or "global-newindex" => + "missing", + "__unm" or "__len" or "reverse-le" => left, + "__call" => 17, + _ => right, + }; + var isAssignment = operation is "__newindex" or "global-newindex"; + Assert.That( + context.ArgumentCount, + Is.EqualTo(isAssignment || operation == "__call" ? 3 : 2) + ); + Assert.That(context.GetArgument(0), Is.EqualTo(first)); + Assert.That(context.GetArgument(1), Is.EqualTo(second)); + if (isAssignment || operation == "__call") + Assert.That( + context.GetArgument(2), + Is.EqualTo(new LuaValue(isAssignment ? calls : 18)) + ); + return context.Return(); + } + ); + var methodName = operation switch + { + "reverse-le" => "__lt", + "global-index" or "self" => "__index", + "global-newindex" => "__newindex", + _ => operation, + }; + var resultExpression = operation switch + { + "__eq" or "__lt" or "__le" => "true", + "reverse-le" => "false", + "self" => "function(self) assert(self==a);return 1 end", + "__concat" => "'joined'", + _ => "1", + }; + var arguments = fixedParameters == 0 ? "..." : "head,..."; + var methodBody = tailCall + ? $"return finish({arguments})" + : $"observe({arguments});return {resultExpression},{arguments}"; + var result = await state.DoStringAsync( + $$""" + local a,b=left,right + local observe=observe + local function finish(head,...) + observe(head,...) + return {{resultExpression}},head,... + end + local function method({{arguments}}) {{methodBody}} end + local mt={{{methodName}}=method} + setmetatable(a,mt) + setmetatable(b,mt) + {{(operation.StartsWith("global-") ? "setmetatable(_ENV,mt)" : "")}} + local n=0 + for i=1,{{Iterations}} do {{statement}} end + return n + """ + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { Iterations })); + Assert.That(calls, Is.EqualTo(Iterations)); + Assert.That(positions.Skip(1).Distinct().Count(), Is.EqualTo(1)); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + [TestCase("__lt", "if item + { + if (++calls == Iterations) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + Assert.That( + references.Take(Iterations - 1).Any(reference => reference.IsAlive), + Is.False + ); + } + return new(context.Return()); + } + ); + var result = await state.DoStringAsync( + $$""" + local observe=observe + local mt={{{methodName}}=function(...) + observe(...) + return {{(methodName == "__lt" ? "true" : "1")}},... + end} + for i=1,{{Iterations}} do setmetatable(items[i],mt) end + local a=setmetatable({},mt) + local n=0 + for i=1,{{Iterations}} do + local item=items[i] + items[i]=nil + {{statement}} + item=nil + end + return n + """ + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { Iterations })); + Assert.That(calls, Is.EqualTo(Iterations)); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static List InstallOperands(LuaState state) + { + var references = new List(); + var items = new LuaTable(); + for (var i = 1; i <= Iterations; i++) + { + var payload = new object(); + references.Add(new(payload)); + items[i] = new LuaTable { ["payload"] = LuaValue.FromObject(payload) }; + } + state.Environment["items"] = items; + return references; + } +} From 52d6c263390b3da05b44324b94887fb2de3d1f20 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:15:18 +0900 Subject: [PATCH 20/32] fix: release coroutine transfers and preserve suspended yields Release yielded values once their transfer finishes, including when the receiving stack is full. Capture the result range so cleanup reclaims the transferred slots while preserving live locals and suspended captures. Separate coroutine-body failures from receiving-stack failures. Report "too many results to resume" with Lua 5.2 protected/wrapped behavior, keep failed yield transfers resumable and cancellable, and release completed coroutines even when their final results cannot be transferred. Push the success flag and results together with one capacity check. Add 36 regression cases covering both string modes, resume/wrap, nested resumers, payload collection, captures, retries, cancellation, asynchronous bodies and oversized final returns. All 2,438 runtime tests pass on each of .NET 8 and .NET 10, and all four Release library targets build successfully. Paired .NET 10 yield benchmarks show unchanged allocations and 0.4-2.3% additional time for prompt result cleanup. --- src/Lua/Internal/CoroutineCore.cs | 133 ++++++----- tests/Lua.Tests/CoroutineTransferTests.cs | 271 ++++++++++++++++++++++ 2 files changed, 349 insertions(+), 55 deletions(-) create mode 100644 tests/Lua.Tests/CoroutineTransferTests.cs diff --git a/src/Lua/Internal/CoroutineCore.cs b/src/Lua/Internal/CoroutineCore.cs index 274c47c3..a0a2f967 100644 --- a/src/Lua/Internal/CoroutineCore.cs +++ b/src/Lua/Internal/CoroutineCore.cs @@ -14,10 +14,14 @@ readonly struct YieldContext(LuaStack stack, int argCount) public ReadOnlySpan Results => stack.AsSpan()[^argCount..]; } - readonly struct ResumeContext(LuaStack? stack, int argCount) + readonly struct ResumeContext(LuaStack stack, int argCount) { - public ReadOnlySpan Results => stack!.AsSpan()[^argCount..]; + readonly int resultBase = stack.Count - argCount; + + public ReadOnlySpan Results => stack.GetBuffer().Slice(resultBase, argCount); public bool IsDead => stack == null; + + public void ClearResults() => stack.PopUntil(resultBase); } internal byte status = (byte)LuaThreadStatus.Suspended; @@ -152,76 +156,95 @@ internal async ValueTask ResumeAsyncCore( try { - if (isFirstCall) + ResumeContext result = default; + try { - Thread.Stack.PushRange(stack.AsSpan()[^argCount..]); - functionTask = Thread.RunAsync( - Function!, - Thread.Stack.Count, - cancellationToken - ); - Volatile.Write(ref isFirstCall, false); - if (!functionTask.IsCompleted) + if (isFirstCall) { - // UnsafeOnCompleted: OnCompleted would capture SyncContext and deadlock on WebGL. - functionTask.GetAwaiter().UnsafeOnCompleted(OnFunctionCompleted); + Thread.Stack.PushRange(stack.AsSpan()[^argCount..]); + functionTask = Thread.RunAsync( + Function!, + Thread.Stack.Count, + cancellationToken + ); + Volatile.Write(ref isFirstCall, false); + if (!functionTask.IsCompleted) + { + // UnsafeOnCompleted: OnCompleted would capture SyncContext and deadlock on WebGL. + functionTask.GetAwaiter().UnsafeOnCompleted(OnFunctionCompleted); + } + } + else + { + if (Interlocked.CompareExchange(ref yieldCompleted, 1, 0) == 0) + yield.SetResult(new(stack, argCount)); } - } - else - { - if (Interlocked.CompareExchange(ref yieldCompleted, 1, 0) == 0) - yield.SetResult(new(stack, argCount)); - } - ResumeContext result0; - if (functionTask.IsCompleted || (result0 = await resumeTask).IsDead) + if (functionTask.IsCompleted || (result = await resumeTask).IsDead) + { + _ = functionTask.Result; + functionTask = default; + resumeCancellationToken = default; + } + } + catch (Exception ex) { - _ = functionTask.Result; functionTask = default; resumeCancellationToken = default; - stack.PopUntil(returnBase); - stack.Push(true); - stack.PushRange(Thread.Stack.AsSpan()); + if (ex is ILuaTracebackBuildable tracebackBuildable) + { + traceback = tracebackBuildable.BuildOrGet(); + } + Thread.Dispose(); Function = null; Volatile.Write(ref status, (byte)LuaThreadStatus.Dead); - return stack.Count - returnBase; + if (!IsProtectedMode || ex is OperationCanceledException) + throw; + + stack.PopUntil(returnBase); + stack.Push(false); + // Wrapped C# exceptions carry a message rather than a Lua error object. + // Keep error(nil) intact when the exception originated in Lua. + stack.Push( + ex is LuaRuntimeException { InnerException: null } luaEx + ? luaEx.ErrorObject + : ex.Message + ); + return 2; } - else + + try { - var results = result0.Results; + var results = result.IsDead ? Thread.Stack.AsSpan() : result.Results; stack.PopUntil(returnBase); - stack.Push(true); - stack.PushRange(results); - Volatile.Write(ref status, (byte)LuaThreadStatus.Suspended); + stack.PushRange(true, results); return results.Length + 1; } - } - catch (Exception ex) - { - functionTask = default; - resumeCancellationToken = default; - if (ex is ILuaTracebackBuildable tracebackBuildable) + catch (LuaStackOverflowException) { - traceback = tracebackBuildable.BuildOrGet(); - } + const string message = "too many results to resume"; + if (!IsProtectedMode) + throw new LuaRuntimeException(baseThread, message); - Thread.Dispose(); - Function = null; - Volatile.Write(ref status, (byte)LuaThreadStatus.Dead); - if (!IsProtectedMode || ex is OperationCanceledException) - throw; - - stack.PopUntil(returnBase); - stack.Push(false); - // Wrapped C# exceptions carry a message rather than a Lua error object. - // Keep error(nil) intact when the exception originated in Lua. - stack.Push( - ex is LuaRuntimeException { InnerException: null } luaEx - ? luaEx.ErrorObject - : ex.Message - ); - return 2; + stack.Push(false); + stack.Push(message); + return 2; + } + finally + { + if (result.IsDead) + { + Thread.Dispose(); + Function = null; + Volatile.Write(ref status, (byte)LuaThreadStatus.Dead); + } + else + { + result.ClearResults(); + Volatile.Write(ref status, (byte)LuaThreadStatus.Suspended); + } + } } finally { diff --git a/tests/Lua.Tests/CoroutineTransferTests.cs b/tests/Lua.Tests/CoroutineTransferTests.cs new file mode 100644 index 00000000..ed92f645 --- /dev/null +++ b/tests/Lua.Tests/CoroutineTransferTests.cs @@ -0,0 +1,271 @@ +using System.Runtime.CompilerServices; +using Lua.Platforms; +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class CoroutineTransferTests(LuaStringMode mode) +{ + const int OversizedResultCount = 1 << 19; + + LuaState CreateState() + { + var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + return state; + } + + [Test] + public async Task TransferredValuesAreCollectibleWhileLocalsRemainAlive( + [Values] bool wrap, + [Values] bool asynchronous + ) + { + using var state = CreateState(); + var references = new List(); + state.Environment["wrap"] = wrap; + state.Environment["make"] = new LuaFunction( + async (context, _) => + { + if (asynchronous) + await Task.Yield(); + return MakePayload(context, references); + } + ); + await state.DoStringAsync( + """ + local function body() + co=coroutine.running() + local keep=make() + local plain=17 + get=function() return keep end + local x,y=coroutine.yield(make(),nil,73) + return keep,plain,x,y + end + local value,empty,n + if wrap then + run=coroutine.wrap(body) + value,empty,n=run() + else + co=coroutine.create(body) + local ok + ok,value,empty,n=coroutine.resume(co) + assert(ok) + end + assert(type(value)=='userdata' and empty==nil and n==73) + """ + ); + Collect(); + Assert.That(references.Count, Is.EqualTo(2)); + Assert.That(references[0].IsAlive, Is.True); + Assert.That(references[1].IsAlive, Is.False); + var thread = state.Environment["co"].Read(); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Suspended)); + + var result = await state.DoStringAsync( + wrap ? "return run(42,'resumed')" : "return coroutine.resume(co,42,'resumed')" + ); + LuaValue[] expected = [LuaValue.FromObject(references[0].Target!), 17, 42, "resumed"]; + Assert.That(result, Is.EqualTo(wrap ? expected : new LuaValue[] { true }.Concat(expected))); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead)); + Assert.That(thread.CoroutineFunction, Is.Null); + Assert.That(thread.GetStackValues().Length, Is.Zero); + } + + [Test] + public async Task OversizedYieldReleasesResultsAndPreservesSuspendedExecution( + [Values] bool protectedMode, + [Values] bool asynchronous, + [Values] bool cancel + ) + { + using var state = CreateState(); + using var cancellation = new CancellationTokenSource(); + var yielded = new LuaStack(OversizedResultCount); + yielded.NotifyTop(OversizedResultCount); + var reference = StorePayload(yielded.AsSpan()); + var thread = state.CreateCoroutine( + new LuaFunction( + async (context, token) => + { + if (asynchronous) + await Task.Yield(); + var count = await context.State.YieldAsync(yielded, token); + return context.Return(yielded[0], count); + } + ), + protectedMode + ); + try + { + var results = new LuaStack(); + await AssertTransferError(thread, results, protectedMode, cancellation.Token); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Suspended)); + Assert.That(thread.CanResume, Is.True); + Assert.That(thread.CallStackFrameCount, Is.GreaterThan(0)); + Assert.That(thread.CoroutineFunction, Is.Not.Null); + Assert.That(yielded.Count, Is.Zero); + Collect(); + Assert.That(reference.IsAlive, Is.False); + + if (cancel) + { + cancellation.Cancel(); + await Task.Yield(); + Assert.That( + () => thread.GetStatus(), + Is.EqualTo(LuaThreadStatus.Dead).After(1000, 10) + ); + } + else + { + results.Clear(); + results.Push(42); + Assert.That(await thread.ResumeAsync(results), Is.EqualTo(3)); + Assert.That(results.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 42, 1 })); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead)); + } + Assert.That(thread.CoroutineFunction, Is.Null); + Assert.That(thread.GetStackValues().Length, Is.Zero); + Assert.That(thread.CallStackFrameCount, Is.Zero); + } + finally + { + cancellation.Cancel(); + if (thread.CallStackFrameCount == 0) + thread.Dispose(); + } + } + + [Test] + public async Task LuaTransferErrorRestoresTheResumerAndAllowsRetry([Values] bool wrap) + { + using var state = CreateState(); + var yielded = new LuaStack(OversizedResultCount); + yielded.NotifyTop(OversizedResultCount); + var reference = StorePayload(yielded.AsSpan()); + state.Environment["wrap"] = wrap; + state.Environment["many"] = new LuaFunction( + async (context, token) => + { + await context.State.YieldAsync(yielded, token); + return context.Return(); + } + ); + var result = await state.DoStringAsync( + """ + local function body() + co=coroutine.running() + many() + return 42 + end + if wrap then run=coroutine.wrap(body) else co=coroutine.create(body) end + local parent=coroutine.create(function() + local ok,err + if wrap then ok,err=pcall(run) else ok,err=coroutine.resume(co) end + assert(coroutine.status(coroutine.running())=='running') + return ok,err + end) + return coroutine.resume(parent) + """ + ); + Assert.That(result.Length, Is.EqualTo(3)); + Assert.That(result[0], Is.EqualTo(new LuaValue(true))); + Assert.That(result[1], Is.EqualTo(new LuaValue(false))); + Assert.That(result[2].Read(), Does.Contain("too many results to resume")); + var thread = state.Environment["co"].Read(); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Suspended)); + Assert.That(yielded.Count, Is.Zero); + Collect(); + Assert.That(reference.IsAlive, Is.False); + result = await state.DoStringAsync(wrap ? "return run()" : "return coroutine.resume(co)"); + Assert.That(result, Is.EqualTo(wrap ? new LuaValue[] { 42 } : new LuaValue[] { true, 42 })); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead)); + Assert.That(thread.CoroutineFunction, Is.Null); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + [Test] + public async Task OversizedFinalReturnReleasesCompletedExecution( + [Values] bool protectedMode, + [Values] bool asynchronous + ) + { + using var state = CreateState(); + WeakReference? reference = null; + using var thread = state.CreateCoroutine( + new LuaFunction( + async (context, _) => + { + if (asynchronous) + await Task.Yield(); + reference = StorePayload(context.GetReturnBuffer(OversizedResultCount)); + return OversizedResultCount; + } + ), + protectedMode + ); + await AssertTransferError(thread, new LuaStack(), protectedMode); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead)); + Assert.That(thread.CoroutineFunction, Is.Null); + Assert.That(thread.GetStackValues().Length, Is.Zero); + Assert.That(thread.CallStackFrameCount, Is.Zero); + Collect(); + Assert.That(reference, Is.Not.Null); + Assert.That(reference!.IsAlive, Is.False); + } + + static async Task AssertTransferError( + LuaState thread, + LuaStack results, + bool protectedMode, + CancellationToken cancellationToken = default + ) + { + if (protectedMode) + { + Assert.That(await thread.ResumeAsync(results, cancellationToken), Is.EqualTo(2)); + Assert.That( + results.AsSpan().ToArray(), + Is.EqualTo(new LuaValue[] { false, "too many results to resume" }) + ); + } + else + { + var error = Assert.ThrowsAsync(async () => + await thread.ResumeAsync(results, cancellationToken) + )!; + Assert.That(error.Message, Does.Contain("too many results to resume")); + Assert.That(results.Count, Is.Zero); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static int MakePayload(LuaFunctionExecutionContext context, List references) + { + var payload = new object(); + references.Add(new(payload)); + return context.Return(LuaValue.FromObject(payload)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference StorePayload(Span values) + { + var payload = new object(); + values[0] = LuaValue.FromObject(payload); + return new(payload); + } + + static void Collect() + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } +} From 17e00e23ede2544d0960fd78b49740c71477d853 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:52:29 +0900 Subject: [PATCH 21/32] fix: protect coroutine yield transfers from cancellation Defer cancellation unwind until the resumer releases the yielded stack range, including when result transfer overflows. Preserve an unstarted coroutine and report Lua 5.2's argument-transfer error if its initial arguments do not fit. Add 24 regression cases covering protected and unprotected resumes, both string modes, cancellation from either token, result overflow, and entry retries. All new cases fail against the previous assembly. The full runtime suite passes 2,462 tests on each of .NET 8 and .NET 10; all four runtime targets build. Paired coroutine benchmarks show unchanged allocations and 0.5-3% more time. --- src/Lua/Internal/CoroutineCore.cs | 29 ++++++ tests/Lua.Tests/CoroutineTransferTests.cs | 105 ++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/src/Lua/Internal/CoroutineCore.cs b/src/Lua/Internal/CoroutineCore.cs index a0a2f967..bdf6c793 100644 --- a/src/Lua/Internal/CoroutineCore.cs +++ b/src/Lua/Internal/CoroutineCore.cs @@ -34,6 +34,7 @@ readonly struct ResumeContext(LuaStack stack, int argCount) const int ResumeWaiting = 1; const int ResumeYielded = 2; const int ResumeCompleted = 3; + const int ResumeCancelPending = 4; int resumePhase; ManualResetValueTaskSourceCore resume; @@ -187,6 +188,19 @@ internal async ValueTask ResumeAsyncCore( resumeCancellationToken = default; } } + catch (LuaStackOverflowException) when (isFirstCall) + { + resumeCancellationToken = default; + Volatile.Write(ref status, (byte)LuaThreadStatus.Suspended); + const string message = "too many arguments to resume"; + if (!IsProtectedMode) + throw new LuaRuntimeException(baseThread, message); + + stack.PopUntil(returnBase); + stack.Push(false); + stack.Push(message); + return 2; + } catch (Exception ex) { functionTask = default; @@ -250,6 +264,15 @@ internal async ValueTask ResumeAsyncCore( { resume.Reset(); var phase = Interlocked.CompareExchange(ref resumePhase, ResumeIdle, ResumeYielded); + if (phase == ResumeCancelPending) + { + CancelYield(); + phase = Interlocked.CompareExchange( + ref resumePhase, + ResumeIdle, + ResumeCancelPending + ); + } if (phase == ResumeCompleted) CompleteSuspendedFunction(); else if (phase == ResumeWaiting) @@ -278,6 +301,12 @@ void OnFunctionCompleted() void CancelYield() { + // Cancellation must not unwind the stack while the resumer owns its yielded range. + if ( + Interlocked.CompareExchange(ref resumePhase, ResumeCancelPending, ResumeYielded) + == ResumeYielded + ) + return; if (Interlocked.CompareExchange(ref yieldCompleted, 1, 0) == 0) yield.SetException(new OperationCanceledException()); } diff --git a/tests/Lua.Tests/CoroutineTransferTests.cs b/tests/Lua.Tests/CoroutineTransferTests.cs index ed92f645..8a422aa5 100644 --- a/tests/Lua.Tests/CoroutineTransferTests.cs +++ b/tests/Lua.Tests/CoroutineTransferTests.cs @@ -221,6 +221,111 @@ [Values] bool asynchronous Assert.That(reference!.IsAlive, Is.False); } + [Test] + public async Task CancellationWaitsForYieldedResultTransfer( + [Values] bool protectedMode, + [Values] bool cancelResume, + [Values] bool oversized + ) + { + using var state = CreateState(); + using var cancellation = new CancellationTokenSource(); + var count = oversized ? OversizedResultCount : 1; + using var thread = state.CreateCoroutine( + new LuaFunction( + async (context, token) => + { + context.GetReturnBuffer(count).Fill(42); + var pending = context.State.YieldAsync( + context with + { + ArgumentCount = count, + }, + cancelResume ? token : cancellation.Token + ); + // The first resume has not returned from RunAsync to copy the published yield. + cancellation.Cancel(); + Assert.That(pending.IsCompleted, Is.False); + Assert.That(context.State.Stack.Count, Is.EqualTo(count)); + Assert.That(context.State.Stack[count - 1], Is.EqualTo(new LuaValue(42))); + return await pending; + } + ), + protectedMode + ); + var results = new LuaStack(); + var resumeToken = cancelResume ? cancellation.Token : default; + if (oversized) + { + await AssertTransferError(thread, results, protectedMode, resumeToken); + } + else + { + Assert.That(await thread.ResumeAsync(results, resumeToken), Is.EqualTo(2)); + Assert.That(results.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 42 })); + } + Assert.That(() => thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead).After(1000, 10)); + Assert.That(thread.CoroutineFunction, Is.Null); + Assert.That(thread.GetStackValues().Length, Is.Zero); + Assert.That(thread.CallStackFrameCount, Is.Zero); + } + + [Test] + public async Task OversizedInitialArgumentsPreserveTheEntryForRetry( + [Values] bool protectedMode, + [Values] bool asynchronous + ) + { + using var state = CreateState(); + using var cancellation = new CancellationTokenSource(); + var entered = false; + var entry = new LuaFunction( + async (context, _) => + { + entered = true; + if (asynchronous) + await Task.Yield(); + return context.Return(context.GetArgument(0)); + } + ); + using var thread = state.CreateCoroutine(entry, protectedMode); + var arguments = new LuaStack(OversizedResultCount + 1); + arguments.NotifyTop(OversizedResultCount + 1); + if (protectedMode) + { + Assert.That(await thread.ResumeAsync(arguments, cancellation.Token), Is.EqualTo(2)); + Assert.That( + arguments.AsSpan().ToArray(), + Is.EqualTo(new LuaValue[] { false, "too many arguments to resume" }) + ); + } + else + { + var error = Assert.ThrowsAsync(async () => + await thread.ResumeAsync(arguments, cancellation.Token) + )!; + Assert.That(error.Message, Does.Contain("too many arguments to resume")); + Assert.That(arguments.Count, Is.EqualTo(OversizedResultCount + 1)); + } + Assert.That(entered, Is.False); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Suspended)); + Assert.That(thread.CanResume, Is.True); + Assert.That(thread.CoroutineFunction, Is.SameAs(entry)); + Assert.That(thread.GetStackValues().Length, Is.Zero); + Assert.That(thread.CallStackFrameCount, Is.Zero); + cancellation.Cancel(); + + var retry = new LuaStack(); + retry.Push(42); + Assert.That(await thread.ResumeAsync(retry), Is.EqualTo(2)); + Assert.That(retry.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 42 })); + Assert.That(entered, Is.True); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead)); + Assert.That(thread.CoroutineFunction, Is.Null); + Assert.That(thread.GetStackValues().Length, Is.Zero); + Assert.That(thread.CallStackFrameCount, Is.Zero); + } + static async Task AssertTransferError( LuaState thread, LuaStack results, From 26e85a0653e6800a51c1acb333dcb16e4c414167 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:36:50 +0900 Subject: [PATCH 22/32] fix: preserve runtime state when argument transfers overflow Reserve a suspended yield while preparing incoming argument capacity so a failed later resume remains retryable without losing its frames or captures. Defer cancellation during preparation and release the reservation on failure. Append complete metamethod argument packets atomically, including callable receivers. Share synchronous setup across arithmetic, unary and comparison helpers while keeping operand copies out of the async invocation method. Add 120 regression cases covering all affected operations, both string modes, callable receivers, capacity boundaries, captured locals and external yield stacks. Validate the runtime tests on .NET 8 and .NET 10, all four build targets, formatting, cancellation handoffs and paired Release performance benchmarks. --- src/Lua/Internal/CoroutineCore.cs | 63 ++++++++-- src/Lua/Runtime/LuaStack.cs | 19 +++ src/Lua/Runtime/LuaVirtualMachine.cs | 105 ++++++---------- tests/Lua.Tests/CoroutineTransferTests.cs | 88 ++++++++++++++ .../OperationArgumentTransferTests.cs | 115 ++++++++++++++++++ 5 files changed, 313 insertions(+), 77 deletions(-) create mode 100644 tests/Lua.Tests/OperationArgumentTransferTests.cs diff --git a/src/Lua/Internal/CoroutineCore.cs b/src/Lua/Internal/CoroutineCore.cs index bdf6c793..2f837275 100644 --- a/src/Lua/Internal/CoroutineCore.cs +++ b/src/Lua/Internal/CoroutineCore.cs @@ -28,7 +28,13 @@ readonly struct ResumeContext(LuaStack stack, int argCount) bool isFirstCall = true; ValueTask functionTask; CancellationToken resumeCancellationToken; - int yieldCompleted; + const int YieldWaiting = 0; + const int YieldCompleted = 1; + const int YieldPreparing = 2; + const int YieldCancelPending = 3; + int yieldPhase; + LuaStack? yieldStack; + int yieldReturnBase; const int ResumeIdle = 0; const int ResumeWaiting = 1; @@ -158,11 +164,13 @@ internal async ValueTask ResumeAsyncCore( try { ResumeContext result = default; + var transferringArguments = true; try { if (isFirstCall) { Thread.Stack.PushRange(stack.AsSpan()[^argCount..]); + transferringArguments = false; functionTask = Thread.RunAsync( Function!, Thread.Stack.Count, @@ -175,12 +183,19 @@ internal async ValueTask ResumeAsyncCore( functionTask.GetAwaiter().UnsafeOnCompleted(OnFunctionCompleted); } } - else + else if (TryPrepareYield(argCount)) { - if (Interlocked.CompareExchange(ref yieldCompleted, 1, 0) == 0) + transferringArguments = false; + if ( + Interlocked.Exchange(ref yieldPhase, YieldCompleted) + == YieldCancelPending + ) + yield.SetException(new OperationCanceledException()); + else yield.SetResult(new(stack, argCount)); } + transferringArguments = false; if (functionTask.IsCompleted || (result = await resumeTask).IsDead) { _ = functionTask.Result; @@ -188,7 +203,7 @@ internal async ValueTask ResumeAsyncCore( resumeCancellationToken = default; } } - catch (LuaStackOverflowException) when (isFirstCall) + catch (LuaStackOverflowException) when (transferringArguments) { resumeCancellationToken = default; Volatile.Write(ref status, (byte)LuaThreadStatus.Suspended); @@ -307,8 +322,39 @@ void CancelYield() == ResumeYielded ) return; - if (Interlocked.CompareExchange(ref yieldCompleted, 1, 0) == 0) - yield.SetException(new OperationCanceledException()); + while (true) + { + var phase = Volatile.Read(ref yieldPhase); + if (phase is not (YieldWaiting or YieldPreparing)) + return; + var next = phase == YieldPreparing ? YieldCancelPending : YieldCompleted; + if (Interlocked.CompareExchange(ref yieldPhase, next, phase) != phase) + continue; + if (next == YieldCompleted) + yield.SetException(new OperationCanceledException()); + return; + } + } + + bool TryPrepareYield(int argCount) + { + if ( + Interlocked.CompareExchange(ref yieldPhase, YieldPreparing, YieldWaiting) + != YieldWaiting + ) + return false; + try + { + // Reserve capacity before waking the body; cancellation waits for this preparation. + yieldStack!.EnsureCapacity(yieldReturnBase + argCount); + return true; + } + catch + { + if (Interlocked.Exchange(ref yieldPhase, YieldWaiting) == YieldCancelPending) + CancelYield(); + throw; + } } void CompleteSuspendedFunction() @@ -380,6 +426,8 @@ internal async ValueTask YieldAsyncCore( { cancellationToken.ThrowIfCancellationRequested(); Thread.SuspendUpValues(); + yieldStack = stack; + yieldReturnBase = returnBase; // Yielded values remain owned by the resumer until its copy completes. Volatile.Write(ref resumePhase, ResumeYielded); resume.SetResult(new(stack, argCount)); @@ -399,8 +447,9 @@ internal async ValueTask YieldAsyncCore( { registration.Dispose(); linkedSource?.Dispose(); + yieldStack = null; yield.Reset(); - Volatile.Write(ref yieldCompleted, 0); + Volatile.Write(ref yieldPhase, YieldWaiting); } } } diff --git a/src/Lua/Runtime/LuaStack.cs b/src/Lua/Runtime/LuaStack.cs index 758ec0df..43070dac 100644 --- a/src/Lua/Runtime/LuaStack.cs +++ b/src/Lua/Runtime/LuaStack.cs @@ -74,6 +74,25 @@ public void PushRange(params ReadOnlySpan values) top += values.Length; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void PushRange(LuaValue first, LuaValue second) + { + EnsureCapacity(top + 2); + array[top] = first; + array[top + 1] = second; + top += 2; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void PushRange(LuaValue first, LuaValue second, LuaValue third) + { + EnsureCapacity(top + 3); + array[top] = first; + array[top + 1] = second; + array[top + 2] = third; + top += 3; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] internal void PushRange(LuaValue first, ReadOnlySpan values) { diff --git a/src/Lua/Runtime/LuaVirtualMachine.cs b/src/Lua/Runtime/LuaVirtualMachine.cs index 322ea7ce..eea0cab4 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.cs @@ -2604,30 +2604,7 @@ CancellationToken ct || vc.TryGetMetamethod(state.GlobalState, name, out metamethod) ) { - var stack = state.Stack; - var newBase = stack.Count; - var callable = metamethod; - if (!metamethod.TryReadFunction(out var func)) - { - if ( - metamethod.TryGetMetamethod( - state.GlobalState, - Metamethods.Call, - out metamethod - ) && metamethod.TryReadFunction(out func) - ) - { - stack.Push(callable); - } - else - { - LuaRuntimeException.AttemptInvalidOperation(state, "call", metamethod); - } - } - - stack.Push(vb); - stack.Push(vc); - return InvokeOperationMetamethod(state, func, newBase, ct); + return InvokeOperationMetamethod(state, metamethod, vb, vc, ct); } LuaRuntimeException.AttemptInvalidOperation(state, description, vb, vc); @@ -2767,30 +2744,7 @@ CancellationToken cancellationToken if (vb.TryGetMetamethod(state.GlobalState, name, out var metamethod)) { - var stack = state.Stack; - var newBase = stack.Count; - var callable = metamethod; - if (!metamethod.TryReadFunction(out var func)) - { - if ( - metamethod.TryGetMetamethod( - state.GlobalState, - Metamethods.Call, - out metamethod - ) && metamethod.TryReadFunction(out func) - ) - { - stack.Push(callable); - } - else - { - LuaRuntimeException.AttemptInvalidOperation(state, "call", metamethod); - } - } - - stack.Push(vb); - stack.Push(vb); - return InvokeOperationMetamethod(state, func, newBase, cancellationToken); + return InvokeOperationMetamethod(state, metamethod, vb, vb, cancellationToken); } if (opCode == OpCode.Len && vb.TryReadTable(out var table)) @@ -2806,6 +2760,32 @@ out metamethod } } + static ValueTask InvokeOperationMetamethod( + LuaState state, + LuaValue metamethod, + LuaValue left, + LuaValue right, + CancellationToken cancellationToken + ) + { + var stack = state.Stack; + var top = stack.Count; + var callable = metamethod; + if (!metamethod.TryReadFunction(out var function)) + { + if ( + !metamethod.TryGetMetamethod(state.GlobalState, Metamethods.Call, out metamethod) + || !metamethod.TryReadFunction(out function) + ) + LuaRuntimeException.AttemptInvalidOperation(state, "call", metamethod); + stack.PushRange(callable, left, right); + } + else + stack.PushRange(left, right); + + return InvokeOperationMetamethod(state, function, top, cancellationToken); + } + [AsyncMethodBuilder(typeof(LightAsyncValueTaskMethodBuilder<>))] static async ValueTask InvokeOperationMetamethod( LuaState state, @@ -3029,28 +3009,13 @@ CancellationToken cancellationToken ReCheck: if (TryGetCompareMetamethod(state.GlobalState, vb, vc, name, out var metamethod)) { - var stack = state.Stack; - var top = stack.Count; - var newBase = top; - var callable = metamethod; - if (!metamethod.TryReadFunction(out var func)) - { - if ( - metamethod.TryGetMetamethod(state.GlobalState, Metamethods.Call, out metamethod) - && metamethod.TryReadFunction(out func) - ) - { - stack.Push(callable); - } - else - { - LuaRuntimeException.AttemptInvalidOperation(state, "call", metamethod); - } - } - - stack.Push(vb); - stack.Push(vc); - var result = await InvokeOperationMetamethod(state, func, top, cancellationToken); + var result = await InvokeOperationMetamethod( + state, + metamethod, + vb, + vc, + cancellationToken + ); return reverseLe ? !result.ToBoolean() : result.ToBoolean(); } diff --git a/tests/Lua.Tests/CoroutineTransferTests.cs b/tests/Lua.Tests/CoroutineTransferTests.cs index 8a422aa5..55d6bc1a 100644 --- a/tests/Lua.Tests/CoroutineTransferTests.cs +++ b/tests/Lua.Tests/CoroutineTransferTests.cs @@ -326,6 +326,94 @@ await thread.ResumeAsync(arguments, cancellation.Token) Assert.That(thread.CallStackFrameCount, Is.Zero); } + [Test] + public async Task OversizedResumeArgumentsPreserveSuspension( + [Values] bool protectedMode, + [Values] bool asynchronous, + [Values] bool externalStack + ) + { + using var state = CreateState(); + using var cancellation = new CancellationTokenSource(); + var yielded = new LuaStack(); + yielded.Push(7); + state.Environment["pause"] = new LuaFunction( + async (context, _) => + { + if (asynchronous) + await Task.Yield(); + return context.Return(); + } + ); + state.Environment["wait"] = new LuaFunction( + async (context, token) => + { + if (!externalStack) + return await context.State.YieldAsync(context, token); + await context.State.YieldAsync(yielded, token); + return context.Return(yielded.AsSpan()); + } + ); + using var thread = state.CreateCoroutine( + state.Load( + """ + local value,x=42,17 + saved=function() return value,x end + pause() + x=wait(7) + return value,x + """, + "resume-arguments" + ), + protectedMode + ); + var results = new LuaStack(); + await thread.ResumeAsync(results); + Assert.That(results.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 7 })); + var frameCount = thread.CallStackFrameCount; + var argumentCount = OversizedResultCount + (externalStack ? 1 : 0); + var arguments = new LuaStack(argumentCount); + arguments.NotifyTop(argumentCount); + if (protectedMode) + { + Assert.That(await thread.ResumeAsync(arguments, cancellation.Token), Is.EqualTo(2)); + Assert.That( + arguments.AsSpan().ToArray(), + Is.EqualTo(new LuaValue[] { false, "too many arguments to resume" }) + ); + } + else + { + var error = Assert.ThrowsAsync(async () => + await thread.ResumeAsync(arguments, cancellation.Token) + )!; + Assert.That(error.Message, Does.Contain("too many arguments to resume")); + Assert.That(arguments.Count, Is.EqualTo(argumentCount)); + } + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Suspended)); + Assert.That(thread.CanResume, Is.True); + Assert.That(thread.CallStackFrameCount, Is.EqualTo(frameCount)); + Assert.That(thread.CoroutineFunction, Is.Not.Null); + Assert.That( + await state.DoStringAsync("return saved()"), + Is.EqualTo(new LuaValue[] { 42, 17 }) + ); + cancellation.Cancel(); + + results.Clear(); + results.Push(9); + Assert.That(await thread.ResumeAsync(results), Is.EqualTo(3)); + Assert.That(results.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 42, 9 })); + Assert.That( + await state.DoStringAsync("return saved()"), + Is.EqualTo(new LuaValue[] { 42, 9 }) + ); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead)); + Assert.That(thread.CoroutineFunction, Is.Null); + Assert.That(thread.CallStackFrameCount, Is.Zero); + Assert.That(thread.GetStackValues().Length, Is.Zero); + } + static async Task AssertTransferError( LuaState thread, LuaStack results, diff --git a/tests/Lua.Tests/OperationArgumentTransferTests.cs b/tests/Lua.Tests/OperationArgumentTransferTests.cs new file mode 100644 index 00000000..2b02474e --- /dev/null +++ b/tests/Lua.Tests/OperationArgumentTransferTests.cs @@ -0,0 +1,115 @@ +using Lua.Platforms; +using Lua.Runtime; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class OperationArgumentTransferTests(LuaStringMode mode) +{ + [Test] + public async Task FailedArgumentTransferPreservesStackAndAllowsRetry( + [Values( + "add", + "sub", + "mul", + "div", + "mod", + "pow", + "concat", + "unm", + "len", + "lt", + "le", + "eq", + "reverse-le" + )] + string operation, + [Values] bool callableTable, + [Values(0, 1)] int available + ) + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + var calls = 0; + LuaValue[] expectedArguments = []; + var function = new LuaFunction( + (context, _) => + { + calls++; + Assert.That(context.Arguments.ToArray(), Is.EqualTo(expectedArguments)); + return new(context.Return(operation == "reverse-le" ? new LuaValue(false) : 42)); + } + ); + LuaValue method = callableTable + ? new LuaTable { Metatable = new LuaTable { ["__call"] = function } } + : function; + var metatable = new LuaTable + { + [operation == "reverse-le" ? "__lt" : "__" + operation] = method, + }; + LuaValue left = new LuaTable { Metatable = metatable }; + LuaValue right = new LuaTable { Metatable = metatable }; + LuaValue[] operands = operation switch + { + "unm" or "len" => [left, left], + "reverse-le" => [right, left], + _ => [left, right], + }; + expectedArguments = callableTable ? [method, .. operands] : operands; + var top = (1 << 19) - available - (callableTable ? 1 : 0) - (operation == "concat" ? 2 : 0); + state.Stack.EnsureCapacity(1 << 19); + state.Stack.NotifyTop(top); + state.Stack[top - 1] = "sentinel"; + + Assert.ThrowsAsync(async () => + await ExecuteOperation(state, operation, left, right) + ); + Assert.That(calls, Is.Zero); + Assert.That(state.Stack.Count, Is.EqualTo(top)); + Assert.That(state.Stack[top - 1], Is.EqualTo(new LuaValue("sentinel"))); + Assert.That(state.Stack.GetBuffer()[top..].ToArray(), Is.All.EqualTo(LuaValue.Nil)); + Assert.That(state.CallStackFrameCount, Is.Zero); + + state.Stack.Clear(); + state.Stack.Push("sentinel"); + var result = await ExecuteOperation(state, operation, left, right); + Assert.That( + result, + Is.EqualTo(operation is "lt" or "le" or "eq" or "reverse-le" ? new LuaValue(true) : 42) + ); + Assert.That(calls, Is.EqualTo(1)); + Assert.That(state.Stack.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { "sentinel" })); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + static async ValueTask ExecuteOperation( + LuaState state, + string operation, + LuaValue left, + LuaValue right + ) + { + if (operation == "concat") + { + await state.ConcatAsync([left, right]); + return state.Stack.Pop(); + } + return operation switch + { + "add" => await state.AddAsync(left, right), + "sub" => await state.SubAsync(left, right), + "mul" => await state.MulAsync(left, right), + "div" => await state.DivAsync(left, right), + "mod" => await state.ModAsync(left, right), + "pow" => await state.PowAsync(left, right), + "unm" => await state.UnmAsync(left), + "len" => await state.LenAsync(left), + "lt" => await state.LessThanAsync(left, right), + "le" or "reverse-le" => await state.LessThanOrEqualsAsync(left, right), + "eq" => await state.EqualsAsync(left, right), + _ => throw new ArgumentOutOfRangeException(nameof(operation)), + }; + } +} From 6a3578d8ef388e2594f1d741495fb67524ca1efc Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:55:53 +0900 Subject: [PATCH 23/32] fix: propagate coroutine resume cancellation and simplify handoffs Keep a stable execution token for native callbacks and VM executions across resumes, and retire replaced resume-token registrations. Preserve the original cancellation token in errors and release cancellation sources on completion. Use one handoff state to protect stack transfers and consume function completion once. Defer cancellation until published packets have been copied, including callbacks awaiting YieldAsync through WaitAsync. Share synchronous metamethod preparation across VM and managed operations, and copy wrapped coroutine results directly while preserving Lua 5.2 slot and error behavior. Add dispatch, lifetime, transfer, and wrapped-packet regressions plus parameterized coroutine transfer benchmarks. Validation: 2,850 runtime tests pass on each of .NET 8 and .NET 10; the final rollback cleanup also passes all 448 coroutine cases on each runtime. All four runtime targets and the benchmark project build. One existing .NET 8 async GC assertion was intermittent; three focused reruns and the full retry passed. Nine paired samples on .NET 10 against 26e85a0 show Lua coroutine workloads 1-15% faster, native coroutine yields 2.3% slower, and numeric/metamethod workloads broadly unchanged. The stable token adds 64 bytes per uncancellable coroutine (152 bytes with an external token), with no allocation added per yield. --- .../Benchmark/CoroutineTransferBenchmark.cs | 61 ++ sandbox/Benchmark/Program.cs | 1 + src/Lua/Internal/CoroutineCore.cs | 603 ++++++++++-------- src/Lua/LuaState.cs | 18 + src/Lua/LuaThreadExtensions.cs | 2 +- src/Lua/Runtime/LuaVirtualMachine.cs | 151 ++--- src/Lua/Standard/CoroutineLibrary.cs | 16 +- .../CoroutineCancellationDispatchTests.cs | 265 ++++++++ tests/Lua.Tests/CoroutineTransferTests.cs | 124 +++- 9 files changed, 841 insertions(+), 400 deletions(-) create mode 100644 sandbox/Benchmark/CoroutineTransferBenchmark.cs create mode 100644 tests/Lua.Tests/CoroutineCancellationDispatchTests.cs diff --git a/sandbox/Benchmark/CoroutineTransferBenchmark.cs b/sandbox/Benchmark/CoroutineTransferBenchmark.cs new file mode 100644 index 00000000..f0bc2acb --- /dev/null +++ b/sandbox/Benchmark/CoroutineTransferBenchmark.cs @@ -0,0 +1,61 @@ +using BenchmarkDotNet.Attributes; +using Lua; +using Lua.Runtime; +using Lua.Standard; + +[Config(typeof(ComparisonConfig))] +public class CoroutineTransferBenchmark +{ + [Params("resume", "wrap")] + public string Operation { get; set; } = "resume"; + + [Params(1, 64, 4096)] + public int ResultCount { get; set; } + + [Params(false, true)] + public bool Cancellable { get; set; } + + LuaState state = default!; + LuaClosure closure = default!; + CancellationTokenSource cancellation = default!; + + [GlobalSetup] + public void Setup() + { + state = LuaState.Create(); + state.OpenStandardLibraries(); + cancellation = new(); + state.Environment["packet"] = new LuaFunction( + (context, _) => + { + context.GetReturnBuffer(ResultCount).Fill(42); + return new(ResultCount); + } + ); + closure = state.Load( + Operation == "wrap" + ? "local run=coroutine.wrap(function() for i=1,64 do coroutine.yield(packet()) end end); for i=1,64 do run() end; run(); return 42" + : "local co=coroutine.create(function() for i=1,64 do coroutine.yield(packet()) end end); for i=1,64 do coroutine.resume(co) end; coroutine.resume(co); return 42", + "coroutine-transfer" + ); + if (Run() != 42) + throw new InvalidOperationException("Unexpected coroutine transfer result"); + } + + [Benchmark(OperationsPerInvoke = 64)] + public int Run() + { + state + .RunAsync(closure, 0, Cancellable ? cancellation.Token : default) + .GetAwaiter() + .GetResult(); + return state.Stack.Pop().Read(); + } + + [GlobalCleanup] + public void Cleanup() + { + cancellation.Dispose(); + state.Dispose(); + } +} diff --git a/sandbox/Benchmark/Program.cs b/sandbox/Benchmark/Program.cs index b8a5a94c..bcfce358 100644 --- a/sandbox/Benchmark/Program.cs +++ b/sandbox/Benchmark/Program.cs @@ -30,6 +30,7 @@ typeof(CompilationComparison), typeof(StartupComparison), typeof(GeneratedUserDataBenchmark), + typeof(CoroutineTransferBenchmark), ]) .Run(args); diff --git a/src/Lua/Internal/CoroutineCore.cs b/src/Lua/Internal/CoroutineCore.cs index 2f837275..44806fd3 100644 --- a/src/Lua/Internal/CoroutineCore.cs +++ b/src/Lua/Internal/CoroutineCore.cs @@ -1,4 +1,5 @@ -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; using System.Threading.Tasks.Sources; using Lua.Internal.CompilerServices; using Lua.Runtime; @@ -17,83 +18,80 @@ readonly struct YieldContext(LuaStack stack, int argCount) readonly struct ResumeContext(LuaStack stack, int argCount) { readonly int resultBase = stack.Count - argCount; - public ReadOnlySpan Results => stack.GetBuffer().Slice(resultBase, argCount); public bool IsDead => stack == null; public void ClearResults() => stack.PopUntil(resultBase); } + const int Suspended = 0; + const int Preparing = 1; + const int Running = 2; + const int Copying = 3; + const int Cancelling = 4; + const int Completed = 5; + const int Closed = 6; + const int CancelPending = 8; + + // Preparing and Copying give the resumer exclusive access to the stack range. + // Cancellation waits until that ownership is released before waking the yield. + int handoff; internal byte status = (byte)LuaThreadStatus.Suspended; bool isFirstCall = true; ValueTask functionTask; - CancellationToken resumeCancellationToken; - const int YieldWaiting = 0; - const int YieldCompleted = 1; - const int YieldPreparing = 2; - const int YieldCancelPending = 3; - int yieldPhase; LuaStack? yieldStack; int yieldReturnBase; - - const int ResumeIdle = 0; - const int ResumeWaiting = 1; - const int ResumeYielded = 2; - const int ResumeCompleted = 3; - const int ResumeCancelPending = 4; - int resumePhase; - + CancellationTokenSource? executionSource; + CancellationToken executionToken; + CancellationScope? cancellation; ManualResetValueTaskSourceCore resume; ManualResetValueTaskSourceCore yield; Traceback? traceback; - public bool IsProtectedMode { get; private set; } = isProtectedMode; + public bool IsProtectedMode { get; } = isProtectedMode; public LuaFunction? Function { get; private set; } = function; - public LuaState Thread { get; private set; } = state; + public LuaState Thread { get; } = state; public Traceback? Traceback => traceback; - YieldContext IValueTaskSource.GetResult(short token) - { - return yield.GetResult(token); - } + internal CancellationToken GetCancellationOrigin(CancellationToken token) => + cancellation == null ? token : cancellation.GetOrigin(token); - ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) - { - return yield.GetStatus(token); - } + YieldContext IValueTaskSource.GetResult(short token) => yield.GetResult(token); + ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) => + yield.GetStatus(token); + + // Coroutine continuations must not require a SynchronizationContext (Unity WebGL). void IValueTaskSource.OnCompleted( Action continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags - ) - { - // Avoid SynchronizationContext capture (deadlocks Unity WebGL sync-over-async). - flags &= ~ValueTaskSourceOnCompletedFlags.UseSchedulingContext; - yield.OnCompleted(continuation, state, token, flags); - } + ) => + yield.OnCompleted( + continuation, + state, + token, + flags & ~ValueTaskSourceOnCompletedFlags.UseSchedulingContext + ); - ResumeContext IValueTaskSource.GetResult(short token) - { - return resume.GetResult(token); - } + ResumeContext IValueTaskSource.GetResult(short token) => resume.GetResult(token); - ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) - { - return resume.GetStatus(token); - } + ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) => + resume.GetStatus(token); void IValueTaskSource.OnCompleted( Action continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags - ) - { - flags &= ~ValueTaskSourceOnCompletedFlags.UseSchedulingContext; - resume.OnCompleted(continuation, state, token, flags); - } + ) => + resume.OnCompleted( + continuation, + state, + token, + flags & ~ValueTaskSourceOnCompletedFlags.UseSchedulingContext + ); [AsyncMethodBuilder(typeof(LightAsyncValueTaskMethodBuilder<>))] internal async ValueTask ResumeAsyncCore( @@ -101,197 +99,168 @@ internal async ValueTask ResumeAsyncCore( int argCount, int returnBase, LuaState? baseThread, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + bool includeStatus = true ) { if (baseThread != null) { baseThread.UnsafeSetStatus(LuaThreadStatus.Normal); - baseThread.GlobalState.ThreadStack.Push(Thread); } - try { - switch ((LuaThreadStatus)Volatile.Read(ref status)) + var currentStatus = (LuaThreadStatus)Volatile.Read(ref status); + if (currentStatus != LuaThreadStatus.Suspended) + return ResumeError( + stack, + returnBase, + baseThread, + currentStatus == LuaThreadStatus.Dead + ? "cannot resume dead coroutine" + : "cannot resume non-suspended coroutine", + includeStatus + ); + + var phase = Interlocked.CompareExchange(ref handoff, Preparing, Suspended); + if (phase != Suspended) + return ResumeError( + stack, + returnBase, + baseThread, + phase is Completed or Closed or Cancelling + ? "cannot resume dead coroutine" + : "cannot resume non-suspended coroutine", + includeStatus + ); + try { - case LuaThreadStatus.Suspended: - var phase = Interlocked.CompareExchange( - ref resumePhase, - ResumeWaiting, - ResumeIdle + if (isFirstCall) + Thread.Stack.EnsureCapacity(Thread.Stack.Count + argCount); + else + yieldStack!.EnsureCapacity(yieldReturnBase + argCount); + if (executionSource == null) + { + // A callback can retain its execution token across multiple resumes. + executionSource = new(); + executionToken = executionSource.Token; + } + SetResumeToken(cancellationToken); + if (isFirstCall) + Thread.Stack.PushRange(stack.AsSpan()[^argCount..]); + } + catch (Exception error) + { + ReleaseTransfer(Preparing); + if (error is LuaStackOverflowException) + return ResumeError( + stack, + returnBase, + baseThread, + "too many arguments to resume", + includeStatus ); - if (phase == ResumeCompleted) - goto case LuaThreadStatus.Dead; - if (phase != ResumeIdle) - goto case LuaThreadStatus.Running; - Volatile.Write(ref status, (byte)LuaThreadStatus.Running); - - break; - case LuaThreadStatus.Normal: - case LuaThreadStatus.Running: - if (IsProtectedMode) - { - stack.PopUntil(returnBase); - stack.Push(false); - stack.Push("cannot resume non-suspended coroutine"); - return 2; - } - else - { - throw new LuaRuntimeException( - baseThread, - "cannot resume non-suspended coroutine" - ); - } - case LuaThreadStatus.Dead: - if (IsProtectedMode) - { - stack.PopUntil(returnBase); - stack.Push(false); - stack.Push("cannot resume dead coroutine"); - return 2; - } - else - { - throw new LuaRuntimeException(baseThread, "cannot resume dead coroutine"); - } + throw; } - ValueTask resumeTask = new(this, resume.Version); - resumeCancellationToken = cancellationToken; - + var resumeTask = new ValueTask(this, resume.Version); + Volatile.Write(ref status, (byte)LuaThreadStatus.Running); + var cancelled = + Interlocked.CompareExchange(ref handoff, Running, Preparing) + == (Preparing | CancelPending); + if (cancelled) + Interlocked.Exchange(ref handoff, Running | CancelPending); try { - ResumeContext result = default; - var transferringArguments = true; - try + Exception? cancellationFailure = null; + if (isFirstCall) { - if (isFirstCall) - { - Thread.Stack.PushRange(stack.AsSpan()[^argCount..]); - transferringArguments = false; - functionTask = Thread.RunAsync( - Function!, - Thread.Stack.Count, - cancellationToken - ); - Volatile.Write(ref isFirstCall, false); - if (!functionTask.IsCompleted) - { - // UnsafeOnCompleted: OnCompleted would capture SyncContext and deadlock on WebGL. - functionTask.GetAwaiter().UnsafeOnCompleted(OnFunctionCompleted); - } - } - else if (TryPrepareYield(argCount)) + if (cancelled) + executionSource!.Cancel(); + isFirstCall = false; + functionTask = Thread.RunAsync(Function!, Thread.Stack.Count, executionToken); + if (functionTask.IsCompleted) + OnFunctionCompleted(); + else + functionTask.GetAwaiter().UnsafeOnCompleted(OnFunctionCompleted); + } + else if (cancelled) + { + var error = new OperationCanceledException( + GetCancellationOrigin(executionToken) + ); + try { - transferringArguments = false; - if ( - Interlocked.Exchange(ref yieldPhase, YieldCompleted) - == YieldCancelPending - ) - yield.SetException(new OperationCanceledException()); - else - yield.SetResult(new(stack, argCount)); + executionSource!.Cancel(); } - - transferringArguments = false; - if (functionTask.IsCompleted || (result = await resumeTask).IsDead) + catch (Exception failure) { - _ = functionTask.Result; - functionTask = default; - resumeCancellationToken = default; + cancellationFailure = failure; } + yield.SetException(error); } - catch (LuaStackOverflowException) when (transferringArguments) - { - resumeCancellationToken = default; - Volatile.Write(ref status, (byte)LuaThreadStatus.Suspended); - const string message = "too many arguments to resume"; - if (!IsProtectedMode) - throw new LuaRuntimeException(baseThread, message); + else + yield.SetResult(new(stack, argCount)); - stack.PopUntil(returnBase); - stack.Push(false); - stack.Push(message); - return 2; + ResumeContext result; + try + { + result = await resumeTask; } - catch (Exception ex) + catch (Exception error) { - functionTask = default; - resumeCancellationToken = default; - if (ex is ILuaTracebackBuildable tracebackBuildable) - { - traceback = tracebackBuildable.BuildOrGet(); - } - - Thread.Dispose(); - Function = null; - Volatile.Write(ref status, (byte)LuaThreadStatus.Dead); - if (!IsProtectedMode || ex is OperationCanceledException) + if (cancellationFailure != null) + ExceptionDispatchInfo.Capture(cancellationFailure).Throw(); + if (!IsProtectedMode || error is OperationCanceledException) throw; - stack.PopUntil(returnBase); - stack.Push(false); - // Wrapped C# exceptions carry a message rather than a Lua error object. - // Keep error(nil) intact when the exception originated in Lua. - stack.Push( - ex is LuaRuntimeException { InnerException: null } luaEx - ? luaEx.ErrorObject - : ex.Message - ); - return 2; + var value = error is LuaRuntimeException { InnerException: null } luaError + ? luaError.ErrorObject + : new LuaValue(error.Message); + if (includeStatus) + stack.PushRange(false, value); + else + stack.Push(value); + return includeStatus ? 2 : 1; } - try { var results = result.IsDead ? Thread.Stack.AsSpan() : result.Results; stack.PopUntil(returnBase); - stack.PushRange(true, results); - return results.Length + 1; + if (includeStatus) + stack.PushRange(true, results); + else + { + // Lua 5.2 reserves one spare slot for wrapped results too. + stack.EnsureCapacity(returnBase + results.Length + 1); + results.CopyTo(stack.GetBuffer()[returnBase..]); + stack.NotifyTop(returnBase + results.Length); + } + return results.Length + (includeStatus ? 1 : 0); } catch (LuaStackOverflowException) { - const string message = "too many results to resume"; - if (!IsProtectedMode) - throw new LuaRuntimeException(baseThread, message); - - stack.Push(false); - stack.Push(message); - return 2; + return ResumeError( + stack, + returnBase, + baseThread, + "too many results to resume", + includeStatus + ); } finally { - if (result.IsDead) - { - Thread.Dispose(); - Function = null; - Volatile.Write(ref status, (byte)LuaThreadStatus.Dead); - } - else - { + if (!result.IsDead) result.ClearResults(); - Volatile.Write(ref status, (byte)LuaThreadStatus.Suspended); - } } } finally { resume.Reset(); - var phase = Interlocked.CompareExchange(ref resumePhase, ResumeIdle, ResumeYielded); - if (phase == ResumeCancelPending) - { - CancelYield(); - phase = Interlocked.CompareExchange( - ref resumePhase, - ResumeIdle, - ResumeCancelPending - ); - } - if (phase == ResumeCompleted) - CompleteSuspendedFunction(); - else if (phase == ResumeWaiting) - Interlocked.CompareExchange(ref resumePhase, ResumeIdle, ResumeWaiting); + if (Volatile.Read(ref handoff) == Completed) + FinishExecution(); + else + ReleaseTransfer(Copying); } } finally @@ -304,78 +273,178 @@ internal async ValueTask ResumeAsyncCore( } } + int ResumeError( + LuaStack stack, + int returnBase, + LuaState? baseThread, + string message, + bool includeStatus + ) + { + if (!IsProtectedMode) + throw new LuaRuntimeException(baseThread, message); + stack.PopUntil(returnBase); + if (includeStatus) + stack.PushRange(false, new LuaValue(message)); + else + stack.Push(message); + return includeStatus ? 2 : 1; + } + void OnFunctionCompleted() { - resumeCancellationToken = default; - var phase = Interlocked.Exchange(ref resumePhase, ResumeCompleted); - if (phase == ResumeWaiting) - resume.SetResult(default); - else if (phase == ResumeIdle) - CompleteSuspendedFunction(); + Exception? error = null; + try + { + _ = functionTask.GetAwaiter().GetResult(); + } + catch (Exception exception) + { + error = + exception is OperationCanceledException cancelled + && exception is not LuaCanceledException + ? new LuaCanceledException( + Thread, + GetCancellationOrigin(cancelled.CancellationToken), + exception + ) + : exception; + if (error is ILuaTracebackBuildable buildable) + traceback = buildable.BuildOrGet(); + } + functionTask = default; + var phase = Interlocked.Exchange(ref handoff, Completed); + if (phase == Cancelling) + FinishExecution(); + else if ((phase & ~CancelPending) == Running) + { + if (error != null) + resume.SetException(error); + else + resume.SetResult(default); + } + } + + void ReleaseTransfer(int phase) + { + Volatile.Write(ref status, (byte)LuaThreadStatus.Suspended); + if (Interlocked.CompareExchange(ref handoff, Suspended, phase) == (phase | CancelPending)) + { + CompleteCancellation(phase | CancelPending); + } } - void CancelYield() + void RequestCancellation() { - // Cancellation must not unwind the stack while the resumer owns its yielded range. - if ( - Interlocked.CompareExchange(ref resumePhase, ResumeCancelPending, ResumeYielded) - == ResumeYielded - ) - return; while (true) { - var phase = Volatile.Read(ref yieldPhase); - if (phase is not (YieldWaiting or YieldPreparing)) + var phase = Volatile.Read(ref handoff); + var next = phase switch + { + Preparing or Copying => phase | CancelPending, + Running => Running | CancelPending, + Suspended => Copying | CancelPending, + _ => phase, + }; + if (next == phase) return; - var next = phase == YieldPreparing ? YieldCancelPending : YieldCompleted; - if (Interlocked.CompareExchange(ref yieldPhase, next, phase) != phase) + if (Interlocked.CompareExchange(ref handoff, next, phase) != phase) continue; - if (next == YieldCompleted) - yield.SetException(new OperationCanceledException()); + if (phase == Suspended) + CompleteCancellation(next); + else if (phase == Running) + executionSource!.Cancel(); return; } } - bool TryPrepareYield(int argCount) + void CompleteCancellation(int phase) { - if ( - Interlocked.CompareExchange(ref yieldPhase, YieldPreparing, YieldWaiting) - != YieldWaiting - ) - return false; + var error = new OperationCanceledException(GetCancellationOrigin(executionToken)); try { - // Reserve capacity before waking the body; cancellation waits for this preparation. - yieldStack!.EnsureCapacity(yieldReturnBase + argCount); - return true; + executionSource!.Cancel(); } - catch + finally { - if (Interlocked.Exchange(ref yieldPhase, YieldWaiting) == YieldCancelPending) - CancelYield(); - throw; + yield.SetException(error); + if (Interlocked.CompareExchange(ref handoff, Cancelling, phase) == Completed) + FinishExecution(); } } - void CompleteSuspendedFunction() + CancellationScope EnsureCancellation() => cancellation ??= new(this, executionSource!); + + void SetResumeToken(CancellationToken token) { - if (Volatile.Read(ref status) != (byte)LuaThreadStatus.Suspended) - return; - try + if (token.CanBeCanceled && token != executionToken) + EnsureCancellation(); + cancellation?.SetResumeToken(token, isFirstCall); + } + + void FinishExecution() + { + Thread.Dispose(); + Function = null; + cancellation?.Dispose(); + cancellation = null; + executionSource?.Dispose(); + executionSource = null; + executionToken = default; + Volatile.Write(ref handoff, Closed); + Volatile.Write(ref status, (byte)LuaThreadStatus.Dead); + } + + sealed class CancellationScope : IDisposable + { + readonly CoroutineCore owner; + CancellationToken lifetimeToken; + CancellationToken resumeToken; + internal CancellationToken YieldToken; + CancellationTokenRegistration lifetimeRegistration; + CancellationTokenRegistration resumeRegistration; + + internal CancellationScope(CoroutineCore owner, CancellationTokenSource source) { - functionTask.GetAwaiter().GetResult(); + this.owner = owner; + Token = source.Token; } - catch (Exception ex) + + internal CancellationToken Token { get; } + + internal CancellationToken GetOrigin(CancellationToken token) => + token != Token && token.IsCancellationRequested ? token + : lifetimeToken.IsCancellationRequested ? lifetimeToken + : resumeToken.IsCancellationRequested ? resumeToken + : YieldToken.IsCancellationRequested ? YieldToken + : token; + + internal void Cancel() => owner.RequestCancellation(); + + internal void SetResumeToken(CancellationToken token, bool first) { - if (ex is ILuaTracebackBuildable tracebackBuildable) - traceback = tracebackBuildable.BuildOrGet(); + resumeRegistration.Dispose(); + resumeRegistration = default; + resumeToken = token; + if (first) + { + lifetimeToken = token; + if (token.CanBeCanceled) + lifetimeRegistration = Register(token); + } + else if (token.CanBeCanceled && token != lifetimeToken && token != Token) + resumeRegistration = Register(token); } - finally + + internal CancellationTokenRegistration Register(CancellationToken token) => + token.UnsafeRegister(static value => ((CancellationScope)value!).Cancel(), this); + + public void Dispose() { - functionTask = default; - Thread.Dispose(); - Function = null; - Volatile.Write(ref status, (byte)LuaThreadStatus.Dead); + lifetimeRegistration.Dispose(); + resumeRegistration.Dispose(); + lifetimeRegistration = resumeRegistration = default; + lifetimeToken = resumeToken = YieldToken = default; } } @@ -389,67 +458,59 @@ internal async ValueTask YieldAsyncCore( ) { if (Volatile.Read(ref status) != (byte)LuaThreadStatus.Running) - { throw new LuaRuntimeException(baseThread, "cannot yield from a non-running coroutine"); - } - if (baseThread != null) { var frames = baseThread.GetCallStackFrames(); if (frames.Length > 1 && frames[^2].Function is not LuaClosure) - { throw new LuaRuntimeException( baseThread, "attempt to yield across a C#-call boundary" ); - } - } - - CancellationTokenSource? linkedSource = null; - var resumeToken = resumeCancellationToken; - if (resumeToken.CanBeCanceled && resumeToken != cancellationToken) - { - if (cancellationToken.CanBeCanceled) - { - linkedSource = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken, - resumeToken - ); - cancellationToken = linkedSource.Token; - } - else - cancellationToken = resumeToken; } - + cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.CanBeCanceled && cancellationToken != executionToken) + EnsureCancellation(); + Thread.ThrowIfCancellationRequested(executionToken); CancellationTokenRegistration registration = default; + var published = false; + if (Interlocked.CompareExchange(ref handoff, Copying, Running) != Running) + throw new LuaCanceledException(Thread, GetCancellationOrigin(executionToken)); try { - cancellationToken.ThrowIfCancellationRequested(); Thread.SuspendUpValues(); yieldStack = stack; yieldReturnBase = returnBase; - // Yielded values remain owned by the resumer until its copy completes. - Volatile.Write(ref resumePhase, ResumeYielded); + if (cancellation != null) + cancellation.YieldToken = cancellationToken; + if (cancellationToken.CanBeCanceled && cancellationToken != executionToken) + registration = cancellation!.Register(cancellationToken); + published = true; resume.SetResult(new(stack, argCount)); - if (cancellationToken.CanBeCanceled) - registration = cancellationToken.UnsafeRegister( - static x => ((CoroutineCore)x!).CancelYield(), - this - ); var result = await new ValueTask(this, yield.Version); - resumeCancellationToken.ThrowIfCancellationRequested(); + Thread.ThrowIfCancellationRequested(executionToken); Thread.ResumeUpValues(); stack.PopUntil(returnBase); stack.PushRange(result.Results); return result.Results.Length; } + catch + { + if (!published) + { + Thread.ResumeUpValues(); + if (Interlocked.Exchange(ref handoff, Running) == (Copying | CancelPending)) + RequestCancellation(); + } + throw; + } finally { registration.Dispose(); - linkedSource?.Dispose(); yieldStack = null; + if (cancellation != null) + cancellation.YieldToken = default; yield.Reset(); - Volatile.Write(ref yieldPhase, YieldWaiting); } } } diff --git a/src/Lua/LuaState.cs b/src/Lua/LuaState.cs index 48902c23..c845db34 100644 --- a/src/Lua/LuaState.cs +++ b/src/Lua/LuaState.cs @@ -123,6 +123,24 @@ public ValueTask ResumeAsync(LuaStack stack, CancellationToken cancellation return new(2); } + internal ValueTask ResumeWrappedAsync( + LuaFunctionExecutionContext context, + CancellationToken cancellationToken + ) => + coroutine != null + ? coroutine.ResumeAsyncCore( + context.State.Stack, + context.ArgumentCount, + context.ReturnFrameBase, + context.State, + cancellationToken, + includeStatus: false + ) + : ResumeAsync(context, cancellationToken); + + internal CancellationToken GetCancellationOrigin(CancellationToken token) => + coroutine == null ? token : coroutine.GetCancellationOrigin(token); + public ValueTask YieldAsync( LuaFunctionExecutionContext context, CancellationToken cancellationToken = default diff --git a/src/Lua/LuaThreadExtensions.cs b/src/Lua/LuaThreadExtensions.cs index 3c887f5b..073d51c0 100644 --- a/src/Lua/LuaThreadExtensions.cs +++ b/src/Lua/LuaThreadExtensions.cs @@ -16,7 +16,7 @@ CancellationToken cancellationToken static void Throw(LuaState state, CancellationToken cancellationToken) { - throw new LuaCanceledException(state, cancellationToken); + throw new LuaCanceledException(state, state.GetCancellationOrigin(cancellationToken)); } } } diff --git a/src/Lua/Runtime/LuaVirtualMachine.cs b/src/Lua/Runtime/LuaVirtualMachine.cs index eea0cab4..51172b21 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.cs @@ -321,10 +321,13 @@ e is not (LuaRuntimeException or LuaCanceledException) && PostOperation != PostOperationType.Return ) { - Exception newException = - e is OperationCanceledException - ? new LuaCanceledException(State, CancellationToken, e) - : new LuaRuntimeException(State, e); + Exception newException = e is OperationCanceledException cancelled + ? new LuaCanceledException( + State, + State.GetCancellationOrigin(cancelled.CancellationToken), + e + ) + : new LuaRuntimeException(State, e); PopOnTopCallStackFrames(); throw newException; } @@ -1126,10 +1129,13 @@ e is not (LuaRuntimeException or LuaCanceledException) && context.PostOperation != PostOperationType.Return ) { - Exception newException = - e is OperationCanceledException - ? new LuaCanceledException(context.State, context.CancellationToken, e) - : new LuaRuntimeException(context.State, e); + Exception newException = e is OperationCanceledException cancelled + ? new LuaCanceledException( + context.State, + context.State.GetCancellationOrigin(cancelled.CancellationToken), + e + ) + : new LuaRuntimeException(context.State, e); context.PopOnTopCallStackFrames(); throw newException; } @@ -1390,31 +1396,7 @@ OpCode opCode { var stack = context.Stack; var newBase = stack.Count; - var callable = metamethod; - if (!metamethod.TryReadFunction(out var func)) - { - if ( - metamethod.TryGetMetamethod( - context.GlobalState, - Metamethods.Call, - out metamethod - ) && metamethod.TryReadFunction(out func) - ) - { - stack.Push(callable); - } - else - { - LuaRuntimeException.AttemptInvalidOperation( - GetstateWithCurrentPc(context), - "call", - metamethod - ); - } - } - - stack.Push(vb); - stack.Push(vc); + var func = PrepareOperationMetamethod(context.State, metamethod, vb, vc, context); var (argCount, varArgCount) = PrepareForFunctionCall(context.State, func, newBase); newBase += varArgCount; @@ -2497,31 +2479,7 @@ out bool doRestart { var stack = context.Stack; var newBase = stack.Count; - var callable = metamethod; - if (!metamethod.TryReadFunction(out var func)) - { - if ( - metamethod.TryGetMetamethod( - context.GlobalState, - Metamethods.Call, - out metamethod - ) && metamethod.TryReadFunction(out func) - ) - { - stack.Push(callable); - } - else - { - LuaRuntimeException.AttemptInvalidOperation( - GetstateWithCurrentPc(context), - "call", - metamethod - ); - } - } - - stack.Push(vb); - stack.Push(vc); + var func = PrepareOperationMetamethod(context.State, metamethod, vb, vc, context); var (argCount, variableArgumentCount) = PrepareForFunctionCall( context.State, func, @@ -2631,31 +2589,7 @@ out bool doRestart if (vb.TryGetMetamethod(context.GlobalState, name, out var metamethod)) { var newBase = stack.Count; - var callable = metamethod; - if (!metamethod.TryReadFunction(out var func)) - { - if ( - metamethod.TryGetMetamethod( - context.GlobalState, - Metamethods.Call, - out metamethod - ) && metamethod.TryReadFunction(out func) - ) - { - stack.Push(callable); - } - else - { - LuaRuntimeException.AttemptInvalidOperation( - GetstateWithCurrentPc(context), - "call", - metamethod - ); - } - } - - stack.Push(vb); - stack.Push(vb); + var func = PrepareOperationMetamethod(context.State, metamethod, vb, vb, context); var (argCount, variableArgumentCount) = PrepareForFunctionCall( context.State, func, @@ -2760,16 +2694,16 @@ CancellationToken cancellationToken } } - static ValueTask InvokeOperationMetamethod( + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static LuaFunction PrepareOperationMetamethod( LuaState state, LuaValue metamethod, LuaValue left, LuaValue right, - CancellationToken cancellationToken + VirtualMachineExecutionContext? context = null ) { var stack = state.Stack; - var top = stack.Count; var callable = metamethod; if (!metamethod.TryReadFunction(out var function)) { @@ -2777,12 +2711,29 @@ CancellationToken cancellationToken !metamethod.TryGetMetamethod(state.GlobalState, Metamethods.Call, out metamethod) || !metamethod.TryReadFunction(out function) ) - LuaRuntimeException.AttemptInvalidOperation(state, "call", metamethod); + LuaRuntimeException.AttemptInvalidOperation( + context == null ? state : GetstateWithCurrentPc(context), + "call", + metamethod + ); stack.PushRange(callable, left, right); } else stack.PushRange(left, right); + return function; + } + + static ValueTask InvokeOperationMetamethod( + LuaState state, + LuaValue metamethod, + LuaValue left, + LuaValue right, + CancellationToken cancellationToken + ) + { + var top = state.Stack.Count; + var function = PrepareOperationMetamethod(state, metamethod, left, right); return InvokeOperationMetamethod(state, function, top, cancellationToken); } @@ -2878,31 +2829,7 @@ out bool doRestart { var stack = context.Stack; var newBase = stack.Count; - var callable = metamethod; - if (!metamethod.TryReadFunction(out var func)) - { - if ( - metamethod.TryGetMetamethod( - context.GlobalState, - Metamethods.Call, - out metamethod - ) && metamethod.TryReadFunction(out func) - ) - { - stack.Push(callable); - } - else - { - LuaRuntimeException.AttemptInvalidOperation( - GetstateWithCurrentPc(context), - "call", - metamethod - ); - } - } - - stack.Push(vb); - stack.Push(vc); + var func = PrepareOperationMetamethod(context.State, metamethod, vb, vc, context); var (argCount, varArgCount) = PrepareForFunctionCall(context.State, func, newBase); newBase += varArgCount; var newFrame = func.CreateNewFrame(context, newBase, newBase, varArgCount); diff --git a/src/Lua/Standard/CoroutineLibrary.cs b/src/Lua/Standard/CoroutineLibrary.cs index 1601844f..be65f9cb 100644 --- a/src/Lua/Standard/CoroutineLibrary.cs +++ b/src/Lua/Standard/CoroutineLibrary.cs @@ -1,5 +1,3 @@ -using System.Runtime.CompilerServices; -using Lua.Internal.CompilerServices; using Lua.Runtime; namespace Lua.Standard; @@ -86,23 +84,13 @@ CancellationToken cancellationToken return new(context.Return(new CSharpClosure("wrap", [state], WrapResume))); } - [AsyncMethodBuilder(typeof(LightAsyncValueTaskMethodBuilder<>))] - static async ValueTask WrapResume( + static ValueTask WrapResume( LuaFunctionExecutionContext context, CancellationToken cancellationToken ) { var state = context.GetCsClosure()!.UpValues[0].Read(); - if (!state.IsCoroutine) - { - return await state.ResumeAsync(context, cancellationToken); - } - - await state.ResumeAsync(context, cancellationToken); - var result = context.GetReturnBuffer(context.State.Stack.Count - context.ReturnFrameBase); - result[1..].CopyTo(result); - context.State.Stack.Pop(); - return result.Length - 1; + return state.ResumeWrappedAsync(context, cancellationToken); } public ValueTask Yield( diff --git a/tests/Lua.Tests/CoroutineCancellationDispatchTests.cs b/tests/Lua.Tests/CoroutineCancellationDispatchTests.cs new file mode 100644 index 00000000..e3eda37d --- /dev/null +++ b/tests/Lua.Tests/CoroutineCancellationDispatchTests.cs @@ -0,0 +1,265 @@ +using Lua.Platforms; +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class CoroutineCancellationDispatchTests(LuaStringMode mode) +{ + [Test] + public async Task LaterResumeCancellationReachesCallbacks( + [Values( + "call", + "add", + "unm", + "len", + "concat", + "lt", + "le", + "eq", + "index", + "newindex", + "callable", + "iterator", + "hook" + )] + string operation, + [Values] bool asynchronous, + [Values] bool firstCancellable, + [Values] bool protectedMode + ) + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + using var lifetime = new CancellationTokenSource(); + using var cancellation = new CancellationTokenSource(); + var calls = 0; + var work = new LuaFunction( + async (context, token) => + { + calls++; + if (asynchronous) + await Task.Yield(); + cancellation.Cancel(); + token.ThrowIfCancellationRequested(); + return context.Return(42); + } + ); + state.Environment["work"] = work; + var expression = operation switch + { + "call" or "hook" => "return work()", + "add" => "return a+a", + "unm" => "return -a", + "len" => "return #a", + "concat" => "return a..a", + "lt" => "return a "return a<=b", + "eq" => "return a==b", + "index" => "return a.missing", + "newindex" => "a.missing=1", + "callable" => "return a()", + "iterator" => "for value in work do return value end", + _ => throw new ArgumentOutOfRangeException(nameof(operation)), + }; + using var thread = state.CreateCoroutine( + state.Load( + """ + local mt={__add=work,__unm=work,__len=work,__concat=work,__lt=work, + __le=work,__eq=work,__index=work,__newindex=work,__call=work} + local a,b=setmetatable({},mt),setmetatable({},mt) + coroutine.yield(7) + """ + expression, + "later-resume-dispatch" + ), + protectedMode + ); + var output = new LuaStack(); + await thread.ResumeAsync(output, firstCancellable ? lifetime.Token : default); + Assert.That(output.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 7 })); + if (operation == "hook") + thread.SetHook(work, "l"); + output.Clear(); + var error = Assert.CatchAsync(async () => + await thread.ResumeAsync(output, cancellation.Token) + )!; + Assert.That(error.CancellationToken, Is.EqualTo(cancellation.Token)); + Assert.That(calls, Is.EqualTo(1)); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead)); + Assert.That(thread.CoroutineFunction, Is.Null); + Assert.That(thread.CallStackFrameCount, Is.Zero); + Assert.That(thread.GetStackValues().Length, Is.Zero); + } + + [Test] + public async Task LaterResumeCancellationIsCheckedByTheVm([Values] bool protectedMode) + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + using var cancellation = new CancellationTokenSource(); + state.Environment["cancel"] = new LuaFunction( + (context, _) => + { + cancellation.Cancel(); + return new(context.Return()); + } + ); + using var thread = state.CreateCoroutine( + state.Load( + "coroutine.yield(); cancel(); local n=0; for i=1,1000 do n=n+i end; return n", + "later-resume-loop" + ), + protectedMode + ); + var output = new LuaStack(); + await thread.ResumeAsync(output); + output.Clear(); + var error = Assert.CatchAsync(async () => + await thread.ResumeAsync(output, cancellation.Token) + )!; + Assert.That(error.CancellationToken, Is.EqualTo(cancellation.Token)); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead)); + } + + [Test] + public async Task LaterResumeCancellationStopsAnAwaitingCallback( + [Values] bool protectedMode, + [Values("lua", "callback", "entry")] string path + ) + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + using var cancellation = new CancellationTokenSource(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + CancellationToken received = default; + var work = new LuaFunction( + async (context, token) => + { + received = token; + if (path != "lua") + await context.State.YieldAsync(context, token); + entered.SetResult(); + await Task.Delay(500, token); + return context.Return(42); + } + ); + state.Environment["work"] = work; + using var thread = state.CreateCoroutine( + path == "entry" + ? work + : state.Load( + path == "lua" ? "coroutine.yield(); return work()" : "return work()", + "cancel-awaiting-callback" + ), + protectedMode + ); + var output = new LuaStack(); + await thread.ResumeAsync(output); + output.Clear(); + var running = thread.ResumeAsync(output, cancellation.Token).AsTask(); + await entered.Task; + cancellation.Cancel(); + var error = Assert.CatchAsync(async () => await running)!; + Assert.That(error.CancellationToken, Is.EqualTo(cancellation.Token)); + Assert.That(received.IsCancellationRequested, Is.True); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead)); + Assert.That(thread.CallStackFrameCount, Is.Zero); + } + + [Test] + public async Task LaterCancellationReachesNestedAndRestoredVmExecutions( + [Values] bool afterNestedReturn + ) + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + using var cancellation = new CancellationTokenSource(); + var inner = state.Load( + "coroutine.yield(7); local n=0; for i=1,1000 do n=n+i end; return n", + "nested-execution" + ); + state.Environment["nested"] = new LuaFunction( + async (context, token) => + { + await context.State.RunAsync(inner, 0, token); + return context.Return(42); + } + ); + using var thread = state.CreateCoroutine( + state.Load( + "nested(); coroutine.yield(8); local n=0; for i=1,1000 do n=n+i end; return n", + "outer-execution" + ) + ); + var output = new LuaStack(); + await thread.ResumeAsync(output); + Assert.That(output.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 7 })); + output.Clear(); + if (afterNestedReturn) + { + await thread.ResumeAsync(output); + Assert.That(output.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 8 })); + output.Clear(); + } + thread.SetHook( + new LuaFunction( + (context, _) => + { + cancellation.Cancel(); + return new(context.Return()); + } + ), + "", + 1 + ); + var error = Assert.CatchAsync(async () => + await thread.ResumeAsync(output, cancellation.Token) + )!; + Assert.That(error.CancellationToken, Is.EqualTo(cancellation.Token)); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead)); + Assert.That(thread.CallStackFrameCount, Is.Zero); + } + + [Test] + public async Task ReplacedResumeTokenDoesNotCancelTheNextResume([Values] bool firstCancellable) + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + using var lifetime = new CancellationTokenSource(); + using var previous = new CancellationTokenSource(); + state.Environment["cancelPrevious"] = new LuaFunction( + (context, token) => + { + previous.Cancel(); + token.ThrowIfCancellationRequested(); + return new(context.Return(42)); + } + ); + using var thread = state.CreateCoroutine( + state.Load( + "coroutine.yield(1); coroutine.yield(2); return cancelPrevious()", + "replace-resume-token" + ) + ); + var output = new LuaStack(); + await thread.ResumeAsync(output, firstCancellable ? lifetime.Token : default); + output.Clear(); + await thread.ResumeAsync(output, previous.Token); + output.Clear(); + await thread.ResumeAsync(output); + Assert.That(output.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 42 })); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead)); + } +} diff --git a/tests/Lua.Tests/CoroutineTransferTests.cs b/tests/Lua.Tests/CoroutineTransferTests.cs index 55d6bc1a..59f08e75 100644 --- a/tests/Lua.Tests/CoroutineTransferTests.cs +++ b/tests/Lua.Tests/CoroutineTransferTests.cs @@ -225,7 +225,8 @@ [Values] bool asynchronous public async Task CancellationWaitsForYieldedResultTransfer( [Values] bool protectedMode, [Values] bool cancelResume, - [Values] bool oversized + [Values] bool oversized, + [Values] bool waitAsync ) { using var state = CreateState(); @@ -246,9 +247,10 @@ context with // The first resume has not returned from RunAsync to copy the published yield. cancellation.Cancel(); Assert.That(pending.IsCompleted, Is.False); + Assert.That(token.IsCancellationRequested, Is.False); Assert.That(context.State.Stack.Count, Is.EqualTo(count)); Assert.That(context.State.Stack[count - 1], Is.EqualTo(new LuaValue(42))); - return await pending; + return waitAsync ? await pending.AsTask().WaitAsync(token) : await pending; } ), protectedMode @@ -414,6 +416,124 @@ await state.DoStringAsync("return saved()"), Assert.That(thread.GetStackValues().Length, Is.Zero); } + [Test] + public async Task WrappedPacketsPreserveTheirCountAndValues( + [Values(0, 1, 3, 2048)] int count, + [Values] bool asynchronous + ) + { + using var state = CreateState(); + state.Environment["count"] = count; + state.Environment["many"] = new LuaFunction( + async (context, _) => + { + if (asynchronous) + await Task.Yield(); + return ReturnPacket(context, count); + } + ); + var result = await state.DoStringAsync( + """ + local run=coroutine.wrap(function() + co=coroutine.running() + coroutine.yield(many()) + return many() + end) + for attempt=1,2 do + local packet=table.pack(run()) + assert(packet.n==count) + for i=1,count do + local expected + if i==1 then expected=false + elseif (i-1)%3~=1 then expected=i-1 end + assert(packet[i]==expected) + end + end + return coroutine.status(co) + """ + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { "dead" })); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + [Test] + public void CompletedCoroutinesReleaseCancellationSources() + { + using var state = CreateState(); + var references = CompleteCancellableCoroutine(state); + Collect(); + Assert.That(references.All(reference => !reference.IsAlive), Is.True); + GC.KeepAlive(state); + } + + [Test] + public void AnOwnedTokenDoesNotRootAnAbandonedCoroutine() + { + using var state = CreateState(); + var (reference, token) = SuspendWithOwnedToken(state); + Collect(); + Assert.That(reference.IsAlive, Is.False); + GC.KeepAlive(token); + GC.KeepAlive(state); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static (WeakReference, CancellationToken) SuspendWithOwnedToken(LuaState state) + { + CancellationToken received = default; + var thread = state.CreateCoroutine( + new LuaFunction( + async (context, token) => + { + received = token; + await context.State.YieldAsync(context, token); + await context.State.YieldAsync(context, token); + return context.Return(); + } + ) + ); + var output = new LuaStack(); + thread.ResumeAsync(output).GetAwaiter().GetResult(); + output.Clear(); + thread.ResumeAsync(output, received).GetAwaiter().GetResult(); + return (new(thread), received); + } + + static int ReturnPacket(LuaFunctionExecutionContext context, int count) + { + var buffer = context.GetReturnBuffer(count); + for (var i = 0; i < count; i++) + buffer[i] = + i == 0 ? new LuaValue(false) + : i % 3 == 1 ? LuaValue.Nil + : i; + return count; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference[] CompleteCancellableCoroutine(LuaState state) + { + var lifetime = new CancellationTokenSource(); + var resumed = new CancellationTokenSource(); + using var thread = state.CreateCoroutine( + new LuaFunction( + static async (context, token) => + { + await context.State.YieldAsync(context, token); + return context.Return(42); + } + ) + ); + var output = new LuaStack(); + thread.ResumeAsync(output, lifetime.Token).GetAwaiter().GetResult(); + output.Clear(); + thread.ResumeAsync(output, resumed.Token).GetAwaiter().GetResult(); + state.Environment["completed"] = thread; + Assert.That(output.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 42 })); + return [new(lifetime), new(resumed)]; + } + static async Task AssertTransferError( LuaState thread, LuaStack results, From 25a7b0283c958f15e01f228e922fbfe781c86c26 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:18:20 +0900 Subject: [PATCH 24/32] refactor: trim coroutine cancellation overhead and redundant test cases Use the handoff state as the resume guard, reuse unchanged resume-token registrations, and share the lifetime registration when a callback yields with that token. Remove the cancellation scope's duplicate execution token and callback forwarding method, and check external yield tokens once. Trim 224 redundant Cartesian test combinations while retaining every callback dispatch, sync/async path, string mode, and distinct transfer failure. Extend the existing token tests through repeated resumes, retirement, and a shared resume/yield token instead of adding another test fixture. Validation: all 2,627 tests pass on both .NET 8 and .NET 10, all four runtime targets and the benchmark project build, and formatting checks pass. Nine paired .NET 10 samples against 6a3578d show repeated-token and lifetime-token native workloads about 7.7% faster, with 8 fewer bytes allocated per cancellable coroutine. Other changed coroutine workloads remain within roughly 2%. --- src/Lua/Internal/CoroutineCore.cs | 58 +++++++------------ .../CoroutineCancellationDispatchTests.cs | 50 +++++++--------- tests/Lua.Tests/CoroutineLifetimeTests.cs | 34 ++++++----- tests/Lua.Tests/CoroutineTransferTests.cs | 5 +- .../OperationArgumentTransferTests.cs | 5 +- 5 files changed, 64 insertions(+), 88 deletions(-) diff --git a/src/Lua/Internal/CoroutineCore.cs b/src/Lua/Internal/CoroutineCore.cs index 44806fd3..52722715 100644 --- a/src/Lua/Internal/CoroutineCore.cs +++ b/src/Lua/Internal/CoroutineCore.cs @@ -110,18 +110,6 @@ internal async ValueTask ResumeAsyncCore( } try { - var currentStatus = (LuaThreadStatus)Volatile.Read(ref status); - if (currentStatus != LuaThreadStatus.Suspended) - return ResumeError( - stack, - returnBase, - baseThread, - currentStatus == LuaThreadStatus.Dead - ? "cannot resume dead coroutine" - : "cannot resume non-suspended coroutine", - includeStatus - ); - var phase = Interlocked.CompareExchange(ref handoff, Preparing, Suspended); if (phase != Suspended) return ResumeError( @@ -341,8 +329,7 @@ void RequestCancellation() var phase = Volatile.Read(ref handoff); var next = phase switch { - Preparing or Copying => phase | CancelPending, - Running => Running | CancelPending, + Preparing or Running or Copying => phase | CancelPending, Suspended => Copying | CancelPending, _ => phase, }; @@ -373,7 +360,7 @@ void CompleteCancellation(int phase) } } - CancellationScope EnsureCancellation() => cancellation ??= new(this, executionSource!); + CancellationScope EnsureCancellation() => cancellation ??= new(this); void SetResumeToken(CancellationToken token) { @@ -395,49 +382,48 @@ void FinishExecution() Volatile.Write(ref status, (byte)LuaThreadStatus.Dead); } - sealed class CancellationScope : IDisposable + sealed class CancellationScope(CoroutineCore owner) : IDisposable { - readonly CoroutineCore owner; CancellationToken lifetimeToken; CancellationToken resumeToken; internal CancellationToken YieldToken; CancellationTokenRegistration lifetimeRegistration; CancellationTokenRegistration resumeRegistration; - internal CancellationScope(CoroutineCore owner, CancellationTokenSource source) - { - this.owner = owner; - Token = source.Token; - } - - internal CancellationToken Token { get; } - internal CancellationToken GetOrigin(CancellationToken token) => - token != Token && token.IsCancellationRequested ? token + token != owner.executionToken && token.IsCancellationRequested ? token : lifetimeToken.IsCancellationRequested ? lifetimeToken : resumeToken.IsCancellationRequested ? resumeToken : YieldToken.IsCancellationRequested ? YieldToken : token; - internal void Cancel() => owner.RequestCancellation(); - internal void SetResumeToken(CancellationToken token, bool first) { + if (token == resumeToken) + return; resumeRegistration.Dispose(); resumeRegistration = default; resumeToken = token; if (first) { lifetimeToken = token; - if (token.CanBeCanceled) - lifetimeRegistration = Register(token); + lifetimeRegistration = Register(token); } - else if (token.CanBeCanceled && token != lifetimeToken && token != Token) + else if (token != lifetimeToken && token != owner.executionToken) resumeRegistration = Register(token); } - internal CancellationTokenRegistration Register(CancellationToken token) => - token.UnsafeRegister(static value => ((CancellationScope)value!).Cancel(), this); + CancellationTokenRegistration Register(CancellationToken token) => + token.UnsafeRegister( + static value => ((CoroutineCore)value!).RequestCancellation(), + owner + ); + + internal CancellationTokenRegistration RegisterYieldToken(CancellationToken token) + { + YieldToken = token; + return token == lifetimeToken ? default : Register(token); + } public void Dispose() { @@ -469,8 +455,6 @@ internal async ValueTask YieldAsyncCore( ); } cancellationToken.ThrowIfCancellationRequested(); - if (cancellationToken.CanBeCanceled && cancellationToken != executionToken) - EnsureCancellation(); Thread.ThrowIfCancellationRequested(executionToken); CancellationTokenRegistration registration = default; var published = false; @@ -481,10 +465,8 @@ internal async ValueTask YieldAsyncCore( Thread.SuspendUpValues(); yieldStack = stack; yieldReturnBase = returnBase; - if (cancellation != null) - cancellation.YieldToken = cancellationToken; if (cancellationToken.CanBeCanceled && cancellationToken != executionToken) - registration = cancellation!.Register(cancellationToken); + registration = EnsureCancellation().RegisterYieldToken(cancellationToken); published = true; resume.SetResult(new(stack, argCount)); var result = await new ValueTask(this, yield.Version); diff --git a/tests/Lua.Tests/CoroutineCancellationDispatchTests.cs b/tests/Lua.Tests/CoroutineCancellationDispatchTests.cs index e3eda37d..bb855fd4 100644 --- a/tests/Lua.Tests/CoroutineCancellationDispatchTests.cs +++ b/tests/Lua.Tests/CoroutineCancellationDispatchTests.cs @@ -8,6 +8,15 @@ namespace Lua.Tests; [TestFixture(LuaStringMode.Bytes)] public class CoroutineCancellationDispatchTests(LuaStringMode mode) { + LuaState CreateState() + { + var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + return state; + } + [Test] public async Task LaterResumeCancellationReachesCallbacks( [Values( @@ -26,16 +35,10 @@ public async Task LaterResumeCancellationReachesCallbacks( "hook" )] string operation, - [Values] bool asynchronous, - [Values] bool firstCancellable, - [Values] bool protectedMode + [Values] bool asynchronous ) { - using var state = LuaState.Create( - mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default - ); - state.OpenStandardLibraries(); - using var lifetime = new CancellationTokenSource(); + using var state = CreateState(); using var cancellation = new CancellationTokenSource(); var calls = 0; var work = new LuaFunction( @@ -75,11 +78,10 @@ [Values] bool protectedMode coroutine.yield(7) """ + expression, "later-resume-dispatch" - ), - protectedMode + ) ); var output = new LuaStack(); - await thread.ResumeAsync(output, firstCancellable ? lifetime.Token : default); + await thread.ResumeAsync(output); Assert.That(output.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 7 })); if (operation == "hook") thread.SetHook(work, "l"); @@ -98,10 +100,7 @@ await thread.ResumeAsync(output, cancellation.Token) [Test] public async Task LaterResumeCancellationIsCheckedByTheVm([Values] bool protectedMode) { - using var state = LuaState.Create( - mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default - ); - state.OpenStandardLibraries(); + using var state = CreateState(); using var cancellation = new CancellationTokenSource(); state.Environment["cancel"] = new LuaFunction( (context, _) => @@ -133,10 +132,7 @@ public async Task LaterResumeCancellationStopsAnAwaitingCallback( [Values("lua", "callback", "entry")] string path ) { - using var state = LuaState.Create( - mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default - ); - state.OpenStandardLibraries(); + using var state = CreateState(); using var cancellation = new CancellationTokenSource(); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); CancellationToken received = default; @@ -179,10 +175,7 @@ public async Task LaterCancellationReachesNestedAndRestoredVmExecutions( [Values] bool afterNestedReturn ) { - using var state = LuaState.Create( - mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default - ); - state.OpenStandardLibraries(); + using var state = CreateState(); using var cancellation = new CancellationTokenSource(); var inner = state.Load( "coroutine.yield(7); local n=0; for i=1,1000 do n=n+i end; return n", @@ -233,10 +226,7 @@ await thread.ResumeAsync(output, cancellation.Token) [Test] public async Task ReplacedResumeTokenDoesNotCancelTheNextResume([Values] bool firstCancellable) { - using var state = LuaState.Create( - mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default - ); - state.OpenStandardLibraries(); + using var state = CreateState(); using var lifetime = new CancellationTokenSource(); using var previous = new CancellationTokenSource(); state.Environment["cancelPrevious"] = new LuaFunction( @@ -249,7 +239,7 @@ public async Task ReplacedResumeTokenDoesNotCancelTheNextResume([Values] bool fi ); using var thread = state.CreateCoroutine( state.Load( - "coroutine.yield(1); coroutine.yield(2); return cancelPrevious()", + "coroutine.yield(1); coroutine.yield(2); coroutine.yield(3); return cancelPrevious()", "replace-resume-token" ) ); @@ -257,6 +247,10 @@ public async Task ReplacedResumeTokenDoesNotCancelTheNextResume([Values] bool fi await thread.ResumeAsync(output, firstCancellable ? lifetime.Token : default); output.Clear(); await thread.ResumeAsync(output, previous.Token); + Assert.That(output.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 2 })); + output.Clear(); + await thread.ResumeAsync(output, previous.Token); + Assert.That(output.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 3 })); output.Clear(); await thread.ResumeAsync(output); Assert.That(output.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 42 })); diff --git a/tests/Lua.Tests/CoroutineLifetimeTests.cs b/tests/Lua.Tests/CoroutineLifetimeTests.cs index 241275ae..ee839f40 100644 --- a/tests/Lua.Tests/CoroutineLifetimeTests.cs +++ b/tests/Lua.Tests/CoroutineLifetimeTests.cs @@ -49,16 +49,12 @@ public async Task LaterResumeTokenCancelsTheNextYield(bool protectedMode, bool a new LuaFunction( async (context, token) => { - context.State.Stack.Push(1); - await context.State.YieldAsync(context with { ArgumentCount = 1 }, token); - context.State.Stack.Push(2); - return await context.State.YieldAsync( - context with - { - ArgumentCount = 1, - }, - token - ); + for (var value = 1; value <= 3; value++) + { + context.State.Stack.Push(value); + await context.State.YieldAsync(context with { ArgumentCount = 1 }, token); + } + return context.Return(); } ), protectedMode @@ -76,8 +72,12 @@ await thread.ResumeAsync(stack, source.Token) } else { - await thread.ResumeAsync(stack, source.Token); - Assert.That(stack.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 2 })); + for (var value = 2; value <= 3; value++) + { + stack.Clear(); + await thread.ResumeAsync(stack, source.Token); + Assert.That(stack.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, value })); + } source.Cancel(); } Assert.That(() => thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead).After(1000, 10)); @@ -85,15 +85,17 @@ await thread.ResumeAsync(stack, source.Token) Assert.That(thread.GetStackValues().Length, Is.Zero); } - [TestCase(false)] - [TestCase(true)] - public async Task EitherDistinctCancellationTokenCanCancelSuspendedYield(bool cancelResume) + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(true, true)] + public async Task ResumeAndYieldTokensCanCancelSuspendedYield(bool cancelResume, bool sameToken) { using var state = LuaState.Create(); using var resumeSource = new CancellationTokenSource(); using var yieldSource = new CancellationTokenSource(); + var yieldToken = sameToken ? resumeSource.Token : yieldSource.Token; using var thread = state.CreateCoroutine( - new LuaFunction((context, _) => context.State.YieldAsync(context, yieldSource.Token)) + new LuaFunction((context, _) => context.State.YieldAsync(context, yieldToken)) ); var stack = new LuaStack(); stack.Push(42); diff --git a/tests/Lua.Tests/CoroutineTransferTests.cs b/tests/Lua.Tests/CoroutineTransferTests.cs index 59f08e75..420aeb49 100644 --- a/tests/Lua.Tests/CoroutineTransferTests.cs +++ b/tests/Lua.Tests/CoroutineTransferTests.cs @@ -225,8 +225,7 @@ [Values] bool asynchronous public async Task CancellationWaitsForYieldedResultTransfer( [Values] bool protectedMode, [Values] bool cancelResume, - [Values] bool oversized, - [Values] bool waitAsync + [Values] bool oversized ) { using var state = CreateState(); @@ -250,7 +249,7 @@ context with Assert.That(token.IsCancellationRequested, Is.False); Assert.That(context.State.Stack.Count, Is.EqualTo(count)); Assert.That(context.State.Stack[count - 1], Is.EqualTo(new LuaValue(42))); - return waitAsync ? await pending.AsTask().WaitAsync(token) : await pending; + return await pending.AsTask().WaitAsync(token); } ), protectedMode diff --git a/tests/Lua.Tests/OperationArgumentTransferTests.cs b/tests/Lua.Tests/OperationArgumentTransferTests.cs index 2b02474e..3e9390a6 100644 --- a/tests/Lua.Tests/OperationArgumentTransferTests.cs +++ b/tests/Lua.Tests/OperationArgumentTransferTests.cs @@ -25,8 +25,7 @@ public async Task FailedArgumentTransferPreservesStackAndAllowsRetry( "reverse-le" )] string operation, - [Values] bool callableTable, - [Values(0, 1)] int available + [Values] bool callableTable ) { using var state = LuaState.Create( @@ -58,7 +57,7 @@ public async Task FailedArgumentTransferPreservesStackAndAllowsRetry( _ => [left, right], }; expectedArguments = callableTable ? [method, .. operands] : operands; - var top = (1 << 19) - available - (callableTable ? 1 : 0) - (operation == "concat" ? 2 : 0); + var top = (1 << 19) - 1 - (callableTable ? 1 : 0) - (operation == "concat" ? 2 : 0); state.Stack.EnsureCapacity(1 << 19); state.Stack.NotifyTop(top); state.Stack[top - 1] = "sentinel"; From 18f635c750f54131edf89b769055f28e4a3e9ad8 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:53:15 +0900 Subject: [PATCH 25/32] perf: preserve table length caching and spread numeric hashes Keep internal VM writes separate from writable views that escape to host code. Use a bulk array setter for SETLIST, retain nil-write metadata updates, and check table mutability once before growing arrays. Share ContainsKey lookup and dictionary key comparison logic. Mix numeric hashes with HashCode.Combine so negative and large integer keys spread across the power-of-two buckets instead of forming long chains. Extend existing mutation tests to cover literal/proxy writes, numeric reinsertion, fractional and large keys, and signed zero. Paired .NET 10 measurements against 25a7b02: repeated length queries are about 86-90% faster; collision-heavy numeric lookups and writes are about 99% faster. Plain VM array/string writes remain within 2%, with allocations unchanged. --- src/Lua/Internal/LuaValueDictionary.cs | 51 +++++++----------------- src/Lua/LuaTable.cs | 38 ++++++------------ src/Lua/LuaValue.cs | 4 +- src/Lua/Runtime/LuaVirtualMachine.cs | 3 +- tests/Lua.Tests/TableKeyLifetimeTests.cs | 9 +++-- tests/Lua.Tests/TableTests.cs | 11 +++++ 6 files changed, 47 insertions(+), 69 deletions(-) diff --git a/src/Lua/Internal/LuaValueDictionary.cs b/src/Lua/Internal/LuaValueDictionary.cs index ad012b43..7c6db950 100644 --- a/src/Lua/Internal/LuaValueDictionary.cs +++ b/src/Lua/Internal/LuaValueDictionary.cs @@ -140,18 +140,7 @@ internal ref LuaValue FindValue(LuaValue key, out int index) } entry = ref Unsafe.Add(ref entriesRef, i); - if ( - entry.hashCode == hashCode - && entry.keyType == key.Type - && key.Type switch - { - LuaValueType.String => Unsafe.As(entry.keyObject) - == key.UnsafeReadString(), - LuaValueType.Number or LuaValueType.Boolean => entry.keyNumber - == key.UnsafeReadDouble(), - _ => entry.keyObject == key.UnsafeReadObject(), - } - ) + if (entry.hashCode == hashCode && entry.KeyEquals(key)) { index = i; goto ReturnFound; @@ -228,18 +217,7 @@ void Insert(LuaValue key, LuaValue value) while ((uint)i < (uint)entries.Length) { entry = ref entries[i]; - if ( - entry.hashCode == hashCode - && entry.keyType == key.Type - && key.Type switch - { - LuaValueType.Number or LuaValueType.Boolean => entry.keyNumber - == key.UnsafeReadDouble(), - LuaValueType.String => Unsafe.As(entry.keyObject) - == key.UnsafeReadString(), - _ => entry.keyObject == key.UnsafeReadObject(), - } - ) + if (entry.hashCode == hashCode && entry.KeyEquals(key)) { if (entry.value.Type is LuaValueType.Nil) { @@ -388,18 +366,7 @@ public bool Remove(LuaValue key) { ref var entry = ref entries[i]; - if ( - entry.hashCode == hashCode - && entry.keyType == key.Type - && key.Type switch - { - LuaValueType.Number or LuaValueType.Boolean => entry.keyNumber - == key.UnsafeReadDouble(), - LuaValueType.String => Unsafe.As(entry.keyObject) - == key.UnsafeReadString(), - _ => entry.keyObject == key.UnsafeReadObject(), - } - ) + if (entry.hashCode == hashCode && entry.KeyEquals(key)) { if (last < 0) { @@ -493,7 +460,7 @@ int FindIterationIndex(LuaValue key) ref var entry = ref entries[i]; if (entry.hashCode != hashCode) continue; - if (new LuaValue(entry.keyType, entry.keyNumber, entry.keyObject).EqualsForDict(key)) + if (entry.KeyEquals(key)) return i; if ( entry.keyType == LuaValueType.Nil @@ -533,6 +500,16 @@ static void ReturnDeletedKey(WeakReference key) struct Entry { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly bool KeyEquals(LuaValue key) => + keyType == key.Type + && keyType switch + { + LuaValueType.Number or LuaValueType.Boolean => keyNumber == key.UnsafeReadDouble(), + LuaValueType.String => Unsafe.As(keyObject) == key.UnsafeReadString(), + _ => keyObject == key.UnsafeReadObject(), + }; + public uint hashCode; public LuaValueType keyType; public object? keyObject; diff --git a/src/Lua/LuaTable.cs b/src/Lua/LuaTable.cs index 146f9eeb..dd6212ee 100644 --- a/src/Lua/LuaTable.cs +++ b/src/Lua/LuaTable.cs @@ -187,6 +187,7 @@ public bool TryGetValue(LuaValue key, out LuaValue value) return dictionary.TryGetValue(key, out value) && value.Type is not LuaValueType.Nil; } + // Callers use SetExistingValue so nil writes update length and hash metadata. [MethodImpl(MethodImplOptions.AggressiveInlining)] internal ref LuaValue FindValueForWrite(LuaValue key) { @@ -196,32 +197,12 @@ internal ref LuaValue FindValueForWrite(LuaValue key) ThrowIndexIsNil(); } - if (TryGetInteger(key, out var index)) - { - if (index > 0 && index <= array.Length) - { - hasWritableArrayReferences = true; - return ref array[index - 1]; - } - } - + if (TryGetInteger(key, out var index) && index > 0 && index <= array.Length) + return ref array[index - 1]; return ref dictionary.FindValue(key, out _); } - public bool ContainsKey(LuaValue key) - { - if (key.Type is LuaValueType.Nil) - { - return false; - } - - if (TryGetInteger(key, out var index) && index > 0 && index <= array.Length) - { - return array[index - 1].Type != LuaValueType.Nil; - } - - return dictionary.TryGetValue(key, out var value) && value.Type is not LuaValueType.Nil; - } + public bool ContainsKey(LuaValue key) => TryGetValue(key, out _); [MethodImpl(MethodImplOptions.AggressiveInlining)] internal void SetExistingValue(LuaValue key, ref LuaValue existing, LuaValue value) @@ -442,7 +423,15 @@ public Span GetArraySpan() public ReadOnlySpan GetArrayReadOnlySpan() => array; - internal void EnsureArrayCapacity(int newCapacity) + internal void SetArrayValues(int start, ReadOnlySpan values) + { + ThrowIfReadOnly(); + EnsureArrayCapacity(start + values.Length); + values.CopyTo(array.AsSpan(start)); + knownArrayPrefixLength = Math.Min(knownArrayPrefixLength, start); + } + + void EnsureArrayCapacity(int newCapacity) { newCapacity = Math.Min(Math.Max(array.Length, newCapacity), MaxArraySize); while ( @@ -458,7 +447,6 @@ internal void EnsureArrayCapacity(int newCapacity) return; } - ThrowIfReadOnly(); var prevLength = array.Length; var newLength = newCapacity <= 8 ? 8 : MathEx.NextPowerOfTwo(newCapacity); diff --git a/src/Lua/LuaValue.cs b/src/Lua/LuaValue.cs index 077b77dc..cba8fb21 100644 --- a/src/Lua/LuaValue.cs +++ b/src/Lua/LuaValue.cs @@ -611,7 +611,9 @@ public override int GetHashCode() return Type switch { LuaValueType.Nil => 0, - LuaValueType.Boolean or LuaValueType.Number => value.GetHashCode(), + LuaValueType.Boolean => value.GetHashCode(), + // Double's hash leaves low bits constant for many integer keys. + LuaValueType.Number => HashCode.Combine(value), LuaValueType.String => Unsafe.As(referenceValue)!.GetHashCode(), _ => RuntimeHelpers.GetHashCode(referenceValue!), }; diff --git a/src/Lua/Runtime/LuaVirtualMachine.cs b/src/Lua/Runtime/LuaVirtualMachine.cs index 51172b21..2ab9174d 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.cs @@ -1874,8 +1874,7 @@ static void SetList(VirtualMachineExecutionContext context) c = context.Prototype.Code[context.Pc].Ax; } - table.EnsureArrayCapacity(((c - 1) * 50) + count); - stack.GetBuffer().Slice(RA + 1, count).CopyTo(table.GetArraySpan()[((c - 1) * 50)..]); + table.SetArrayValues((c - 1) * 50, stack.GetBuffer().Slice(RA + 1, count)); stack.PopUntil(RA + 1); } diff --git a/tests/Lua.Tests/TableKeyLifetimeTests.cs b/tests/Lua.Tests/TableKeyLifetimeTests.cs index 11846ef5..8d03319f 100644 --- a/tests/Lua.Tests/TableKeyLifetimeTests.cs +++ b/tests/Lua.Tests/TableKeyLifetimeTests.cs @@ -177,20 +177,21 @@ public async Task ReinsertingDeletedKeysPreservesValuesAndCounts() state.Environment["t"] = table; await state.DoStringAsync( """ - local keys={-1,false,'name',{}} - for i=1,4 do + local keys={-1,0,1e30,0.5,false,'name',{}} + for i=1,#keys do local k=keys[i] t[k]=i t[k]=nil t[k]=nil t[k]=i+10 end + t[-0.0]=t[0] """ ); - Assert.That(table.HashMapCount, Is.EqualTo(4)); + Assert.That(table.HashMapCount, Is.EqualTo(7)); Assert.That( table.Select(pair => pair.Value.Read()).Order(), - Is.EqualTo(new[] { 11, 12, 13, 14 }) + Is.EqualTo(new[] { 11, 12, 13, 14, 15, 16, 17 }) ); } diff --git a/tests/Lua.Tests/TableTests.cs b/tests/Lua.Tests/TableTests.cs index bec23560..2e47fa07 100644 --- a/tests/Lua.Tests/TableTests.cs +++ b/tests/Lua.Tests/TableTests.cs @@ -253,11 +253,22 @@ public async Task Test_ArrayLength_AfterVirtualMachineWrites() assert(#t == 0) t[1] = 1 assert(#t == 2) + local literal={1,2,3} + assert(#literal==3) + literal[2]=nil + assert(#literal==1) + literal[2]=2 + assert(#literal==3) + local proxy=setmetatable({}, {__newindex=literal}) + proxy[1]=nil + assert(#literal==0) t[2] = nil return #t """ ); Assert.That(results[0].Read(), Is.EqualTo(1)); Assert.That(table.ArrayLength, Is.EqualTo(1)); + await state.SetTableAsync(table, 1, LuaValue.Nil); + Assert.That(table.ArrayLength, Is.Zero); } } From 56109f4226ca17f61a72d7ac2da024871d5610be Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:53:31 +0900 Subject: [PATCH 26/32] perf: streamline buffered stream reads Decode complete buffered lines directly, and accumulate only lines that cross buffers. Reuse PooledList for multi-buffer lines and read-all operations, share text refill handling, and remove the duplicate byte-buffer growth code. Consume CRLF as one terminator even when it crosses a buffer boundary, while preserving raw CR bytes in Lua byte-string mode. Cover short, boundary-sized, and multi-buffer lines, kept endings, Unicode tails, and EOF in both modes. Paired .NET 10 measurements against 25a7b02 show short-line reads 36-44% faster without additional allocation; longer UTF-16 reads remain broadly unchanged. --- src/Lua/Internal/Utf8Reader.cs | 183 +++++++------------------ tests/Lua.Tests/StreamLifetimeTests.cs | 23 ++++ 2 files changed, 75 insertions(+), 131 deletions(-) diff --git a/src/Lua/Internal/Utf8Reader.cs b/src/Lua/Internal/Utf8Reader.cs index 07ef21ba..931f237a 100644 --- a/src/Lua/Internal/Utf8Reader.cs +++ b/src/Lua/Internal/Utf8Reader.cs @@ -26,129 +26,75 @@ public Utf8Reader(LuaStringMode stringMode = LuaStringMode.Utf16) public long Remain => bufLen - bufPos; + bool ReadTextBuffer(Stream stream) + { + bufLen = stream.Read(buffer, 0, buffer.Length); + bufPos = !byteStrings && buffer.AsSpan(0, bufLen).StartsWith(BomUtility.BomUtf8) ? 3 : 0; + return bufPos < bufLen; + } + public string? ReadLine(Stream stream, bool keepEol = false) { - var resultBuffer = ArrayPool.Shared.Rent(1024); - var lineLen = 0; - try + using PooledList result = new(); + while (true) { - while (true) + if (bufPos >= bufLen && !ReadTextBuffer(stream)) + break; + + var span = buffer.AsSpan(bufPos, bufLen - bufPos); + // Raw Lua byte streams end a line at LF; CR remains part of the data. + var end = byteStrings + ? span.IndexOf((byte)'\n') + : span.IndexOfAny((byte)'\r', (byte)'\n'); + if (end < 0) { - if (bufPos >= bufLen) - { - bufLen = stream.Read(buffer, 0, buffer.Length); - bufPos = - !byteStrings && buffer.AsSpan(0, bufLen).StartsWith(BomUtility.BomUtf8) - ? 3 - : 0; - if (bufLen <= bufPos) - { - break; // EOF - } - } - - Span span = new(buffer, bufPos, bufLen - bufPos); - // Raw Lua byte streams end a line at LF; CR remains part of the data. - var idx = byteStrings - ? span.IndexOf((byte)'\n') - : span.IndexOfAny((byte)'\r', (byte)'\n'); + result.AddRange(span); + bufPos = bufLen; + continue; + } - if (idx >= 0) + var count = end + (keepEol ? 1 : 0); + bufPos += end + 1; + if (span[end] == '\r') + { + if (bufPos == bufLen) { - // Add the line content (before the newline) - AppendToBuffer(ref resultBuffer, span[..idx], ref lineLen); - - var nl = span[idx]; - var eolStart = bufPos + idx; - bufPos += idx + 1; - - // Handle CRLF - check if we have \r\n - var isCRLF = false; - if (nl == (byte)'\r' && bufPos < bufLen && buffer[bufPos] == (byte)'\n') + // Preserve the segment before looking for LF in the next buffer. + result.AddRange(span[..count]); + if (PeekByte(stream) == '\n') { - isCRLF = true; - bufPos++; // Skip the \n as well + bufPos++; + if (keepEol) + result.Add((byte)'\n'); } - - // Add end-of-line characters if keepEol is true - if (keepEol) - { - if (isCRLF) - { - // Add \r\n - AppendToBuffer(ref resultBuffer, "\r\n"u8, ref lineLen); - } - else - { - // Add just the single newline character (\r or \n) - AppendToBuffer( - ref resultBuffer, - nl == '\r' ? "\r"u8 : "\n"u8, - ref lineLen - ); - } - } - - return encoding.GetString(resultBuffer, 0, lineLen); + return encoding.GetString(result.AsSpan()); } - else + if (buffer[bufPos] == '\n') { - // No newline found → add all to line buffer - AppendToBuffer(ref resultBuffer, span, ref lineLen); - bufPos = bufLen; + bufPos++; + if (keepEol) + count++; } } - - if (lineLen == 0) - { - return null; - } - - return encoding.GetString(resultBuffer, 0, lineLen); - } - finally - { - ArrayPool.Shared.Return(resultBuffer); + if (result.Count == 0) + return encoding.GetString(span[..count]); + result.AddRange(span[..count]); + return encoding.GetString(result.AsSpan()); } + return result.Count == 0 ? null : encoding.GetString(result.AsSpan()); } public string ReadToEnd(Stream stream) { - var resultBuffer = ArrayPool.Shared.Rent(1024); - var len = 0; - try + using PooledList result = new(); + while (true) { - while (true) - { - if (bufPos >= bufLen) - { - bufLen = stream.Read(buffer, 0, buffer.Length); - bufPos = - !byteStrings && buffer.AsSpan(0, bufLen).StartsWith(BomUtility.BomUtf8) - ? 3 - : 0; - if (bufLen <= bufPos) - { - break; // EOF - } - } - - Span span = new(buffer, bufPos, bufLen - bufPos); - AppendToBuffer(ref resultBuffer, span, ref len); - bufPos = bufLen; - } - - if (len == 0) - { - return ""; - } - - return encoding.GetString(resultBuffer, 0, len); - } - finally - { - ArrayPool.Shared.Return(resultBuffer); + if (bufPos >= bufLen && !ReadTextBuffer(stream)) + break; + result.AddRange(buffer.AsSpan(bufPos, bufLen - bufPos)); + bufPos = bufLen; } + return encoding.GetString(result.AsSpan()); } public int ReadByte(Stream stream) @@ -189,18 +135,8 @@ public void CopyBufferedBytes(IBufferWriter writer) { while (len < charCount) { - if (bufPos >= bufLen) - { - bufLen = stream.Read(buffer, 0, buffer.Length); - bufPos = - !byteStrings && buffer.AsSpan(0, bufLen).StartsWith(BomUtility.BomUtf8) - ? 3 - : 0; - if (bufLen <= bufPos) - { - break; // EOF - } - } + if (bufPos >= bufLen && !ReadTextBuffer(stream)) + break; ReadOnlySpan byteSpan = new(buffer, bufPos, bufLen - bufPos); Span charSpan = new(resultBuffer, len, charCount - len); @@ -250,21 +186,6 @@ out _ } } - static void AppendToBuffer(ref byte[] buffer, ReadOnlySpan segment, ref int length) - { - if (length + segment.Length > buffer.Length) - { - var newSize = Math.Max(buffer.Length * 2, length + segment.Length); - var newBuffer = ArrayPool.Shared.Rent(newSize); - Array.Copy(buffer, newBuffer, length); - ArrayPool.Shared.Return(buffer); - buffer = newBuffer; - } - - segment.CopyTo(buffer.AsSpan(length)); - length += segment.Length; - } - static bool IsAsciiSpace(char c) => c == ' ' || (c >= '\t' && c <= '\r'); public void Clear() diff --git a/tests/Lua.Tests/StreamLifetimeTests.cs b/tests/Lua.Tests/StreamLifetimeTests.cs index cefe608b..716d1997 100644 --- a/tests/Lua.Tests/StreamLifetimeTests.cs +++ b/tests/Lua.Tests/StreamLifetimeTests.cs @@ -13,6 +13,29 @@ public class StreamLifetimeTests(LuaStringMode stringMode) LuaStream CreateStream(string text) => new(LuaFileOpenMode.Read, new MemoryStream(Encoding.UTF8.GetBytes(text)), stringMode); + [TestCase(3, false)] + [TestCase(3, true)] + [TestCase(1023, false)] + [TestCase(1023, true)] + [TestCase(2048, false)] + [TestCase(2048, true)] + public async Task BufferedLinesPreserveEndingsAndFinalLine(int length, bool keepEol) + { + var line = new string('x', length); + using var stream = CreateStream(line + "\r\n\nlast é"); + var ending = stringMode == LuaStringMode.Bytes ? "\r" : ""; + Assert.That( + await stream.ReadLineAsync(keepEol, default), + Is.EqualTo(line + (keepEol ? "\r\n" : ending)) + ); + Assert.That(await stream.ReadLineAsync(keepEol, default), Is.EqualTo(keepEol ? "\n" : "")); + Assert.That( + await stream.ReadLineAsync(keepEol, default), + Is.EqualTo(stringMode == LuaStringMode.Bytes ? "last \u00c3\u00a9" : "last é") + ); + Assert.That(await stream.ReadLineAsync(keepEol, default), Is.Null); + } + [TestCase(SeekOrigin.Begin, -1)] [TestCase(SeekOrigin.Current, -8)] [TestCase(SeekOrigin.End, -8)] From 3be7afcb63e8c4608759717fba0c7e50d97f8217 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:53:31 +0900 Subject: [PATCH 27/32] test: trim overlapping metamethod and metatable cases Keep one dispatch case for each arithmetic operator and use addition for their shared variadic, async, and tail-call matrix. Keep representative generated metatable mutation checks; the general frozen-table tests cover the remaining write mechanisms. Remove 58 redundant cases while retaining all metamethods. Combined validation: 2,581 tests pass on each of .NET 8 and .NET 10. The final VM assignment shape also passes all 494 table/metamethod cases on both runtimes. All four runtime targets and the benchmark project build with no warnings; formatting and diff checks pass. --- tests/Lua.Tests/GeneratedMetatableLifetimeTests.cs | 4 ---- tests/Lua.Tests/VariadicMetamethodLifetimeTests.cs | 6 ++++++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/Lua.Tests/GeneratedMetatableLifetimeTests.cs b/tests/Lua.Tests/GeneratedMetatableLifetimeTests.cs index fcea027a..091a96d2 100644 --- a/tests/Lua.Tests/GeneratedMetatableLifetimeTests.cs +++ b/tests/Lua.Tests/GeneratedMetatableLifetimeTests.cs @@ -34,12 +34,8 @@ LuaState CreateState() return state; } - [TestCase("mt.__index=function() return 99 end")] [TestCase("mt.__index=nil")] [TestCase("mt.owner=coroutine.running()")] - [TestCase("rawset(mt,'__index',nil)")] - [TestCase("rawset(mt,'payload',{})")] - [TestCase("setmetatable(mt,{})")] [TestCase("debug.setmetatable(mt,{})")] public async Task DefaultMetatableRejectsMutation(string mutation) { diff --git a/tests/Lua.Tests/VariadicMetamethodLifetimeTests.cs b/tests/Lua.Tests/VariadicMetamethodLifetimeTests.cs index fee39c0d..96301a57 100644 --- a/tests/Lua.Tests/VariadicMetamethodLifetimeTests.cs +++ b/tests/Lua.Tests/VariadicMetamethodLifetimeTests.cs @@ -55,6 +55,12 @@ public static IEnumerable Operations() foreach (var (name, statement) in operations) foreach (var (fixedParameters, suspend, tailCall) in variants) { + // The arithmetic operators share argument preparation and result handling. + if ( + name is "__sub" or "__mul" or "__div" or "__mod" or "__pow" + && (fixedParameters != 0 || suspend || tailCall) + ) + continue; yield return new TestCaseData(name, statement, fixedParameters, suspend, tailCall); } } From 4a9f0729954d0e7a030d792e38813b0f3e9fac9a Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:26:09 +0900 Subject: [PATCH 28/32] refactor: share managed call and debug hook setup Reuse CreateCallStackFrame from RunAsync so argument padding, vararg rotation, and hook flags have one implementation. Inline that builder to preserve the cost of short calls. Share call/return hook invocation and its cleanup while retaining the per-instruction hook ownership rules. Push hook argument pairs atomically through the existing fixed-arity stack helper. Trim 12 repeated argument-normalization cases while retaining every hook event and all adjustment branches. The 600 focused cases pass on both runtimes. Paired .NET 10 samples against 3be7afc show managed-call workloads 2-6% faster; hook workloads range from 5% faster to 2.4% slower, with allocation unchanged. --- src/Lua/LuaState.cs | 36 ++------------ src/Lua/Runtime/LuaVirtualMachine.Debug.cs | 56 +++++++--------------- tests/Lua.Tests/HookArgumentTests.cs | 17 ++++--- 3 files changed, 32 insertions(+), 77 deletions(-) diff --git a/src/Lua/LuaState.cs b/src/Lua/LuaState.cs index c845db34..54a9801d 100644 --- a/src/Lua/LuaState.cs +++ b/src/Lua/LuaState.cs @@ -301,6 +301,7 @@ public ReadOnlySpan GetCallStackFrames() return CoreData == null ? default : CoreData!.CallStack.AsSpan(); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] internal CallStackFrame CreateCallStackFrame( LuaFunction function, int argumentCount, @@ -581,39 +582,8 @@ public async ValueTask RunAsync( try { state.ThrowIfCancellationRequested(cancellationToken); - var varArgumentCount = function.GetArgumentAdjustment(argumentCount); - if (varArgumentCount != 0) - { - if (varArgumentCount < 0) - { - state.Stack.SetTop(state.Stack.Count - varArgumentCount); - argumentCount -= varArgumentCount; - varArgumentCount = 0; - } - else - { - LuaVirtualMachine.PrepareVariableArgument( - state.Stack, - argumentCount, - varArgumentCount - ); - } - - argumentCount -= varArgumentCount; - } - - CallStackFrame frame = new() - { - Base = state.Stack.Count - argumentCount, - VariableArgumentCount = varArgumentCount, - Function = function, - ReturnBase = returnBase, - }; - if (state.IsInHook) - { - frame.Flags |= CallStackFrameFlags.InHook; - } - + var frame = state.CreateCallStackFrame(function, argumentCount, returnBase, 0); + argumentCount = state.Stack.Count - frame.Base; state.PushCallStackFrame(frame); LuaFunctionExecutionContext context = new() { diff --git a/src/Lua/Runtime/LuaVirtualMachine.Debug.cs b/src/Lua/Runtime/LuaVirtualMachine.Debug.cs index 2229b339..b562532e 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.Debug.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.Debug.cs @@ -37,8 +37,7 @@ static async ValueTask Impl(VirtualMachineExecutionContext context) var stack = context.State.Stack; var top = stack.Count; - stack.Push("count"); - stack.Push(LuaValue.Nil); + stack.PushRange("count", LuaValue.Nil); context.State.IsInHook = true; try { @@ -84,8 +83,7 @@ static async ValueTask Impl(VirtualMachineExecutionContext context) var hook = context.State.Hook!; var stack = context.State.Stack; var top = stack.Count; - stack.Push("line"); - stack.Push(line); + stack.PushRange("line", line); context.State.IsInHook = true; try { @@ -148,7 +146,6 @@ internal static async ValueTask ExecuteCallHook( ) { var argCount = context.ArgumentCount; - var hook = context.State.Hook!; var stack = context.State.Stack; { ref readonly var frame = ref context.State.GetCurrentFrame(); @@ -157,34 +154,12 @@ internal static async ValueTask ExecuteCallHook( } if (context.State.IsCallHookEnabled) { - var top = stack.Count; - stack.Push(isTailCall ? "tail call" : "call"); - - stack.Push(LuaValue.Nil); - context.State.IsInHook = true; - var framePushed = false; - try - { - var frame = context.State.CreateCallStackFrame(hook, 2, top, 0); - context.State.PushCallStackFrame(frame); - framePushed = true; - LuaFunctionExecutionContext funcContext = new() - { - State = context.State, - ArgumentCount = stack.Count - frame.Base, - ReturnFrameBase = frame.ReturnBase, - }; - await hook.Func(funcContext, cancellationToken); - } - finally - { - context.State.IsInHook = false; - // A failed push leaves the caller's frame on top; do not pop it. - if (framePushed) - { - context.State.PopCallStackFrameWithStackPop(); - } - } + await ExecuteCallOrReturnHook( + context.State, + isTailCall ? "tail call" : "call", + 0, + cancellationToken + ); } context.State.ThrowIfCancellationRequested(cancellationToken); @@ -212,7 +187,7 @@ internal static async ValueTask ExecuteCallHook( } context.State.ThrowIfCancellationRequested(cancellationToken); - await ExecuteReturnHook(context.State, 0, cancellationToken); + await ExecuteCallOrReturnHook(context.State, "return", 0, cancellationToken); return r; } } @@ -222,7 +197,12 @@ static bool ExecuteReturnHook(VirtualMachineExecutionContext context) { // Return hooks inspect the callee before its registers are reclaimed. context.PostOperation = PostOperationType.Return; - var task = ExecuteReturnHook(context.State, context.Pc, context.CancellationToken); + var task = ExecuteCallOrReturnHook( + context.State, + "return", + context.Pc, + context.CancellationToken + ); if (!task.IsCompleted) { context.Task = task; @@ -234,8 +214,9 @@ static bool ExecuteReturnHook(VirtualMachineExecutionContext context) } [AsyncMethodBuilder(typeof(LightAsyncValueTaskMethodBuilder<>))] - static async ValueTask ExecuteReturnHook( + static async ValueTask ExecuteCallOrReturnHook( LuaState state, + string eventName, int pc, CancellationToken cancellationToken ) @@ -243,8 +224,7 @@ CancellationToken cancellationToken var hook = state.Hook!; var stack = state.Stack; var top = stack.Count; - stack.Push("return"); - stack.Push(LuaValue.Nil); + stack.PushRange(eventName, LuaValue.Nil); state.IsInHook = true; var framePushed = false; try diff --git a/tests/Lua.Tests/HookArgumentTests.cs b/tests/Lua.Tests/HookArgumentTests.cs index a56dc392..c052f591 100644 --- a/tests/Lua.Tests/HookArgumentTests.cs +++ b/tests/Lua.Tests/HookArgumentTests.cs @@ -18,12 +18,17 @@ LuaState CreateState() return state; } - [Test] - [Combinatorial] - public async Task LuaHooksReceiveFixedAndVariableArguments( - [Values(0, 1, 2, 3)] int fixedParameters, - [Values("c", "r", "l", "")] string mask - ) + [TestCase(0, "c")] + [TestCase(1, "c")] + [TestCase(2, "c")] + [TestCase(3, "c")] + [TestCase(0, "r")] + [TestCase(3, "r")] + [TestCase(0, "l")] + [TestCase(3, "l")] + [TestCase(0, "")] + [TestCase(3, "")] + public async Task LuaHooksReceiveFixedAndVariableArguments(int fixedParameters, string mask) { using var state = CreateState(); var calls = new List(); From 2d825346e4e006e82c207a9a27eec092affa0c91 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:26:09 +0900 Subject: [PATCH 29/32] perf: avoid temporary strings when reading stream numbers Parse the pooled character span before returning its buffer, and return the nullable number directly to LuaStream. This removes one temporary string and the redundant null/parse handling while preserving the existing scanner, numeric parser, and stream positioning behavior. Paired .NET 10 samples against 3be7afc show numeric reads about 9-10% faster and 16 KiB less allocation per 512 short numbers in either string mode. Combined validation: all 2,569 tests pass on each of .NET 8 and .NET 10, all four runtime targets and the benchmark project build without warnings, and formatting and diff checks pass. --- src/Lua/IO/LuaStream.cs | 11 +---------- src/Lua/Internal/Utf8Reader.cs | 4 ++-- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/Lua/IO/LuaStream.cs b/src/Lua/IO/LuaStream.cs index db676ad9..71452bd3 100644 --- a/src/Lua/IO/LuaStream.cs +++ b/src/Lua/IO/LuaStream.cs @@ -85,16 +85,7 @@ public ValueTask ReadByteAsync(CancellationToken cancellationToken) mode.ThrowIfNotReadable(); reader ??= new(stringMode); - // Use the Utf8Reader's ReadNumber method which handles positioning correctly - var numberStr = reader.ReadNumber(innerStream); - if (numberStr == null) - { - return new((double?)null); - } - - // Parse using the shared utility - var result = NumberReaderHelper.ParseNumber(numberStr.AsSpan()); - return new(result); + return new(reader.ReadNumber(innerStream)); } public ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken) diff --git a/src/Lua/Internal/Utf8Reader.cs b/src/Lua/Internal/Utf8Reader.cs index 931f237a..b85aed7c 100644 --- a/src/Lua/Internal/Utf8Reader.cs +++ b/src/Lua/Internal/Utf8Reader.cs @@ -194,7 +194,7 @@ public void Clear() bufLen = 0; } - public string? ReadNumber(Stream stream) + public double? ReadNumber(Stream stream) { var resultBuffer = ArrayPool.Shared.Rent(64); // Numbers shouldn't be too long var len = 0; @@ -331,7 +331,7 @@ public void Clear() } } - return len == 0 ? null : resultBuffer.AsSpan(0, len).ToString(); + return len == 0 ? null : NumberReaderHelper.ParseNumber(resultBuffer.AsSpan(0, len)); } finally { From 806beb443979fc280b41de3913f1b33a19194e7f Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:05:43 +0900 Subject: [PATCH 30/32] perf: bulk copy serialized instruction and line arrays Read instruction and line-info arrays through one checked block copy, then reverse their words only for the opposite byte order. Keep all serialized length and bytecode validation at the input boundary. Avoid invoking the same operand verifier through two APIs for every invalid instruction case. Keep both caller contexts for inferred upvalue initialization and test the context-independent explicit environment once per string mode. Paired .NET 10 samples against 2d82534 show medium/large bytecode loads about 19-30% faster, with unchanged allocation. Tiny chunks remain broadly unchanged. The attempted upvalue accessor merge was discarded after profiling; final upvalue controls stay within 1% of baseline. Validation: all 2,567 runtime tests pass on each of .NET 8 and .NET 10, including both byte orders and malformed chunks. All four runtime targets and the benchmark project build without warnings; formatting and diff checks pass. --- src/Lua/CodeAnalysis/Compilation/Dump.cs | 6 ++++-- tests/Lua.Tests/BytecodeValidationTests.cs | 1 - tests/Lua.Tests/ClosureBindingTests.cs | 1 - 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Lua/CodeAnalysis/Compilation/Dump.cs b/src/Lua/CodeAnalysis/Compilation/Dump.cs index 86f4163f..4811b82b 100644 --- a/src/Lua/CodeAnalysis/Compilation/Dump.cs +++ b/src/Lua/CodeAnalysis/Compilation/Dump.cs @@ -457,9 +457,11 @@ Prototype UndumpFunction(int depth) void ReadInToIntSpan(Span toWrite) { - for (var i = 0; i < toWrite.Length; i++) + Read(MemoryMarshal.AsBytes(toWrite)); + if (otherEndian) { - toWrite[i] = ReadInt(); + foreach (ref var value in toWrite) + value = BinaryPrimitives.ReverseEndianness(value); } } diff --git a/tests/Lua.Tests/BytecodeValidationTests.cs b/tests/Lua.Tests/BytecodeValidationTests.cs index 0c64fd66..5623e64c 100644 --- a/tests/Lua.Tests/BytecodeValidationTests.cs +++ b/tests/Lua.Tests/BytecodeValidationTests.cs @@ -88,7 +88,6 @@ static IEnumerable InvalidInstructions() public void RejectsInvalidOperandsBeforeExecution(Instruction[] code) { var bytes = Create(code).ToBytecode(); - Assert.Throws(() => Prototype.FromBytecode(bytes, "invalid")); using var state = LuaState.Create(); Assert.Throws(() => state.Load(bytes)); } diff --git a/tests/Lua.Tests/ClosureBindingTests.cs b/tests/Lua.Tests/ClosureBindingTests.cs index 7efc7cb0..a5d827a6 100644 --- a/tests/Lua.Tests/ClosureBindingTests.cs +++ b/tests/Lua.Tests/ClosureBindingTests.cs @@ -84,7 +84,6 @@ public async Task CurrentEnvironmentCanFollowAnotherCapturedVariable() [TestCase(false, false)] [TestCase(true, false)] [TestCase(false, true)] - [TestCase(true, true)] public async Task ReloadedFunctionInitializesAllUpvaluesWithoutCapturingCaller( bool duringCall, bool explicitEnvironment From 9ead679cd8a2a88382ca1c0748c03c2bccafd203 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:05:43 +0900 Subject: [PATCH 31/32] refactor: remove redundant metatable emission failure path Make metatable emission a void operation, remove its unused production context, and call it directly. The routine always succeeded and emitted no diagnostics; this preserves its generated code while simplifying the caller. Validation: all 69 source-generator tests pass. The full runtime suite passes on .NET 8 and .NET 10, including generated userdata behavior and lifetime tests. --- src/Lua.SourceGenerator/LuaObjectGenerator.Emit.cs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/Lua.SourceGenerator/LuaObjectGenerator.Emit.cs b/src/Lua.SourceGenerator/LuaObjectGenerator.Emit.cs index 9b979644..fc3fc078 100644 --- a/src/Lua.SourceGenerator/LuaObjectGenerator.Emit.cs +++ b/src/Lua.SourceGenerator/LuaObjectGenerator.Emit.cs @@ -236,10 +236,7 @@ TempCollections tempCollections metamethodSet.Add(LuaObjectMetamethod.NewIndex); - if (!TryEmitMetatable(builder, metamethodSet, context)) - { - return false; - } + EmitMetatable(builder, metamethodSet); // implicit operator builder.AppendLine( @@ -1342,11 +1339,7 @@ static void EmitMethodFunctionBody( } } - static bool TryEmitMetatable( - CodeBuilder builder, - IEnumerable metamethods, - in SourceProductionContext context - ) + static void EmitMetatable(CodeBuilder builder, IEnumerable metamethods) { builder.AppendLine("global::Lua.LuaTable? global::Lua.ILuaUserData.Metatable"); using (builder.BeginBlockScope()) @@ -1415,7 +1408,5 @@ in SourceProductionContext context "static readonly global::Lua.LuaTable __metatable = __CreateMetatable();" ); builder.AppendLine(); - - return true; } } From a0a933dd06a8b8fa3da7d70a335929b612d85942 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:48:45 +0900 Subject: [PATCH 32/32] chore: format and document runtime benchmarks Format the benchmark source-generator reference for the CI formatter and document the coroutine transfer benchmark alongside generated userdata. Validation: Release solution build succeeds without warnings; all 2,567 runtime tests pass on each of .NET 8 and .NET 10, and all 69 generator tests pass. CSharpier checks all 340 files and benchmark --verify passes. --- sandbox/Benchmark/Benchmark.csproj | 6 +++++- sandbox/Benchmark/README.md | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/sandbox/Benchmark/Benchmark.csproj b/sandbox/Benchmark/Benchmark.csproj index 5fb5bbf2..dd064769 100644 --- a/sandbox/Benchmark/Benchmark.csproj +++ b/sandbox/Benchmark/Benchmark.csproj @@ -16,7 +16,11 @@ - + diff --git a/sandbox/Benchmark/README.md b/sandbox/Benchmark/README.md index 410d8b3c..9f441ce1 100644 --- a/sandbox/Benchmark/README.md +++ b/sandbox/Benchmark/README.md @@ -26,6 +26,7 @@ measure Unicode encoding or arbitrary binary strings. | `CompilationComparison` | Compile source in an existing state; no execution | Small loop, n-body, control flow, identifiers, plain strings, escaped strings | | `StartupComparison` | Create a state, open libraries, dispose where supported | One lifecycle per engine | | `GeneratedUserDataBenchmark` | Execute 1,000 generated C# member accesses or comparisons; report time per operation | Property reads/writes, method calls, comparisons, with default and explicitly replaced metatables | +| `CoroutineTransferBenchmark` | Transfer 64 yielded packets; report time per transfer | `resume` and `wrap`, 1–4,096 results per packet, with and without cancellation | Only the engine being measured is initialized for a benchmark. Execution excludes source compilation and state setup. Scripts rebuild their mutable @@ -54,6 +55,7 @@ dotnet bin/Release/net10.0/Benchmark.dll --filter '*InterpreterComparison*host-c dotnet bin/Release/net10.0/Benchmark.dll --filter '*CompilationComparison*' dotnet bin/Release/net10.0/Benchmark.dll --filter '*StartupComparison*' dotnet bin/Release/net10.0/Benchmark.dll --filter '*GeneratedUserDataBenchmark*' +dotnet bin/Release/net10.0/Benchmark.dll --filter '*CoroutineTransferBenchmark*' dotnet bin/Release/net10.0/Benchmark.dll --legacy --filter '*GSubBenchmark*' ```