Skip to content

Dedicated crash handler thread. #21826

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 20 commits into
base: devel
Choose a base branch
from
Open

Conversation

neunhoef
Copy link
Member

@neunhoef neunhoef commented Jun 27, 2025

Scope & Purpose

This PR implements a dedicated crash handler thread.
The read signal handler triggers the dedicated thread to
write out useful information before it continues with the crash.
This is useful since a dedicated thread has none of the restrictions
of a signal handler and can for example use system calls.

This allows us to dump a lot more information out at crash time.

  • [*] 💩 Bugfix
  • [*] 🍕 New feature

Checklist

  • Tests
    • Regression tests
    • C++ Unit tests
    • integration tests
    • resilience tests
  • 📖 CHANGELOG entry made
  • [*] Backports: none planned

@neunhoef neunhoef added this to the devel milestone Jun 27, 2025
@neunhoef neunhoef self-assigned this Jun 27, 2025
@cla-bot cla-bot bot added the cla-signed label Jun 27, 2025
Comment on lines 99 to 100
/// @brief dedicated crash handler thread
std::unique_ptr<std::thread> crashHandlerThread = nullptr;
Copy link
Member

Choose a reason for hiding this comment

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

Should we use a std::jthread instead?

Copy link
Member Author

Choose a reason for hiding this comment

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

Done, but it does not really make a difference, as far as I see.

Copy link
Member

Choose a reason for hiding this comment

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

I think it's a better default, and there's generally very little reason to use std::thread.

Mostly it will be automatically joined on destruction instead of terminating the process, if it hasn't been joined or detached, and it also allows the use of stop tokens/sources.

Comment on lines 94 to 103
/// @brief atomic variable for coordinating between signal handler and crash
/// handler thread
std::atomic<arangodb::CrashHandlerState> crashHandlerState(
arangodb::CrashHandlerState::IDLE);

/// @brief dedicated crash handler thread
std::unique_ptr<std::thread> crashHandlerThread = nullptr;

/// @brief mutex to protect thread creation/destruction
std::mutex crashHandlerThreadMutex;
Copy link
Member

Choose a reason for hiding this comment

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

I think these variables should be tied together, with crashHandlerThreadFunction(), either into a common struct or a namespace.

Copy link
Member Author

Choose a reason for hiding this comment

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

They are collected in a common anonymous namespace.

