From 16b41da8e97e6f954e8187727d09575436d91d2b Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Fri, 24 Apr 2026 16:42:49 +0200 Subject: [PATCH 1/2] Fix reentrancy of Code.eval_* Keep env and dbg callbacks on stacks in process dict Fixes #15303 --- lib/elixir/lib/kernel.ex | 6 +-- lib/elixir/src/elixir.erl | 66 +++++++++++++++++++++------- lib/elixir/test/elixir/code_test.exs | 36 +++++++++++++++ 3 files changed, 88 insertions(+), 20 deletions(-) diff --git a/lib/elixir/lib/kernel.ex b/lib/elixir/lib/kernel.ex index 0be54e7f2d..017412032b 100644 --- a/lib/elixir/lib/kernel.ex +++ b/lib/elixir/lib/kernel.ex @@ -6325,11 +6325,11 @@ defmodule Kernel do @doc since: "1.14.0" defmacro dbg(code \\ quote(do: binding()), options \\ []) do # The compiling process may override the callback by putting it in - # the process dictionary. + # the process dictionary. A stack is used to support nested eval calls. dbg_callback = case :erlang.get({:elixir, :dbg_callback}) do - :undefined -> Application.compile_env!(__CALLER__, :elixir, :dbg_callback) - value -> value + [value | _] -> value + _ -> Application.compile_env!(__CALLER__, :elixir, :dbg_callback) end {mod, fun, args} = dbg_callback diff --git a/lib/elixir/src/elixir.erl b/lib/elixir/src/elixir.erl index a1fe775db5..2bc63fe72f 100644 --- a/lib/elixir/src/elixir.erl +++ b/lib/elixir/src/elixir.erl @@ -304,10 +304,16 @@ eval_forms(Tree, Binding, OrigE) -> eval_forms(Tree, Binding, OrigE, []). eval_forms(Tree, Binding, OrigE, Opts) -> Prune = proplists:get_value(prune_binding, Opts, false), - case proplists:get_value(dbg_callback, Opts) of - undefined -> ok; - DbgCallback -> erlang:put({elixir, dbg_callback}, DbgCallback) - end, + %% We keep a stack of dbg_callbacks in the process dictionary so nested + %% eval calls in the same process do not clobber the outer callback. + Pushed = + case proplists:get_value(dbg_callback, Opts) of + undefined -> + false; + DbgCallback -> + push_pdict({elixir, dbg_callback}, DbgCallback), + true + end, try {ExVars, ErlVars, ErlBinding} = elixir_erl_var:load_binding(Binding, Prune), E = elixir_env:with_vars(OrigE, ExVars), @@ -332,7 +338,10 @@ eval_forms(Tree, Binding, OrigE, Opts) -> {Value, DumpedBinding, NewE#{versioned_vars := DumpedVars}} end after - erlang:erase({elixir, dbg_callback}) + case Pushed of + true -> pop_pdict({elixir, dbg_callback}); + false -> ok + end end. %% Evaluate Erlang code with careful handling of local and external functions @@ -341,20 +350,43 @@ erl_eval(Expr, Binding, Env) -> LocalHandler = {value, fun ?MODULE:eval_local_handler/2}, ExternalHandler = {value, fun ?MODULE:eval_external_handler/3}, + %% ?elixir_eval_env is used by the external handler. + %% + %% The reason why we use the process dictionary to pass the environment + %% is because we want to avoid passing closures to erl_eval, as that + %% would effectively tie the eval code to the Elixir version and it is + %% best if it depends solely on Erlang/OTP. + %% + %% The downside is that functions that escape the eval context will no + %% longer have the original environment they came from. + %% + %% We keep a stack of envs in the process dictionary so nested eval calls + %% in the same process do not clobber the outer env. + push_pdict(?elixir_eval_env, Env), try - %% ?elixir_eval_env is used by the external handler. - %% - %% The reason why we use the process dictionary to pass the environment - %% is because we want to avoid passing closures to erl_eval, as that - %% would effectively tie the eval code to the Elixir version and it is - %% best if it depends solely on Erlang/OTP. - %% - %% The downside is that functions that escape the eval context will no - %% longer have the original environment they came from. - erlang:put(?elixir_eval_env, Env), erl_eval:expr(Expr, Binding, LocalHandler, ExternalHandler) after - erlang:erase(?elixir_eval_env) + pop_pdict(?elixir_eval_env) + end. + +push_pdict(Key, Value) -> + Stack = case erlang:get(Key) of + undefined -> []; + Existing -> Existing + end, + erlang:put(Key, [Value | Stack]). + +pop_pdict(Key) -> + case erlang:get(Key) of + [_] -> erlang:erase(Key); + [_ | Rest] -> erlang:put(Key, Rest); + _ -> erlang:erase(Key) + end. + +peek_pdict(Key) -> + case erlang:get(Key) of + [Top | _] -> Top; + _ -> undefined end. eval_local_handler(FunName, Args) -> @@ -402,7 +434,7 @@ eval_external_handler(Ann, FunOrModFun, Args) -> %% Add file+line information at the bottom Bottom = - case erlang:get(?elixir_eval_env) of + case peek_pdict(?elixir_eval_env) of #{'__struct__' := 'Elixir.Macro.Env'} = E -> 'Elixir.Macro.Env':stacktrace(E#{line := erl_anno:line(Ann)}); _ -> diff --git a/lib/elixir/test/elixir/code_test.exs b/lib/elixir/test/elixir/code_test.exs index 143c4ca0b7..1e772b6227 100644 --- a/lib/elixir/test/elixir/code_test.exs +++ b/lib/elixir/test/elixir/code_test.exs @@ -288,6 +288,42 @@ defmodule CodeTest do assert {1, _binding} = Code.eval_string("dbg(1)", []) end) end + + test "nested eval preserves outer :dbg_callback" do + opts = [dbg_callback: {__MODULE__, :dbg_callback_add_one, []}] + + assert {2, _binding} = + Code.eval_string( + """ + Code.eval_string("1 + 1") + dbg(1) + """, + [], + opts + ) + end + + test "nested eval preserves outer env in exception stacktrace" do + env = %{Code.env_for_eval([]) | file: "outer_file.ex"} + + stacktrace = + try do + Code.eval_string( + """ + Code.eval_string("1 + 1") + raise "boom" + """, + [], + env + ) + rescue + _ -> __STACKTRACE__ + end + + assert Enum.any?(stacktrace, fn + {_, _, _, meta} -> Keyword.get(meta, :file) == ~c"outer_file.ex" + end) + end end describe "eval_quoted/1" do From 2a4ea042a71d2a046ec2307d9f9b7fae4b214762 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Tue, 28 Apr 2026 13:36:13 +0200 Subject: [PATCH 2/2] Address PR feedback: simplify save/restore and fix dbg test - Replace process-dict stack with direct save/restore via local variables in eval_forms/4 and erl_eval/3 (per @josevalim). - Fix nested dbg_callback test to actually exercise the path via Code.eval_string("dbg(1)") (per @jonatanklosko). --- lib/elixir/lib/kernel.ex | 6 +-- lib/elixir/src/elixir.erl | 57 ++++++++++------------------ lib/elixir/test/elixir/code_test.exs | 5 +-- 3 files changed, 24 insertions(+), 44 deletions(-) diff --git a/lib/elixir/lib/kernel.ex b/lib/elixir/lib/kernel.ex index 017412032b..0be54e7f2d 100644 --- a/lib/elixir/lib/kernel.ex +++ b/lib/elixir/lib/kernel.ex @@ -6325,11 +6325,11 @@ defmodule Kernel do @doc since: "1.14.0" defmacro dbg(code \\ quote(do: binding()), options \\ []) do # The compiling process may override the callback by putting it in - # the process dictionary. A stack is used to support nested eval calls. + # the process dictionary. dbg_callback = case :erlang.get({:elixir, :dbg_callback}) do - [value | _] -> value - _ -> Application.compile_env!(__CALLER__, :elixir, :dbg_callback) + :undefined -> Application.compile_env!(__CALLER__, :elixir, :dbg_callback) + value -> value end {mod, fun, args} = dbg_callback diff --git a/lib/elixir/src/elixir.erl b/lib/elixir/src/elixir.erl index 2bc63fe72f..9fd968e56c 100644 --- a/lib/elixir/src/elixir.erl +++ b/lib/elixir/src/elixir.erl @@ -304,16 +304,13 @@ eval_forms(Tree, Binding, OrigE) -> eval_forms(Tree, Binding, OrigE, []). eval_forms(Tree, Binding, OrigE, Opts) -> Prune = proplists:get_value(prune_binding, Opts, false), - %% We keep a stack of dbg_callbacks in the process dictionary so nested - %% eval calls in the same process do not clobber the outer callback. - Pushed = - case proplists:get_value(dbg_callback, Opts) of - undefined -> - false; - DbgCallback -> - push_pdict({elixir, dbg_callback}, DbgCallback), - true - end, + %% We save and restore the previous dbg_callback so nested eval calls in + %% the same process do not clobber the outer callback. + PreviousDbg = erlang:get({elixir, dbg_callback}), + case proplists:get_value(dbg_callback, Opts) of + undefined -> ok; + DbgCallback -> erlang:put({elixir, dbg_callback}, DbgCallback) + end, try {ExVars, ErlVars, ErlBinding} = elixir_erl_var:load_binding(Binding, Prune), E = elixir_env:with_vars(OrigE, ExVars), @@ -338,9 +335,9 @@ eval_forms(Tree, Binding, OrigE, Opts) -> {Value, DumpedBinding, NewE#{versioned_vars := DumpedVars}} end after - case Pushed of - true -> pop_pdict({elixir, dbg_callback}); - false -> ok + case PreviousDbg of + undefined -> erlang:erase({elixir, dbg_callback}); + _ -> erlang:put({elixir, dbg_callback}, PreviousDbg) end end. @@ -360,33 +357,17 @@ erl_eval(Expr, Binding, Env) -> %% The downside is that functions that escape the eval context will no %% longer have the original environment they came from. %% - %% We keep a stack of envs in the process dictionary so nested eval calls - %% in the same process do not clobber the outer env. - push_pdict(?elixir_eval_env, Env), + %% We save and restore the previous env so nested eval calls in the same + %% process do not clobber the outer env. + PreviousEvalEnv = erlang:get(?elixir_eval_env), + erlang:put(?elixir_eval_env, Env), try erl_eval:expr(Expr, Binding, LocalHandler, ExternalHandler) after - pop_pdict(?elixir_eval_env) - end. - -push_pdict(Key, Value) -> - Stack = case erlang:get(Key) of - undefined -> []; - Existing -> Existing - end, - erlang:put(Key, [Value | Stack]). - -pop_pdict(Key) -> - case erlang:get(Key) of - [_] -> erlang:erase(Key); - [_ | Rest] -> erlang:put(Key, Rest); - _ -> erlang:erase(Key) - end. - -peek_pdict(Key) -> - case erlang:get(Key) of - [Top | _] -> Top; - _ -> undefined + case PreviousEvalEnv of + undefined -> erlang:erase(?elixir_eval_env); + _ -> erlang:put(?elixir_eval_env, PreviousEvalEnv) + end end. eval_local_handler(FunName, Args) -> @@ -434,7 +415,7 @@ eval_external_handler(Ann, FunOrModFun, Args) -> %% Add file+line information at the bottom Bottom = - case peek_pdict(?elixir_eval_env) of + case erlang:get(?elixir_eval_env) of #{'__struct__' := 'Elixir.Macro.Env'} = E -> 'Elixir.Macro.Env':stacktrace(E#{line := erl_anno:line(Ann)}); _ -> diff --git a/lib/elixir/test/elixir/code_test.exs b/lib/elixir/test/elixir/code_test.exs index 1e772b6227..1e22e1fb1a 100644 --- a/lib/elixir/test/elixir/code_test.exs +++ b/lib/elixir/test/elixir/code_test.exs @@ -292,11 +292,10 @@ defmodule CodeTest do test "nested eval preserves outer :dbg_callback" do opts = [dbg_callback: {__MODULE__, :dbg_callback_add_one, []}] - assert {2, _binding} = + assert {{2, []}, _binding} = Code.eval_string( """ - Code.eval_string("1 + 1") - dbg(1) + Code.eval_string("dbg(1)") """, [], opts