Skip to content

Diagnostics: report the message of an uncaught C++ exception - #1834

Merged
bkaradzic-microsoft merged 2 commits into
BabylonJS:masterfrom
bkaradzic-microsoft:diagnostics/symbolized-callstacks
Aug 13, 2026
Merged

Diagnostics: report the message of an uncaught C++ exception#1834
bkaradzic-microsoft merged 2 commits into
BabylonJS:masterfrom
bkaradzic-microsoft:diagnostics/symbolized-callstacks

Conversation

@bkaradzic-microsoft

@bkaradzic-microsoft bkaradzic-microsoft commented Aug 12, 2026

Copy link
Copy Markdown
Member

An uncaught C++ exception currently produces this, and nothing more:

--- BN: ABORT ---
SIGABRT raised.

The report never says what actually failed. The exception that is still propagating is now described:

--- BN: ABORT ---
SIGABRT raised.
uncaught std::exception: synthetic uncaught exception on a worker thread

Why it is reported from two handlers

The reporting is done from both the terminate handler and the SIGABRT handler on purpose.

The standard says std::set_terminate() is global, but the Microsoft CRT keeps the terminate handler per-thread. A terminate on a worker thread therefore never reaches a handler installed on the main thread — it goes straight to abort() and lands in the SIGABRT handler. Handling both keeps worker-thread failures diagnosable on Windows, and still uses the terminate handler on platforms where it is genuinely global.

I found this the hard way: the first version of this PR only installed a terminate handler, and it silently never fired.

What changed since the first revision

This PR originally also carried an ad-hoc dbghelp symbolizer for callstacks. That has been dropped — the unresolved frames were a bug in bx's own resolver, not something Babylon Native should work around locally. Two bx bugs are responsible:

  1. DbgHelpSymbolResolve::resolve() requires SymFromAddr and SymGetLineFromAddr to both succeed. Modules that ship public symbols only (every system DLL) have a perfectly good function name but no line info, so the name was thrown away and the frame printed as <Unknown?>.
  2. write(WriterI*, const void*, ...) in string.cpp casts through uint32_t, so every pointer printed via bx's %p loses its top 32 bits on a 64-bit build. Callstack PCs came out as 0x115cc3f6 instead of 0x00007ff9115cc3f6, which cannot be fed back into a debugger.

With both fixed in bx, the same crash goes from 15 of 23 frames unresolved to all 23 resolved, with full-width addresses, and no Babylon Native change is needed:

	 0: ... 0x7ff90ef349da  RaiseException
	 4: ... 0x7ff794bccc29  WndProc                    (Apps/Playground/Win32/App.cpp:425)
	 5: ... 0x7ff9115cc3f6  CallWindowProcW
	 6: ... 0x7ff9115cb703  SendMessageW
	16: ... 0x7ff911ba4cb4  KiUserCallbackDispatcherContinue

So this PR is now just the uncaught-exception message.

Scope

Cross-platform; no new dependencies. Verified on Windows by throwing from a worker thread and confirming the message appears and the exit code stays 3.

Copilot AI lite review requested due to automatic review settings August 12, 2026 19:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Improve crash diagnostics in the Playground by producing actionable symbolized callstacks (MSVC/Windows) and including messages for uncaught C++ exceptions.

Changes:

  • Add MSVC-only dbghelp-based symbolization to print module+RVA, symbol names, and file:line when available.
  • Add terminate + SIGABRT reporting to include the message of an uncaught exception in crash output.
Suppressed comments (1)

Apps/Playground/Shared/Diagnostics.cpp:227

  • On non-MSVC platforms this is a POSIX signal handler; calling into the C++ runtime (std::current_exception, std::rethrow_exception, std::string allocations) from a signal handler is not async-signal-safe and can deadlock or crash (especially if the abort happens while the allocator/runtime holds internal locks). Since you already install a terminate handler (which is global on typical non-MSVC libcs), consider keeping OnSignalAbort minimal on non-MSVC (just print the fixed message / exit), and do exception description only in OnTerminate. If you need a message in SIGABRT too, capture it earlier into a preallocated thread-local buffer in OnTerminate and only print that buffer in the signal handler.
    void OnSignalAbort(int /*signal*/)
    {
        const std::string detail = DescribeCurrentException();
        Diagnostics::DumpFailure("ABORT", nullptr, 0, 1, "SIGABRT raised.%s%s",
            detail.empty() ? "" : "\n", detail.c_str());
        Diagnostics::SetExitCode(3);
        Diagnostics::PrintFinishLine();
        std::_Exit(3);
    }

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Apps/Playground/Shared/Diagnostics.cpp Outdated
Comment thread Apps/Playground/Shared/Diagnostics.cpp Outdated
Comment thread Apps/Playground/Shared/Diagnostics.cpp Outdated
An uncaught exception currently produces "SIGABRT raised." and nothing else,
so a crash report never says what actually failed. The exception that is
still propagating is now described:

    --- BN: ABORT ---
    SIGABRT raised.
    uncaught std::exception: <what()>

