Skip to content

fix(beam): keep the original stacktrace when an async error is re-raised#4818

Merged
dbrattli merged 1 commit into
mainfrom
fix/beam-async-stacktrace
Jul 18, 2026
Merged

fix(beam): keep the original stacktrace when an async error is re-raised#4818
dbrattli merged 1 commit into
mainfrom
fix/beam-async-stacktrace

Conversation

@dbrattli

Copy link
Copy Markdown
Collaborator

Split out of #4817, where it rode along as an unrelated second fix.

Problem

An error inside an async computation does not propagate as an exception: it is caught where it happens and handed to the context's on_error as a bare term. By the time run_synchronously re-raised it, the original stacktrace was long gone, so erlang:error/1 built a fresh one and every async failure was reported at the re-raise line — a line whose only job is to re-raise.

before:  top frame: {fable_async,run_synchronously,2,[...,{line,147}]}
after:   top frame: {st_demo,boom,0,[{file,"st_demo.erl"},{line,4}]}

Fix

Each catch site in fable_async/fable_async_builder that hands an error onward parks the stacktrace it caught, and the re-raise restores it when it belongs to that error. Errors that crossed a process boundary, or that were rewritten by wrap_error, fall back to a fresh stacktrace.

A parked entry is tagged with the enclosing run_synchronously call's token, and only that call can consume it. Untagged, a stacktrace parked for an error that was later handled (a try_with whose handler succeeded) would linger in the process dictionary and could be restored onto an unrelated later error that happened to be an equal term — attaching a plausible but wrong stacktrace, which is the very failure this mechanism exists to remove. try_with additionally drops the parked entry on the paths where it handles an error rather than propagating it.

start_with_continuations deliberately does not park: the caller supplies OnError and receives the error term directly, so there is no re-raise to restore onto. That is now stated in a comment rather than left as a silent gap.

Tests

tests/Beam/erl/async_stacktrace_tests.erl — native Erlang rather than F#, because the assertions are about Erlang stack frames, which have no counterpart on .NET (the F# suite runs there first). Picked up automatically by erl_test_runner.

Each test discriminates against the relevant baseline:

main tagging removed this PR
original stacktrace kept
handled stacktrace not reused
nested run keeps own

Verification

In-tree BEAM suite on this branch: 2636 passed, 0 failed (2633 baseline + the 3 new tests), plus both entry-point program tests.

🤖 Generated with Claude Code

An error inside an async computation does not propagate as an exception: it is
caught where it happens and handed to the context's `on_error` as a bare term.
By the time `run_synchronously` re-raised it, the original stacktrace was gone,
so `erlang:error/1` built a fresh one and every async failure was reported at
the re-raise line rather than where it happened:

  before:  top frame: {fable_async,run_synchronously,2,[...,{line,147}]}
  after:   top frame: {st_demo,boom,0,[{file,"st_demo.erl"},{line,4}]}

Each catch site in `fable_async`/`fable_async_builder` that hands an error
onward now parks the stacktrace it caught, and the re-raise restores it when it
belongs to that error. Errors that crossed a process boundary or were rewritten
by `wrap_error` fall back to a fresh stacktrace.

A parked entry is tagged with the enclosing `run_synchronously` call's token so
only that call can consume it. Untagged, a stacktrace parked for an error that
was later handled (a `try_with` whose handler succeeded) would linger in the
process dictionary and could be restored onto an unrelated later error that
happened to be an equal term — attaching a plausible but wrong stacktrace, the
very failure this mechanism exists to remove. `try_with` also drops the parked
entry on the paths where it handles the error rather than propagating it.

Adds native Erlang tests: these assert on Erlang stack frames, which have no
counterpart on .NET, so they cannot be expressed in the F# suite that runs
there first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dbrattli
dbrattli merged commit d7afa27 into main Jul 18, 2026
52 of 53 checks passed
@dbrattli
dbrattli deleted the fix/beam-async-stacktrace branch July 18, 2026 14:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant