From 9d81207025068ea247d8b4364e48bb88591cfc6e Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Wed, 10 Jun 2026 14:12:28 -0700 Subject: [PATCH 1/7] perf(vm): rebuild call_stack lazily on the Lua->Lua call path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The executor's hot :lua_closure call path eagerly allocated a 4-key call_info map per call and pushed it onto state.call_stack, even though that map is only read when the stack is actually inspected (debug.getinfo, debug.traceback, and error tracebacks). Drop the eager push: each frame now records the call-site line and name_hint (the only bits not derivable from the caller's proto), and rebuild_call_stack/1 synthesizes the identical 4-key entries on demand at the boundaries that read the stack — native-call dispatch, the dispatcher hand-off, generic_for iterator calls, and the :call error raise sites. The O(1) call_depth counter remains the sole per-call bookkeeping cost. Traceback and debug.* output are byte-identical to the eager implementation (verified against main); the lua53 errors.lua/db.lua suites are the guardrail. Plan: C --- lib/lua/vm/executor.ex | 103 ++++++++++++++--- test/lua/vm/lazy_call_info_test.exs | 166 ++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+), 16 deletions(-) create mode 100644 test/lua/vm/lazy_call_info_test.exs diff --git a/lib/lua/vm/executor.ex b/lib/lua/vm/executor.ex index 8f930465..9bea2a31 100644 --- a/lib/lua/vm/executor.ex +++ b/lib/lua/vm/executor.ex @@ -542,6 +542,45 @@ defmodule Lua.VM.Executor do %{source: proto.source, line: line, name: hint_name(name_hint), namewhat: hint_namewhat(name_hint)} end + # Synthesizes `state.call_stack` entries from the executor's `frames` stack. + # + # The hot `:lua_closure` call path no longer pushes a per-call `call_info` + # map onto `state.call_stack`; instead each frame records the call-site + # `line` and `name_hint` (the only bits not derivable from the caller's + # `proto`). This walks the `frames` stack innermost-first and rebuilds the + # exact 4-key shape (`source`/`line`/`name`/`namewhat`) consumed by + # `Lua.VM.ErrorFormatter` and `debug.getinfo`/`debug.traceback`. `frame.proto` + # is the caller's proto, matching the eager `call_info`'s `source`. + # + # Called only at the boundaries that actually read the stack — native-call + # dispatch, dispatcher hand-off, and error raise sites — never on the hot + # success path. + @spec rebuild_call_stack(list(map())) :: list(map()) + defp rebuild_call_stack(frames) do + Enum.map(frames, fn frame -> + %{ + source: frame.proto.source, + line: frame.line, + name: hint_name(frame.name_hint), + namewhat: hint_namewhat(frame.name_hint) + } + end) + end + + # Invokes a `generic_for` iterator with the executor's in-flight Lua frames + # materialized into `state.call_stack` for the duration of the call. The + # iterator may itself be a Lua closure (recursing back through `do_execute`) + # or a native function reading the live stack — both need the enclosing + # executor frames visible, just like the `:call` opcode's native dispatch. + # The inherited stack is restored on the returned state so the lazy + # bookkeeping stays balanced. + defp call_iterator(iter_func, args, proto, state, line, frames) do + inherited_call_stack = state.call_stack + state = %{state | call_stack: rebuild_call_stack(frames) ++ inherited_call_stack} + {results, state} = call_value(iter_func, args, proto, state, line) + {results, %{state | call_stack: inherited_call_stack}} + end + # ── Break ────────────────────────────────────────────────────────────────── defp do_execute([:break | _rest], regs, upvalues, proto, state, cont, frames, line) do @@ -641,7 +680,7 @@ defmodule Lua.VM.Executor do invariant_state = elem(regs, base + 1) control = elem(regs, base + 2) - {results, state} = call_value(iter_func, [invariant_state, control], proto, state, line) + {results, state} = call_iterator(iter_func, [invariant_state, control], proto, state, line, frames) first_result = List.first(results) if first_result == nil do @@ -926,7 +965,7 @@ defmodule Lua.VM.Executor do invariant_state = elem(regs, base + 1) control = elem(regs, base + 2) - {results, state} = call_value(iter_func, [invariant_state, control], proto, state, line) + {results, state} = call_iterator(iter_func, [invariant_state, control], proto, state, line, frames) first_result = List.first(results) if first_result == nil do @@ -1036,9 +1075,23 @@ defmodule Lua.VM.Executor do args = collect_args(regs, base + 1, total_args) call_info = %{source: proto.source, line: line, name: hint_name(name_hint), namewhat: hint_namewhat(name_hint)} State.check_call_depth!(state) - state = %{state | call_stack: [call_info | state.call_stack], call_depth: state.call_depth + 1} + + # The executor's own in-flight Lua frames are tracked lazily via the + # `frames` argument and are NOT in `state.call_stack`. Materialize them + # here (innermost-first, prepended to the inherited stack) so the + # dispatcher and any native callback it runs see a complete stack for + # `debug.*` / tracebacks. Restore the inherited stack on return so the + # lazy bookkeeping stays balanced. + inherited_call_stack = state.call_stack + + state = %{ + state + | call_stack: [call_info | rebuild_call_stack(frames) ++ inherited_call_stack], + call_depth: state.call_depth + 1 + } + {results, state} = Dispatcher.execute(callee_proto, args, callee_upvalues, state) - state = %{state | call_stack: tl(state.call_stack), call_depth: state.call_depth - 1} + state = %{state | call_stack: inherited_call_stack, call_depth: state.call_depth - 1} continue_after_call(results, regs, rest, upvalues, proto, state, cont, frames, line, base, result_count) {:lua_closure, callee_proto, callee_upvalues} -> @@ -1060,6 +1113,13 @@ defmodule Lua.VM.Executor do callee_proto end + # The frame carries the caller context plus the two bits a call_stack + # entry needs that can't be derived from the caller's `proto`: the + # call-site `line` and the `name_hint`. `state.call_stack` is left + # untouched on this hot path — entries are synthesized lazily from + # `frames` only when a native call or an error actually reads the + # stack (see `rebuild_call_stack/1`). The O(1) `call_depth` counter + # is the sole per-call bookkeeping cost. frame = %{ rest: rest, cont: cont, @@ -1068,17 +1128,16 @@ defmodule Lua.VM.Executor do proto: proto, base: base, result_count: result_count, - open_upvalues: state.open_upvalues + open_upvalues: state.open_upvalues, + line: line, + name_hint: name_hint } - call_info = %{source: proto.source, line: line, name: hint_name(name_hint), namewhat: hint_namewhat(name_hint)} - State.check_call_depth!(state) state = %{ state - | call_stack: [call_info | state.call_stack], - call_depth: state.call_depth + 1, + | call_depth: state.call_depth + 1, open_upvalues: %{} } @@ -1107,14 +1166,23 @@ defmodule Lua.VM.Executor do prev_pos = Process.get(@position_key, @unset) set_position(line, proto.source) + # The executor's in-flight Lua frames are tracked lazily via `frames`, + # so they are not in `state.call_stack`. Materialize them for the + # duration of the native call: `debug.getinfo`/`debug.traceback` read + # `state.call_stack` live during successful execution, not just on the + # error path. Restore the inherited stack on the returned state so the + # lazy bookkeeping stays balanced once control returns to the executor. + inherited_call_stack = state.call_stack + state = %{state | call_stack: rebuild_call_stack(frames) ++ inherited_call_stack} + {results, state} = try do case fun.(args, state) do {r, %State{} = s} when is_list(r) -> - {r, s} + {r, %{s | call_stack: inherited_call_stack}} {r, %State{} = s} -> - {List.wrap(r), s} + {List.wrap(r), %{s | call_stack: inherited_call_stack}} other -> raise InternalError, @@ -1132,7 +1200,7 @@ defmodule Lua.VM.Executor do raise TypeError, value: "attempt to call a nil value" <> format_target_hint(name_hint), source: proto.source, - call_stack: state.call_stack, + call_stack: rebuild_call_stack(frames) ++ state.call_stack, line: line, error_kind: :call_nil, value_type: nil, @@ -1144,7 +1212,7 @@ defmodule Lua.VM.Executor do raise TypeError, value: "attempt to call a #{Value.type_name(other)} value" <> format_target_hint(name_hint), source: proto.source, - call_stack: state.call_stack, + call_stack: rebuild_call_stack(frames) ++ state.call_stack, line: line, error_kind: :call_non_function, value_type: value_type(other), @@ -1158,7 +1226,7 @@ defmodule Lua.VM.Executor do raise TypeError, value: "attempt to call a #{Value.type_name(other)} value" <> format_target_hint(name_hint), source: proto.source, - call_stack: state.call_stack, + call_stack: rebuild_call_stack(frames) ++ state.call_stack, line: line, error_kind: :call_non_function, value_type: value_type(other), @@ -1986,10 +2054,13 @@ defmodule Lua.VM.Executor do open_upvalues: saved_open_upvalues } = frame + # The `:lua_closure` call path does not push onto `state.call_stack` + # (frames are tracked lazily — see the `:call` clause), so there is + # nothing to pop here. Only the O(1) `call_depth` counter moves in + # lockstep with the `frames` stack. state = %{ state - | call_stack: tl(state.call_stack), - call_depth: state.call_depth - 1, + | call_depth: state.call_depth - 1, open_upvalues: saved_open_upvalues } diff --git a/test/lua/vm/lazy_call_info_test.exs b/test/lua/vm/lazy_call_info_test.exs new file mode 100644 index 00000000..7710fa98 --- /dev/null +++ b/test/lua/vm/lazy_call_info_test.exs @@ -0,0 +1,166 @@ +defmodule Lua.VM.LazyCallInfoTest do + @moduledoc """ + Guards the lazily-rebuilt `call_stack` on the executor's Lua->Lua call path. + + The executor tracks in-flight Lua frames in its `frames` argument and only + materializes `state.call_stack` at the boundaries that read it: native-call + dispatch (where `debug.getinfo`/`debug.traceback` read the stack LIVE during + successful execution), the dispatcher hand-off, and error raise sites. These + golden values were captured from the previous eager-`call_info` executor and + must remain byte-identical. + """ + use ExUnit.Case, async: true + + alias Lua.Compiler + alias Lua.Parser + alias Lua.VM + alias Lua.VM.RuntimeError, as: LuaRuntimeError + alias Lua.VM.State + alias Lua.VM.Stdlib + alias Lua.VM.TypeError, as: LuaTypeError + + defp run(code) do + {:ok, ast} = Parser.parse(code) + {:ok, proto} = Compiler.compile(ast, source: "test.lua") + state = Stdlib.install(State.new()) + VM.execute(proto, state) + end + + defp run!(code) do + {:ok, results, _state} = run(code) + results + end + + describe "debug.getinfo reads the live executor stack" do + test "name/namewhat/currentline for a nested Lua->Lua call chain" do + code = """ + function outer() + return inner() + end + function inner() + local info = debug.getinfo(2, "nSl") + return info.name, info.namewhat, info.currentline, info.source, info.what + end + return outer() + """ + + assert run!(code) == ["outer", "global", 0, "test.lua", "Lua"] + end + + test "level 1 currentline" do + code = """ + function f() + return debug.getinfo(1, "Sl").currentline + end + return f() + """ + + assert run!(code) == [-1] + end + + test "three-deep chain resolves distinct levels" do + code = """ + function a() return b() end + function b() return c() end + function c() + local l2 = debug.getinfo(2, "n").name + local l3 = debug.getinfo(3, "n").name + return l2, l3 + end + return a() + """ + + assert run!(code) == ["b", "a"] + end + end + + describe "debug.traceback reads the live executor stack" do + test "renders one line per active Lua frame" do + code = """ + function outer() return inner() end + function inner() return debug.traceback() end + return outer() + """ + + assert run!(code) == ["stack traceback:\n\ttest.lua:0: in ?\n\ttest.lua:3: in ?"] + end + end + + describe "error tracebacks materialize the executor stack" do + test "attempt to call a nil value from a nested call" do + code = """ + function outer() + return inner() + end + function inner() + return notafunction() + end + return outer() + """ + + assert_raise LuaTypeError, ~r/attempt to call a nil value \(global 'notafunction'\)/, fn -> + run(code) + end + end + end + + describe "call_stack stays balanced" do + test "is empty after a successful nested-call program" do + code = """ + local function nested() return 42 end + local function outer() return nested() end + return outer() + """ + + assert {:ok, [42], state} = run(code) + assert state.call_stack == [] + end + + test "is empty after a program that invokes a native callback mid-stack" do + # The native `debug.getinfo` call materializes the executor frames into + # call_stack for its duration; it must be restored afterward. + code = """ + local function deep() + debug.getinfo(1, "l") + return true + end + local function outer() return deep() end + return outer() + """ + + assert {:ok, [true], state} = run(code) + assert state.call_stack == [] + end + + test "call_depth returns to zero after recursion" do + code = """ + local fact + fact = function(n) + if n <= 1 then return 1 else return n * fact(n - 1) end + end + return fact(6) + """ + + assert {:ok, [720], state} = run(code) + assert state.call_depth == 0 + assert state.call_stack == [] + end + end + + describe "max_call_depth still fires off the O(1) counter" do + test "recursion past the limit raises stack overflow" do + code = """ + local function rec(n) return rec(n + 1) end + return rec(0) + """ + + {:ok, ast} = Parser.parse(code) + {:ok, proto} = Compiler.compile(ast, source: "test.lua") + state = %{Stdlib.install(State.new()) | max_call_depth: 50} + + assert_raise LuaRuntimeError, ~r/stack overflow/, fn -> + VM.execute(proto, state) + end + end + end +end From cbf70687f319ffb33962cec02410bd330e7b7281 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Wed, 10 Jun 2026 15:33:19 -0700 Subject: [PATCH 2/7] fix(vm): materialize lazy frames at the __call dispatch boundary The lazy-call_info refactor covered every boundary that reads state.call_stack -- native dispatch, dispatcher hand-off, generic_for iterator calls, and the :call error raise sites -- except the __call metamethod branch of the :call opcode. That site dispatches through call_function/3, which can recurse into a Lua closure or run a native callback (debug.traceback / debug.getinfo) that reads the live stack, but the executor's in-flight Lua frames were never materialized. The result was a truncated traceback and a currentline of -1 inside a __call callback on the interpreter path, diverging from the eager implementation. Mirror the sibling boundaries: rebuild the frames into state.call_stack for the duration of the call and restore the inherited stack on return so the lazy bookkeeping stays balanced. Verified byte-identical to the pre-refactor executor (currentline 7, traceback line 7 for the regression probe). The lazy_call_info suite gains __call traceback/getinfo/balance coverage. Plan: C --- lib/lua/vm/executor.ex | 12 +++++++ test/lua/vm/lazy_call_info_test.exs | 56 +++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/lib/lua/vm/executor.ex b/lib/lua/vm/executor.ex index 9bea2a31..3f9d7bad 100644 --- a/lib/lua/vm/executor.ex +++ b/lib/lua/vm/executor.ex @@ -1234,7 +1234,19 @@ defmodule Lua.VM.Executor do call_mm -> args = collect_args(regs, base + 1, total_args) + + # The `__call` metamethod is dispatched via `call_function/3`, + # which may recurse into a Lua closure or run a native callback + # that reads `state.call_stack` for `debug.*`/tracebacks. Like + # the native-dispatch and dispatcher hand-off boundaries above, + # the executor's in-flight Lua `frames` are tracked lazily and + # are NOT in `state.call_stack`, so materialize them for the + # duration of the call and restore the inherited stack on return + # to keep the lazy bookkeeping balanced. + inherited_call_stack = state.call_stack + state = %{state | call_stack: rebuild_call_stack(frames) ++ inherited_call_stack} {results, state} = call_function(call_mm, [other | args], state) + state = %{state | call_stack: inherited_call_stack} continue_after_call(results, regs, rest, upvalues, proto, state, cont, frames, line, base, result_count) end end diff --git a/test/lua/vm/lazy_call_info_test.exs b/test/lua/vm/lazy_call_info_test.exs index 7710fa98..741bb837 100644 --- a/test/lua/vm/lazy_call_info_test.exs +++ b/test/lua/vm/lazy_call_info_test.exs @@ -5,9 +5,10 @@ defmodule Lua.VM.LazyCallInfoTest do The executor tracks in-flight Lua frames in its `frames` argument and only materializes `state.call_stack` at the boundaries that read it: native-call dispatch (where `debug.getinfo`/`debug.traceback` read the stack LIVE during - successful execution), the dispatcher hand-off, and error raise sites. These - golden values were captured from the previous eager-`call_info` executor and - must remain byte-identical. + successful execution), the dispatcher hand-off, the `generic_for` iterator + call, the `__call` metamethod dispatch, and error raise sites. These golden + values were captured from the previous eager-`call_info` executor and must + remain byte-identical. """ use ExUnit.Case, async: true @@ -86,6 +87,55 @@ defmodule Lua.VM.LazyCallInfoTest do end end + describe "__call metamethod dispatch materializes the executor stack" do + # The `:call` opcode's `__call` branch dispatches through `call_function/3`. + # The enclosing executor frames are tracked lazily, so they must be + # materialized into `state.call_stack` for the duration of the metamethod + # call exactly like the native-dispatch and dispatcher hand-off boundaries. + # Without it, a callback reading the stack sees a truncated traceback and a + # `currentline` of -1 instead of the live call-site line. + + test "debug.traceback inside a __call callback sees the calling frame" do + code = """ + local t = setmetatable({}, {__call = function(self) + return debug.traceback("cm") + end}) + function p() + return t() + end + return p() + """ + + assert run!(code) == ["cm\nstack traceback:\n\ttest.lua:7: in ?"] + end + + test "debug.getinfo currentline inside a __call callback is the call site" do + code = """ + local t = setmetatable({}, {__call = function(self) + return debug.getinfo(2, "nl").currentline + end}) + function p() + return t() + end + return p() + """ + + assert run!(code) == [7] + end + + test "call_stack is restored to empty after a __call dispatch" do + code = """ + local t = setmetatable({}, {__call = function(self) return 7 end}) + local function outer() return t() end + return outer() + """ + + assert {:ok, [7], state} = run(code) + assert state.call_stack == [] + assert state.call_depth == 0 + end + end + describe "error tracebacks materialize the executor stack" do test "attempt to call a nil value from a nested call" do code = """ From 9a508141156c8f9d9d435169a649a8a6cb1b46ee Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Thu, 11 Jun 2026 04:00:51 -0700 Subject: [PATCH 3/7] fix(vm): materialize lazy frames in stack-overflow traceback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The executor's hot :lua_closure path tracks in-flight Lua frames lazily in its frames argument, not state.call_stack. When check_call_depth! fired on overflow it reported state.call_stack, which on that path is empty — the rendered stack-overflow error carried no traceback at all, unlike the eager dispatcher/compiled path which renders the full stack. Materialize the lazy frames into the reported stack at both depth-check sites: add check_call_depth!/2 taking an explicit overflow stack, and call_depth_ok?/1 so the :lua_closure hot path only rebuilds the stack on the rare failing branch (no success-path cost). The :compiled_closure path now checks against its already-materialized stack. Add a regression test on the pure-interpreter path asserting traceback contents (frame count and 'in function rec' lines), not just the message regex. --- lib/lua/vm/executor.ex | 21 ++++++++-- lib/lua/vm/state.ex | 45 +++++++++++++++++++-- test/lua/vm/recursion_depth_test.exs | 59 ++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/lib/lua/vm/executor.ex b/lib/lua/vm/executor.ex index 3f9d7bad..8423bc98 100644 --- a/lib/lua/vm/executor.ex +++ b/lib/lua/vm/executor.ex @@ -1074,7 +1074,6 @@ defmodule Lua.VM.Executor do # without going through this branch. args = collect_args(regs, base + 1, total_args) call_info = %{source: proto.source, line: line, name: hint_name(name_hint), namewhat: hint_namewhat(name_hint)} - State.check_call_depth!(state) # The executor's own in-flight Lua frames are tracked lazily via the # `frames` argument and are NOT in `state.call_stack`. Materialize them @@ -1083,10 +1082,15 @@ defmodule Lua.VM.Executor do # `debug.*` / tracebacks. Restore the inherited stack on return so the # lazy bookkeeping stays balanced. inherited_call_stack = state.call_stack + materialized_call_stack = [call_info | rebuild_call_stack(frames) ++ inherited_call_stack] + + # Check depth against the materialized stack so an overflow here + # reports the full traceback, not the bare inherited stack. + State.check_call_depth!(state, materialized_call_stack) state = %{ state - | call_stack: [call_info | rebuild_call_stack(frames) ++ inherited_call_stack], + | call_stack: materialized_call_stack, call_depth: state.call_depth + 1 } @@ -1133,7 +1137,18 @@ defmodule Lua.VM.Executor do name_hint: name_hint } - State.check_call_depth!(state) + # Hot path: `call_depth_ok?/1` is a pure comparison, so the lazy + # frames are only materialized into a traceback when the limit is + # actually hit. `frames` excludes the call about to be made, so its + # synthesized entry is prepended. + if State.call_depth_ok?(state) do + :ok + else + overflow_call_stack = + [dispatcher_call_info(proto, name_hint, line) | rebuild_call_stack(frames)] ++ state.call_stack + + State.check_call_depth!(state, overflow_call_stack) + end state = %{ state diff --git a/lib/lua/vm/state.ex b/lib/lua/vm/state.ex index 74f416ad..a529b891 100644 --- a/lib/lua/vm/state.ex +++ b/lib/lua/vm/state.ex @@ -73,15 +73,52 @@ defmodule Lua.VM.State do No-op when depth is under the limit or when `max_call_depth` is `:infinity` (the default). The clauses are ordered so both common cases resolve in a single function-head match with no struct rebuild. + + Callers on the executor's lazy `:lua_closure`/`:compiled_closure` paths + keep their in-flight frames in the `frames` argument rather than in + `state.call_stack`, so passing `state` alone would render an empty + overflow traceback. Such callers use `check_call_depth!/2` to supply the + materialized stack that should be reported when the limit is hit. """ @spec check_call_depth!(t()) :: :ok - def check_call_depth!(%__MODULE__{max_call_depth: :infinity}), do: :ok - def check_call_depth!(%__MODULE__{call_depth: depth, max_call_depth: max}) when depth < max, do: :ok + def check_call_depth!(%__MODULE__{} = state), do: check_call_depth!(state, nil) + + @doc """ + Like `check_call_depth!/1`, but reports `overflow_call_stack` (instead of + `state.call_stack`) in the raised `"stack overflow"` error. + + The executor's hot call paths track in-flight Lua frames lazily in their + `frames` argument; `overflow_call_stack` lets them materialize that stack + only when the limit is actually hit, preserving traceback fidelity + without paying the cost on the success path. + """ + @spec check_call_depth!(t(), [map()] | nil) :: :ok + def check_call_depth!(%__MODULE__{max_call_depth: :infinity}, _overflow_call_stack), do: :ok - def check_call_depth!(%__MODULE__{call_stack: call_stack} = state) do - raise Lua.VM.RuntimeError, value: "stack overflow", call_stack: call_stack, state: state + def check_call_depth!(%__MODULE__{call_depth: depth, max_call_depth: max}, _overflow_call_stack) when depth < max, + do: :ok + + def check_call_depth!(%__MODULE__{call_stack: call_stack} = state, overflow_call_stack) do + raise Lua.VM.RuntimeError, + value: "stack overflow", + call_stack: overflow_call_stack || call_stack, + state: state end + @doc """ + Returns `true` when another call may be pushed without exceeding + `max_call_depth`. + + A pure, allocation-free counterpart to `check_call_depth!/1` for the + executor's hot `:lua_closure` path: it lets the caller defer building the + overflow traceback (`rebuild_call_stack/1`) to the rare failing branch + instead of paying for it on every call. The clauses mirror + `check_call_depth!/1` exactly. + """ + @spec call_depth_ok?(t()) :: boolean() + def call_depth_ok?(%__MODULE__{max_call_depth: :infinity}), do: true + def call_depth_ok?(%__MODULE__{call_depth: depth, max_call_depth: max}), do: depth < max + @doc """ Recovers the state a protected call should continue with after trapping an error. diff --git a/test/lua/vm/recursion_depth_test.exs b/test/lua/vm/recursion_depth_test.exs index 16099c75..7f462f39 100644 --- a/test/lua/vm/recursion_depth_test.exs +++ b/test/lua/vm/recursion_depth_test.exs @@ -4,6 +4,11 @@ defmodule Lua.VM.RecursionDepthTest do catchable Lua `"stack overflow"` error, the depth counter unwinds on return and after a caught error, and `:infinity` (the default) imposes no bound. + + Also pins traceback fidelity on overflow: the executor's hot + `:lua_closure` path tracks in-flight frames lazily (outside + `state.call_stack`), so the overflow report must materialize them or the + traceback comes back empty. """ use ExUnit.Case, async: true @@ -56,6 +61,60 @@ defmodule Lua.VM.RecursionDepthTest do end end + describe "overflow traceback fidelity" do + # The `n & 1` bitwise op is not encodable to bytecode, so `rec` falls + # back to the pure-interpreter `:lua_closure` path where in-flight + # frames live only in the lazy `frames` argument, not `state.call_stack`. + @lazy_recursion """ + local function rec(n) + local x = n & 1 + if n > 0 then return rec(n - 1) end + return x + end + return rec(50) + """ + + test "overflow on the lazy interpreter path carries a full traceback" do + lua = Lua.new(max_call_depth: 20) + + err = + assert_raise RuntimeException, ~r/stack overflow/, fn -> + eval!(lua, @lazy_recursion) + end + + # The lazy frames must be materialized into the report, not dropped. + assert length(err.call_stack) >= 20 + + message = Exception.message(err) + assert message =~ "Stack trace:" + assert message =~ "in function 'rec'" + end + + test "the lazy path's traceback matches the eager path's depth" do + compiled_recursion = """ + local function rec(n) + if n > 0 then return rec(n - 1) end + return n + end + return rec(50) + """ + + lua = Lua.new(max_call_depth: 20) + + lazy = + assert_raise RuntimeException, ~r/stack overflow/, fn -> eval!(lua, @lazy_recursion) end + + eager = + assert_raise RuntimeException, ~r/stack overflow/, fn -> eval!(lua, compiled_recursion) end + + # Both paths report a full 'rec' traceback at the same recursion depth + # (the lazy path additionally includes the overflowing call, so allow + # the off-by-one). + assert abs(length(lazy.call_stack) - length(eager.call_stack)) <= 1 + assert Enum.all?(lazy.call_stack, &(&1.name == "rec")) + end + end + describe "default behavior (:infinity)" do test "moderately deep recursion runs with no limit" do {[200], _lua} = From b9fbbe54715707e1df6f32c2e6e60a837c7a5fd9 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Thu, 11 Jun 2026 04:14:26 -0700 Subject: [PATCH 4/7] test(vm): cover the lazy :lua_closure call path in lazy_call_info The existing lazy_call_info tests used plain function/setmetatable closures, whose sub-prototypes compile WITH bytecode and so route through the :compiled_closure dispatcher hand-off, not the lazy :lua_closure interpreter path the module name refers to. Add forced-lazy variants (sprinkling a non-bytecode-encodable `3 & 1` op into each function) for the getinfo, traceback, __call, native-callback balance, and error-raise scenarios, and update the moduledoc to name both materialization sites. Also tighten the rebuild_call_stack/1 doc: the interpreter -> dispatcher hand-off materializes the stack eagerly on every successful call (the dispatcher pushes its own entries and may run native callbacks that read it live), so the "never on the hot success path" wording was inaccurate for that one boundary. --- lib/lua/vm/executor.ex | 10 +- test/lua/vm/lazy_call_info_test.exs | 161 ++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 2 deletions(-) diff --git a/lib/lua/vm/executor.ex b/lib/lua/vm/executor.ex index 8423bc98..657fabb2 100644 --- a/lib/lua/vm/executor.ex +++ b/lib/lua/vm/executor.ex @@ -553,8 +553,14 @@ defmodule Lua.VM.Executor do # is the caller's proto, matching the eager `call_info`'s `source`. # # Called only at the boundaries that actually read the stack — native-call - # dispatch, dispatcher hand-off, and error raise sites — never on the hot - # success path. + # dispatch, dispatcher hand-off, `generic_for` iterator calls, and error + # raise sites. The pure interpreter → interpreter `:lua_closure` path never + # materializes (it only appends a lazy `frame`), so a Lua-only call chain + # pays nothing here. The interpreter → dispatcher hand-off is the one + # success-path exception: it materializes eagerly on every call because the + # dispatcher pushes its own entries onto this stack and may run native + # callbacks mid-execution that read it live, so it needs a complete stack + # for the dispatcher's whole duration, not just on overflow. @spec rebuild_call_stack(list(map())) :: list(map()) defp rebuild_call_stack(frames) do Enum.map(frames, fn frame -> diff --git a/test/lua/vm/lazy_call_info_test.exs b/test/lua/vm/lazy_call_info_test.exs index 741bb837..b74c1edd 100644 --- a/test/lua/vm/lazy_call_info_test.exs +++ b/test/lua/vm/lazy_call_info_test.exs @@ -9,6 +9,22 @@ defmodule Lua.VM.LazyCallInfoTest do call, the `__call` metamethod dispatch, and error raise sites. These golden values were captured from the previous eager-`call_info` executor and must remain byte-identical. + + Two distinct executor materialization sites are covered: + + * The compiled `:compiled_closure` hand-off — exercised by plain + `function f() ... end` / `setmetatable` closures, whose sub-prototypes + compile WITH bytecode and so route through `Dispatcher.execute/4`. On + this path the executor strips call-site line info (`currentline` reports + `0` / `-1`). + * The lazy `:lua_closure` interpreter path — the branch this module's name + refers to, where `state.call_stack` is left untouched and entries are + synthesized from `frames` only at a read boundary. Sub-prototypes only + route here when they can NOT be lowered to bytecode, so the + lazy-variant tests below sprinkle a bitwise op (`3 & 1`, not + bytecode-encodable) into each function to force the interpreter path. + On this path the live call-site line survives, so the golden + `currentline` / traceback line numbers differ from the compiled path. """ use ExUnit.Case, async: true @@ -48,6 +64,27 @@ defmodule Lua.VM.LazyCallInfoTest do assert run!(code) == ["outer", "global", 0, "test.lua", "Lua"] end + test "name/namewhat/currentline on the forced-lazy interpreter path" do + # The `3 & 1` bitwise op is not bytecode-encodable, so both functions + # fall onto the `:lua_closure` interpreter path. Unlike the compiled + # hand-off above, the live call-site `line` survives, so `currentline` + # is 3 (the `return inner()` site) rather than 0. + code = """ + function outer() + local _ = 3 & 1 + return inner() + end + function inner() + local _ = 3 & 1 + local info = debug.getinfo(2, "nSl") + return info.name, info.namewhat, info.currentline, info.source, info.what + end + return outer() + """ + + assert run!(code) == ["outer", "global", 3, "test.lua", "Lua"] + end + test "level 1 currentline" do code = """ function f() @@ -59,6 +96,20 @@ defmodule Lua.VM.LazyCallInfoTest do assert run!(code) == [-1] end + test "level 1 currentline on the forced-lazy interpreter path" do + # On the lazy `:lua_closure` path the call site survives, so level-1 + # `currentline` is the live line (3) rather than -1. + code = """ + function f() + local _ = 3 & 1 + return debug.getinfo(1, "Sl").currentline + end + return f() + """ + + assert run!(code) == [3] + end + test "three-deep chain resolves distinct levels" do code = """ function a() return b() end @@ -73,6 +124,22 @@ defmodule Lua.VM.LazyCallInfoTest do assert run!(code) == ["b", "a"] end + + test "three-deep chain resolves distinct levels on the forced-lazy path" do + code = """ + function a() local _ = 3 & 1 return b() end + function b() local _ = 3 & 1 return c() end + function c() + local _ = 3 & 1 + local l2 = debug.getinfo(2, "n").name + local l3 = debug.getinfo(3, "n").name + return l2, l3 + end + return a() + """ + + assert run!(code) == ["b", "a"] + end end describe "debug.traceback reads the live executor stack" do @@ -85,6 +152,18 @@ defmodule Lua.VM.LazyCallInfoTest do assert run!(code) == ["stack traceback:\n\ttest.lua:0: in ?\n\ttest.lua:3: in ?"] end + + test "renders the live call-site lines on the forced-lazy path" do + # The compiled path above strips the line to 0; the lazy `:lua_closure` + # path preserves each frame's call-site line (outer at 1, inner at 3). + code = """ + function outer() local _ = 3 & 1 return inner() end + function inner() local _ = 3 & 1 return debug.traceback() end + return outer() + """ + + assert run!(code) == ["stack traceback:\n\ttest.lua:1: in ?\n\ttest.lua:3: in ?"] + end end describe "__call metamethod dispatch materializes the executor stack" do @@ -109,6 +188,22 @@ defmodule Lua.VM.LazyCallInfoTest do assert run!(code) == ["cm\nstack traceback:\n\ttest.lua:7: in ?"] end + test "debug.traceback inside a __call callback on the forced-lazy path" do + code = """ + local t = setmetatable({}, {__call = function(self) + local _ = 3 & 1 + return debug.traceback("cm") + end}) + function p() + local _ = 3 & 1 + return t() + end + return p() + """ + + assert run!(code) == ["cm\nstack traceback:\n\ttest.lua:9: in ?"] + end + test "debug.getinfo currentline inside a __call callback is the call site" do code = """ local t = setmetatable({}, {__call = function(self) @@ -123,6 +218,22 @@ defmodule Lua.VM.LazyCallInfoTest do assert run!(code) == [7] end + test "debug.getinfo currentline inside a __call callback on the forced-lazy path" do + code = """ + local t = setmetatable({}, {__call = function(self) + local _ = 3 & 1 + return debug.getinfo(2, "nl").currentline + end}) + function p() + local _ = 3 & 1 + return t() + end + return p() + """ + + assert run!(code) == [9] + end + test "call_stack is restored to empty after a __call dispatch" do code = """ local t = setmetatable({}, {__call = function(self) return 7 end}) @@ -134,6 +245,18 @@ defmodule Lua.VM.LazyCallInfoTest do assert state.call_stack == [] assert state.call_depth == 0 end + + test "call_stack is restored to empty after a __call dispatch on the forced-lazy path" do + code = """ + local t = setmetatable({}, {__call = function(self) local _ = 3 & 1 return 7 end}) + local function outer() local _ = 3 & 1 return t() end + return outer() + """ + + assert {:ok, [7], state} = run(code) + assert state.call_stack == [] + assert state.call_depth == 0 + end end describe "error tracebacks materialize the executor stack" do @@ -152,6 +275,24 @@ defmodule Lua.VM.LazyCallInfoTest do run(code) end end + + test "attempt to call a nil value from a nested call on the forced-lazy path" do + code = """ + function outer() + local _ = 3 & 1 + return inner() + end + function inner() + local _ = 3 & 1 + return notafunction() + end + return outer() + """ + + assert_raise LuaTypeError, ~r/attempt to call a nil value \(global 'notafunction'\)/, fn -> + run(code) + end + end end describe "call_stack stays balanced" do @@ -182,6 +323,26 @@ defmodule Lua.VM.LazyCallInfoTest do assert state.call_stack == [] end + test "is empty after a native callback mid-stack on the forced-lazy path" do + # Same as above but the bitwise op forces both functions onto the + # `:lua_closure` path, where the native-dispatch boundary materializes + # the lazy `frames` into call_stack and must restore the inherited + # (empty) stack afterward. + code = """ + local function deep() + local _ = 3 & 1 + debug.getinfo(1, "l") + return true + end + local function outer() local _ = 3 & 1 return deep() end + return outer() + """ + + assert {:ok, [true], state} = run(code) + assert state.call_stack == [] + assert state.call_depth == 0 + end + test "call_depth returns to zero after recursion" do code = """ local fact From 42578fd1632fa7e561bfd58029ed31975dc016af Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Thu, 11 Jun 2026 04:35:36 -0700 Subject: [PATCH 5/7] fix(vm): materialize lazy frames at metamethod dispatch boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Metamethod dispatch that re-enters Lua via call_function/3 — __index, __newindex, arithmetic/bitwise/comparison/concat/__len, and the generic_for iterator — ran from inside the interpreter opcode clauses where the in-flight Lua frames are tracked lazily in the do_execute `frames` argument and are NOT in state.call_stack. The leaf helpers never threaded `frames` in, so call_function/3 recursed with frames=[] and a metamethod reading state.call_stack (debug.traceback / debug.getinfo, error tracebacks) saw a truncated stack with the enclosing Lua frame dropped. Thread `frames` through index_value, table_index, table_newindex, invoke_metamethod and the try_*/compare_le helpers, and route the call_function/3 leaf sites through a new call_metamethod/4 that performs the same inherited-stack / rebuild_call_stack / restore dance already used for the __call, native-dispatch, dispatcher hand-off, and generic_for boundaries. The public table_index/3, table_newindex/4 and table_length/2 arities (reached from the dispatcher and stdlib, where state.call_stack is already materialized) pass :materialized so the dance is a no-op. Add regressions in lazy_call_info_test asserting debug.traceback / debug.getinfo currentline observe the enclosing Lua frame inside __index, __newindex and an arithmetic metamethod, plus the generic_for iterator boundary, on both the compiled and forced-lazy paths, and that call_stack restores to [] afterward. --- lib/lua/vm/executor.ex | 532 +++++++++++++++++++--------- test/lua/vm/lazy_call_info_test.exs | 176 +++++++++ 2 files changed, 549 insertions(+), 159 deletions(-) diff --git a/lib/lua/vm/executor.ex b/lib/lua/vm/executor.ex index 657fabb2..1122ee98 100644 --- a/lib/lua/vm/executor.ex +++ b/lib/lua/vm/executor.ex @@ -243,79 +243,128 @@ defmodule Lua.VM.Executor do @spec dispatcher_binop(atom(), term(), term(), State.t(), term(), term(), term()) :: {term(), State.t()} def dispatcher_binop(:add, a, b, state, proto, hint_a, hint_b) do - try_binary_metamethod("__add", a, b, state, fn -> - safe_add(a, b, 0, proto.source, hint_a, hint_b, state) - end) + try_binary_metamethod( + "__add", + a, + b, + state, + fn -> + safe_add(a, b, 0, proto.source, hint_a, hint_b, state) + end, + :materialized + ) end def dispatcher_binop(:subtract, a, b, state, proto, hint_a, hint_b) do - try_binary_metamethod("__sub", a, b, state, fn -> - safe_subtract(a, b, 0, proto.source, hint_a, hint_b, state) - end) + try_binary_metamethod( + "__sub", + a, + b, + state, + fn -> + safe_subtract(a, b, 0, proto.source, hint_a, hint_b, state) + end, + :materialized + ) end def dispatcher_binop(:multiply, a, b, state, proto, hint_a, hint_b) do - try_binary_metamethod("__mul", a, b, state, fn -> - safe_multiply(a, b, 0, proto.source, hint_a, hint_b, state) - end) + try_binary_metamethod( + "__mul", + a, + b, + state, + fn -> + safe_multiply(a, b, 0, proto.source, hint_a, hint_b, state) + end, + :materialized + ) end def dispatcher_binop(:divide, a, b, state, proto, hint_a, hint_b) do - try_binary_metamethod("__div", a, b, state, fn -> - safe_divide(a, b, 0, proto.source, hint_a, hint_b, state) - end) + try_binary_metamethod( + "__div", + a, + b, + state, + fn -> + safe_divide(a, b, 0, proto.source, hint_a, hint_b, state) + end, + :materialized + ) end def dispatcher_binop(:floor_divide, a, b, state, proto, hint_a, hint_b) do - try_binary_metamethod("__idiv", a, b, state, fn -> - safe_floor_divide(a, b, 0, proto.source, hint_a, hint_b, state) - end) + try_binary_metamethod( + "__idiv", + a, + b, + state, + fn -> + safe_floor_divide(a, b, 0, proto.source, hint_a, hint_b, state) + end, + :materialized + ) end def dispatcher_binop(:modulo, a, b, state, proto, hint_a, hint_b) do - try_binary_metamethod("__mod", a, b, state, fn -> - safe_modulo(a, b, 0, proto.source, hint_a, hint_b, state) - end) + try_binary_metamethod( + "__mod", + a, + b, + state, + fn -> + safe_modulo(a, b, 0, proto.source, hint_a, hint_b, state) + end, + :materialized + ) end def dispatcher_binop(:power, a, b, state, proto, hint_a, hint_b) do - try_binary_metamethod("__pow", a, b, state, fn -> - safe_power(a, b, 0, proto.source, hint_a, hint_b, state) - end) + try_binary_metamethod( + "__pow", + a, + b, + state, + fn -> + safe_power(a, b, 0, proto.source, hint_a, hint_b, state) + end, + :materialized + ) end @doc false @spec dispatcher_unop(atom(), term(), State.t(), term(), term()) :: {term(), State.t()} def dispatcher_unop(:negate, val, state, proto, hint) do - try_unary_metamethod("__unm", val, state, fn -> safe_negate(val, 0, proto.source, hint, state) end) + try_unary_metamethod("__unm", val, state, fn -> safe_negate(val, 0, proto.source, hint, state) end, :materialized) end @doc false @spec dispatcher_cmp(atom(), term(), term(), State.t(), term()) :: {term(), State.t()} def dispatcher_cmp(:less_than, a, b, state, proto) do - try_binary_metamethod("__lt", a, b, state, fn -> safe_compare_lt(a, b, 0, proto.source, state) end) + try_binary_metamethod("__lt", a, b, state, fn -> safe_compare_lt(a, b, 0, proto.source, state) end, :materialized) end def dispatcher_cmp(:less_equal, a, b, state, proto) do - compare_le(a, b, state, 0, proto.source) + compare_le(a, b, state, 0, proto.source, :materialized) end def dispatcher_cmp(:greater_than, a, b, state, proto) do # Lua 5.3 §3.4.4: a > b dispatches __lt with swapped operands. - try_binary_metamethod("__lt", b, a, state, fn -> safe_compare_lt(b, a, 0, proto.source, state) end) + try_binary_metamethod("__lt", b, a, state, fn -> safe_compare_lt(b, a, 0, proto.source, state) end, :materialized) end def dispatcher_cmp(:greater_equal, a, b, state, proto) do # Lua 5.3 §3.4.4: a >= b is rewritten to b <= a. - compare_le(b, a, state, 0, proto.source) + compare_le(b, a, state, 0, proto.source, :materialized) end def dispatcher_cmp(:equal, a, b, state, _proto) do - try_equality_metamethod(a, b, state, fn -> lua_equal(a, b) end) + try_equality_metamethod(a, b, state, fn -> lua_equal(a, b) end, :materialized) end def dispatcher_cmp(:not_equal, a, b, state, _proto) do - {eq, new_state} = try_equality_metamethod(a, b, state, fn -> lua_equal(a, b) end) + {eq, new_state} = try_equality_metamethod(a, b, state, fn -> lua_equal(a, b) end, :materialized) {not eq, new_state} end @@ -334,13 +383,13 @@ defmodule Lua.VM.Executor do _ -> case :erlang.map_get(:metatable, table) do nil -> {nil, state} - _ -> index_value(tref, name, state, 0, proto.source, name_hint) + _ -> index_value(tref, name, state, 0, proto.source, name_hint, :materialized) end end end def dispatcher_get_field(value, name, state, proto, name_hint) do - index_value(value, name, state, 0, proto.source, name_hint) + index_value(value, name, state, 0, proto.source, name_hint, :materialized) end # ── Dispatcher bridges: table opcodes ─────────────────────────────────── @@ -357,7 +406,7 @@ defmodule Lua.VM.Executor do @spec dispatcher_get_table(term(), term(), State.t(), term(), term()) :: {term(), State.t()} def dispatcher_get_table(value, key, state, proto, name_hint) do - index_value(value, key, state, 0, proto.source, name_hint) + index_value(value, key, state, 0, proto.source, name_hint, :materialized) end @doc false @@ -389,22 +438,28 @@ defmodule Lua.VM.Executor do @doc false @spec dispatcher_length(term(), State.t(), term()) :: {term(), State.t()} def dispatcher_length(value, state, _proto) do - try_unary_metamethod("__len", value, state, fn -> - case value do - {:tref, id} -> - table = Map.fetch!(state.tables, id) - Table.length(table) + try_unary_metamethod( + "__len", + value, + state, + fn -> + case value do + {:tref, id} -> + table = Map.fetch!(state.tables, id) + Table.length(table) - v when is_binary(v) -> - byte_size(v) + v when is_binary(v) -> + byte_size(v) - v when is_list(v) -> - length(v) + v when is_list(v) -> + length(v) - _ -> - 0 - end - end) + _ -> + 0 + end + end, + :materialized + ) end @doc false @@ -430,7 +485,7 @@ defmodule Lua.VM.Executor do @spec dispatcher_index_method_target(term(), term(), State.t(), term(), term()) :: {term(), State.t()} def dispatcher_index_method_target(obj, method_name, state, proto, name_hint) do - index_value(obj, method_name, state, 0, proto.source, name_hint) + index_value(obj, method_name, state, 0, proto.source, name_hint, :materialized) end # `:generic_for` step: invoke the iterator function. The iterator can be @@ -456,9 +511,16 @@ defmodule Lua.VM.Executor do def dispatcher_concat(left, right, state, proto) do src = proto.source - try_binary_metamethod("__concat", left, right, state, fn -> - concat_checked(concat_coerce(left, 0, src, state), concat_coerce(right, 0, src, state), state) - end) + try_binary_metamethod( + "__concat", + left, + right, + state, + fn -> + concat_checked(concat_coerce(left, 0, src, state), concat_coerce(right, 0, src, state), state) + end, + :materialized + ) end # `:call_*` out-of-mode bridge with name_hint-aware error wording. @@ -573,6 +635,32 @@ defmodule Lua.VM.Executor do end) end + # Dispatches a metamethod (or an `__index`/`__newindex` function) via + # `call_function/3` with the executor's in-flight Lua frames materialized + # into `state.call_stack` for the duration of the call. + # + # The metamethod may re-enter a Lua closure (recursing through `do_execute` + # with `frames = []`) or run a native callback that reads `state.call_stack` + # for `debug.*` / tracebacks — both need the enclosing executor frames + # visible, exactly like the `:call` opcode's native and `__call` dispatch. + # The inherited stack is restored on the returned state so the lazy + # bookkeeping stays balanced. + # + # On the interpreter path `frames` is the list of lazy executor frames. The + # dispatcher bridges and stdlib helpers reach the metamethod helpers via the + # public `def` arities, where `state.call_stack` is already materialized; + # they pass `:materialized` so the dance is a no-op (`materialize_frames/1` + # returns `[]`, leaving `state.call_stack` untouched). + defp call_metamethod(func, args, state, frames) do + inherited_call_stack = state.call_stack + state = %{state | call_stack: materialize_frames(frames) ++ inherited_call_stack} + {results, state} = call_function(func, args, state) + {results, %{state | call_stack: inherited_call_stack}} + end + + defp materialize_frames(:materialized), do: [] + defp materialize_frames(frames) when is_list(frames), do: rebuild_call_stack(frames) + # Invokes a `generic_for` iterator with the executor's in-flight Lua frames # materialized into `state.call_stack` for the duration of the call. The # iterator may itself be a Lua closure (recursing back through `do_execute`) @@ -1393,9 +1481,16 @@ defmodule Lua.VM.Executor do src = proto.source {result, new_state} = - try_binary_metamethod("__add", val_a, val_b, state, fn -> - safe_add(val_a, val_b, line, src, hint_a, hint_b, state) - end) + try_binary_metamethod( + "__add", + val_a, + val_b, + state, + fn -> + safe_add(val_a, val_b, line, src, hint_a, hint_b, state) + end, + frames + ) regs = put_elem(regs, dest, result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) @@ -1420,9 +1515,16 @@ defmodule Lua.VM.Executor do src = proto.source {result, new_state} = - try_binary_metamethod("__sub", val_a, val_b, state, fn -> - safe_subtract(val_a, val_b, line, src, hint_a, hint_b, state) - end) + try_binary_metamethod( + "__sub", + val_a, + val_b, + state, + fn -> + safe_subtract(val_a, val_b, line, src, hint_a, hint_b, state) + end, + frames + ) regs = put_elem(regs, dest, result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) @@ -1447,9 +1549,16 @@ defmodule Lua.VM.Executor do src = proto.source {result, new_state} = - try_binary_metamethod("__mul", val_a, val_b, state, fn -> - safe_multiply(val_a, val_b, line, src, hint_a, hint_b, state) - end) + try_binary_metamethod( + "__mul", + val_a, + val_b, + state, + fn -> + safe_multiply(val_a, val_b, line, src, hint_a, hint_b, state) + end, + frames + ) regs = put_elem(regs, dest, result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) @@ -1462,9 +1571,16 @@ defmodule Lua.VM.Executor do src = proto.source {result, new_state} = - try_binary_metamethod("__div", val_a, val_b, state, fn -> - safe_divide(val_a, val_b, line, src, hint_a, hint_b, state) - end) + try_binary_metamethod( + "__div", + val_a, + val_b, + state, + fn -> + safe_divide(val_a, val_b, line, src, hint_a, hint_b, state) + end, + frames + ) regs = put_elem(regs, dest, result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) @@ -1476,9 +1592,16 @@ defmodule Lua.VM.Executor do src = proto.source {result, new_state} = - try_binary_metamethod("__idiv", val_a, val_b, state, fn -> - safe_floor_divide(val_a, val_b, line, src, hint_a, hint_b, state) - end) + try_binary_metamethod( + "__idiv", + val_a, + val_b, + state, + fn -> + safe_floor_divide(val_a, val_b, line, src, hint_a, hint_b, state) + end, + frames + ) regs = put_elem(regs, dest, result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) @@ -1490,9 +1613,16 @@ defmodule Lua.VM.Executor do src = proto.source {result, new_state} = - try_binary_metamethod("__mod", val_a, val_b, state, fn -> - safe_modulo(val_a, val_b, line, src, hint_a, hint_b, state) - end) + try_binary_metamethod( + "__mod", + val_a, + val_b, + state, + fn -> + safe_modulo(val_a, val_b, line, src, hint_a, hint_b, state) + end, + frames + ) regs = put_elem(regs, dest, result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) @@ -1504,9 +1634,16 @@ defmodule Lua.VM.Executor do src = proto.source {result, new_state} = - try_binary_metamethod("__pow", val_a, val_b, state, fn -> - safe_power(val_a, val_b, line, src, hint_a, hint_b, state) - end) + try_binary_metamethod( + "__pow", + val_a, + val_b, + state, + fn -> + safe_power(val_a, val_b, line, src, hint_a, hint_b, state) + end, + frames + ) regs = put_elem(regs, dest, result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) @@ -1538,9 +1675,16 @@ defmodule Lua.VM.Executor do src = proto.source {result, new_state} = - try_binary_metamethod("__concat", left, right, state, fn -> - concat_checked(concat_coerce(left, line, src, state), concat_coerce(right, line, src, state), state) - end) + try_binary_metamethod( + "__concat", + left, + right, + state, + fn -> + concat_checked(concat_coerce(left, line, src, state), concat_coerce(right, line, src, state), state) + end, + frames + ) regs = put_elem(regs, dest, result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) @@ -1555,11 +1699,18 @@ defmodule Lua.VM.Executor do src = proto.source {result, new_state} = - try_binary_metamethod("__band", val_a, val_b, state, fn -> - Numeric.to_signed_int64( - Bitwise.band(to_integer!(val_a, line, src, hint_a, state), to_integer!(val_b, line, src, hint_b, state)) - ) - end) + try_binary_metamethod( + "__band", + val_a, + val_b, + state, + fn -> + Numeric.to_signed_int64( + Bitwise.band(to_integer!(val_a, line, src, hint_a, state), to_integer!(val_b, line, src, hint_b, state)) + ) + end, + frames + ) regs = put_elem(regs, dest, result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) @@ -1571,11 +1722,18 @@ defmodule Lua.VM.Executor do src = proto.source {result, new_state} = - try_binary_metamethod("__bor", val_a, val_b, state, fn -> - Numeric.to_signed_int64( - Bitwise.bor(to_integer!(val_a, line, src, hint_a, state), to_integer!(val_b, line, src, hint_b, state)) - ) - end) + try_binary_metamethod( + "__bor", + val_a, + val_b, + state, + fn -> + Numeric.to_signed_int64( + Bitwise.bor(to_integer!(val_a, line, src, hint_a, state), to_integer!(val_b, line, src, hint_b, state)) + ) + end, + frames + ) regs = put_elem(regs, dest, result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) @@ -1587,11 +1745,18 @@ defmodule Lua.VM.Executor do src = proto.source {result, new_state} = - try_binary_metamethod("__bxor", val_a, val_b, state, fn -> - Numeric.to_signed_int64( - Bitwise.bxor(to_integer!(val_a, line, src, hint_a, state), to_integer!(val_b, line, src, hint_b, state)) - ) - end) + try_binary_metamethod( + "__bxor", + val_a, + val_b, + state, + fn -> + Numeric.to_signed_int64( + Bitwise.bxor(to_integer!(val_a, line, src, hint_a, state), to_integer!(val_b, line, src, hint_b, state)) + ) + end, + frames + ) regs = put_elem(regs, dest, result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) @@ -1603,9 +1768,16 @@ defmodule Lua.VM.Executor do src = proto.source {result, new_state} = - try_binary_metamethod("__shl", val_a, val_b, state, fn -> - lua_shift_left(to_integer!(val_a, line, src, hint_a, state), to_integer!(val_b, line, src, hint_b, state)) - end) + try_binary_metamethod( + "__shl", + val_a, + val_b, + state, + fn -> + lua_shift_left(to_integer!(val_a, line, src, hint_a, state), to_integer!(val_b, line, src, hint_b, state)) + end, + frames + ) regs = put_elem(regs, dest, result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) @@ -1617,9 +1789,16 @@ defmodule Lua.VM.Executor do src = proto.source {result, new_state} = - try_binary_metamethod("__shr", val_a, val_b, state, fn -> - lua_shift_right(to_integer!(val_a, line, src, hint_a, state), to_integer!(val_b, line, src, hint_b, state)) - end) + try_binary_metamethod( + "__shr", + val_a, + val_b, + state, + fn -> + lua_shift_right(to_integer!(val_a, line, src, hint_a, state), to_integer!(val_b, line, src, hint_b, state)) + end, + frames + ) regs = put_elem(regs, dest, result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) @@ -1630,9 +1809,15 @@ defmodule Lua.VM.Executor do src = proto.source {result, new_state} = - try_unary_metamethod("__bnot", val, state, fn -> - Numeric.to_signed_int64(Bitwise.bnot(to_integer!(val, line, src, hint, state))) - end) + try_unary_metamethod( + "__bnot", + val, + state, + fn -> + Numeric.to_signed_int64(Bitwise.bnot(to_integer!(val, line, src, hint, state))) + end, + frames + ) regs = put_elem(regs, dest, result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) @@ -1658,7 +1843,7 @@ defmodule Lua.VM.Executor do true -> {result, new_state} = - try_equality_metamethod(val_a, val_b, state, fn -> lua_equal(val_a, val_b) end) + try_equality_metamethod(val_a, val_b, state, fn -> lua_equal(val_a, val_b) end, frames) regs = put_elem(regs, dest, result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) @@ -1682,7 +1867,14 @@ defmodule Lua.VM.Executor do src = proto.source {result, new_state} = - try_binary_metamethod("__lt", val_a, val_b, state, fn -> safe_compare_lt(val_a, val_b, line, src, state) end) + try_binary_metamethod( + "__lt", + val_a, + val_b, + state, + fn -> safe_compare_lt(val_a, val_b, line, src, state) end, + frames + ) regs = put_elem(regs, dest, result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) @@ -1703,7 +1895,7 @@ defmodule Lua.VM.Executor do do_execute(rest, regs, upvalues, proto, state, cont, frames, line) true -> - {result, new_state} = compare_le(val_a, val_b, state, line, proto.source) + {result, new_state} = compare_le(val_a, val_b, state, line, proto.source, frames) regs = put_elem(regs, dest, result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) @@ -1728,7 +1920,14 @@ defmodule Lua.VM.Executor do # Lua 5.3 §3.4.4: a > b is translated to b < a, which dispatches __lt. {result, new_state} = - try_binary_metamethod("__lt", val_b, val_a, state, fn -> safe_compare_lt(val_b, val_a, line, src, state) end) + try_binary_metamethod( + "__lt", + val_b, + val_a, + state, + fn -> safe_compare_lt(val_b, val_a, line, src, state) end, + frames + ) regs = put_elem(regs, dest, result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) @@ -1750,7 +1949,7 @@ defmodule Lua.VM.Executor do true -> # Lua 5.3 §3.4.4: a >= b is translated to b <= a. - {result, new_state} = compare_le(val_b, val_a, state, line, proto.source) + {result, new_state} = compare_le(val_b, val_a, state, line, proto.source, frames) regs = put_elem(regs, dest, result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) @@ -1772,7 +1971,7 @@ defmodule Lua.VM.Executor do true -> {eq_result, new_state} = - try_equality_metamethod(val_a, val_b, state, fn -> lua_equal(val_a, val_b) end) + try_equality_metamethod(val_a, val_b, state, fn -> lua_equal(val_a, val_b) end, frames) regs = put_elem(regs, dest, not eq_result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) @@ -1784,7 +1983,10 @@ defmodule Lua.VM.Executor do defp do_execute([{:negate, dest, source, hint} | rest], regs, upvalues, proto, state, cont, frames, line) do val = elem(regs, source) src = proto.source - {result, new_state} = try_unary_metamethod("__unm", val, state, fn -> safe_negate(val, line, src, hint, state) end) + + {result, new_state} = + try_unary_metamethod("__unm", val, state, fn -> safe_negate(val, line, src, hint, state) end, frames) + regs = put_elem(regs, dest, result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) end @@ -1799,22 +2001,28 @@ defmodule Lua.VM.Executor do value = elem(regs, source) {result, new_state} = - try_unary_metamethod("__len", value, state, fn -> - case value do - {:tref, id} -> - table = Map.fetch!(state.tables, id) - Table.length(table) - - v when is_binary(v) -> - byte_size(v) - - v when is_list(v) -> - length(v) - - _ -> - 0 - end - end) + try_unary_metamethod( + "__len", + value, + state, + fn -> + case value do + {:tref, id} -> + table = Map.fetch!(state.tables, id) + Table.length(table) + + v when is_binary(v) -> + byte_size(v) + + v when is_list(v) -> + length(v) + + _ -> + 0 + end + end, + frames + ) regs = put_elem(regs, dest, result) do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line) @@ -1856,7 +2064,7 @@ defmodule Lua.VM.Executor do do_execute(rest, regs, upvalues, proto, state, cont, frames, line) _ -> - {value, state} = index_value(table_val, key, state, line, proto.source, name_hint) + {value, state} = index_value(table_val, key, state, line, proto.source, name_hint, frames) regs = put_elem(regs, dest, value) do_execute(rest, regs, upvalues, proto, state, cont, frames, line) end @@ -1882,14 +2090,14 @@ defmodule Lua.VM.Executor do do_execute(rest, regs, upvalues, proto, state, cont, frames, line) _ -> - {value, state} = index_value(table_val, key, state, line, proto.source, name_hint) + {value, state} = index_value(table_val, key, state, line, proto.source, name_hint, frames) regs = put_elem(regs, dest, value) do_execute(rest, regs, upvalues, proto, state, cont, frames, line) end end _ -> - {value, state} = index_value(table_val, key, state, line, proto.source, name_hint) + {value, state} = index_value(table_val, key, state, line, proto.source, name_hint, frames) regs = put_elem(regs, dest, value) do_execute(rest, regs, upvalues, proto, state, cont, frames, line) end @@ -1913,7 +2121,7 @@ defmodule Lua.VM.Executor do {:tref, _} -> key = elem(regs, key_reg) value = elem(regs, value_reg) - state = table_newindex(table_val, key, value, state) + state = table_newindex(table_val, key, value, state, 0, frames) do_execute(rest, regs, upvalues, proto, state, cont, frames, line) _ -> @@ -1956,14 +2164,14 @@ defmodule Lua.VM.Executor do do_execute(rest, regs, upvalues, proto, state, cont, frames, line) _ -> - {value, state} = index_value(table_val, name, state, line, proto.source, name_hint) + {value, state} = index_value(table_val, name, state, line, proto.source, name_hint, frames) regs = put_elem(regs, dest, value) do_execute(rest, regs, upvalues, proto, state, cont, frames, line) end end _ -> - {value, state} = index_value(table_val, name, state, line, proto.source, name_hint) + {value, state} = index_value(table_val, name, state, line, proto.source, name_hint, frames) regs = put_elem(regs, dest, value) do_execute(rest, regs, upvalues, proto, state, cont, frames, line) end @@ -1986,7 +2194,7 @@ defmodule Lua.VM.Executor do case table_val do {:tref, _} -> value = elem(regs, value_reg) - state = table_newindex(table_val, name, value, state) + state = table_newindex(table_val, name, value, state, 0, frames) do_execute(rest, regs, upvalues, proto, state, cont, frames, line) _ -> @@ -2044,7 +2252,7 @@ defmodule Lua.VM.Executor do line ) do obj = elem(regs, obj_reg) - {func, state} = index_value(obj, method_name, state, line, proto.source, name_hint) + {func, state} = index_value(obj, method_name, state, line, proto.source, name_hint, frames) regs = put_elem(regs, base + 1, obj) regs = put_elem(regs, base, func) @@ -2406,11 +2614,11 @@ defmodule Lua.VM.Executor do defp get_metatable(_value, _state), do: nil - defp index_value({:tref, _} = tref, key, state, _line, _source, _name_hint) do - table_index(tref, key, state) + defp index_value({:tref, _} = tref, key, state, _line, _source, _name_hint, frames) do + table_index(tref, key, state, 0, frames) end - defp index_value(value, key, state, line, source, name_hint) do + defp index_value(value, key, state, line, source, name_hint, frames) do case get_metatable(value, state) do nil -> raise_index_type_error(value, line, source, name_hint, state) @@ -2423,10 +2631,10 @@ defmodule Lua.VM.Executor do raise_index_type_error(value, line, source, name_hint, state) {:tref, _} = idx_tbl -> - table_index(idx_tbl, key, state) + table_index(idx_tbl, key, state, 0, frames) func when is_tuple(func) -> - {results, state} = call_function(func, [value, key], state) + {results, state} = call_metamethod(func, [value, key], state, frames) {List.first(results), state} end end @@ -2490,10 +2698,10 @@ defmodule Lua.VM.Executor do """ @spec table_index({:tref, non_neg_integer()}, term(), State.t()) :: {term(), State.t()} def table_index({:tref, _} = tref, key, state) do - table_index(tref, key, state, 0) + table_index(tref, key, state, 0, :materialized) end - defp table_index({:tref, id}, key, state, depth) do + defp table_index({:tref, id}, key, state, depth, frames) do if depth >= @metamethod_chain_limit do raise RuntimeError, value: "'__index' chain too long; possible loop", state: state end @@ -2514,10 +2722,10 @@ defmodule Lua.VM.Executor do {nil, state} {:tref, _} = index_table -> - table_index(index_table, key, state, depth + 1) + table_index(index_table, key, state, depth + 1, frames) func when is_tuple(func) -> - {results, state} = call_function(func, [{:tref, id}, key], state) + {results, state} = call_metamethod(func, [{:tref, id}, key], state, frames) {List.first(results), state} end end @@ -2538,10 +2746,10 @@ defmodule Lua.VM.Executor do """ @spec table_newindex({:tref, non_neg_integer()}, term(), term(), State.t()) :: State.t() def table_newindex({:tref, _} = tref, key, value, state) do - table_newindex(tref, key, value, state, 0) + table_newindex(tref, key, value, state, 0, :materialized) end - defp table_newindex({:tref, id}, key, value, state, depth) do + defp table_newindex({:tref, id}, key, value, state, depth, frames) do if depth >= @metamethod_chain_limit do raise RuntimeError, value: "'__newindex' chain too long; possible loop", state: state end @@ -2571,10 +2779,10 @@ defmodule Lua.VM.Executor do %{state | tables: Map.put(state.tables, id, updated)} {:tref, _} = newindex_table -> - table_newindex(newindex_table, key, value, state, depth + 1) + table_newindex(newindex_table, key, value, state, depth + 1, frames) func when is_tuple(func) -> - {_results, state} = call_function(func, [{:tref, id}, key, value], state) + {_results, state} = call_metamethod(func, [{:tref, id}, key, value], state, frames) state end end @@ -2595,11 +2803,17 @@ defmodule Lua.VM.Executor do {integer(), State.t()} def table_length({:tref, _} = tref, state) do {raw, state} = - try_unary_metamethod("__len", tref, state, fn -> - {:tref, id} = tref - table = Map.fetch!(state.tables, id) - Table.length(table) - end) + try_unary_metamethod( + "__len", + tref, + state, + fn -> + {:tref, id} = tref + table = Map.fetch!(state.tables, id) + Table.length(table) + end, + :materialized + ) case raw do n when is_integer(n) -> @@ -2625,12 +2839,12 @@ defmodule Lua.VM.Executor do end end - defp try_binary_metamethod(metamethod_name, a, b, state, default_fn) do + defp try_binary_metamethod(metamethod_name, a, b, state, default_fn, frames) do metamethod = lookup_metamethod(a, metamethod_name, state) || lookup_metamethod(b, metamethod_name, state) - invoke_metamethod(metamethod, [a, b], state, default_fn) + invoke_metamethod(metamethod, [a, b], state, default_fn, frames) end # Lua 5.3 §3.4.4: a <= b is dispatched to __le when present on either @@ -2639,7 +2853,7 @@ defmodule Lua.VM.Executor do # negates the result. Only when neither metamethod is defined does it # fall through to the primitive comparison (which raises on # incompatible types). - defp compare_le(a, b, state, line, source) do + defp compare_le(a, b, state, line, source, frames) do case lookup_metamethod(a, "__le", state) || lookup_metamethod(b, "__le", state) do nil -> case lookup_metamethod(b, "__lt", state) || lookup_metamethod(a, "__lt", state) do @@ -2648,13 +2862,13 @@ defmodule Lua.VM.Executor do lt -> {lt_result, new_state} = - invoke_metamethod(lt, [b, a], state, fn -> safe_compare_lt(b, a, line, source, state) end) + invoke_metamethod(lt, [b, a], state, fn -> safe_compare_lt(b, a, line, source, state) end, frames) {not Value.truthy?(lt_result), new_state} end le -> - invoke_metamethod(le, [a, b], state, fn -> safe_compare_le(a, b, line, source, state) end) + invoke_metamethod(le, [a, b], state, fn -> safe_compare_le(a, b, line, source, state) end, frames) end end @@ -2665,26 +2879,26 @@ defmodule Lua.VM.Executor do end end - defp try_unary_metamethod(metamethod_name, a, state, default_fn) do + defp try_unary_metamethod(metamethod_name, a, state, default_fn, frames) do metamethod = lookup_metamethod(a, metamethod_name, state) - invoke_metamethod(metamethod, [a], state, default_fn) + invoke_metamethod(metamethod, [a], state, default_fn, frames) end # Per Lua 5.3 §3.4.4, __eq is only consulted when both operands have the # same primitive type and rawequal returns false. Lua looks at the first # operand's __eq, falling back to the second operand's. The two metamethods # do *not* need to be the same function (that was Lua 5.1 behaviour). - defp try_equality_metamethod(a, b, state, default_fn) do + defp try_equality_metamethod(a, b, state, default_fn, frames) do if eq_metamethod_eligible?(a, b) do case lookup_metamethod(a, "__eq", state) do nil -> case lookup_metamethod(b, "__eq", state) do nil -> {default_fn.(), state} - eq -> invoke_metamethod(eq, [a, b], state, default_fn) + eq -> invoke_metamethod(eq, [a, b], state, default_fn, frames) end eq -> - invoke_metamethod(eq, [a, b], state, default_fn) + invoke_metamethod(eq, [a, b], state, default_fn, frames) end else {default_fn.(), state} @@ -2698,21 +2912,21 @@ defmodule Lua.VM.Executor do # back to default_fn when the metamethod is missing or unsupported. Delegates # to call_function/3 so vararg metamethods receive operands through proto # varargs rather than being silently dropped. - defp invoke_metamethod(metamethod, args, state, default_fn) do + defp invoke_metamethod(metamethod, args, state, default_fn, frames) do case metamethod do nil -> {default_fn.(), state} {:native_func, _} = func -> - {results, new_state} = call_function(func, args, state) + {results, new_state} = call_metamethod(func, args, state, frames) {List.first(results), new_state} {:lua_closure, _, _} = func -> - {results, new_state} = call_function(func, args, state) + {results, new_state} = call_metamethod(func, args, state, frames) {List.first(results), new_state} {:compiled_closure, _, _} = func -> - {results, new_state} = call_function(func, args, state) + {results, new_state} = call_metamethod(func, args, state, frames) {List.first(results), new_state} _ -> diff --git a/test/lua/vm/lazy_call_info_test.exs b/test/lua/vm/lazy_call_info_test.exs index b74c1edd..f6c3851d 100644 --- a/test/lua/vm/lazy_call_info_test.exs +++ b/test/lua/vm/lazy_call_info_test.exs @@ -259,6 +259,182 @@ defmodule Lua.VM.LazyCallInfoTest do end end + describe "metamethod dispatch materializes the executor stack" do + # `__index`/`__newindex`/arithmetic metamethods re-enter via + # `call_function/3` from inside the interpreter opcode clauses, where the + # enclosing Lua frames are tracked lazily in the `frames` argument and are + # NOT in `state.call_stack`. They must be materialized for the duration of + # the metamethod call exactly like the native-dispatch and `__call` + # boundaries — otherwise a callback reading the stack sees a truncated + # traceback (the enclosing frame gone) and `currentline` of -1. + + test "debug.traceback inside an __index callback sees the enclosing frame" do + code = """ + local t = setmetatable({}, {__index = function(self, k) + return debug.traceback("im") + end}) + function p() + return t.foo + end + return p() + """ + + assert run!(code) == ["im\nstack traceback:\n\ttest.lua:7: in ?"] + end + + test "debug.traceback inside an __index callback on the forced-lazy path" do + code = """ + local t = setmetatable({}, {__index = function(self, k) + local _ = 3 & 1 + return debug.traceback("im") + end}) + function p() + local _ = 3 & 1 + return t.foo + end + return p() + """ + + assert run!(code) == ["im\nstack traceback:\n\ttest.lua:9: in ?"] + end + + test "debug.getinfo currentline inside an __index callback is the call site" do + code = """ + local t = setmetatable({}, {__index = function(self, k) + return debug.getinfo(2, "nl").currentline + end}) + function p() + return t.foo + end + return p() + """ + + assert run!(code) == [7] + end + + test "debug.getinfo currentline inside an __index callback on the forced-lazy path" do + code = """ + local t = setmetatable({}, {__index = function(self, k) + local _ = 3 & 1 + return debug.getinfo(2, "nl").currentline + end}) + function p() + local _ = 3 & 1 + return t.foo + end + return p() + """ + + assert run!(code) == [9] + end + + test "debug.traceback inside a __newindex callback on the forced-lazy path" do + code = """ + local t = setmetatable({}, {__newindex = function(self, k, v) + local _ = 3 & 1 + _G.captured = debug.traceback("nm") + end}) + function p() + local _ = 3 & 1 + t.foo = 1 + end + p() + return _G.captured + """ + + assert run!(code) == ["nm\nstack traceback:\n\ttest.lua:9: in ?"] + end + + test "debug.traceback inside an arithmetic metamethod on the forced-lazy path" do + code = """ + local t = setmetatable({}, {__add = function(a, b) + local _ = 3 & 1 + return debug.traceback("am") + end}) + function p() + local _ = 3 & 1 + return t + 1 + end + return p() + """ + + assert run!(code) == ["am\nstack traceback:\n\ttest.lua:9: in ?"] + end + + test "call_stack is restored to empty after an __index dispatch on the forced-lazy path" do + code = """ + local t = setmetatable({}, {__index = function(self, k) local _ = 3 & 1 return 5 end}) + local function outer() local _ = 3 & 1 return t.foo end + return outer() + """ + + assert {:ok, [5], state} = run(code) + assert state.call_stack == [] + assert state.call_depth == 0 + end + end + + describe "generic_for iterator dispatch materializes the executor stack" do + # `call_iterator/6` materializes the lazy `frames` into `state.call_stack` + # for a `for _ in iter, ... do` iterator call. The iterator can read the + # live stack via `debug.*`, so the enclosing `for`-loop frame must be + # visible — and the inherited stack restored afterward. + + test "debug.traceback inside the iterator sees the enclosing frame" do + code = """ + local function iter(s, c) + if c < 1 then return c + 1, debug.traceback("it") end + end + function driver() + for _, v in iter, nil, 0 do + return v + end + end + return driver() + """ + + assert run!(code) == ["it\nstack traceback:\n\ttest.lua:9: in ?"] + end + + test "debug.traceback inside the iterator on the forced-lazy path" do + code = """ + local function iter(s, c) + local _ = 3 & 1 + if c < 1 then return c + 1, debug.traceback("it") end + end + function driver() + local _ = 3 & 1 + for _, v in iter, nil, 0 do + return v + end + end + return driver() + """ + + assert run!(code) == ["it\nstack traceback:\n\ttest.lua:11: in ?"] + end + + test "call_stack is restored to empty after a generic_for loop on the forced-lazy path" do + code = """ + local function iter(s, c) + local _ = 3 & 1 + if c < 1 then return c + 1, c end + end + local function driver() + local _ = 3 & 1 + local sum = 0 + for _, v in iter, nil, 0 do sum = sum + v end + return sum + end + return driver() + """ + + assert {:ok, [0], state} = run(code) + assert state.call_stack == [] + assert state.call_depth == 0 + end + end + describe "error tracebacks materialize the executor stack" do test "attempt to call a nil value from a nested call" do code = """ From 43ba9fbf302194203afcbc30c35d55c83faf0be5 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Sat, 13 Jun 2026 17:48:51 -0700 Subject: [PATCH 6/7] fix(vm): reconcile lazy call-info with main's compiled bitwise ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging main into this branch produced a semantic conflict that compiled locally in isolation but broke `refs/pull/352/merge` in CI: - main's #347 added the `dispatcher_bitwise/7` and `dispatcher_bnot/4` compiled paths, which call `try_binary_metamethod`/`try_unary_metamethod` at the pre-lazy arity. This branch added a trailing frames argument to those helpers, so the merged tree failed to compile (`try_binary_metamethod/5 undefined`). Thread `:materialized` through the six bitwise/bnot call sites, matching the sibling dispatcher_* helpers. - main's #347 also made bitwise ops bytecode-encodable, so the `3 & 1` token the lazy-path tests used to force the `:lua_closure` interpreter path now compiles — silently moving every forced-lazy case onto the compiled path (line attribution 0/-1 instead of the live line). Switch the forcing op to `1 and 2`, whose `test_and` opcode is still not encodable, restoring genuine lazy-path coverage with the line goldens intact. Full suite green (2373 passed, 19 skipped); format clean. --- lib/lua/vm/executor.ex | 81 ++++++++++++++++++++++------- test/lua/vm/lazy_call_info_test.exs | 77 +++++++++++++-------------- 2 files changed, 102 insertions(+), 56 deletions(-) diff --git a/lib/lua/vm/executor.ex b/lib/lua/vm/executor.ex index 2ebc0a52..4a1b3be3 100644 --- a/lib/lua/vm/executor.ex +++ b/lib/lua/vm/executor.ex @@ -351,41 +351,80 @@ defmodule Lua.VM.Executor do def dispatcher_bitwise(:band, a, b, state, proto, hint_a, hint_b) do src = proto.source - try_binary_metamethod("__band", a, b, state, fn -> - Numeric.to_signed_int64(Bitwise.band(to_integer!(a, 0, src, hint_a, state), to_integer!(b, 0, src, hint_b, state))) - end) + try_binary_metamethod( + "__band", + a, + b, + state, + fn -> + Numeric.to_signed_int64( + Bitwise.band(to_integer!(a, 0, src, hint_a, state), to_integer!(b, 0, src, hint_b, state)) + ) + end, + :materialized + ) end def dispatcher_bitwise(:bor, a, b, state, proto, hint_a, hint_b) do src = proto.source - try_binary_metamethod("__bor", a, b, state, fn -> - Numeric.to_signed_int64(Bitwise.bor(to_integer!(a, 0, src, hint_a, state), to_integer!(b, 0, src, hint_b, state))) - end) + try_binary_metamethod( + "__bor", + a, + b, + state, + fn -> + Numeric.to_signed_int64(Bitwise.bor(to_integer!(a, 0, src, hint_a, state), to_integer!(b, 0, src, hint_b, state))) + end, + :materialized + ) end def dispatcher_bitwise(:bxor, a, b, state, proto, hint_a, hint_b) do src = proto.source - try_binary_metamethod("__bxor", a, b, state, fn -> - Numeric.to_signed_int64(Bitwise.bxor(to_integer!(a, 0, src, hint_a, state), to_integer!(b, 0, src, hint_b, state))) - end) + try_binary_metamethod( + "__bxor", + a, + b, + state, + fn -> + Numeric.to_signed_int64( + Bitwise.bxor(to_integer!(a, 0, src, hint_a, state), to_integer!(b, 0, src, hint_b, state)) + ) + end, + :materialized + ) end def dispatcher_bitwise(:shl, a, b, state, proto, hint_a, hint_b) do src = proto.source - try_binary_metamethod("__shl", a, b, state, fn -> - lua_shift_left(to_integer!(a, 0, src, hint_a, state), to_integer!(b, 0, src, hint_b, state)) - end) + try_binary_metamethod( + "__shl", + a, + b, + state, + fn -> + lua_shift_left(to_integer!(a, 0, src, hint_a, state), to_integer!(b, 0, src, hint_b, state)) + end, + :materialized + ) end def dispatcher_bitwise(:shr, a, b, state, proto, hint_a, hint_b) do src = proto.source - try_binary_metamethod("__shr", a, b, state, fn -> - lua_shift_right(to_integer!(a, 0, src, hint_a, state), to_integer!(b, 0, src, hint_b, state)) - end) + try_binary_metamethod( + "__shr", + a, + b, + state, + fn -> + lua_shift_right(to_integer!(a, 0, src, hint_a, state), to_integer!(b, 0, src, hint_b, state)) + end, + :materialized + ) end @doc false @@ -393,9 +432,15 @@ defmodule Lua.VM.Executor do def dispatcher_bnot(val, state, proto, hint) do src = proto.source - try_unary_metamethod("__bnot", val, state, fn -> - Numeric.to_signed_int64(Bitwise.bnot(to_integer!(val, 0, src, hint, state))) - end) + try_unary_metamethod( + "__bnot", + val, + state, + fn -> + Numeric.to_signed_int64(Bitwise.bnot(to_integer!(val, 0, src, hint, state))) + end, + :materialized + ) end @doc false diff --git a/test/lua/vm/lazy_call_info_test.exs b/test/lua/vm/lazy_call_info_test.exs index f6c3851d..6693b1ff 100644 --- a/test/lua/vm/lazy_call_info_test.exs +++ b/test/lua/vm/lazy_call_info_test.exs @@ -21,10 +21,11 @@ defmodule Lua.VM.LazyCallInfoTest do refers to, where `state.call_stack` is left untouched and entries are synthesized from `frames` only at a read boundary. Sub-prototypes only route here when they can NOT be lowered to bytecode, so the - lazy-variant tests below sprinkle a bitwise op (`3 & 1`, not - bytecode-encodable) into each function to force the interpreter path. - On this path the live call-site line survives, so the golden - `currentline` / traceback line numbers differ from the compiled path. + lazy-variant tests below sprinkle a short-circuit op (`1 and 2`, whose + `test_and` opcode is not bytecode-encodable) into each function to force + the interpreter path. On this path the live call-site line survives, so + the golden `currentline` / traceback line numbers differ from the + compiled path. """ use ExUnit.Case, async: true @@ -65,17 +66,17 @@ defmodule Lua.VM.LazyCallInfoTest do end test "name/namewhat/currentline on the forced-lazy interpreter path" do - # The `3 & 1` bitwise op is not bytecode-encodable, so both functions - # fall onto the `:lua_closure` interpreter path. Unlike the compiled + # The `1 and 2` short-circuit op is not bytecode-encodable, so both + # functions fall onto the `:lua_closure` interpreter path. Unlike the compiled # hand-off above, the live call-site `line` survives, so `currentline` # is 3 (the `return inner()` site) rather than 0. code = """ function outer() - local _ = 3 & 1 + local _ = 1 and 2 return inner() end function inner() - local _ = 3 & 1 + local _ = 1 and 2 local info = debug.getinfo(2, "nSl") return info.name, info.namewhat, info.currentline, info.source, info.what end @@ -101,7 +102,7 @@ defmodule Lua.VM.LazyCallInfoTest do # `currentline` is the live line (3) rather than -1. code = """ function f() - local _ = 3 & 1 + local _ = 1 and 2 return debug.getinfo(1, "Sl").currentline end return f() @@ -127,10 +128,10 @@ defmodule Lua.VM.LazyCallInfoTest do test "three-deep chain resolves distinct levels on the forced-lazy path" do code = """ - function a() local _ = 3 & 1 return b() end - function b() local _ = 3 & 1 return c() end + function a() local _ = 1 and 2 return b() end + function b() local _ = 1 and 2 return c() end function c() - local _ = 3 & 1 + local _ = 1 and 2 local l2 = debug.getinfo(2, "n").name local l3 = debug.getinfo(3, "n").name return l2, l3 @@ -157,8 +158,8 @@ defmodule Lua.VM.LazyCallInfoTest do # The compiled path above strips the line to 0; the lazy `:lua_closure` # path preserves each frame's call-site line (outer at 1, inner at 3). code = """ - function outer() local _ = 3 & 1 return inner() end - function inner() local _ = 3 & 1 return debug.traceback() end + function outer() local _ = 1 and 2 return inner() end + function inner() local _ = 1 and 2 return debug.traceback() end return outer() """ @@ -191,11 +192,11 @@ defmodule Lua.VM.LazyCallInfoTest do test "debug.traceback inside a __call callback on the forced-lazy path" do code = """ local t = setmetatable({}, {__call = function(self) - local _ = 3 & 1 + local _ = 1 and 2 return debug.traceback("cm") end}) function p() - local _ = 3 & 1 + local _ = 1 and 2 return t() end return p() @@ -221,11 +222,11 @@ defmodule Lua.VM.LazyCallInfoTest do test "debug.getinfo currentline inside a __call callback on the forced-lazy path" do code = """ local t = setmetatable({}, {__call = function(self) - local _ = 3 & 1 + local _ = 1 and 2 return debug.getinfo(2, "nl").currentline end}) function p() - local _ = 3 & 1 + local _ = 1 and 2 return t() end return p() @@ -248,8 +249,8 @@ defmodule Lua.VM.LazyCallInfoTest do test "call_stack is restored to empty after a __call dispatch on the forced-lazy path" do code = """ - local t = setmetatable({}, {__call = function(self) local _ = 3 & 1 return 7 end}) - local function outer() local _ = 3 & 1 return t() end + local t = setmetatable({}, {__call = function(self) local _ = 1 and 2 return 7 end}) + local function outer() local _ = 1 and 2 return t() end return outer() """ @@ -285,11 +286,11 @@ defmodule Lua.VM.LazyCallInfoTest do test "debug.traceback inside an __index callback on the forced-lazy path" do code = """ local t = setmetatable({}, {__index = function(self, k) - local _ = 3 & 1 + local _ = 1 and 2 return debug.traceback("im") end}) function p() - local _ = 3 & 1 + local _ = 1 and 2 return t.foo end return p() @@ -315,11 +316,11 @@ defmodule Lua.VM.LazyCallInfoTest do test "debug.getinfo currentline inside an __index callback on the forced-lazy path" do code = """ local t = setmetatable({}, {__index = function(self, k) - local _ = 3 & 1 + local _ = 1 and 2 return debug.getinfo(2, "nl").currentline end}) function p() - local _ = 3 & 1 + local _ = 1 and 2 return t.foo end return p() @@ -331,11 +332,11 @@ defmodule Lua.VM.LazyCallInfoTest do test "debug.traceback inside a __newindex callback on the forced-lazy path" do code = """ local t = setmetatable({}, {__newindex = function(self, k, v) - local _ = 3 & 1 + local _ = 1 and 2 _G.captured = debug.traceback("nm") end}) function p() - local _ = 3 & 1 + local _ = 1 and 2 t.foo = 1 end p() @@ -348,11 +349,11 @@ defmodule Lua.VM.LazyCallInfoTest do test "debug.traceback inside an arithmetic metamethod on the forced-lazy path" do code = """ local t = setmetatable({}, {__add = function(a, b) - local _ = 3 & 1 + local _ = 1 and 2 return debug.traceback("am") end}) function p() - local _ = 3 & 1 + local _ = 1 and 2 return t + 1 end return p() @@ -363,8 +364,8 @@ defmodule Lua.VM.LazyCallInfoTest do test "call_stack is restored to empty after an __index dispatch on the forced-lazy path" do code = """ - local t = setmetatable({}, {__index = function(self, k) local _ = 3 & 1 return 5 end}) - local function outer() local _ = 3 & 1 return t.foo end + local t = setmetatable({}, {__index = function(self, k) local _ = 1 and 2 return 5 end}) + local function outer() local _ = 1 and 2 return t.foo end return outer() """ @@ -399,11 +400,11 @@ defmodule Lua.VM.LazyCallInfoTest do test "debug.traceback inside the iterator on the forced-lazy path" do code = """ local function iter(s, c) - local _ = 3 & 1 + local _ = 1 and 2 if c < 1 then return c + 1, debug.traceback("it") end end function driver() - local _ = 3 & 1 + local _ = 1 and 2 for _, v in iter, nil, 0 do return v end @@ -417,11 +418,11 @@ defmodule Lua.VM.LazyCallInfoTest do test "call_stack is restored to empty after a generic_for loop on the forced-lazy path" do code = """ local function iter(s, c) - local _ = 3 & 1 + local _ = 1 and 2 if c < 1 then return c + 1, c end end local function driver() - local _ = 3 & 1 + local _ = 1 and 2 local sum = 0 for _, v in iter, nil, 0 do sum = sum + v end return sum @@ -455,11 +456,11 @@ defmodule Lua.VM.LazyCallInfoTest do test "attempt to call a nil value from a nested call on the forced-lazy path" do code = """ function outer() - local _ = 3 & 1 + local _ = 1 and 2 return inner() end function inner() - local _ = 3 & 1 + local _ = 1 and 2 return notafunction() end return outer() @@ -506,11 +507,11 @@ defmodule Lua.VM.LazyCallInfoTest do # (empty) stack afterward. code = """ local function deep() - local _ = 3 & 1 + local _ = 1 and 2 debug.getinfo(1, "l") return true end - local function outer() local _ = 3 & 1 return deep() end + local function outer() local _ = 1 and 2 return deep() end return outer() """ From 43b4adbb7db96b4c810c7be1bace8a613e0c1d72 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Sat, 13 Jun 2026 18:38:56 -0700 Subject: [PATCH 7/7] test(vm): update compiled-path currentline golden for plumbed source lines Merging main brought in #355 (plumb per-call source lines through the compiled dispatcher), so the compiled `:compiled_closure` hand-off now resolves the live frame's `currentline` to the call-site line (2) instead of the stripped `-1`. Update the level-1 golden and the moduledoc note. --- test/lua/vm/lazy_call_info_test.exs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/lua/vm/lazy_call_info_test.exs b/test/lua/vm/lazy_call_info_test.exs index 6693b1ff..302961f2 100644 --- a/test/lua/vm/lazy_call_info_test.exs +++ b/test/lua/vm/lazy_call_info_test.exs @@ -14,9 +14,10 @@ defmodule Lua.VM.LazyCallInfoTest do * The compiled `:compiled_closure` hand-off — exercised by plain `function f() ... end` / `setmetatable` closures, whose sub-prototypes - compile WITH bytecode and so route through `Dispatcher.execute/4`. On - this path the executor strips call-site line info (`currentline` reports - `0` / `-1`). + compile WITH bytecode and so route through `Dispatcher.execute/4`. The + per-call source line is baked into the call opcode, so the live frame's + `currentline` resolves on this path; caller frames reached across the + hand-off still report `0` / `-1`. * The lazy `:lua_closure` interpreter path — the branch this module's name refers to, where `state.call_stack` is left untouched and entries are synthesized from `frames` only at a read boundary. Sub-prototypes only @@ -87,6 +88,10 @@ defmodule Lua.VM.LazyCallInfoTest do end test "level 1 currentline" do + # On the compiled hand-off the per-call source line is now baked into + # the call opcode (feat: plumb per-call source lines through the + # compiled dispatcher), so level-1 `currentline` resolves to the live + # `debug.getinfo` call site (line 2) rather than the stripped `-1`. code = """ function f() return debug.getinfo(1, "Sl").currentline @@ -94,7 +99,7 @@ defmodule Lua.VM.LazyCallInfoTest do return f() """ - assert run!(code) == [-1] + assert run!(code) == [2] end test "level 1 currentline on the forced-lazy interpreter path" do