feat(diagnostics): make the logging + crash infrastructure actually work (W7-2, W7-3, W7-4)

The Foundation tier — answers the original "make it easier to diagnose problems" ask.

- W7-2 (keystone): the app-level Logger file sink was never initialized, so LOG/LOGF/
  VERBOSE_LOGF went nowhere and dragonx-debug.log didn't exist on Linux/macOS at all.
  main() now calls Logger::init(<config>/dragonx-debug.log) on every platform. Fixed a
  latent deadlock this exposed: init() wrote its banner via write(), which re-locks the
  non-recursive mutex_ it already holds — now written directly. On Windows the raw
  stdout/stderr freopen moved to a separate dragonx-stdout.log so the two writers don't
  contend on one file. Added testLoggerFileSink (also a deadlock guard — it would hang if
  the fix regressed).

- W7-3: no crash handler existed on Linux/macOS. Added an async-signal-safe sigaction
  handler (SIGSEGV/ABRT/BUS/FPE/ILL) that writes the signal id + a backtrace_symbols_fd
  backtrace to dragonx-crash.log, then re-raises the default disposition for a core dump —
  the POSIX counterpart of the Windows SEH filter.

- W7-4: Logger::init now rotates the log to a single .1 backup past 10 MB, so a long or
  verbose session can't grow it unbounded.

Build-clean; ctest 1/1. Remaining Foundation: the QoL bundle (mostly UI). See
docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 15:31:23 -05:00
parent 05b00b158b
commit 207f9074db
4 changed files with 138 additions and 9 deletions

View File

@@ -21,7 +21,7 @@ Status legend: ☐ not started · ◐ in progress · ☑ landed & verified
| **P1-A** | W3-1, W3-2, W3-4 ✓ · W3-3 ⚑ | Migrate-to-seed correctness (fund-adjacent) | ◐ 3/4 | | **P1-A** | W3-1, W3-2, W3-4 ✓ · W3-3 ⚑ | Migrate-to-seed correctness (fund-adjacent) | ◐ 3/4 |
| **P1-B** | W1-1, W1-2, W1-3, W1-4 ✓ + startup guard | Missing/wrong wallet-file safety | ☑ | | **P1-B** | W1-1, W1-2, W1-3, W1-4 ✓ + startup guard | Missing/wrong wallet-file safety | ☑ |
| **P2** | W5-1, W5-2, W6-1, W6-3 ✓ · W6-2 ☐ | Stale state & lite save-failure surfacing | ◐ 4/5 | | **P2** | W5-1, W5-2, W6-1, W6-3 ✓ · W6-2 ☐ | Stale state & lite save-failure surfacing | ◐ 4/5 |
| **F** | W7-2, W7-3, W7-4, QoL | Diagnostics foundation + QoL bundle | | | **F** | W7-2, W7-3, W7-4 ✓ · QoL | Diagnostics foundation + QoL bundle | ◐ infra done |
--- ---
@@ -112,6 +112,11 @@ Land W7-2 first — it unblocks the rest.
## Progress log ## Progress log
- **Foundation / W7-2 · W7-3 · W7-4 (diagnostics infrastructure)** — ☑ landed (answers the original "easier to diagnose" ask — the logging/crash foundation now actually works):
- **W7-2 (Med, keystone):** the app-level `Logger` file sink was never initialized, so `LOG`/`LOGF`/`VERBOSE_LOGF` went nowhere and `dragonx-debug.log` didn't exist on Linux/macOS at all. `main()` now calls `Logger::init(<config>/dragonx-debug.log)` on all platforms. Also fixed a **latent deadlock** this exposed: `init()` wrote its banner via `write()`, which re-locks the non-recursive `mutex_` it already holds — now written directly. On Windows the raw stdout/stderr `freopen` was moved to a separate `dragonx-stdout.log` so the two writers don't contend. New `testLoggerFileSink` (also a deadlock guard — it would hang if that regressed).
- **W7-3 (Med):** no crash handler existed on Linux/macOS. Added an **async-signal-safe** `sigaction` handler (SIGSEGV/ABRT/BUS/FPE/ILL) that writes a signal id + `backtrace_symbols_fd` backtrace to `dragonx-crash.log`, then re-raises the default disposition for a core dump — the POSIX counterpart of the Windows SEH filter.
- **W7-4 (Low):** `Logger::init` now rotates the log to a single `.1` backup when it exceeds 10 MB, so a long/verbose session can't grow it unbounded.
Build-clean; `ctest` 1/1. **Remaining Foundation:** the QoL bundle (mostly UI) — "copy diagnostics for support", an "open log folder" action, persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge.
- **P2 / W5-1 · W5-2 · W6-1 · W6-3 (localized batch)** — ☑ landed: - **P2 / W5-1 · W5-2 · W6-1 · W6-3 (localized batch)** — ☑ landed:
- **W5-1 (Med):** `persistAfterBroadcast` (lite send/shield save) returned false on a persistent save failure but both callers discarded it and it never logged — completely silent. It now `liteLog`s the failure (the note re-derives on next sync, so it's a robustness gap, not fund loss). - **W5-1 (Med):** `persistAfterBroadcast` (lite send/shield save) returned false on a persistent save failure but both callers discarded it and it never logged — completely silent. It now `liteLog`s the failure (the note re-derives on next sync, so it's a robustness gap, not fund loss).
- **W5-2 (Med):** the post-**sync** and post-**rescan** `save` results (in the detached scan threads) were ignored; both now `liteLog` on failure (`LiteDiagnostics::log` is mutex-guarded, safe from those threads). - **W5-2 (Med):** the post-**sync** and post-**rescan** `save` results (in the detached scan threads) were ignored; both now `liteLog` on failure (`LiteDiagnostics::log` is mutex-guarded, safe from those threads).

