BEAM Stacktraces Missing Frame
If you write Erlang/Elixir, you might use the erlang.apply
or Kernel.apply
functions for dynamic calling. You might have also noticed something weird, which is that when a function calls apply
on the last line, you'll get a stackframe eaten!
For example:
defmodule Demo do def start do perform(__MODULE__, :target, []) end def perform(module, function, args) do {_, stacktrace} = Process.info(self(), :current_stacktrace) IO.inspect(stacktrace, label: "Stack trace in 'perform'") apply(module, function, args) end def target do {_, stacktrace} = Process.info(self(), :current_stacktrace) IO.inspect(stacktrace, label: "Stack trace in 'target'") end end Demo.start()
Outputs:
iex(10)> Demo.start() Stack trace in 'perform': [ {Process, :info, 2, [file: ~c"lib/process.ex", line: 860]}, {Demo, :perform, 3, [file: ~c"iex", line: 14]}, {:elixir, :eval_external_handler, 3, [file: ~c"src/elixir.erl", line: 405]}, {:erl_eval, :do_apply, 7, [file: ~c"erl_eval.erl", line: 750]}, {:elixir, :eval_forms, 4, [file: ~c"src/elixir.erl", line: 378]}, {Module.ParallelChecker, :verify, 1, [file: ~c"lib/module/parallel_checker.ex", line: 112]}, {IEx.Evaluator, :eval_and_inspect, 3, [file: ~c"lib/iex/evaluator.ex", line: 331]}, {IEx.Evaluator, :eval_and_inspect_parsed, 3, [file: ~c"lib/iex/evaluator.ex", line: 305]} ] Stack trace in 'target': [ {Process, :info, 2, [file: ~c"lib/process.ex", line: 860]}, {Demo, :target, 0, [file: ~c"iex", line: 20]}, {:elixir, :eval_external_handler, 3, [file: ~c"src/elixir.erl", line: 405]}, {:erl_eval, :do_apply, 7, [file: ~c"erl_eval.erl", line: 750]}, {:elixir, :eval_forms, 4, [file: ~c"src/elixir.erl", line: 378]}, {Module.ParallelChecker, :verify, 1, [file: ~c"lib/module/parallel_checker.ex", line: 112]}, {IEx.Evaluator, :eval_and_inspect, 3, [file: ~c"lib/iex/evaluator.ex", line: 331]}, {IEx.Evaluator, :eval_and_inspect_parsed, 3, [file: ~c"lib/iex/evaluator.ex", line: 305]} ]
You'll notice that despite being called by :perform
, the stacktrace for :target
doesn't have :perform
in it.
If you've done TCO before, this should smell similar to you, where recursive functions that call themselves reuse an existing stackframe so that the system doesn't blow up with frames.
I ran into this problem when trying to build a mechanism for debugging stacktraces, specifically you can find some discussion on it here: https://github.com/elixir-lang/elixir/issues/6357#issuecomment-316154723.
Effectively, this is a super-TCO, where calling apply
as the last function reuses the frame. Now, if we introduce some side effects by modifying the :perform
function to execute additional code before apply
is called:
def perform(module, function, args) do {_, stacktrace} = Process.info(self(), :current_stacktrace) IO.inspect(stacktrace, label: "Stack trace in 'perform'") res = apply(module, function, args) String.trim("") res end
This now outputs:
Stack trace in 'target': [ {Process, :info, 2, [file: ~c"lib/process.ex", line: 860]}, {Demo, :target, 0, [file: ~c"iex", line: 28]}, {Demo, :perform, 3, [file: ~c"iex", line: 22]}, {:elixir, :eval_external_handler, 3, [file: ~c"src/elixir.erl", line: 405]}, {:erl_eval, :do_apply, 7, [file: ~c"erl_eval.erl", line: 750]}, {:elixir, :eval_forms, 4, [file: ~c"src/elixir.erl", line: 378]}, {Module.ParallelChecker, :verify, 1, [file: ~c"lib/module/parallel_checker.ex", line: 112]}, {IEx.Evaluator, :eval_and_inspect, 3, [file: ~c"lib/iex/evaluator.ex", line: 331]} ]
and you can see :perform
shows up in the stacktrace as expected.
The Erlang documentation has a cute warning on this:
Basically, this means that you should be careful if you use apply
and are trying to debug with stacktraces, you might miss a frame in your trace.