diff --git a/docs/wallet-hardening.md b/docs/wallet-hardening.md index 10f420e..8d94fe2 100644 --- a/docs/wallet-hardening.md +++ b/docs/wallet-hardening.md @@ -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-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 | -| **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 +- **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(/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: - **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). diff --git a/src/main.cpp b/src/main.cpp index 68af202..acfe29a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -721,6 +721,63 @@ static void handleDisplayScaleChange(SDL_Window* window, float newScale, } } +#if !defined(_WIN32) +#include +#include +#include +#include +#if defined(__has_include) +# if __has_include() +# include +# 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[]) { // Ensure ObsidianDragon config directory exists early (before any file I/O) @@ -738,11 +795,31 @@ int main(int argc, char* argv[]) } } -#ifdef _WIN32 - // Redirect stdout/stderr to a log file so diagnostic output is visible - // even when built as a GUI app (WIN32_EXECUTABLE hides the console). + // W7-2: initialize the app-level Logger's file sink on ALL platforms so LOG/LOGF/VERBOSE_LOGF are + // actually persisted to dragonx-debug.log. Previously init() was never called, so on Linux/macOS the + // 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(), "a", stderr); } diff --git a/src/util/logger.cpp b/src/util/logger.cpp index a912767..ba7b493 100644 --- a/src/util/logger.cpp +++ b/src/util/logger.cpp @@ -5,10 +5,12 @@ #include "logger.h" #include +#include #include #include #include #include +#include namespace dragonx { namespace util { @@ -35,14 +37,30 @@ bool Logger::init(const std::string& path) if (file_.is_open()) { 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); initialized_ = file_.is_open(); - + 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_; } diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 1f784a7..2fe7757 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -5,6 +5,7 @@ #include "daemon/daemon_controller.h" #include "daemon/embedded_daemon.h" #include "util/connect_stall.h" +#include "util/logger.h" #include "data/transaction_history_cache.h" #include "data/address_book.h" #include "data/wallet_index.h" @@ -2546,6 +2547,33 @@ void testConsoleSecretRedaction() 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() { using dragonx::util::connectHasStalled; @@ -6906,6 +6934,7 @@ int main() testIsLocalHost(); testAllowsPlaintextRemote(); testConsoleSecretRedaction(); + testLoggerFileSink(); testDaemonLifecycleExecution(); testDaemonLifecycleAdapters(); testConsoleTextLayout();