From 981c44621693002f6631eaa735e37d7d9b83a6ca Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Wed, 15 Jul 2026 11:32:19 -0700 Subject: [PATCH] errors: drop the :message field from public runtime exceptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lua.RuntimeException carried a :message struct field that was populated eagerly for some constructor shapes but left nil for the common case of a wrapped VM exception (rendered lazily to keep the #384 ANSI gate honest). Reading exception.message directly therefore returned nil for most runtime errors out of eval!/call_function — a silent footgun for anyone logging the conventional field. Remove the field entirely and make Exception.message/1 the single renderer, composing lazily from the already-present semantic fields (:original, :kind, :value, :line, :source, :call_stack). Drop the dead {:api_error, ...} clause while here, and normalize Lua.VM.InternalError the same way (its :message was always stringify(value); every raise site passes only value:). Lands before the 1.0.0 tag while the public surface is still unfrozen. --- guides/migrating-to-1.0.md | 13 ++--- lib/lua/runtime_exception.ex | 75 ++++++++++++++--------------- lib/lua/vm/internal_error.ex | 10 ++-- tasks/lua.suite.ex | 2 +- test/lua/runtime_exception_test.exs | 48 +++++++----------- 5 files changed, 67 insertions(+), 81 deletions(-) diff --git a/guides/migrating-to-1.0.md b/guides/migrating-to-1.0.md index a2c728b..8d4368c 100644 --- a/guides/migrating-to-1.0.md +++ b/guides/migrating-to-1.0.md @@ -139,16 +139,17 @@ exception's `:value` field, matching what `pcall` hands back inside Lua. instead of `{:error, [String.t()]}`; its `:errors` field carries the bare, ANSI-free messages for programmatic inspection. -Render messages through `Exception.message/1`, **not** the `:message` -struct field — messages render lazily, so `:message` may be `nil` on the -struct: +Render messages through `Exception.message/1`. There is no `:message` +struct field to read — the message is composed lazily from the exception's +semantic fields (`:kind`, `:value`, `:original`, `:line`, `:source`) at +render time: ```elixir -# Prefer this: +# The only way to render: Exception.message(exception) -# Not this — the field may be nil: -exception.message +# There is no exception.message field — inspect :kind / :value / :original +# for programmatic access instead. ``` ## Parser error messages have a new format diff --git a/lib/lua/runtime_exception.ex b/lib/lua/runtime_exception.ex index d7a421c..50e8f7c 100644 --- a/lib/lua/runtime_exception.ex +++ b/lib/lua/runtime_exception.ex @@ -4,15 +4,14 @@ defmodule Lua.RuntimeException do arithmetic on a non-number, indexing a nil, an explicit `error()` call from Lua, or any other dynamic failure inside the VM. - Use `Exception.message/1` to render the message — do not read the `:message` - field directly. When this wraps a VM exception the field is `nil` and the - message is rendered lazily so ANSI color is gated on `IO.ANSI.enabled?/0` at - output time rather than frozen at construction (issue #384). + Always render with `Exception.message/1` — there is no `:message` struct + field. The message is composed lazily from `:original` (and the semantic + fields below) so ANSI color is gated on `IO.ANSI.enabled?/0` at output time + rather than frozen at construction (issue #384), and so there is no field + that reads back `nil` for the common case of a wrapped VM error. Fields: - * `:message` — pre-rendered string for terminal-independent errors, or - `nil` when rendered lazily from `:original` * `:original` — the underlying VM error term * `:kind` — the category of failure: `:error` (an explicit `error()` call), `:type`, `:argument`, `:assertion`, or `:internal`; `nil` for @@ -33,34 +32,26 @@ defmodule Lua.RuntimeException do @type t :: %__MODULE__{} - defexception [:message, :original, :kind, :value, :state, :line, :source, :call_stack] + defexception [:original, :kind, :value, :state, :line, :source, :call_stack] @impl true def exception({:lua_error, error, _state}) do - %__MODULE__{ - original: error, - message: prefix_message(Util.format_error(error)) - } - end - - def exception({:api_error, details, state}) do - %__MODULE__{original: details, state: state, message: "Lua API error: #{details}"} + %__MODULE__{original: error} end def exception(list) when is_list(list) do - scope = Keyword.fetch!(list, :scope) - function = Keyword.fetch!(list, :function) - message = Keyword.fetch!(list, :message) - - %__MODULE__{ - original: list, - state: nil, - message: prefix_message("#{format_function(scope, function)} failed, #{message}") - } + # Validate the descriptor eagerly so a missing key fails at the raise site + # rather than lazily inside `message/1`. The list itself is the semantic + # source — `message/1` reads `:scope`/`:function`/`:message` back off it. + Keyword.fetch!(list, :scope) + Keyword.fetch!(list, :function) + Keyword.fetch!(list, :message) + + %__MODULE__{original: list} end def exception(error) when is_binary(error) do - %__MODULE__{message: prefix_message(String.trim(error))} + %__MODULE__{original: String.trim(error)} end def exception(error) do @@ -72,8 +63,8 @@ defmodule Lua.RuntimeException do {line, source, call_stack} = extract_context(error) kind = kind(error) - # `:message` is left nil so `message/1` renders the wrapped error lazily. - # The inner VM exceptions gate ANSI on `IO.ANSI.enabled?/0` at render time + # No message is stored — `message/1` renders the wrapped error lazily. The + # inner VM exceptions gate ANSI on `IO.ANSI.enabled?/0` at render time # (issue #384); freezing their rendered message here would bake in escape # codes from the TTY-attached VM process and leak them into log sinks. %__MODULE__{ @@ -86,21 +77,27 @@ defmodule Lua.RuntimeException do } end - # The `:lua_error`, `:api_error`, keyword-list, and binary clauses build - # ANSI-free strings that don't depend on the terminal, so they memoize into - # `:message`. The generic clause defers to keep the ANSI gate honest. + # Everything renders lazily from `:original`, so the ANSI gate on wrapped VM + # exceptions is evaluated when the message is written, not frozen here. @impl true - def message(%__MODULE__{message: message}) when is_binary(message), do: message + def message(%__MODULE__{original: original}) when is_exception(original) do + prefix_message(Exception.message(original)) + end + + def message(%__MODULE__{original: list}) when is_list(list) do + prefix_message("#{format_function(list[:scope], list[:function])} failed, #{list[:message]}") + end + + def message(%__MODULE__{original: binary}) when is_binary(binary) do + prefix_message(String.trim(binary)) + end + + def message(%__MODULE__{original: {tag, _, _} = term}) when tag in [:badarith, :illegal_index] do + prefix_message(Util.format_error(term)) + end def message(%__MODULE__{original: original}) do - rendered = - if is_exception(original) do - Exception.message(original) - else - inspect(original) - end - - prefix_message(rendered) + prefix_message(inspect(original)) end defp prefix_message(@runtime_prefix <> _ = msg), do: msg diff --git a/lib/lua/vm/internal_error.ex b/lib/lua/vm/internal_error.ex index 7a1dc7a..4124c09 100644 --- a/lib/lua/vm/internal_error.ex +++ b/lib/lua/vm/internal_error.ex @@ -8,16 +8,16 @@ defmodule Lua.VM.InternalError do # Raised for internal VM errors: bad native function returns, unimplemented # instructions, and other invariant violations. - defexception [:value, :message] + defexception [:value] @impl true def exception(opts) do - value = Keyword.get(opts, :value) - message = Keyword.get(opts, :message) || stringify(value) - - %__MODULE__{value: value, message: message} + %__MODULE__{value: Keyword.get(opts, :value)} end + @impl true + def message(%__MODULE__{value: value}), do: stringify(value) + defp stringify(nil), do: "nil" defp stringify(v) when is_binary(v), do: v defp stringify(v), do: inspect(v) diff --git a/tasks/lua.suite.ex b/tasks/lua.suite.ex index c9d0c0b..ba4d453 100644 --- a/tasks/lua.suite.ex +++ b/tasks/lua.suite.ex @@ -150,7 +150,7 @@ defmodule Mix.Tasks.Lua.Suite do {:timeout, file, timeout} {:exit, reason} -> - {:fail, file, %Lua.RuntimeException{message: "exited: #{inspect(reason)}"}} + {:fail, file, Lua.RuntimeException.exception("exited: #{inspect(reason)}")} end end diff --git a/test/lua/runtime_exception_test.exs b/test/lua/runtime_exception_test.exs index 0a48fdf..dddd912 100644 --- a/test/lua/runtime_exception_test.exs +++ b/test/lua/runtime_exception_test.exs @@ -78,32 +78,6 @@ defmodule Lua.RuntimeExceptionTest do end end - describe "exception/1 with {:api_error, details, state}" do - test "creates exception with api error message" do - state = State.new() - details = "invalid function call" - - exception = RuntimeException.exception({:api_error, details, state}) - - assert Exception.message(exception) == "Lua API error: invalid function call" - assert exception.original == details - assert exception.state == state - end - - test "handles complex api error details" do - state = State.new() - details = "function returned invalid type: expected table, got nil" - - exception = RuntimeException.exception({:api_error, details, state}) - - assert Exception.message(exception) == - "Lua API error: function returned invalid type: expected table, got nil" - - assert exception.original == details - assert exception.state == state - end - end - describe "exception/1 with keyword list [scope:, function:, message:]" do test "formats error with empty scope" do exception = @@ -197,7 +171,8 @@ defmodule Lua.RuntimeExceptionTest do exception = RuntimeException.exception("something went wrong") assert Exception.message(exception) == "Lua runtime error: something went wrong" - assert exception.original == nil + # The trimmed host string is the semantic source, stored on `:original`. + assert exception.original == "something went wrong" assert exception.state == nil end @@ -205,7 +180,7 @@ defmodule Lua.RuntimeExceptionTest do exception = RuntimeException.exception(" error with spaces \n") assert Exception.message(exception) == "Lua runtime error: error with spaces" - assert exception.original == nil + assert exception.original == "error with spaces" assert exception.state == nil end @@ -213,7 +188,7 @@ defmodule Lua.RuntimeExceptionTest do exception = RuntimeException.exception("") assert Exception.message(exception) == "Lua runtime error: " - assert exception.original == nil + assert exception.original == "" assert exception.state == nil end @@ -226,7 +201,7 @@ defmodule Lua.RuntimeExceptionTest do exception = RuntimeException.exception(error_message) assert Exception.message(exception) == "Lua runtime error: multi-line error\nwith details" - assert exception.original == nil + assert exception.original == "multi-line error\nwith details" assert exception.state == nil end end @@ -386,10 +361,15 @@ defmodule Lua.RuntimeExceptionTest do test "wrapping an InternalError yields kind :internal" do inner = Lua.VM.InternalError.exception(value: "invariant violated") + # InternalError renders via message/1 from :value (no stored :message). + refute Map.has_key?(inner, :message) + assert Exception.message(inner) == "invariant violated" + exception = RuntimeException.exception(inner) assert exception.kind == :internal assert exception.value == "invariant violated" + assert Exception.message(exception) == "Lua runtime error: invariant violated" end test "wrapping an arbitrary Elixir exception leaves kind and value nil" do @@ -442,6 +422,14 @@ defmodule Lua.RuntimeExceptionTest do assert Exception.message(exception) == "Lua runtime error: test error" end + test "struct has no :message field — Exception.message/1 is the only renderer" do + # The message is composed lazily from the semantic fields; there is no + # `:message` field to read back nil (issue #384 / 1.0 cleanup). + exception = RuntimeException.exception("test error") + + refute Map.has_key?(exception, :message) + end + test "can be raised with raise/2" do assert_raise RuntimeException, "Lua runtime error: test", fn -> raise RuntimeException, "test"