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:

/assets/blog/2024/01/21/beam-stacktraces-missing-frame/2024-01-21_17-58-42_screenshot.png

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.

Posted: 2024-01-21
Filed Under: computer