Skip to content

fix(vm): return terse error value for ArgumentError at protected-call boundary#354

Merged
davydog187 merged 3 commits into
mainfrom
fix/argument-error-protected-call-boundary
Jun 12, 2026
Merged

fix(vm): return terse error value for ArgumentError at protected-call boundary#354
davydog187 merged 3 commits into
mainfrom
fix/argument-error-protected-call-boundary

Conversation

@davydog187

@davydog187 davydog187 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Lua.VM.ProtectedCall.error_value/1 had no clause for Lua.VM.ArgumentError, so it fell through to Exception.message/1 — embedding ANSI codes and the at <source>:<line>: header in the reason returned by Lua.call_function/3 and pcall/xpcall. PR fix(vm): return terse Lua error value from call_function, not terminal render (#336) #337 only fixed the :value-bearing exceptions (RuntimeError, TypeError, AssertionError); ArgumentError builds its message from individual fields and has no :value slot.
  • Promote ArgumentError.build_base/5 to a public raw_message/1, and add an ArgumentError clause to error_value/1 that returns it.
  • Regression tests for both protected-call boundaries (call_function/3 and pcall), plus tests pinning call_function!/3's structured-exception passthrough.

Fixes #342.

Repro (before)

chunk = ~LUA"""
function foo()
  for i in pairs("asdf") do
    print(i)
  end
end
"""c
{_, lua} = Lua.eval!(chunk)
Lua.call_function(lua, [:foo], [])
# {:error,
#  "\e[2mat -no-source-:\e[0m\n\n  bad argument #1 to 'pairs' (table expected, got string)",
#  #Lua<>}

After

call_function/3 returns the §6.1-faithful terse string (matching pcall's contract):

Lua.call_function(lua, [:foo], [])
# {:error, "bad argument #1 to 'pairs' (table expected, got string)", #Lua<>}

call_function!/3 is the escape hatch for Elixir callers who want the structured exception — it raises Lua.RuntimeException whose :original field carries the underlying VM exception verbatim, so you can pattern-match on it:

try do
  Lua.call_function!(lua, [:foo], [])
rescue
  e in Lua.RuntimeException ->
    case e.original do
      %Lua.VM.ArgumentError{function_name: fn_name, arg_num: n, expected: ex, got: g} ->
        # programmatic dispatch on the structured fields
        ...

      %Lua.VM.RuntimeError{value: v} ->
        # whatever Lua code passed to error()
        ...

      %Lua.VM.TypeError{error_kind: kind} ->
        # :call_nil, :index_non_table, :arith_non_number, etc.
        ...
    end
end

Test plan

  • mix test test/lua/call_function_error_value_test.exs (14 cases, +6: ArgumentError regression, ANSI-on variant, and 3 cases pinning call_function!/3's .original contract for ArgumentError/RuntimeError/TypeError)
  • mix test test/lua/vm/pcall_error_value_test.exs (+2 cases, one per engine)
  • mix test — full suite green (2256+ passed, 19 skipped)
  • iex repro from Lua.call_function/3 expected to return Lua.RuntimeException instead of string with ANSI codes #342 returns terse one-line string with no ANSI / no header / no newlines
  • mix docs --warnings-as-errors clean
  • mix format clean

… boundary

`Lua.VM.ProtectedCall.error_value/1` had no clause for
`Lua.VM.ArgumentError`, so it fell through to `Exception.message/1` —
which calls `ErrorFormatter.format/3` and embeds ANSI escape codes plus
the `at <source>:<line>:` header in the returned reason. PR #337 plugged
this for the `:value`-bearing exceptions (`RuntimeError`, `TypeError`,
`AssertionError`) but `ArgumentError` builds its message from individual
fields and has no `:value` slot, so it was missed.

Affected both `Lua.call_function/3` and `pcall`/`xpcall`: any stdlib
bad-argument raise (e.g. `pairs("asdf")`) returned the rendered string
instead of the bare §6.1 error value.

Promote `ArgumentError.build_base/5` to a public `raw_message/1` and add
a clause to `error_value/1` that returns it. Regression tests added for
both protected-call boundaries.

Fixes #342.
`Lua.VM.ProtectedCall` is `@moduledoc false`, so the autolink from
`ArgumentError.raw_message/1`'s public doc tripped `mix docs
--warnings-as-errors` on CI.
`Lua.call_function!/3` raises `Lua.RuntimeException` whose `:original`
field carries the underlying VM exception verbatim. That's the escape
hatch for Elixir callers who want to pattern-match on the structured
error (e.g. `%Lua.VM.ArgumentError{function_name: "pairs", arg_num: 1,
expected: "table", got: "string"}`) instead of parsing the terse §6.1
string that `call_function/3` returns.

The contract was already implemented (RuntimeException.exception/1 line
66 stores `original: error`) but wasn't pinned by a test, so a future
refactor that wrapped or stringified the underlying exception would have
silently regressed it.
@davydog187 davydog187 merged commit f2c5122 into main Jun 12, 2026
5 checks passed
@davydog187 davydog187 deleted the fix/argument-error-protected-call-boundary branch June 12, 2026 02:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Lua.call_function/3 expected to return Lua.RuntimeException instead of string with ANSI codes

1 participant