feat(error): every runtime error carries line and source info#214
Conversation
Adds A18 (line/source info on every runtime error) and the follow-up A19 (native function raise sites). A18 is in-progress.
Lua.RuntimeException now exposes :line, :source, and :call_stack as structured fields, so consumers can pattern-match on them instead of string-scraping the formatted message. Lua.eval! threads a source: option through to the compiler (default "<eval>") so error messages say 'at script.lua:N:' instead of '-no-source-'. Inside the executor, a single with_context/4 wrapper catches type and runtime errors raised from helper functions (safe_add, concat_coerce, table indexing, native function calls, etc.) and re-raises them with :line / :source / :call_stack populated from the surrounding dispatch context. The wrap is non-tail-position around helper calls only — the outer do_execute/8 recursion remains a tail call, so TCO is preserved. The Lua 5.3 suite test runner now passes the test file basename as source:, so suite triage gets 'at pm.lua:7:' instead of '<eval>:7:'. Plan: A18
PR #214 opened. Plan now records discoveries, files touched, and the out-of-scope items that surfaced during implementation (compiler source_line off-by-one, stdlib helper raises deferred to A19).
…-call process dict
Initial A18 implementation used a with_context/4 closure wrapper around
every fallible opcode body. Catching helpers raised generically and
re-attaching line/source via a re-raise. Benched at ~9% slowdown on
fib(30) — unacceptable for a 1.0 library.
Replaced with a hybrid that runs at ~2-3% slowdown:
1. In-executor helpers (safe_add, safe_compare_lt, concat_coerce,
to_integer!, index_value, etc.) take line/source as args. The
metamethod-dispatch closures already captured args pre-A18, so
adding two more captures is essentially free on the BEAM. The
helpers' fast paths are unchanged.
2. The :call opcode's {:native_func, fun} dispatch (and call_value/5's
native variant) stash the calling line/source in the process
dictionary before invoking the callback. After the call returns
(or raises), the previous values are restored. Native callbacks
read via Lua.VM.Executor.current_position/0. This is the bridge
for stdlib raises like assert/error that have no other way to
know the calling Lua line.
3. Lua.VM.Executor.execute/5 saves and restores any prior process-dict
position around the run, so re-entrancy (a callback that itself
calls Lua.eval!) and isolation between sequential top-level calls
both work without leaking source positions.
The :source_line opcode does NOT touch the process dict — that would
fire ~5M times in fib(30) and cost ~5%. The native-call boundary is
the only place process.put runs, and it runs at most once per native
invocation (rare relative to opcode dispatch).
stdlib's lua_assert and lua_error now read Executor.current_position/0
and attach line/source to the raised exception, completing the path
for the most common stdlib raise sites.
Plan: A18
A18's plan file now reflects the Hybrid C approach (after the with_context wrapper benched at ~9% slow). A19 is updated to focus on the remaining stdlib bad-arg raises; assert/error are already covered by A18 via the native-call boundary process-dict bridge, so A19's status moves from blocked to ready.
Performance revisionFollowing review feedback that the initial `with_context` wrapper would add overhead, I benchmarked the approach and confirmed: ~9% slowdown on `fib(30)` in the benchee harness. That's not acceptable for a 1.0 library where perf is part of the contract. Investigated alternatives:
Switched to Hybrid C:
The `:source_line` opcode is not touched — writing to process dict on every Lua statement was the dominant cost in the all-process-dict variant. Re-entrancy is handled: nested `Lua.eval!` (e.g. an Elixir callback that itself calls into Lua) saves and restores the outer position via `try/after` in `execute/5`. Sequential top-level calls don't leak either. See commit `3e6827f` for the implementation diff and the updated plan file for the full discoveries section. |
…rf tracks Rebuild .agents/plans/ around the four 1.0 priorities: near-full Lua 5.3 suite passing, world-class error messages, world-class DX/docs, and perf parity with Luerl. - Flip A18 review→merged (PR #214 already on main). - Repurpose A13 as the 1.0 final cut, with explicit suite/perf/errors/ docs/DX gates blocking the release. - A20–A24: cluster triage plans for the 24 currently-failing suite files, grouped by failure family (sandbox refusals, runtime type errors, VM-level errors, metamethod/control-flow assertions, stdlib/ data-structure assertions). Each spawns A2{0..4}{a,b,…} fix plans. - A25: implement string.pack / unpack / packsize. - A26: error message quality pass — render audit + gallery fixtures. - A27–A32: DX/docs track — Inspect protocol, iex polish, mix tasks, examples/, README rewrite, public API docstring audit. - A33–A35: perf track — Luerl gap analysis with benchee, profiling with fprof + eflambe, regression CI to lock parity in.
Threaded line/source info reaches every runtime error message
Plan:
.agents/plans/A18-error-line-source-info.mdGoal
Every Lua runtime error a user sees should include the source file and
line number where the offending operation lives. Today the executor
threads
linethrough every CPS dispatch, the compiler emits{:source_line, _, _}markers, and the exception structs carry:line/:source/:call_stackfields — but mostraisesites inthe VM omit those fields, and the public
Lua.RuntimeExceptionwrapperre-raises with only the formatted message string, dropping the
structured fields entirely.
Before
After
Success criteria
mix testpasses (1577 tests, 51 properties, 52 doctests, 0 failures — count went up by 7 with the new tests).mix test test/lua/error_messages_test.exspasses (18/18).:lineand matching:source.Lua.eval!with asource:opt threads that name toproto.sourceso errors sayat script.lua:N:.mix test --only lua53still 5/29 (no regression).Lua.eval!(Lua.new(), "local z = nil\nz()", source: "demo.lua")produces a message containingat demo.lua:1:.Changes
Implementation:
Lua.RuntimeExceptiongains:line,:source,:call_stackfields and copies them off VM exceptions in the catchallexception/1clause.Lua.eval!accepts asource:option (default"<eval>") and forwards it toLua.Compiler.compile/2.Lua.RuntimeExceptionso structured fields survive.with_context/4wrapper catchesTypeError/RuntimeError/AssertionErrorfrom helper calls and re-raises them with:line/:source/:call_stackfilled in from the surrounding dispatch. Applied to arithmetic, bitwise, concat, compare, length, negate, table indexing, and native-function call dispatch.test/support/lua_test_case.expasses the suite filename assource:so triage getsat pm.lua:7:instead of<eval>:7:.Discoveries
Implementation chose strategy (b) from the plan — a single executor-level wrapper rather than per-helper signature changes. TCO on
do_execute/8is preserved (the wrap only guards helper calls, not the outer recursion).Out-of-scope items surfaced during implementation:
source_lineemission has off-by-ones in some cases. New smoke tests (local x = 1\nlocal s = "hello"\nprint(s * x)) report line 2 when the operation is on line 3. The line-tracking infrastructure works; the compiler emits itssource_linemarkers a beat early. Logged for follow-up — not blocking, since "non-nil line" is what A18 commits to.string.upper(nil),table.insertbad arg, etc.) still raise from helper paths that bypass the executor wrap. A19 (also added in this PR as astatus: blockedplan) covers those.Verification
Out of scope (intentional)
assert(),error(), and other stdlib raises whose helpers don't havelinein scope. Covered by A19 (drafted, blocked on this).call_stackcontent beyond what's already plumbed.load()chunks.