From d247dc1432874f88ead2233f3b880708d36aaa77 Mon Sep 17 00:00:00 2001 From: Jackarunda Date: Mon, 6 Jul 2026 14:37:03 -0700 Subject: [PATCH 1/3] fix and tests --- src/Lua/Runtime/LuaVirtualMachine.cs | 7 +++- tests/Lua.Tests/LoopTests.cs | 53 ++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/Lua/Runtime/LuaVirtualMachine.cs b/src/Lua/Runtime/LuaVirtualMachine.cs index 3d28c283..67ee0451 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.cs @@ -170,7 +170,12 @@ public bool PopFromBuffer(int src, int srcCount) // Other opcodes has one result default: Stack.Get(target) = result.Length == 0 ? LuaValue.Nil : result[0]; - State.PopCallStackFrameWithStackPop(target + 1); + // Restore the caller's stack top rather than truncating to the + // result register: the result register may live BELOW other live + // registers (e.g. a numeric for-loop's hidden index/limit/step + // slots, or locals declared before the current expression). The + // metamethod frame's ReturnBase is the caller's top at call time. + State.PopCallStackFrameWithStackPop(Math.Max(target + 1, CurrentReturnFrameBase)); return true; } diff --git a/tests/Lua.Tests/LoopTests.cs b/tests/Lua.Tests/LoopTests.cs index dd791cd5..4d746773 100644 --- a/tests/Lua.Tests/LoopTests.cs +++ b/tests/Lua.Tests/LoopTests.cs @@ -1,3 +1,5 @@ +using Lua.Standard; + namespace Lua.Tests; public class LoopTests @@ -49,4 +51,55 @@ public async Task Test_While() Assert.That(result, Has.Length.EqualTo(1)); Assert.That(result[0], Is.EqualTo(new LuaValue(100))); } + + // Regression: returning from a metamethod truncated the stack to the result + // register. When that register sat below a numeric for-loop's hidden control + // slots (index/limit/step) — which happens when the assignment target is a + // local declared before the loop — the control slots were clobbered, the step + // decayed to 0, and the loop spun forever. A CancellationToken guards the run so + // a re-regression fails fast instead of hanging the whole test suite. + [Test] + public async Task Test_NumericFor_MetamethodResultDoesNotCorruptLoopState() + { + var source = + @" +local mt = {} +mt.__add = function(a, b) return setmetatable({ v = a.v + b.v }, mt) end +local acc = setmetatable({ v = 0 }, mt) +for i = 1, 5 do + acc = acc + setmetatable({ v = i }, mt) +end +return acc.v"; + + var state = LuaState.Create(); + state.OpenStandardLibraries(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var result = await state.DoStringAsync(source, "@test.lua", cts.Token); + + Assert.That(result, Has.Length.EqualTo(1)); + Assert.That(result[0], Is.EqualTo(new LuaValue(15))); + } + + // Same regression, exercised through an __index metamethod (single-result return + // path) with the target local living below the loop's control slots. + [Test] + public async Task Test_NumericFor_IndexMetamethodResultDoesNotCorruptLoopState() + { + var source = + @" +local proxy = setmetatable({}, { __index = function(_, k) return k end }) +local sum = 0 +for i = 1, 5 do + sum = sum + proxy[i] +end +return sum"; + + var state = LuaState.Create(); + state.OpenStandardLibraries(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var result = await state.DoStringAsync(source, "@test.lua", cts.Token); + + Assert.That(result, Has.Length.EqualTo(1)); + Assert.That(result[0], Is.EqualTo(new LuaValue(15))); + } } From 199c09557c1b00c205b773f234a6b13d4776a972 Mon Sep 17 00:00:00 2001 From: Jackarunda Date: Mon, 6 Jul 2026 15:21:29 -0700 Subject: [PATCH 2/3] comments --- tests/Lua.Tests/LoopTests.cs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/Lua.Tests/LoopTests.cs b/tests/Lua.Tests/LoopTests.cs index 4d746773..f8c28311 100644 --- a/tests/Lua.Tests/LoopTests.cs +++ b/tests/Lua.Tests/LoopTests.cs @@ -52,12 +52,6 @@ public async Task Test_While() Assert.That(result[0], Is.EqualTo(new LuaValue(100))); } - // Regression: returning from a metamethod truncated the stack to the result - // register. When that register sat below a numeric for-loop's hidden control - // slots (index/limit/step) — which happens when the assignment target is a - // local declared before the loop — the control slots were clobbered, the step - // decayed to 0, and the loop spun forever. A CancellationToken guards the run so - // a re-regression fails fast instead of hanging the whole test suite. [Test] public async Task Test_NumericFor_MetamethodResultDoesNotCorruptLoopState() { @@ -80,8 +74,6 @@ public async Task Test_NumericFor_MetamethodResultDoesNotCorruptLoopState() Assert.That(result[0], Is.EqualTo(new LuaValue(15))); } - // Same regression, exercised through an __index metamethod (single-result return - // path) with the target local living below the loop's control slots. [Test] public async Task Test_NumericFor_IndexMetamethodResultDoesNotCorruptLoopState() { From 1dd44beab726617c69346a20ea889d08544c4542 Mon Sep 17 00:00:00 2001 From: Jackarunda Date: Mon, 6 Jul 2026 15:24:11 -0700 Subject: [PATCH 3/3] comments --- src/Lua/Runtime/LuaVirtualMachine.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Lua/Runtime/LuaVirtualMachine.cs b/src/Lua/Runtime/LuaVirtualMachine.cs index 67ee0451..2772febe 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.cs @@ -170,11 +170,6 @@ public bool PopFromBuffer(int src, int srcCount) // Other opcodes has one result default: Stack.Get(target) = result.Length == 0 ? LuaValue.Nil : result[0]; - // Restore the caller's stack top rather than truncating to the - // result register: the result register may live BELOW other live - // registers (e.g. a numeric for-loop's hidden index/limit/step - // slots, or locals declared before the current expression). The - // metamethod frame's ReturnBase is the caller's top at call time. State.PopCallStackFrameWithStackPop(Math.Max(target + 1, CurrentReturnFrameBase)); return true; }