diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fdd2c2585..446efab7a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ strict format validation - Fixed locale-dependent decimal separator in `erlang:float_to_binary` and `erlang:float_to_list` - Fixed `erlang:binary_to_float/1` and `erlang:list_to_float/1` returning `inf` for overflow instead of raising `badarg` +- Fixed `erlang:raise/3` with a built stacktrace causing an assertion failure when the re-raised +exception passes through a non-matching catch clause ## [0.7.0-alpha.0] - 2026-03-20 diff --git a/src/libAtomVM/context.c b/src/libAtomVM/context.c index 0e35082ad1..92abd4cf82 100644 --- a/src/libAtomVM/context.c +++ b/src/libAtomVM/context.c @@ -125,7 +125,7 @@ Context *context_new(GlobalContext *glb) context_set_exception_class(ctx, term_nil()); ctx->exception_reason = term_nil(); - ctx->exception_stacktrace = term_nil(); + ctx->exception_stacktrace = term_invalid_term(); ctx->exit_reason = NORMAL_ATOM; diff --git a/src/libAtomVM/jit.c b/src/libAtomVM/jit.c index 903ac2daa4..2866308dcd 100644 --- a/src/libAtomVM/jit.c +++ b/src/libAtomVM/jit.c @@ -288,9 +288,9 @@ static Context *jit_terminate_context(Context *ctx, JITState *jit_state) static Context *jit_handle_error(Context *ctx, JITState *jit_state, int offset) { TRACE("jit_handle_error: ctx->process_id = %" PRId32 ", offset = %d\n", ctx->process_id, offset); - if (offset || term_is_invalid_term(ctx->exception_stacktrace)) { - ctx->exception_stacktrace - = stacktrace_create_raw(ctx, jit_state->module, offset); + if (offset || term_is_invalid_term(ctx->exception_stacktrace) + || term_is_list(ctx->exception_stacktrace)) { + ctx->exception_stacktrace = stacktrace_create_raw(ctx, jit_state->module, offset); } // Copy exception fields to x registers and clear them @@ -299,7 +299,7 @@ static Context *jit_handle_error(Context *ctx, JITState *jit_state, int offset) ctx->x[2] = ctx->exception_stacktrace; context_set_exception_class(ctx, term_nil()); ctx->exception_reason = term_nil(); - ctx->exception_stacktrace = term_nil(); + ctx->exception_stacktrace = term_invalid_term(); int target_label = context_get_catch_label(ctx, &jit_state->module); if (target_label) { diff --git a/src/libAtomVM/opcodesswitch.h b/src/libAtomVM/opcodesswitch.h index 742d9d4d03..bb08d2b9eb 100644 --- a/src/libAtomVM/opcodesswitch.h +++ b/src/libAtomVM/opcodesswitch.h @@ -5245,9 +5245,11 @@ HOT_FUNC int scheduler_entry_point(GlobalContext *glb) TRACE("raw_raise/0\n"); - // This is an optimization from the compiler where we don't need to call - // stacktrace_create_raw here because the stack trace has already been created - // and set in x[2]. + // The compiler emits raw_raise for erlang:raise/3 calls. + // x_regs[2] may hold a built stacktrace (list of {M,F,A,Loc} + // tuples). We use HANDLE_ERROR() instead of a bare goto so + // stacktrace_create_raw_mfa wraps it into a raw 6-tuple that + // OP_RAISE can later process. term ex_class = x_regs[0]; if (UNLIKELY(ex_class != ERROR_ATOM && ex_class != LOWERCASE_EXIT_ATOM && ex_class != THROW_ATOM)) { x_regs[0] = BADARG_ATOM; @@ -5255,7 +5257,7 @@ HOT_FUNC int scheduler_entry_point(GlobalContext *glb) context_set_exception_class(ctx, x_regs[0]); ctx->exception_reason = x_regs[1]; ctx->exception_stacktrace = x_regs[2]; - goto handle_error; + HANDLE_ERROR(); } break; } @@ -6229,7 +6231,7 @@ HOT_FUNC int scheduler_entry_point(GlobalContext *glb) x_regs[2] = ctx->exception_stacktrace; context_set_exception_class(ctx, term_nil()); ctx->exception_reason = term_nil(); - ctx->exception_stacktrace = term_nil(); + ctx->exception_stacktrace = term_invalid_term(); int target_label = context_get_catch_label(ctx, &mod); if (target_label) { diff --git a/src/libAtomVM/stacktrace.c b/src/libAtomVM/stacktrace.c index 92884ab2ae..5ef9163cab 100644 --- a/src/libAtomVM/stacktrace.c +++ b/src/libAtomVM/stacktrace.c @@ -111,10 +111,30 @@ term stacktrace_create_raw_mfa(Context *ctx, Module *mod, int current_offset, te { term exception_class = context_exception_class(ctx); - if (term_is_nonempty_list(ctx->exception_stacktrace)) { - // there is already a built stacktrace, nothing to do here - // (this happens when re-raising with raise/3 - return ctx->exception_stacktrace; + if (term_is_list(ctx->exception_stacktrace)) { + // Already a built stacktrace (possibly empty) from erlang:raise/3 + // NIF (via RAISE_WITH_STACKTRACE) or OP_RAW_RAISE. Wrap it in a raw + // 6-tuple so OP_RAISE can extract the exception class and + // stacktrace_build can return the list as-is. + ctx->x[0] = ctx->exception_stacktrace; + ctx->x[1] = ctx->exception_reason; + // NOLINT(term-use-after-gc) exception_class is always an atom + if (UNLIKELY(memory_ensure_free_with_roots(ctx, TUPLE_SIZE(6), 2, ctx->x, MEMORY_CAN_SHRINK) + != MEMORY_GC_OK)) { + fprintf(stderr, "WARNING: Unable to allocate heap space for raw stacktrace\n"); + return OUT_OF_MEMORY_ATOM; + } + ctx->exception_stacktrace = ctx->x[0]; + ctx->exception_reason = ctx->x[1]; + term built_stacktrace = ctx->exception_stacktrace; + term stack_info = term_alloc_tuple(6, &ctx->heap); + term_put_tuple_element(stack_info, 0, term_from_int(0)); + term_put_tuple_element(stack_info, 1, term_from_int(0)); + term_put_tuple_element(stack_info, 2, term_from_int(0)); + term_put_tuple_element(stack_info, 3, term_from_int(0)); + term_put_tuple_element(stack_info, 4, built_stacktrace); + term_put_tuple_element(stack_info, 5, exception_class); + return stack_info; } // Check if EXCEPTION_USE_LIVE_REGS_FLAG is set @@ -235,10 +255,6 @@ term stacktrace_create_raw_mfa(Context *ctx, Module *mod, int current_offset, te term frame_info; - // on OTP <= 22 module_atom is set to erlang, when calling a function such as erlang:throw - // making this heuristic unreliable since hides the throw caller from the stacktrace - // this means that either this heuristic is not 100% correct, or something changed in OTP-23 - // anyway on OTP >= 23 seem to work as expected. if (module_atom == UNDEFINED_ATOM) { // module_atom has not been provided, let's use mod->module_index @@ -365,12 +381,6 @@ term stacktrace_build(Context *ctx, term *stack_info, uint32_t live) { GlobalContext *glb = ctx->global; - if (term_is_nonempty_list(*stack_info)) { - // stacktrace has been already built. Nothing to do here - // This may happen when re-raising with raise/3 - return *stack_info; - } - if (*stack_info == OUT_OF_MEMORY_ATOM) { return *stack_info; } @@ -383,6 +393,15 @@ term stacktrace_build(Context *ctx, term *stack_info, uint32_t live) int filename_lens = term_to_int(term_get_tuple_element(*stack_info, 2)); int num_mods = term_to_int(term_get_tuple_element(*stack_info, 3)); + // Pre-built stacktrace from erlang:raise/3: element 4 already holds + // the built list, num_frames == 0. Return the list directly. + if (num_frames == 0) { + term raw_stacktrace = term_get_tuple_element(*stack_info, 4); + if (term_is_list(raw_stacktrace)) { + return raw_stacktrace; + } + } + struct ModulePathPair *module_paths = malloc(num_mods * sizeof(struct ModulePathPair)); if (IS_NULL_PTR(module_paths)) { fprintf(stderr, "Unable to allocate space for module paths. Returning raw stacktrace.\n"); diff --git a/tests/erlang_tests/CMakeLists.txt b/tests/erlang_tests/CMakeLists.txt index ca2e95afaa..a9400cfeed 100644 --- a/tests/erlang_tests/CMakeLists.txt +++ b/tests/erlang_tests/CMakeLists.txt @@ -653,6 +653,7 @@ compile_erlang(test_lists_keyfind) compile_erlang(test_reraise) compile_erlang(reraise_reraiser) compile_erlang(reraise_raiser) +compile_erlang(test_raise_built_stacktrace) compile_erlang(stacktrace_function_args) compile_erlang(test_multi_value_comprehension) @@ -1209,6 +1210,7 @@ set(erlang_test_beams test_reraise.beam reraise_reraiser.beam reraise_raiser.beam + test_raise_built_stacktrace.beam stacktrace_function_args.beam diff --git a/tests/erlang_tests/test_raise_built_stacktrace.erl b/tests/erlang_tests/test_raise_built_stacktrace.erl new file mode 100644 index 0000000000..617ed5ddd0 --- /dev/null +++ b/tests/erlang_tests/test_raise_built_stacktrace.erl @@ -0,0 +1,244 @@ +% +% This file is part of AtomVM. +% +% Copyright 2026 Davide Bettio +% +% Licensed under the Apache License, Version 2.0 (the "License"); +% you may not use this file except in compliance with the License. +% You may obtain a copy of the License at +% +% http://www.apache.org/licenses/LICENSE-2.0 +% +% Unless required by applicable law or agreed to in writing, software +% distributed under the License is distributed on an "AS IS" BASIS, +% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +% See the License for the specific language governing permissions and +% limitations under the License. +% +% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later +% + +-module(test_raise_built_stacktrace). + +-export([start/0, id/1, reraise_with/3]). + +start() -> + ok = test_raise_direct(), + ok = test_raise_dynamic(), + ok = test_raise_preserves_stacktrace_direct(), + ok = test_raise_preserves_stacktrace_dynamic(), + ok = test_raise_exit_direct(), + ok = test_raise_exit_dynamic(), + ok = test_raise_throw_direct(), + ok = test_raise_throw_dynamic(), + ok = test_raise_empty_stacktrace_direct(), + ok = test_raise_empty_stacktrace_dynamic(), + 0. + +%% Tests the raw_raise opcode path (erlang:raise/3 compiled to raw_raise). +test_raise_direct() -> + try + try + do_raise_direct() + catch + throw:_ -> should_not_happen + end + catch + error:badarg -> ok + end. + +%% Tests the NIF path (dynamic apply bypasses raw_raise opcode). +test_raise_dynamic() -> + try + try + do_raise_dynamic() + catch + throw:_ -> should_not_happen + end + catch + error:badarg -> ok + end. + +do_raise_direct() -> + try + erlang:error(badarg) + catch + error:badarg:Stacktrace -> + erlang:raise(error, badarg, Stacktrace) + end. + +do_raise_dynamic() -> + try + erlang:error(badarg) + catch + error:badarg:Stacktrace -> + apply(?MODULE:id(erlang), ?MODULE:id(raise), [error, badarg, Stacktrace]) + end. + +id(X) -> X. + +%% The compiler compiles erlang:raise/3 to raw_raise. When called with +%% a built stacktrace (list) from another function, this exercises the +%% OP_RAW_RAISE wrapping fix. +reraise_with(Class, Reason, Stacktrace) -> + erlang:raise(Class, Reason, Stacktrace). + +%% Returns a built stacktrace list. The compiler inserts build_stacktrace +%% because ST is returned as a value, not used directly in erlang:raise/3. +capture_built_stacktrace() -> + try + erlang:error(badarg) + catch + error:badarg:ST -> ST + end. + +%% Verify stacktrace content is preserved through re-raise (opcode path). +%% reraise_with/3 compiles erlang:raise/3 to raw_raise, receiving a built list. +test_raise_preserves_stacktrace_direct() -> + OriginalST = capture_built_stacktrace(), + true = is_list(OriginalST), + true = OriginalST =/= [], + try + try + ?MODULE:reraise_with(error, badarg, OriginalST) + catch + throw:_ -> should_not_happen + end + catch + error:badarg:CaughtST -> + case CaughtST of + OriginalST -> ok; + _ -> error + end + end. + +%% Verify stacktrace content is preserved through re-raise (NIF path). +test_raise_preserves_stacktrace_dynamic() -> + OriginalST = capture_built_stacktrace(), + true = is_list(OriginalST), + true = OriginalST =/= [], + try + try + apply(?MODULE:id(erlang), ?MODULE:id(raise), [error, badarg, OriginalST]) + catch + throw:_ -> should_not_happen + end + catch + error:badarg:CaughtST -> + case CaughtST of + OriginalST -> ok; + _ -> error + end + end. + +%% Tests exit class through raw_raise opcode (via reraise_with/3). +test_raise_exit_direct() -> + try + try + do_raise_exit_direct() + catch + error:_ -> should_not_happen + end + catch + exit:some_reason -> ok + end. + +do_raise_exit_direct() -> + try + erlang:exit(some_reason) + catch + exit:some_reason:Stacktrace -> + ?MODULE:reraise_with(exit, some_reason, Stacktrace) + end. + +%% Tests exit class through NIF path. +test_raise_exit_dynamic() -> + try + try + do_raise_exit_dynamic() + catch + error:_ -> should_not_happen + end + catch + exit:some_reason -> ok + end. + +do_raise_exit_dynamic() -> + try + erlang:exit(some_reason) + catch + exit:some_reason:Stacktrace -> + apply(?MODULE:id(erlang), ?MODULE:id(raise), [exit, some_reason, Stacktrace]) + end. + +%% Tests throw class through raw_raise opcode (via reraise_with/3). +test_raise_throw_direct() -> + try + try + do_raise_throw_direct() + catch + error:_ -> should_not_happen + end + catch + throw:some_reason -> ok + end. + +do_raise_throw_direct() -> + try + erlang:throw(some_reason) + catch + throw:some_reason:Stacktrace -> + ?MODULE:reraise_with(throw, some_reason, Stacktrace) + end. + +%% Tests throw class through NIF path. +test_raise_throw_dynamic() -> + try + try + do_raise_throw_dynamic() + catch + error:_ -> should_not_happen + end + catch + throw:some_reason -> ok + end. + +do_raise_throw_dynamic() -> + try + erlang:throw(some_reason) + catch + throw:some_reason:Stacktrace -> + apply(?MODULE:id(erlang), ?MODULE:id(raise), [throw, some_reason, Stacktrace]) + end. + +%% Tests that raise with empty stacktrace [] preserves it (opcode path). +test_raise_empty_stacktrace_direct() -> + try + try + ?MODULE:reraise_with(error, badarg, []) + catch + throw:_ -> should_not_happen + end + catch + error:badarg:ST -> + case ST of + [] -> ok; + _ -> error + end + end. + +%% Tests that raise with empty stacktrace [] preserves it (NIF path). +test_raise_empty_stacktrace_dynamic() -> + try + try + apply(?MODULE:id(erlang), ?MODULE:id(raise), [error, badarg, []]) + catch + throw:_ -> should_not_happen + end + catch + error:badarg:ST -> + case ST of + [] -> ok; + _ -> error + end + end. diff --git a/tests/test.c b/tests/test.c index 0f80af4fde..2b0815876c 100644 --- a/tests/test.c +++ b/tests/test.c @@ -633,6 +633,7 @@ struct Test tests[] = { TEST_CASE(test_lists_keyfind), TEST_CASE_COND(test_reraise, 0, SKIP_STACKTRACES), + TEST_CASE_COND(test_raise_built_stacktrace, 0, SKIP_STACKTRACES), TEST_CASE_COND(stacktrace_function_args, 0, SKIP_STACKTRACES), TEST_CASE(test_inline_arith),