diff --git a/lib/lua/vm/executor.ex b/lib/lua/vm/executor.ex index cdf72e1..8527d8f 100644 --- a/lib/lua/vm/executor.ex +++ b/lib/lua/vm/executor.ex @@ -248,51 +248,100 @@ 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, nil, proto.source, hint_a, hint_b, state) - end) + try_binary_metamethod( + "__add", + a, + b, + state, + fn -> + safe_add(a, b, nil, 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, nil, proto.source, hint_a, hint_b, state) - end) + try_binary_metamethod( + "__sub", + a, + b, + state, + fn -> + safe_subtract(a, b, nil, 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, nil, proto.source, hint_a, hint_b, state) - end) + try_binary_metamethod( + "__mul", + a, + b, + state, + fn -> + safe_multiply(a, b, nil, 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, nil, proto.source, hint_a, hint_b, state) - end) + try_binary_metamethod( + "__div", + a, + b, + state, + fn -> + safe_divide(a, b, nil, 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, nil, proto.source, hint_a, hint_b, state) - end) + try_binary_metamethod( + "__idiv", + a, + b, + state, + fn -> + safe_floor_divide(a, b, nil, 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, nil, proto.source, hint_a, hint_b, state) - end) + try_binary_metamethod( + "__mod", + a, + b, + state, + fn -> + safe_modulo(a, b, nil, 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, nil, proto.source, hint_a, hint_b, state) - end) + try_binary_metamethod( + "__pow", + a, + b, + state, + fn -> + safe_power(a, b, nil, 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, nil, proto.source, hint, state) end) + try_unary_metamethod("__unm", val, state, fn -> safe_negate(val, nil, proto.source, hint, state) end, :materialized) end # Bitwise bridges mirror the `:bitwise_*` / `:shift_*` interpreter clauses @@ -307,41 +356,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 @@ -349,37 +437,43 @@ 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 @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, nil, proto.source, state) end) + try_binary_metamethod("__lt", a, b, state, fn -> safe_compare_lt(a, b, nil, proto.source, state) end, :materialized) end def dispatcher_cmp(:less_equal, a, b, state, proto) do - compare_le(a, b, state, nil, proto.source) + compare_le(a, b, state, nil, 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, nil, proto.source, state) end) + try_binary_metamethod("__lt", b, a, state, fn -> safe_compare_lt(b, a, nil, 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, nil, proto.source) + compare_le(b, a, state, nil, 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 @@ -398,13 +492,13 @@ defmodule Lua.VM.Executor do _ -> case :erlang.map_get(:metatable, table) do nil -> {nil, state} - _ -> index_value(tref, name, state, nil, proto.source, name_hint) + _ -> index_value(tref, name, state, nil, proto.source, name_hint, :materialized) end end end def dispatcher_get_field(value, name, state, proto, name_hint) do - index_value(value, name, state, nil, proto.source, name_hint) + index_value(value, name, state, nil, proto.source, name_hint, :materialized) end # ── Dispatcher bridges: table opcodes ─────────────────────────────────── @@ -422,7 +516,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, nil, proto.source, name_hint) + index_value(value, key, state, nil, proto.source, name_hint, :materialized) end @doc false @@ -454,22 +548,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 @@ -496,7 +596,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, nil, proto.source, name_hint) + index_value(obj, method_name, state, nil, proto.source, name_hint, :materialized) end # `:generic_for` step: invoke the iterator function. The iterator can be @@ -525,9 +625,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, nil, src, state), concat_coerce(right, nil, src, state), state) - end) + try_binary_metamethod( + "__concat", + left, + right, + state, + fn -> + concat_checked(concat_coerce(left, nil, src, state), concat_coerce(right, nil, src, state), state) + end, + :materialized + ) end # `:call_*` out-of-mode bridge with name_hint-aware error wording. @@ -609,6 +716,77 @@ 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, `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 -> + %{ + source: frame.proto.source, + line: frame.line, + name: hint_name(frame.name_hint), + namewhat: hint_namewhat(frame.name_hint) + } + 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`) + # 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 @@ -708,7 +886,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 @@ -993,7 +1171,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 @@ -1102,10 +1280,28 @@ 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) - 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 + 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: materialized_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} -> @@ -1127,6 +1323,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, @@ -1135,17 +1338,27 @@ 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)} + # 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) + State.check_call_depth!(state, overflow_call_stack) + end state = %{ state - | call_stack: [call_info | state.call_stack], - call_depth: state.call_depth + 1, + | call_depth: state.call_depth + 1, open_upvalues: %{} } @@ -1174,14 +1387,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, @@ -1199,7 +1421,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, @@ -1211,7 +1433,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), @@ -1225,7 +1447,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), @@ -1233,7 +1455,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 @@ -1359,9 +1593,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) @@ -1386,9 +1627,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) @@ -1413,9 +1661,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) @@ -1428,9 +1683,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) @@ -1442,9 +1704,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) @@ -1456,9 +1725,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) @@ -1470,9 +1746,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) @@ -1504,9 +1787,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) @@ -1521,11 +1811,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) @@ -1537,11 +1834,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) @@ -1553,11 +1857,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) @@ -1569,9 +1880,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) @@ -1583,9 +1901,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) @@ -1596,9 +1921,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) @@ -1624,7 +1955,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) @@ -1648,7 +1979,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) @@ -1669,7 +2007,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) @@ -1694,7 +2032,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) @@ -1716,7 +2061,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) @@ -1738,7 +2083,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) @@ -1750,7 +2095,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 @@ -1765,22 +2113,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) @@ -1822,7 +2176,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 @@ -1848,14 +2202,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 @@ -1879,7 +2233,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) _ -> @@ -1922,14 +2276,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 @@ -1952,7 +2306,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) _ -> @@ -2010,7 +2364,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) @@ -2053,10 +2407,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 } @@ -2369,11 +2726,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) @@ -2386,10 +2743,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 @@ -2453,10 +2810,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 @@ -2477,10 +2834,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 @@ -2501,10 +2858,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 @@ -2534,10 +2891,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 @@ -2558,11 +2915,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) -> @@ -2588,12 +2951,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 @@ -2602,7 +2965,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 @@ -2611,13 +2974,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 @@ -2628,26 +2991,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} @@ -2661,21 +3024,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/lib/lua/vm/state.ex b/lib/lua/vm/state.ex index 74f416a..a529b89 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/lazy_call_info_test.exs b/test/lua/vm/lazy_call_info_test.exs new file mode 100644 index 0000000..302961f --- /dev/null +++ b/test/lua/vm/lazy_call_info_test.exs @@ -0,0 +1,559 @@ +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, 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. + + 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`. 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 + route here when they can NOT be lowered to bytecode, so the + 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 + + 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 "name/namewhat/currentline on the forced-lazy interpreter path" do + # 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 _ = 1 and 2 + return inner() + end + function inner() + local _ = 1 and 2 + 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 + # 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 + end + return f() + """ + + assert run!(code) == [2] + 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 _ = 1 and 2 + 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 + 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 + + test "three-deep chain resolves distinct levels on the forced-lazy path" do + code = """ + function a() local _ = 1 and 2 return b() end + function b() local _ = 1 and 2 return c() end + function c() + local _ = 1 and 2 + 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 + + 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 _ = 1 and 2 return inner() end + function inner() local _ = 1 and 2 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 + # 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.traceback inside a __call callback on the forced-lazy path" do + code = """ + local t = setmetatable({}, {__call = function(self) + local _ = 1 and 2 + return debug.traceback("cm") + end}) + function p() + local _ = 1 and 2 + 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) + return debug.getinfo(2, "nl").currentline + end}) + function p() + return t() + end + return p() + """ + + 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 _ = 1 and 2 + return debug.getinfo(2, "nl").currentline + end}) + function p() + local _ = 1 and 2 + 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}) + local function outer() return t() end + return outer() + """ + + assert {:ok, [7], state} = run(code) + 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 _ = 1 and 2 return 7 end}) + local function outer() local _ = 1 and 2 return t() end + return outer() + """ + + assert {:ok, [7], state} = run(code) + assert state.call_stack == [] + assert state.call_depth == 0 + 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 _ = 1 and 2 + return debug.traceback("im") + end}) + function p() + local _ = 1 and 2 + 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 _ = 1 and 2 + return debug.getinfo(2, "nl").currentline + end}) + function p() + local _ = 1 and 2 + 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 _ = 1 and 2 + _G.captured = debug.traceback("nm") + end}) + function p() + local _ = 1 and 2 + 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 _ = 1 and 2 + return debug.traceback("am") + end}) + function p() + local _ = 1 and 2 + 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 _ = 1 and 2 return 5 end}) + local function outer() local _ = 1 and 2 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 _ = 1 and 2 + if c < 1 then return c + 1, debug.traceback("it") end + end + function driver() + local _ = 1 and 2 + 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 _ = 1 and 2 + if c < 1 then return c + 1, c end + end + local function driver() + local _ = 1 and 2 + 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 = """ + 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 + + test "attempt to call a nil value from a nested call on the forced-lazy path" do + code = """ + function outer() + local _ = 1 and 2 + return inner() + end + function inner() + local _ = 1 and 2 + 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 "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 _ = 1 and 2 + debug.getinfo(1, "l") + return true + end + local function outer() local _ = 1 and 2 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 + 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 diff --git a/test/lua/vm/recursion_depth_test.exs b/test/lua/vm/recursion_depth_test.exs index 16099c7..7f462f3 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} =