The reporting is done from both the terminate handler and the SIGABRT
handler on purpose. The standard says std::set_terminate() is global, but
the Microsoft CRT keeps the terminate handler per-thread, so a terminate on
a worker thread never reaches a handler installed on the main thread and
lands in the SIGABRT handler instead. Handling both keeps worker-thread
failures diagnosable on Windows.

Verified on Windows with an exception escaping a worker thread.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
@bkaradzic-microsoft
bkaradzic-microsoft force-pushed the diagnostics/symbolized-callstacks branch 2 times, most recently from 24eabd6 to e4c3320 Compare August 12, 2026 20:15
@bkaradzic-microsoft bkaradzic-microsoft changed the title Diagnostics: symbolize Win32 callstacks and report uncaught exception messages Diagnostics: report the message of an uncaught C++ exception Aug 12, 2026
Copilot flagged that OnSignalAbort is a real signal handler on non-MSVC, and
DescribeCurrentException() is not async-signal-safe: std::current_exception()
and std::string both allocate. abort() is frequently raised from inside the
allocator itself (heap corruption, a glibc malloc assertion), so allocating in
the handler can deadlock against the allocator's own lock in exactly the cases
where the crash report matters most.

The call was also redundant there. Outside the Microsoft CRT std::set_terminate()
is global rather than per-thread, and OnTerminate() ends in std::_Exit(), so an
uncaught exception is reported and the process is gone before abort() is ever
reached. Everything that does land in the POSIX handler -- a direct abort(), a
libc assertion, raise(SIGABRT), kill -ABRT -- has no C++ exception in flight, so
the call returned an empty string anyway.

The MSVC branch keeps the call, because there std::set_terminate() is per-thread
and a worker-thread terminate genuinely bypasses OnTerminate() and lands here
with the exception still current.

Verified by compiling the POSIX branch standalone (MSVC never builds it) at
/W4 /WX: the emitted message is identical with and without an exception in
flight, and OnTerminate() still recovers "uncaught std::exception: ...".

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
@bkaradzic-microsoft

Copy link
Copy Markdown
Member Author

Good catch — fixed in c98e161.

You're right on both counts, and the call was redundant on that platform as well as unsafe. Outside the Microsoft CRT std::set_terminate() is global rather than per-thread, and OnTerminate() ends in std::_Exit(), so an uncaught exception is fully reported and the process is gone before abort() is ever reached. Everything that actually lands in the POSIX handler — a direct abort(), a libc assertion, raise(SIGABRT), kill -ABRT — has no C++ exception in flight, so DescribeCurrentException() was doing async-signal-unsafe work to produce an empty string.

That makes the risk you describe strictly one-sided: abort() is very often raised from inside the allocator (heap corruption, a glibc malloc assertion), so allocating in the handler can deadlock against the allocator's own lock in precisely the cases where the crash report is most valuable.

So the POSIX branch now prints the fixed message and nothing else. I kept the call in the MSVC branch, where it's both safe and necessary: there std::set_terminate() is per-thread, so a terminate on a worker thread genuinely bypasses OnTerminate() and arrives here with the exception still current — that path is the reason this PR exists.

Since MSVC never compiles the branch I changed, I verified it by pulling it into a standalone TU and building at /W4 /WX:

no-exception case: ABORT|SIGABRT raised.
in-flight case:    ABORT|SIGABRT raised.
OnTerminate would report: uncaught std::exception: boom

Identical output with and without an exception in flight (so no behavioural regression on the only path that reaches it), and OnTerminate() still recovers the message. DescribeCurrentException() is still referenced by OnTerminate() on every platform, so there's no unused-function warning.

Worth being explicit about the remaining limitation: DumpFailure()/PrintFinishLine() still use stdio, which isn't strictly async-signal-safe either. Making the whole path write(2)-only is a larger change and would cost the formatted output, so I've left it — this commit removes the unbounded heap allocation, which is the part that can actually deadlock.

@bkaradzic-microsoft
bkaradzic-microsoft merged commit 8b83003 into BabylonJS:master Aug 13, 2026
34 checks passed
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.

4 participants