View File

@@ -721,6 +721,63 @@ static void handleDisplayScaleChange(SDL_Window* window, float newScale,
} }
} }
#if !defined(_WIN32)
#include <csignal>
#include <cstring>
#include <unistd.h>
#include <fcntl.h>
#if defined(__has_include)
# if __has_include(<execinfo.h>)
# include <execinfo.h>
# define DRAGONX_HAVE_BACKTRACE 1
# endif
#endif
// Absolute path to the crash log, filled at install time so the async-signal handler needs no
// allocation. (POSIX counterpart of the Windows SEH CrashHandler above — W7-3.)
static char g_crashLogPath[1024] = {0};
// Async-signal-safe crash handler: only open()/write()/backtrace_symbols_fd()/raise() are used —
// no stdio, std::filesystem or malloc (all unsafe inside a signal handler).
static void PosixCrashHandler(int sig)
{
int fd = g_crashLogPath[0] ? open(g_crashLogPath, O_WRONLY | O_CREAT | O_APPEND, 0600) : -1;
if (fd >= 0) {
auto put = [fd](const char* s) { ssize_t n = write(fd, s, std::strlen(s)); (void)n; };
put("\n=== CRASH: signal ");
char num[16]; int i = 0, v = sig; // signal number -> decimal, no stdio
if (v == 0) { num[i++] = '0'; }
else { char tmp[16]; int t = 0; while (v > 0) { tmp[t++] = char('0' + v % 10); v /= 10; }
while (t > 0) num[i++] = tmp[--t]; }
num[i] = '\n';
ssize_t nn = write(fd, num, i + 1); (void)nn;
#ifdef DRAGONX_HAVE_BACKTRACE
void* frames[64];
int nframes = backtrace(frames, 64);
backtrace_symbols_fd(frames, nframes, fd); // async-signal-safe
#endif
put("=== END CRASH ===\n");
close(fd);
}
// Restore the default disposition and re-raise so we still get a core dump / normal termination.
signal(sig, SIG_DFL);
raise(sig);
}
static void installPosixCrashHandler(const std::string& crashLogPath)
{
std::snprintf(g_crashLogPath, sizeof(g_crashLogPath), "%s", crashLogPath.c_str());
struct sigaction sa;
std::memset(&sa, 0, sizeof(sa));
sa.sa_handler = PosixCrashHandler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
for (int sig : {SIGSEGV, SIGABRT, SIGBUS, SIGFPE, SIGILL}) {
sigaction(sig, &sa, nullptr);
}
}
#endif // !_WIN32
int main(int argc, char* argv[]) int main(int argc, char* argv[])
{ {
// Ensure ObsidianDragon config directory exists early (before any file I/O) // Ensure ObsidianDragon config directory exists early (before any file I/O)
@@ -738,11 +795,31 @@ int main(int argc, char* argv[])
} }
} }
#ifdef _WIN32 // W7-2: initialize the app-level Logger's file sink on ALL platforms so LOG/LOGF/VERBOSE_LOGF are
// Redirect stdout/stderr to a log file so diagnostic output is visible // actually persisted to dragonx-debug.log. Previously init() was never called, so on Linux/macOS the
// even when built as a GUI app (WIN32_EXECUTABLE hides the console). // file never existed at all (the Windows-only stdout freopen below is a separate mechanism).
{ {
std::string logPath = (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-debug.log").string(); const std::string logPath =
(std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-debug.log").string();
dragonx::util::Logger::instance().init(logPath);
}
#if !defined(_WIN32)
// W7-3: install the POSIX crash handler (the Windows SEH filter is installed below). A segfault or
// abort now leaves a backtrace in dragonx-crash.log instead of vanishing silently on Linux/macOS.
{
const std::string crashPath =
(std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-crash.log").string();
installPosixCrashHandler(crashPath);
}
#endif
#ifdef _WIN32
// Redirect raw stdout/stderr (library / daemon-pipe writes) to a log file so it's visible even when
// built as a GUI app (WIN32_EXECUTABLE hides the console). Separate file from the structured Logger
// above so the two writers don't interleave/contend on one file.
{
std::string logPath = (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-stdout.log").string();
freopen(logPath.c_str(), "w", stdout); freopen(logPath.c_str(), "w", stdout);
freopen(logPath.c_str(), "a", stderr); freopen(logPath.c_str(), "a", stderr);
} }

View File

@@ -5,10 +5,12 @@
#include "logger.h" #include "logger.h"
#include <cstdarg> #include <cstdarg>
#include <cstdint>
#include <ctime> #include <ctime>
#include <chrono> #include <chrono>
#include <iomanip> #include <iomanip>
#include <sstream> #include <sstream>
#include <filesystem>
namespace dragonx { namespace dragonx {
namespace util { namespace util {
@@ -35,14 +37,30 @@ bool Logger::init(const std::string& path)
if (file_.is_open()) { if (file_.is_open()) {
file_.close(); file_.close();
} }
// W7-4: cap the log's growth — if the existing file is already large, rotate it to a single .1
// backup before reopening in append mode, so a long-lived or verbose session can't grow it
// without bound.
{
std::error_code ec;
const auto sz = std::filesystem::file_size(path, ec);
constexpr std::uintmax_t kMaxLogBytes = 10ull * 1024ull * 1024ull; // 10 MB
if (!ec && sz > kMaxLogBytes) {
std::filesystem::rename(path, path + ".1", ec); // replaces any previous .1 backup
if (ec) std::filesystem::remove(path, ec); // fall back to truncation if rename fails
}
}
file_.open(path, std::ios::out | std::ios::app); file_.open(path, std::ios::out | std::ios::app);
initialized_ = file_.is_open(); initialized_ = file_.is_open();
if (initialized_) { if (initialized_) {
write("=== Logger initialized ==="); // Write the banner directly, NOT via write(): write() re-locks the non-recursive mutex_ we
// already hold here, which would deadlock (latent — init() was previously never called, W7-2).
file_ << "=== Logger initialized ===" << std::endl;
file_.flush();
} }
return initialized_; return initialized_;
} }

View File

@@ -5,6 +5,7 @@
#include "daemon/daemon_controller.h" #include "daemon/daemon_controller.h"
#include "daemon/embedded_daemon.h" #include "daemon/embedded_daemon.h"
#include "util/connect_stall.h" #include "util/connect_stall.h"
#include "util/logger.h"
#include "data/transaction_history_cache.h" #include "data/transaction_history_cache.h"
#include "data/address_book.h" #include "data/address_book.h"
#include "data/wallet_index.h" #include "data/wallet_index.h"
@@ -2546,6 +2547,33 @@ void testConsoleSecretRedaction()
EXPECT_EQ(RedactConsoleCommand("getwalletinfo"), std::string("getwalletinfo")); EXPECT_EQ(RedactConsoleCommand("getwalletinfo"), std::string("getwalletinfo"));
} }
void testLoggerFileSink()
{
using dragonx::util::Logger;
namespace fsn = std::filesystem;
fsn::path logPath = fsn::temp_directory_path() / "od_logger_test.log";
std::error_code ec;
fsn::remove(logPath, ec);
fsn::remove(logPath.string() + ".1", ec);
// W7-2: init() opens the file sink and must NOT deadlock — it writes the banner under the same
// non-recursive lock it holds (this test would hang if that regressed).
Logger& lg = Logger::instance();
EXPECT_TRUE(lg.init(logPath.string()));
lg.write("hello-w7-2-sink");
EXPECT_TRUE(fsn::exists(logPath));
std::ifstream f(logPath.string());
std::string all, line;
while (std::getline(f, line)) all += line + "\n";
f.close();
EXPECT_TRUE(all.find("hello-w7-2-sink") != std::string::npos);
EXPECT_TRUE(all.find("Logger initialized") != std::string::npos);
fsn::remove(logPath, ec);
fsn::remove(logPath.string() + ".1", ec);
}
void testConnectHasStalled() void testConnectHasStalled()
{ {
using dragonx::util::connectHasStalled; using dragonx::util::connectHasStalled;
@@ -6906,6 +6934,7 @@ int main()
testIsLocalHost(); testIsLocalHost();
testAllowsPlaintextRemote(); testAllowsPlaintextRemote();
testConsoleSecretRedaction(); testConsoleSecretRedaction();
testLoggerFileSink();
testDaemonLifecycleExecution(); testDaemonLifecycleExecution();
testDaemonLifecycleAdapters(); testDaemonLifecycleAdapters();
testConsoleTextLayout(); testConsoleTextLayout();