Comment on lines 391 to 393
while (true) {
arangodb::CrashHandlerState state =
crashHandlerState.load(std::memory_order_acquire);
Copy link
Member

Choose a reason for hiding this comment

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

Note that, since C++20, a std::atomic has methods wait(T value), notify_one() and notify_all(). And if I read https://en.cppreference.com/w/cpp/utility/program/signal correctly, calling these from the signal handler should be OK, as long as the atomic is lock free (which it has to be anyway). I think it's covered by "f is a non-static member function invoked on an object obj, such that obj.is_lock_free() yields true". But please double-check if you agree.

That would simplify this code and the wait function in the crash handler, and make it more polite/efficient.

In addition, if we use std::jthread (see my other comment), we can use stop token handling to address the thread shutdown instead of the atomic. This might be nicer to use (also see my comment on shutdown) and separate the concerns a little.

Comment on lines 475 to 478
// Signal the dedicated crash handler thread (force trigger even if not
// idle)
::crashHandlerState.store(arangodb::CrashHandlerState::CRASH_DETECTED,
std::memory_order_release);
Copy link
Member

Choose a reason for hiding this comment

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

I would vote for both commenting and asserting for everything we do here that we're allowed to do it in the signal handler (or that it's not allowed and we're doing it anyway). So here I'd add

static_assert(decltype(::crashHandlerState)::is_always_lock_free);

or so, and a comment that the store is allowed under this condition.

Comment on lines 470 to 473
// Log signal-specific information in the signal handler
logCrashInfo("signal handler invoked", signal, info, ucontext);
// Morally, we should do this after the work of the crash handler thread,
// however, for backwards compatibility, we keep it here as first thing.
Copy link
Member

Choose a reason for hiding this comment

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

I think it actually preferable to do it in the crash handler thread as much as possible, to reduce or eliminate the amount of UB we have in the crash handler, at least the actual logging.

Copy link
Member Author

Choose a reason for hiding this comment

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

Done.

Comment on lines 483 to 489
// Now log the backtrace from the crashed thread (only the crashed thread
// can do this)
logBacktrace();
logProcessInfo();

// Final cleanup
arangodb::Logger::flush();
arangodb::Logger::shutdown();
Copy link
Member

Choose a reason for hiding this comment

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

Same here, I think it would be preferable to do this in the crash handler thread as much as possible (would need some refactoring/splitting of logBacktrace()).

Copy link
Member Author

Choose a reason for hiding this comment

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

Done.

Comment on lines 67 to 68
/// @brief shuts down the crash handler thread
static void shutdownCrashHandler();
Copy link
Member

Choose a reason for hiding this comment

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

I don't think its workable to have a function that must be called before each call to exit or similar. As of now, you've addressed an exit call in arangod.cpp (there is even a second one there) - and how does this affect the client-tools? And you've addressed to exit()s in the VersionFeature, but it seems we have at least a dozen other places calling exit.

I haven't thought it through, so there may be problems, but some ideas how we might address this:

  • If we move the crashHandlerThread variable in a struct, we might be able to stop the thread in that thread's destructor
  • We could install handlers with std::atexit and std::at_quick_exit (I don't think std::set_terminate is needed?)
  • Don't install the thread globally, but with a scoped variable that we put in runServer, probably where we call CrashHandler::installCrashHandler(). It might then make sense to move installCrashHandler() into the constructor of the object, and maybe even remove the handlers in the destructor - that would clarify exactly in which scope the CrashHandler, including its handlers and thread, is active.

Intuitively I like the third option best, and the second the least.

Copy link
Member Author

Choose a reason for hiding this comment

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

I will try to do this as much as possible. Something with static lifetime will be necessary, or else the signal handler (which can be executed any time from any thread) and the std::terminate handler (which is executed on assertion failure or other catastrophic events, again essentially any time in any thread) do not have access to it. So making it into a totally scoped variable will not be possible.
I will try to avoid the explicit shutdown by additionally creating a scoped CrashHandler object (which can do the normal initialization and shutdown). But an atexit handler will be needed anyway, since we have these places which answer to --version, which use exit to stop the process right away. In this case the scoped variable is no longer cleaned up properly. Let's see what I can come up with.

Copy link
Member Author

Choose a reason for hiding this comment

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

Done as discussed.

Comment on lines 30 to 36
/// @brief States for the crash handler thread coordination
enum class CrashHandlerState : int {
IDLE = 0, ///< idle state, waiting for crashes
CRASH_DETECTED = 1, ///< crash detected, handling in progress
HANDLING_COMPLETE = 2, ///< crash handling complete
SHUTDOWN = 3 ///< shutdown requested
};
Copy link
Member

Choose a reason for hiding this comment

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

If this follows (only) specific state transitions, these should be noted in a comment.

Copy link
Member Author

Choose a reason for hiding this comment

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

Done.

Comment on lines +93 to +94
std::unique_ptr<char[]> backtraceBuffer;
constexpr size_t backtraceBufferSize = 1024 * 1024; // 1MB
Copy link
Member

Choose a reason for hiding this comment

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

You could use a std::array instead of a char[]. That would encode the length in the type, and thus make it slightly easier to access. Not a necessity though.

And not sure if there's a noteworthy tradeoff, but we could also omit the heap allocation and use static memory instead, e.g.

Suggested change
std::unique_ptr<char[]> backtraceBuffer;
constexpr size_t backtraceBufferSize = 1024 * 1024; // 1MB
constexpr size_t backtraceBufferSize = 1024 * 1024; // 1MB
std::array<char, backtraceBufferSize> backtraceBuffer;

Do you think it matters? Don't change it unless you prefer it, it's just a thought, I have no preference.


/// @brief mutex to protect thread creation/destruction
std::mutex crashHandlerThreadMutex;

// Static variables to store crash data for the dedicated crash handler thread
/// @brief stores the crash context/reason
static char const* crashContext = nullptr;
Copy link
Member

Choose a reason for hiding this comment

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

Maybe use a std::string_view instead of a char*? The callers have one available anyway, as far as I can see.

Comment on lines +46 to +47
std::atomic<bool> _threadRunning{false};
static std::atomic<CrashHandler*> _theCrashHandler;
Copy link
Member

Choose a reason for hiding this comment

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

Can we omit _threadRunning, and do a CAS on _theCrashHandler instead?

Comment on lines +143 to +145
// We intentionally do not protect against two signal handlers running
// concurrently. We could do this using atomics, but choose not to
// for simplicity.
Copy link
Member

Choose a reason for hiding this comment

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

I think it would be simple enough to protect against this:
E.g., change crashSignal to be an atomic, set it first but with a CAS, and set the rest only if that succeeded.

Copy link
Member

Choose a reason for hiding this comment

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

Actually, I've just seen that this is handled by ::crashHandlerInvoked.exchange(true) in crashHandlerSignalHandler - so maybe just clarify that in the comment?

Comment on lines 340 to 347
SmallString headerBuffer;
headerBuffer.append("Backtrace of thread ");
headerBuffer.appendUInt64(arangodb::Thread::currentThreadNumber());
headerBuffer.append(" [").append(currentThreadName).append("]\n");

LOG_TOPIC("c962b", INFO, arangodb::Logger::CRASH) << buffer.view();
if (!safeAppend(headerBuffer.view())) {
return totalWritten;
}
Copy link
Member

Choose a reason for hiding this comment

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

Maybe wrap it in a {} block, to make it clear that we don't add up too many of those 4kb buffers on the stack. Though the optimizer might catch that anyway...

/// in a signal handler. Use `acquireBacktrace` above to get the backtrace
/// in the signal handler and then this function in the dedicated
/// CrashHandler thread to do the actual logging!
void logAcquiredBacktrace(char const* buffer, size_t bufferSize) try {
Copy link
Member

Choose a reason for hiding this comment

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

Maybe pass a std::string_view instead of char* + size_t?

Comment on lines +448 to +454
size_t pos = 0;
size_t lineStart = 0;

while (pos <= bufferSize) {
if (pos == bufferSize || buffer[pos] == '\n') {
if (pos > lineStart) {
std::string_view line(buffer + lineStart, pos - lineStart);
Copy link
Member

Choose a reason for hiding this comment

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

If you change buffer into a std::string_view, you can just do

Suggested change
size_t pos = 0;
size_t lineStart = 0;
while (pos <= bufferSize) {
if (pos == bufferSize || buffer[pos] == '\n') {
if (pos > lineStart) {
std::string_view line(buffer + lineStart, pos - lineStart);
for (auto const line : std::views::split(buffer, "\n")) {

instead, if you include <ranges>. :)

Comment on lines +798 to +802
} catch (...) {
// could not allocate memory for backtrace buffer.
// this is not critical - we can still work without it
::backtraceBuffer.release();
}
Copy link
Member

Choose a reason for hiding this comment

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

Maybe add a log message? Seems unlikely, but may confuse us very much without one if it does happen.

@@ -685,6 +840,20 @@ void CrashHandler::installCrashHandler() {

CrashHandler::crash(buffer.view());
});

std::atexit([]() {
CrashHandler* ch = CrashHandler::_theCrashHandler;
Copy link
Member

Choose a reason for hiding this comment

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

Maybe do a

Suggested change
CrashHandler* ch = CrashHandler::_theCrashHandler;
auto* ch = CrashHandler::_theCrashHandler.exchange(nullptr);

instead? And similarly in std::at_quick_exit and ~CrashHandler(). That could avoid races between ~CrashHandler() and the exit handlers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants