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..dd064769 100644 --- a/sandbox/Benchmark/Benchmark.csproj +++ b/sandbox/Benchmark/Benchmark.csproj @@ -15,6 +15,12 @@ + + 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/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..bcfce358 100644 --- a/sandbox/Benchmark/Program.cs +++ b/sandbox/Benchmark/Program.cs @@ -29,6 +29,8 @@ typeof(InterpreterComparison), typeof(CompilationComparison), typeof(StartupComparison), + typeof(GeneratedUserDataBenchmark), + typeof(CoroutineTransferBenchmark), ]) .Run(args); diff --git a/sandbox/Benchmark/README.md b/sandbox/Benchmark/README.md index d45b9fab..9f441ce1 100644 --- a/sandbox/Benchmark/README.md +++ b/sandbox/Benchmark/README.md @@ -25,6 +25,8 @@ 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 | +| `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 @@ -52,6 +54,8 @@ 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 --filter '*CoroutineTransferBenchmark*' 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 567b89b3..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,62 +1339,74 @@ 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()) - { - builder.AppendLine("get => Metatable;"); - builder.AppendLine("set => Metatable = value;"); - } - builder.AppendLine("static global::Lua.LuaTable? Metatable"); - using (builder.BeginBlockScope()) { builder.AppendLine("get"); 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," + "var overrides = global::System.Threading.Volatile.Read(ref __metatableOverrides);" ); builder.AppendLine( - "// including a custom metatable installed while initialization was in progress." + "return overrides != null && overrides.TryGetValue(this, out var entry) ? entry.Value : __metatable;" ); + } + builder.AppendLine("set"); + using (builder.BeginBlockScope()) + { 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("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("metatable.Freeze();"); + 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::Lua.LuaTable __metatable = __CreateMetatable();" + ); builder.AppendLine(); - - return true; } } 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..4811b82b 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,44 +451,49 @@ Prototype UndumpFunction() localVariables, upValues ); + new BytecodeVerifier(prototype, name).Verify(); + return prototype; } 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); } } 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 +503,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 +513,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 +521,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 +546,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 +566,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/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/Exceptions.cs b/src/Lua/Exceptions.cs index 82555a12..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; } @@ -328,7 +326,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( @@ -341,25 +339,28 @@ 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 { - var caller = state.GetCallStackFrames()[^2]; - callerFunction = caller.Function; + var frames = state.GetCallStackFrames(); + if (frames.Length < 2) + return ("function", current.Function.Name); + var caller = frames[^2]; + 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 ); } @@ -487,7 +488,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; } @@ -629,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/IO/LuaStream.cs b/src/Lua/IO/LuaStream.cs index 7bdbfabc..71452bd3 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) @@ -76,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) @@ -190,8 +190,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() @@ -209,12 +210,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 e1c84cc7..52722715 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; @@ -14,67 +15,83 @@ 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); } + 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; - + LuaStack? yieldStack; + int yieldReturnBase; + CancellationTokenSource? executionSource; + CancellationToken executionToken; + CancellationScope? cancellation; ManualResetValueTaskSourceCore resume; ManualResetValueTaskSourceCore yield; Traceback? traceback; - public bool IsProtectedMode { get; private set; } = isProtectedMode; - public LuaFunction Function { get; private set; } = function; - public LuaState Thread { get; private set; } = state; + public bool IsProtectedMode { get; } = isProtectedMode; + public LuaFunction? Function { get; private set; } = function; + 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( @@ -82,143 +99,156 @@ 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 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: - Volatile.Write(ref status, (byte)LuaThreadStatus.Running); - - if (!isFirstCall) - { - yield.SetResult(new(stack, argCount)); - } - - 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"); - } + 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..]); } - - ValueTask resumeTask = new(this, resume.Version); - - CancellationTokenRegistration registration = default; - if (cancellationToken.CanBeCanceled) + catch (Exception error) { - registration = cancellationToken.UnsafeRegister( - static x => - { - var coroutine = (CoroutineCore)x!; - coroutine.yield.SetException(new OperationCanceledException()); - }, - this - ); + ReleaseTransfer(Preparing); + if (error is LuaStackOverflowException) + return ResumeError( + stack, + returnBase, + baseThread, + "too many arguments to resume", + includeStatus + ); + throw; } + 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 { + Exception? cancellationFailure = null; 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); - Volatile.Write(ref isFirstCall, false); - if (!functionTask.IsCompleted) + 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 + { + executionSource!.Cancel(); + } + catch (Exception failure) { - // UnsafeOnCompleted: OnCompleted would capture SyncContext and deadlock on WebGL. - functionTask - .GetAwaiter() - .UnsafeOnCompleted(() => resume.SetResult(default)); + cancellationFailure = failure; } + yield.SetException(error); } + else + yield.SetResult(new(stack, argCount)); - ResumeContext result0; - if (functionTask.IsCompleted || (result0 = await resumeTask).IsDead) + ResumeContext result; + try { - _ = functionTask.Result; - Volatile.Write(ref status, (byte)LuaThreadStatus.Dead); - stack.PopUntil(returnBase); - stack.Push(true); - stack.PushRange(Thread.Stack.AsSpan()); - Thread.Dispose(); - return stack.Count - returnBase; + result = await resumeTask; } - else + catch (Exception error) { - Volatile.Write(ref status, (byte)LuaThreadStatus.Suspended); - var results = result0.Results; + if (cancellationFailure != null) + ExceptionDispatchInfo.Capture(cancellationFailure).Throw(); + if (!IsProtectedMode || error is OperationCanceledException) + throw; stack.PopUntil(returnBase); - stack.Push(true); - stack.PushRange(results); - return results.Length + 1; + 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; } - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - if (IsProtectedMode) + try { - if (ex is ILuaTracebackBuildable tracebackBuildable) + var results = result.IsDead ? Thread.Stack.AsSpan() : result.Results; + stack.PopUntil(returnBase); + if (includeStatus) + stack.PushRange(true, results); + else { - traceback = tracebackBuildable.BuildOrGet(); + // 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); } - - 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 results.Length + (includeStatus ? 1 : 0); + } + catch (LuaStackOverflowException) + { + return ResumeError( + stack, + returnBase, + baseThread, + "too many results to resume", + includeStatus ); - return 2; } - else + finally { - throw; + if (!result.IsDead) + result.ClearResults(); } } finally { - registration.Dispose(); resume.Reset(); + if (Volatile.Read(ref handoff) == Completed) + FinishExecution(); + else + ReleaseTransfer(Copying); } } finally @@ -231,6 +261,179 @@ 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() + { + 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 RequestCancellation() + { + while (true) + { + var phase = Volatile.Read(ref handoff); + var next = phase switch + { + Preparing or Running or Copying => phase | CancelPending, + Suspended => Copying | CancelPending, + _ => phase, + }; + if (next == phase) + return; + if (Interlocked.CompareExchange(ref handoff, next, phase) != phase) + continue; + if (phase == Suspended) + CompleteCancellation(next); + else if (phase == Running) + executionSource!.Cancel(); + return; + } + } + + void CompleteCancellation(int phase) + { + var error = new OperationCanceledException(GetCancellationOrigin(executionToken)); + try + { + executionSource!.Cancel(); + } + finally + { + yield.SetException(error); + if (Interlocked.CompareExchange(ref handoff, Cancelling, phase) == Completed) + FinishExecution(); + } + } + + CancellationScope EnsureCancellation() => cancellation ??= new(this); + + void SetResumeToken(CancellationToken token) + { + 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(CoroutineCore owner) : IDisposable + { + CancellationToken lifetimeToken; + CancellationToken resumeToken; + internal CancellationToken YieldToken; + CancellationTokenRegistration lifetimeRegistration; + CancellationTokenRegistration resumeRegistration; + + internal CancellationToken GetOrigin(CancellationToken token) => + token != owner.executionToken && token.IsCancellationRequested ? token + : lifetimeToken.IsCancellationRequested ? lifetimeToken + : resumeToken.IsCancellationRequested ? resumeToken + : YieldToken.IsCancellationRequested ? YieldToken + : token; + + internal void SetResumeToken(CancellationToken token, bool first) + { + if (token == resumeToken) + return; + resumeRegistration.Dispose(); + resumeRegistration = default; + resumeToken = token; + if (first) + { + lifetimeToken = token; + lifetimeRegistration = Register(token); + } + else if (token != lifetimeToken && token != owner.executionToken) + resumeRegistration = Register(token); + } + + 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() + { + lifetimeRegistration.Dispose(); + resumeRegistration.Dispose(); + lifetimeRegistration = resumeRegistration = default; + lifetimeToken = resumeToken = YieldToken = default; + } + } + [AsyncMethodBuilder(typeof(LightAsyncValueTaskMethodBuilder<>))] internal async ValueTask YieldAsyncCore( LuaStack stack, @@ -241,52 +444,54 @@ 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) { - 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, "attempt to yield across a C#-call boundary" ); - } } - - resume.SetResult(new(stack, argCount)); - + cancellationToken.ThrowIfCancellationRequested(); + Thread.ThrowIfCancellationRequested(executionToken); CancellationTokenRegistration registration = default; - if (cancellationToken.CanBeCanceled) - { - registration = cancellationToken.UnsafeRegister( - static x => - { - var coroutine = (CoroutineCore)x!; - coroutine.yield.SetException(new OperationCanceledException()); - }, - this - ); - } - - RETRY: + var published = false; + if (Interlocked.CompareExchange(ref handoff, Copying, Running) != Running) + throw new LuaCanceledException(Thread, GetCancellationOrigin(executionToken)); try { + Thread.SuspendUpValues(); + yieldStack = stack; + yieldReturnBase = returnBase; + if (cancellationToken.CanBeCanceled && cancellationToken != executionToken) + registration = EnsureCancellation().RegisterYieldToken(cancellationToken); + published = true; + resume.SetResult(new(stack, argCount)); var result = await new ValueTask(this, yield.Version); + Thread.ThrowIfCancellationRequested(executionToken); + Thread.ResumeUpValues(); stack.PopUntil(returnBase); stack.PushRange(result.Results); return result.Results.Length; } - catch (Exception ex) when (ex is not OperationCanceledException) + catch { - yield.Reset(); - goto RETRY; + if (!published) + { + Thread.ResumeUpValues(); + if (Interlocked.Exchange(ref handoff, Running) == (Copying | CancelPending)) + RequestCancellation(); + } + throw; } finally { registration.Dispose(); + yieldStack = null; + if (cancellation != null) + cancellation.YieldToken = default; yield.Reset(); } } 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/LuaGlobalState.cs b/src/Lua/Internal/LuaGlobalState.cs index 34b1f799..c3983ebc 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; @@ -42,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) { @@ -53,7 +53,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/Internal/LuaValueDictionary.cs b/src/Lua/Internal/LuaValueDictionary.cs index e57449e8..7c6db950 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; } @@ -126,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; @@ -189,7 +192,8 @@ void Insert(LuaValue key, LuaValue value) { if (value.Type is LuaValueType.Nil) { - _nilCount++; + Delete(key); + return; } if (_buckets == null) @@ -213,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) { @@ -260,15 +253,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 +278,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 +288,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) @@ -338,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) { @@ -413,8 +430,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 +449,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 (entry.KeyEquals(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,8 +483,33 @@ 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 { + [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/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/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/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/Internal/Utf8Reader.cs b/src/Lua/Internal/Utf8Reader.cs index ca3a78da..b85aed7c 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,173 +22,97 @@ 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; + 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) - { - bufLen = stream.Read(buffer, 0, buffer.Length); - bufPos = !byteStrings && buffer.AsSpan().StartsWith(BomUtility.BomUtf8) ? 3 : 0; - if (bufLen <= bufPos) - { - break; // EOF - } - } + if (bufPos >= bufLen && !ReadTextBuffer(stream)) + break; - 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'); + 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) + { + 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') - { - isCRLF = true; - bufPos++; // Skip the \n as well - } - - // Add end-of-line characters if keepEol is true - if (keepEol) + // Preserve the segment before looking for LF in the next buffer. + result.AddRange(span[..count]); + if (PeekByte(stream) == '\n') { - 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 - ); - } + bufPos++; + if (keepEol) + result.Add((byte)'\n'); } - - 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().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 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) @@ -219,15 +135,8 @@ public byte ReadByte(Stream stream) { while (len < charCount) { - if (bufPos >= bufLen) - { - bufLen = stream.Read(buffer, 0, buffer.Length); - bufPos = !byteStrings && buffer.AsSpan().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); @@ -277,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() @@ -300,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; @@ -437,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 { @@ -476,6 +370,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/LuaState.cs b/src/Lua/LuaState.cs index 884ee884..54a9801d 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 @@ -187,6 +205,7 @@ public void Release() } FastListCore openUpValues; + WeakReference? suspendedReference; internal int CallCount; internal LuaGlobalState GlobalState { get; } ThreadCoreData? CoreData; @@ -200,7 +219,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; @@ -209,6 +228,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; @@ -281,6 +301,7 @@ public ReadOnlySpan GetCallStackFrames() return CoreData == null ? default : CoreData!.CallStack.AsSpan(); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] internal CallStackFrame CreateCallStackFrame( LuaFunction function, int argumentCount, @@ -289,7 +310,7 @@ int callerInstructionIndex ) { var state = this; - var varArgumentCount = function.GetVariableArgumentCount(argumentCount); + var varArgumentCount = function.GetArgumentAdjustment(argumentCount); if (varArgumentCount != 0) { if (varArgumentCount < 0) @@ -306,6 +327,8 @@ int callerInstructionIndex varArgumentCount ); } + + argumentCount -= varArgumentCount; } CallStackFrame frame = new() @@ -368,6 +391,7 @@ internal void PopCallStackFrameWithStackPop() if (CurrentException != null) { ExceptionTrace.Push(popFrame); + CompleteExceptionTrace(callStack.Count); } coreData.Stack.PopUntil(popFrame.ReturnBase); @@ -382,6 +406,7 @@ internal void PopCallStackFrameWithStackPop(int frameBase) if (CurrentException != null) { ExceptionTrace.Push(popFrame); + CompleteExceptionTrace(callStack.Count); } { @@ -398,6 +423,7 @@ internal void PopCallStackFrame() if (CurrentException != null) { ExceptionTrace.Push(popFrame); + CompleteExceptionTrace(callStack.Count); } } @@ -409,29 +435,41 @@ 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(); 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; @@ -537,51 +575,22 @@ public async ValueTask RunAsync( throw new ArgumentNullException(nameof(function)); } - this.ThrowIfCancellationRequested(cancellationToken); var state = this; - var varArgumentCount = function.GetVariableArgumentCount(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; - } - - state.PushCallStackFrame(frame); - LuaFunctionExecutionContext context = new() - { - State = state, - ArgumentCount = argumentCount, - ReturnFrameBase = returnBase, - }; var callStackTop = state.CallStackFrameCount; + var previousCallerPrototype = state.LastCallerPrototype; + var previousPc = state.LastPc; try { + state.ThrowIfCancellationRequested(cancellationToken); + var frame = state.CreateCallStackFrame(function, argumentCount, returnBase, 0); + argumentCount = state.Stack.Count - frame.Base; + state.PushCallStackFrame(frame); + LuaFunctionExecutionContext context = new() + { + State = state, + ArgumentCount = argumentCount, + ReturnFrameBase = returnBase, + }; if (CallOrReturnHookMask.Value != 0 && !IsInHook) { return await LuaVirtualMachine.ExecuteCallHook(context, cancellationToken); @@ -589,9 +598,16 @@ public async ValueTask RunAsync( return await function.Func(context, cancellationToken); } + catch + { + Stack.PopUntil(returnBase); + throw; + } finally { - PopCallStackFrameUntil(callStackTop - 1); + PopCallStackFrameUntil(callStackTop); + LastCallerPrototype = previousCallerPrototype; + LastPc = previousPc; } } @@ -723,10 +739,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; } } @@ -751,6 +769,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/LuaStateExtensions.cs b/src/Lua/LuaStateExtensions.cs index 5206b5b5..cf9836b4 100644 --- a/src/Lua/LuaStateExtensions.cs +++ b/src/Lua/LuaStateExtensions.cs @@ -489,10 +489,10 @@ 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) { - 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/LuaTable.cs b/src/Lua/LuaTable.cs index d2585e5d..dd6212ee 100644 --- a/src/Lua/LuaTable.cs +++ b/src/Lua/LuaTable.cs @@ -61,6 +61,12 @@ public LuaValue this[LuaValue key] [MethodImpl(MethodImplOptions.AggressiveInlining)] set { + ThrowIfReadOnly(); + if (key.Type is LuaValueType.Nil) + { + ThrowIndexIsNil(); + } + if (key.TryReadNumber(out var d)) { if (double.IsNaN(d)) @@ -134,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)] @@ -158,43 +187,35 @@ 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 FindValue(LuaValue key) + internal ref LuaValue FindValueForWrite(LuaValue key) { + ThrowIfReadOnly(); if (key.Type is LuaValueType.Nil) { 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; - } + public bool ContainsKey(LuaValue key) => TryGetValue(key, out _); - 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; + [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) { + ThrowIfReadOnly(); var arrayIndex = index - 1; var value = array[arrayIndex]; @@ -211,6 +232,7 @@ public LuaValue RemoveAt(int index) public void Insert(int index, LuaValue value) { + ThrowIfReadOnly(); var luaArrayLength = ArrayLength; if (index <= 0 || index > luaArrayLength + 1) { @@ -266,6 +288,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) { @@ -291,6 +314,7 @@ internal bool TryInsertWithinArray(int index, int length, LuaValue value) return false; } + ThrowIfReadOnly(); if (length + 1 >= array.Length) { EnsureArrayCapacity(length + 1); @@ -316,6 +340,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)); @@ -375,6 +400,7 @@ public bool TryGetNext(LuaValue key, out KeyValuePair pair) public void Clear() { + ThrowIfReadOnly(); array.AsSpan().Clear(); dictionary.Clear(); knownArrayPrefixLength = 0; @@ -383,17 +409,29 @@ public void Clear() public Memory GetArrayMemory() { + ThrowIfReadOnly(); hasWritableArrayReferences = true; return array.AsMemory(); } public Span GetArraySpan() { + ThrowIfReadOnly(); hasWritableArrayReferences = true; return array.AsSpan(); } - internal void EnsureArrayCapacity(int newCapacity) + public ReadOnlySpan GetArrayReadOnlySpan() => array; + + 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 ( @@ -465,6 +503,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/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/LuaValue.cs b/src/Lua/LuaValue.cs index ae3e4e89..cba8fb21 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!; @@ -606,9 +611,11 @@ 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(), - _ => referenceValue!.GetHashCode(), + _ => RuntimeHelpers.GetHashCode(referenceValue!), }; } diff --git a/src/Lua/Runtime/Instruction.cs b/src/Lua/Runtime/Instruction.cs index a1531e7c..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, - SBx = sBx, + Bx = bx, }; } diff --git a/src/Lua/Runtime/LuaClosure.cs b/src/Lua/Runtime/LuaClosure.cs index 9d13d675..55e7e08c 100644 --- a/src/Lua/Runtime/LuaClosure.cs +++ b/src/Lua/Runtime/LuaClosure.cs @@ -1,55 +1,60 @@ 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 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) + { + if (proto.UpValues.IsEmpty) return; - } - var baseIndex = state.GetCallStackFrames()[^1].Base; + 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); + } - // 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 +77,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/LuaStack.cs b/src/Lua/Runtime/LuaStack.cs index 5a60a3d9..43070dac 100644 --- a/src/Lua/Runtime/LuaStack.cs +++ b/src/Lua/Runtime/LuaStack.cs @@ -74,6 +74,34 @@ 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) + { + 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.Debug.cs b/src/Lua/Runtime/LuaVirtualMachine.Debug.cs index 6e55ef4b..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 { @@ -66,14 +65,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) @@ -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,88 +146,106 @@ 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; - 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); { - 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) + 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 - { - 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 ExecuteCallOrReturnHook(context.State, "return", 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 = ExecuteCallOrReturnHook( + context.State, + "return", + 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 ExecuteCallOrReturnHook( + LuaState state, + string eventName, + int pc, + CancellationToken cancellationToken + ) + { + var hook = state.Hook!; + var stack = state.Stack; + var top = stack.Count; + stack.PushRange(eventName, 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 54b4a946..2ab9174d 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.cs @@ -34,12 +34,9 @@ void Init(LuaState state, in CallStackFrame frame, CancellationToken cancellatio { Stack = state.Stack; State = state; - LuaClosure = (LuaClosure)frame.Function; - FrameBase = frame.Base; - VariableArgumentCount = frame.VariableArgumentCount; - CurrentReturnFrameBase = frame.ReturnBase; + Push(frame); + InitialArgumentBase = frame.Base - frame.VariableArgumentCount; CancellationToken = cancellationToken; - Pc = -1; Instruction = default; PostOperation = PostOperationType.None; BaseCallStackCount = state.CallStackFrameCount; @@ -56,6 +53,7 @@ void Init(LuaState state, in CallStackFrame frame, CancellationToken cancellatio public Prototype Prototype => LuaClosure.Proto; public int FrameBase; + public int InitialArgumentBase; public int VariableArgumentCount; public CancellationToken CancellationToken; public int Pc; @@ -124,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) { @@ -138,7 +137,7 @@ public bool PopFromBuffer(int src, int srcCount) Pc++; } - State.PopCallStackFrameWithStackPop(); + State.PopCallStackFrameWithStackPop(argumentBase); return true; } @@ -165,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; } @@ -202,6 +199,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() @@ -258,6 +256,9 @@ CallStackFrameFlags flags } break; + case PostOperationType.Return: + PostOperation = PostOperationType.None; + return Pop(Instruction, FrameBase); case PostOperationType.Self: SelfPostOperation(this, lastTop, resultsSpan); break; @@ -287,7 +288,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(); @@ -307,15 +312,22 @@ is not (PostOperationType.TailCall or PostOperationType.DontPop) } catch (Exception e) { + // FrameBase follows nested calls; all discarded frames need closing. + State.CloseUpValues(InitialArgumentBase); if (toCatchFlag) { - State.CloseUpValues(FrameBase); - if (e is not (LuaRuntimeException or LuaCanceledException)) + if ( + 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; } @@ -327,6 +339,11 @@ e is OperationCanceledException } finally { + Stack = null!; + State = null!; + LuaClosure = null!; + Task = default; + CancellationToken = default; pool.TryPush(this); } } @@ -357,6 +374,7 @@ enum PostOperationType TForCall, Call, TailCall, + Return, Self, Compare, DontPop, @@ -535,13 +553,17 @@ 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 ) { - valueRef = RKC(ref stackHead, ref constHead, instruction); + luaTable.SetExistingValue( + vb, + ref valueRef, + RKC(ref stackHead, ref constHead, instruction) + ); continue; } } @@ -951,6 +973,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; @@ -1042,7 +1070,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); @@ -1093,13 +1123,19 @@ static void VarArg(VirtualMachineExecutionContext context) } catch (Exception e) { - context.State.CloseUpValues(context.FrameBase); - if (e is not (LuaRuntimeException or LuaCanceledException)) + context.State.CloseUpValues(context.InitialArgumentBase); + if ( + 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; } @@ -1229,26 +1265,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. @@ -1351,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; @@ -1655,12 +1676,16 @@ static bool TailCall(VirtualMachineExecutionContext context, out bool doRestart) newBase, isMetamethod ); - newBase = context.FrameBase + 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.LastCallerFunction = lastFrame.Function; + state.LastCallerPrototype = context.Prototype; var newFrame = func.CreateNewTailCallFrame( context, newBase, @@ -1670,7 +1695,10 @@ 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!; if (state.CallOrReturnHookMask.Value != 0 && !context.State.IsInHook) { @@ -1846,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); } @@ -2186,24 +2213,18 @@ 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) { - 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; } @@ -2343,24 +2364,18 @@ 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) { - 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; } @@ -2463,31 +2478,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, @@ -2570,30 +2561,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); @@ -2620,31 +2588,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, @@ -2733,30 +2677,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)) @@ -2772,6 +2693,49 @@ out metamethod } } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static LuaFunction PrepareOperationMetamethod( + LuaState state, + LuaValue metamethod, + LuaValue left, + LuaValue right, + VirtualMachineExecutionContext? context = null + ) + { + var stack = state.Stack; + var callable = metamethod; + if (!metamethod.TryReadFunction(out var function)) + { + if ( + !metamethod.TryGetMetamethod(state.GlobalState, Metamethods.Call, out metamethod) + || !metamethod.TryReadFunction(out function) + ) + 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); + } + [AsyncMethodBuilder(typeof(LightAsyncValueTaskMethodBuilder<>))] static async ValueTask InvokeOperationMetamethod( LuaState state, @@ -2864,31 +2828,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); @@ -2939,7 +2879,7 @@ out metamethod context.Pc++; } - stack.PopUntil(newFrame.Base - newFrame.VariableArgumentCount + 1); + stack.PopUntil(newFrame.Base - newFrame.VariableArgumentCount); context.State.PopCallStackFrame(); return true; @@ -2995,28 +2935,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(); } @@ -3108,7 +3033,7 @@ bool isMetamethod state.Stack.SetTop(newBase + argumentCount); } - var variableArgumentCount = function.GetVariableArgumentCount(argumentCount); + var variableArgumentCount = function.GetArgumentAdjustment(argumentCount); if (variableArgumentCount < 0) { @@ -3134,7 +3059,7 @@ int newBase { var argumentCount = (int)(state.Stack.Count - newBase); - var variableArgumentCount = function.GetVariableArgumentCount(argumentCount); + var variableArgumentCount = function.GetArgumentAdjustment(argumentCount); if (variableArgumentCount < 0) { @@ -3177,9 +3102,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) @@ -3192,10 +3117,16 @@ bool isMetamethod newBase = currentBase; } - var variableArgumentCount = function.GetVariableArgumentCount(argumentCount); + var variableArgumentCount = function.GetArgumentAdjustment(argumentCount); + + if (variableArgumentCount == 0) + return (argumentCount, 0); - if (variableArgumentCount <= 0) + if (variableArgumentCount < 0) { + stack.PopUntil(newBase + argumentCount); + argumentCount -= variableArgumentCount; + stack.SetTop(newBase + argumentCount); return (argumentCount, 0); } 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/src/Lua/Runtime/UpValue.cs b/src/Lua/Runtime/UpValue.cs index 5050b912..be3d3fc2 100644 --- a/src/Lua/Runtime/UpValue.cs +++ b/src/Lua/Runtime/UpValue.cs @@ -6,72 +6,107 @@ public sealed class UpValue { LuaValue value; - public LuaState? Thread { get; } - 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); + 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/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/CoroutineLibrary.cs b/src/Lua/Standard/CoroutineLibrary.cs index d54cb41f..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,43 +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); - } - - var stack = context.State.Stack; - var frameBase = stack.Count; - context.State.PushCallStackFrame( - new() - { - Base = frameBase, - ReturnBase = context.ReturnFrameBase, - VariableArgumentCount = 0, - Function = state.CoroutineFunction!, - } - ); - 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(); - } + return state.ResumeWrappedAsync(context, cancellationToken); } public ValueTask Yield( diff --git a/src/Lua/Standard/DebugLibrary.cs b/src/Lua/Standard/DebugLibrary.cs index 811753b7..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() { @@ -69,7 +70,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; @@ -89,9 +90,9 @@ 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; - if (nextFrameBase - 1 < frameBase + index) + ? nextFrame.ReturnBase + : nextFrame.Base - nextFrame.VariableArgumentCount; + if (index >= nextFrameBase - frameBase) { name = null; return ref Unsafe.NullRef(); @@ -115,15 +116,18 @@ or OpCode.TailCall if (localId == 0) { name = l.Name; - return ref state.Stack.Get(frameBase + index); + return ref state.GetLocalReference(frameBase + index); } } } 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 (nextFrameBase - 1 < frameBase + index) + if (index >= nextFrameBase - frameBase) { name = null; return ref Unsafe.NullRef(); @@ -131,7 +135,7 @@ or OpCode.TailCall } name = "(*temporary)"; - return ref state.Stack.Get(frameBase + index); + return ref state.GetLocalReference(frameBase + index); } public ValueTask GetLocal( @@ -143,10 +147,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; } } @@ -430,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 4ed4aa7e..296496e8 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) @@ -129,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/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 09e4488c..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) @@ -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/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/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/ArgumentAdjustmentTests.cs b/tests/Lua.Tests/ArgumentAdjustmentTests.cs new file mode 100644 index 00000000..e3d47db4 --- /dev/null +++ b/tests/Lua.Tests/ArgumentAdjustmentTests.cs @@ -0,0 +1,168 @@ +using System.Runtime.CompilerServices; +using Lua.Platforms; +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[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( + 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/BytecodeValidationTests.cs b/tests/Lua.Tests/BytecodeValidationTests.cs new file mode 100644 index 00000000..5623e64c --- /dev/null +++ b/tests/Lua.Tests/BytecodeValidationTests.cs @@ -0,0 +1,255 @@ +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(); + 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/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/ClosureBindingTests.cs b/tests/Lua.Tests/ClosureBindingTests.cs new file mode 100644 index 00000000..a5d827a6 --- /dev/null +++ b/tests/Lua.Tests/ClosureBindingTests.cs @@ -0,0 +1,156 @@ +using Lua.Platforms; +using Lua.Runtime; +using Lua.Standard; + +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 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) + { + 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, false)] + [TestCase(true, false)] + [TestCase(false, true)] + public async Task ReloadedFunctionInitializesAllUpvaluesWithoutCapturingCaller( + bool duringCall, + bool explicitEnvironment + ) + { + 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 environment = explicitEnvironment ? new LuaTable() : null; + var initializedClosed = false; + LuaClosure Load() + { + var result = state.Load(bytes, environment: environment); + 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], + 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() + { + 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/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/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/CoroutineCancellationDispatchTests.cs b/tests/Lua.Tests/CoroutineCancellationDispatchTests.cs new file mode 100644 index 00000000..bb855fd4 --- /dev/null +++ b/tests/Lua.Tests/CoroutineCancellationDispatchTests.cs @@ -0,0 +1,259 @@ +using Lua.Platforms; +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[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( + "call", + "add", + "unm", + "len", + "concat", + "lt", + "le", + "eq", + "index", + "newindex", + "callable", + "iterator", + "hook" + )] + string operation, + [Values] bool asynchronous + ) + { + using var state = CreateState(); + 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" + ) + ); + var output = new LuaStack(); + await thread.ResumeAsync(output); + 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 = CreateState(); + 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 = CreateState(); + 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 = 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", + "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 = CreateState(); + 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); coroutine.yield(3); 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); + 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 })); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead)); + } +} 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/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/CoroutineLifetimeTests.cs b/tests/Lua.Tests/CoroutineLifetimeTests.cs new file mode 100644 index 00000000..ee839f40 --- /dev/null +++ b/tests/Lua.Tests/CoroutineLifetimeTests.cs @@ -0,0 +1,354 @@ +using System.Runtime.CompilerServices; +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 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) => + { + 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 + ); + 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 + { + 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)); + Assert.That(thread.CallStackFrameCount, Is.Zero); + Assert.That(thread.GetStackValues().Length, Is.Zero); + } + + [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, yieldToken)) + ); + 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)] + [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, + [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)] + [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/CoroutineTransferTests.cs b/tests/Lua.Tests/CoroutineTransferTests.cs new file mode 100644 index 00000000..420aeb49 --- /dev/null +++ b/tests/Lua.Tests/CoroutineTransferTests.cs @@ -0,0 +1,583 @@ +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); + } + + [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(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.AsTask().WaitAsync(token); + } + ), + 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); + } + + [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); + } + + [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, + 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(); + } +} diff --git a/tests/Lua.Tests/DebugLocalMetadataTests.cs b/tests/Lua.Tests/DebugLocalMetadataTests.cs new file mode 100644 index 00000000..297df40e --- /dev/null +++ b/tests/Lua.Tests/DebugLocalMetadataTests.cs @@ -0,0 +1,282 @@ +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 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() + { + 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) + { + 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) + { + 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) + { + 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 = StripDebug(proto); + } + 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/ExceptionLifetimeTests.cs b/tests/Lua.Tests/ExceptionLifetimeTests.cs new file mode 100644 index 00000000..c023db53 --- /dev/null +++ b/tests/Lua.Tests/ExceptionLifetimeTests.cs @@ -0,0 +1,342 @@ +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); + } + + [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() + { + 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); + } +} 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/ExecutionFailureTests.cs b/tests/Lua.Tests/ExecutionFailureTests.cs new file mode 100644 index 00000000..b55b6b05 --- /dev/null +++ b/tests/Lua.Tests/ExecutionFailureTests.cs @@ -0,0 +1,276 @@ +using System.Runtime.CompilerServices; +using Lua.Runtime; +using Lua.Standard; + +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)] + [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); + } + + [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] + .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 () => + { + 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))); + } + finally + { + state.PopCallStackFrame(); + } + } + + [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); + Assert.ThrowsAsync(async () => + { + 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/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/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/GeneratedMetatableLifetimeTests.cs b/tests/Lua.Tests/GeneratedMetatableLifetimeTests.cs new file mode 100644 index 00000000..091a96d2 --- /dev/null +++ b/tests/Lua.Tests/GeneratedMetatableLifetimeTests.cs @@ -0,0 +1,162 @@ +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("mt.__index=nil")] + [TestCase("mt.owner=coroutine.running()")] + [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) + { + 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) + assert(not pcall(function() mt.owner=coroutine.running() end)) + assert(not pcall(function() rawset(mt,'payload',payload) end)) + obj=nil;payload=nil + """ + ) + .AsTask() + .GetAwaiter() + .GetResult(); + return (new(state), new(payload)); + } + + [Test] + public async Task IndependentStatesShareFrozenDefaults() + { + using var first = CreateState(); + using var second = CreateState(); + first.Environment["obj"] = new GeneratedMetatableTestObject(); + second.Environment["obj"] = new GeneratedMetatableTestObject(); + 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] + 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)) + 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 + 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) + 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, 42, true, 9, 8 })); + } + + [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/HookArgumentTests.cs b/tests/Lua.Tests/HookArgumentTests.cs new file mode 100644 index 00000000..c052f591 --- /dev/null +++ b/tests/Lua.Tests/HookArgumentTests.cs @@ -0,0 +1,111 @@ +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; + } + + [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(); + 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/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() { 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/LoadEnvironmentTests.cs b/tests/Lua.Tests/LoadEnvironmentTests.cs index 6b732925..3cde6ae0 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,36 @@ await File.WriteAllBytesAsync( Assert.That(result, Has.Length.EqualTo(1)); Assert.That(result[0], Is.EqualTo(LuaValue.Nil)); } + + [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) }; + 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'" + environmentArgument + ")); return f()" + ); + 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/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/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/MemoryLifetimeTests.cs b/tests/Lua.Tests/MemoryLifetimeTests.cs new file mode 100644 index 00000000..086a23d3 --- /dev/null +++ b/tests/Lua.Tests/MemoryLifetimeTests.cs @@ -0,0 +1,270 @@ +using System.Runtime.CompilerServices; +using Lua.Internal; +using Lua.Runtime; +using Lua.Standard; + +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)); + } + + [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(false)] + [TestCase(true)] + public void HandledErrorDoesNotRootPayloadWhileStateIsIdle(bool text) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + AssertCollected(HandleError(state, 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")] + [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(); + 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 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, bool text = false) + { + object payload = text ? new string('x', 65536) : 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) + { + 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); + } +} diff --git a/tests/Lua.Tests/MetatableInitializationTests.cs b/tests/Lua.Tests/MetatableInitializationTests.cs index 98c834ae..c5674c90 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,78 @@ public static int Add(ConcurrentMetatableTestObject a, ConcurrentMetatableTestOb public class MetatableInitializationTests { [Test] - public void ParallelFirstAccess_PublishesOneCompleteMetatable() + public async Task ParallelStatesShareCompleteFrozenMetatable() { 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(); + Assert.That(metatable.IsReadOnly, Is.True); + 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(1)); } [Test] - public async Task ReplacementAndReset_PreserveSharedMetatableBehavior() + public async Task HostReplacementAndRemovalAffectOnlyOneUserData() { using var state = LuaState.Create(); + state.OpenStandardLibraries(); 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)); 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( + (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/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/OperationArgumentTransferTests.cs b/tests/Lua.Tests/OperationArgumentTransferTests.cs new file mode 100644 index 00000000..3e9390a6 --- /dev/null +++ b/tests/Lua.Tests/OperationArgumentTransferTests.cs @@ -0,0 +1,114 @@ +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 + ) + { + 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) - 1 - (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)), + }; + } +} 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); + } +} 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 })); + } +} 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 })); + } +} diff --git a/tests/Lua.Tests/StreamLifetimeTests.cs b/tests/Lua.Tests/StreamLifetimeTests.cs new file mode 100644 index 00000000..716d1997 --- /dev/null +++ b/tests/Lua.Tests/StreamLifetimeTests.cs @@ -0,0 +1,262 @@ +using System.Buffers; +using System.Text; +using Lua.IO; +using Lua.Platforms; +using Lua.Standard; + +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(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)] + 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)] + [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) + { + 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) + { + 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 + ); + } +} diff --git a/tests/Lua.Tests/TableKeyLifetimeTests.cs b/tests/Lua.Tests/TableKeyLifetimeTests.cs new file mode 100644 index 00000000..8d03319f --- /dev/null +++ b/tests/Lua.Tests/TableKeyLifetimeTests.cs @@ -0,0 +1,236 @@ +using System.Runtime.CompilerServices; +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +public class TableKeyLifetimeTests +{ + [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, hostApi); + Collect(reference); + Assert.That(table.HashMapCount, Is.EqualTo(1)); + GC.KeepAlive(table); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference DeleteKey(LuaTable table, string kind, bool hostApi) + { + 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; + 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()); + } + + [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,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(7)); + Assert.That( + table.Select(pair => pair.Value.Read()).Order(), + Is.EqualTo(new[] { 11, 12, 13, 14, 15, 16, 17 }) + ); + } + + [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(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + Assert.That(reference.IsAlive, Is.False); + } + + sealed class KeyUserData : ILuaUserData + { + public LuaTable? Metatable { get; set; } + } +} 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); } } diff --git a/tests/Lua.Tests/TailCallLifetimeTests.cs b/tests/Lua.Tests/TailCallLifetimeTests.cs new file mode 100644 index 00000000..4fe90c2b --- /dev/null +++ b/tests/Lua.Tests/TailCallLifetimeTests.cs @@ -0,0 +1,216 @@ +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")] + [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( + 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(42); + } + ); + 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() + """, + "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) + 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); + } +} 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'")); + } +} 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) + """ + ); + } +} diff --git a/tests/Lua.Tests/VariadicMetamethodLifetimeTests.cs b/tests/Lua.Tests/VariadicMetamethodLifetimeTests.cs new file mode 100644 index 00000000..96301a57 --- /dev/null +++ b/tests/Lua.Tests/VariadicMetamethodLifetimeTests.cs @@ -0,0 +1,232 @@ +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; + } +}