From 6b14098bb2d815f30e17bb9d8becb94d78437e27 Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 2 Oct 2026 00:02:27 -0500 Subject: [PATCH] fix(daemon): make the "keep the node running?" prompt actually appear Quitting during a witness rebuild is supposed to ask before stopping the node, because a stop throws the rebuild away and a killed node replays and redoes it. The guard almost never fired: - it returned false for any daemon >= v1.3.0, on the false premise that v1.3.0 checkpoints a rebuild (it does not; nothing survives a kill) - it read a 16 KB debug.log tail and compared the last progress line with the newest line, so any unrelated line during the rebuild's silent second phase turned it off; and on POSIX that file is stale - daemon_version, which the bypass needed, is zeroed when a busy node drops the connection Now a DaemonBusyTracker (src/daemon/daemon_busy_detect.h, pure) follows dragonxd's own markers in order: rebuilding (Phase-1 / legacy markers, ended by the completion line, an abort line or the next UpdateTip) and replaying (Activating best chain ... Done Loading), reset by a new banner or "Shutdown: done". It is fed from the owned output on the UI thread, including the backlog skipped after a restore. The quit guard prompts when the quit would stop the node and it is rebuilding, or has been alive but only timing out / refusing as busy for >= 30 s. A replay alone never prompts: stopping it is safe. The stop loop uses the same signal to never offer Kill during a rebuild. Also: - daemon output offsets are absolute, so a reader that falls behind a trim of the 1 MB buffer no longer skips new output (which could hide a start or end marker) - a quit deferred while minimized restores and raises the window, and the wizard renders the prompt, so it is never waiting unseen Verified live with the fake daemon: quitting mid-rebuild now shows the prompt for a v1.3.0 node and leaves the node alone; a silent busy node prompts after 30 s. Co-Authored-By: Claude Opus 5.5 --- src/app.cpp | 93 +++++++++---------- src/app.h | 26 +++++- src/app_network.cpp | 3 + src/daemon/daemon_busy_detect.h | 145 ++++++++++++++++++++++++++++++ src/daemon/embedded_daemon.cpp | 1 + src/daemon/embedded_daemon.h | 21 +++-- src/main.cpp | 5 ++ tests/test_phase4.cpp | 155 ++++++++++++++++++++++++++++++++ 8 files changed, 395 insertions(+), 54 deletions(-) create mode 100644 src/daemon/daemon_busy_detect.h diff --git a/src/app.cpp b/src/app.cpp index 5d29a85..f9e9516 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -317,7 +317,11 @@ public: void stopDaemonWithPolicy() override { app_.stopEmbeddedDaemon(); } bool startDaemon() override { return app_.startEmbeddedDaemon(); } - void resetOutputOffset() override { app_.daemon_output_offset_ = 0; } + void resetOutputOffset() override + { + app_.daemon_output_offset_ = 0; + app_.resetDaemonBusy(); + } void requestRpcStopAndDisconnect(const char* context, const char* reason) override { @@ -1086,6 +1090,7 @@ void App::update() if (daemon_controller_ && daemon_controller_->isRunning()) { std::string newOutput = daemon_controller_->outputSince(daemon_output_offset_); if (!newOutput.empty()) { + feedDaemonBusy(newOutput); // witness rebuild / startup replay, for the quit guard + stop loop // Parse inline on the main thread. It's cheap string scanning of a small incremental // buffer, and keeping it OFF the worker is essential: during a rescan the worker can // be blocked for minutes on a getrescaninfo call (it waits on cs_main while the daemon @@ -1589,6 +1594,7 @@ void App::render() // First-run wizard gate — blocks all normal UI if (wizard_phase_ != WizardPhase::None && wizard_phase_ != WizardPhase::Done) { renderFirstRunWizard(); + renderDaemonStopConfirm(); // a quit during the wizard still gets its "keep the node running?" prompt return; } @@ -5146,7 +5152,9 @@ void App::skipDaemonOutputBacklog() // complete" toast. Advance the offset to the current end so only NEW (post-restore) output is parsed. // A genuine user-initiated rescan still surfaces completion via the getrescaninfo monitor. if (daemon_controller_ && daemon_controller_->isRunning()) { - (void)daemon_controller_->outputSince(daemon_output_offset_); // advances daemon_output_offset_ to the end + // Advances daemon_output_offset_ to the end. The busy tracker still sees it: whether the node is + // mid-rebuild must not depend on whether the window was minimized while the rebuild started. + feedDaemonBusy(daemon_controller_->outputSince(daemon_output_offset_)); } } @@ -5375,6 +5383,7 @@ bool App::startEmbeddedDaemon() }); } + resetDaemonBusy(); // a new process: whatever the last one was doing is over return daemon_controller_->start(settings_.get()); } @@ -5441,7 +5450,7 @@ public: return usage_.cpuSeconds; } double ioBytes() override { return usage_.ioBytes; } // sampled with cpuSeconds() - bool rebuildActive() override { return false; } // the witness-rebuild tracker is not wired yet + bool rebuildActive() override { return app_.node_rebuilding_.load(); } // the node's own log says so daemon::StopUserChoice userChoice() override { return static_cast(app_.stop_user_choice_.load()); @@ -5927,7 +5936,12 @@ void App::beginShutdown() // defer shutdown and let render() show the confirm modal. The user's choice re-enters beginShutdown() // with shutdown_confirmed_ set (and, for "keep node running", shutdown_keep_daemon_override_). if (!shutdown_confirmed_ && shouldConfirmDaemonStop()) { + DEBUG_LOGF("beginShutdown: the node is %s — asking before stopping it\n", + node_rebuilding_ ? "rebuilding witnesses" : "too busy to answer RPC"); pending_shutdown_confirm_ = true; + // The modal only renders in a visible frame: a quit from the taskbar while minimized (or behind + // other windows) would otherwise wait on a prompt nobody can see. + if (raise_window_) raise_window_(); return; // NOT shutting down yet — the normal UI + modal keep rendering } @@ -6057,47 +6071,35 @@ std::vector App::tailDaemonDebugLog(int maxLines) const return lines; } -// Parse a "YYYY-MM-DD HH:MM:SS ..." debug.log line prefix to time_t. Interpreted as local time, but it -// is only ever used for DELTAS between two lines of the SAME log, so the timezone cancels. Returns 0 if -// the line has no such timestamp prefix. -static std::time_t parseDaemonLogTimestamp(const std::string& line) +void App::feedDaemonBusy(const std::string& chunk) { - int y = 0, mo = 0, d = 0, h = 0, mi = 0, s = 0; - if (std::sscanf(line.c_str(), "%d-%d-%d %d:%d:%d", &y, &mo, &d, &h, &mi, &s) != 6) return 0; - std::tm tm{}; - tm.tm_year = y - 1900; tm.tm_mon = mo - 1; tm.tm_mday = d; - tm.tm_hour = h; tm.tm_min = mi; tm.tm_sec = s; tm.tm_isdst = -1; - return std::mktime(&tm); + if (daemon_busy_reset_pending_.exchange(false)) daemon_busy_.reset(); + daemon_busy_.feed(chunk); + node_rebuilding_ = daemon_busy_.rebuilding(); } -bool App::daemonWitnessRebuildActive() const +void App::resetDaemonBusy() { - // Scan the debug.log tail for the daemon's witness-rebuild markers (wallet.cpp): "Cleared witness - // data from" (start), "Reading blocks for witness rebuild" / "Setting Initial Sapling Witness" - // (progress), vs. "rebuilt N note witness cache(s)" / "aborting…" (finished). - // - // A genuine ongoing rebuild logs progress CONTINUOUSLY. The routine per-tx witness set the daemon - // does as each new wallet tx lands during normal sync is SPARSE (minutes apart) and must NOT trip - // this — that was firing the "node is rebuilding" prompt on wallets that just receive frequently. - // So require the last progress marker to be (a) later than any completion AND (b) part of the CURRENT - // activity — within a few seconds of the newest log line (same-log timestamp delta → timezone-free). - const auto lines = tailDaemonDebugLog(120); - std::time_t newest = 0, lastProgress = 0, lastDone = 0; - for (const auto& l : lines) { - const std::time_t ts = parseDaemonLogTimestamp(l); - if (ts > newest) newest = ts; - if (l.find("note witness cache(s) to height") != std::string::npos || - l.find("aborting witness rebuild") != std::string::npos || - l.find("aborted during witness rebuild") != std::string::npos) { - if (ts > lastDone) lastDone = ts; - } else if (l.find("Reading blocks for witness rebuild") != std::string::npos || - l.find("Setting Initial Sapling Witness") != std::string::npos || - l.find("Cleared witness data from") != std::string::npos) { - if (ts > lastProgress) lastProgress = ts; - } + // Any thread (a restart/rescan task starts the node): the UI thread applies it before its next feed. + daemon_busy_reset_pending_ = true; + node_rebuilding_ = false; +} + +void App::noteDaemonRpcResult(bool answered, rpc::RpcFailureKind kind) +{ + if (answered) { + rpc_busy_since_ = {}; + } else if (kind == rpc::RpcFailureKind::ReplyTimeout || kind == rpc::RpcFailureKind::QueueFull) { + if (rpc_busy_since_ == std::chrono::steady_clock::time_point{}) + rpc_busy_since_ = std::chrono::steady_clock::now(); } - if (lastProgress == 0 || lastDone >= lastProgress || newest == 0) return false; - return (newest - lastProgress) <= 15; // progress is part of the current activity → ongoing rebuild +} + +bool App::rpcBusyWhileAlive() const +{ + if (rpc_busy_since_ == std::chrono::steady_clock::time_point{}) return false; + if (std::chrono::steady_clock::now() - rpc_busy_since_ < std::chrono::seconds(30)) return false; + return (daemon_controller_ && daemon_controller_->isRunning()) || daemon::EmbeddedDaemon::isRpcPortInUse(); } bool App::shouldConfirmDaemonStop() const @@ -6108,12 +6110,13 @@ bool App::shouldConfirmDaemonStop() const const auto decision = daemon_controller_->shutdownDecision( settings_ && settings_->getKeepDaemonRunning(), settings_ && settings_->getStopExternalDaemon()); - if (decision.action != daemon::DaemonController::ShutdownAction::StopDaemon) return false; - // v1.3.0+ checkpoints witness-rescan progress, so stopping mid-rebuild resumes on the next start - // instead of redoing it from scratch — the warning's premise no longer holds, so don't prompt. - // (daemon_version encodes major*1e6 + minor*1e4 + rev*100 + build; v1.3.0 == 1030000.) - if (state_.daemon_version >= 1030000) return false; - return daemonWitnessRebuildActive(); + const bool stops = decision.action == daemon::DaemonController::ShutdownAction::StopDaemon; + // The tracker reads OUR node's output; once that process is gone its last state means nothing. No + // daemon version suppresses this: v1.3.0 does not checkpoint a witness rebuild, and nothing survives + // a kill. + daemon::DaemonBusyTracker ours; + if (daemon_controller_->isRunning()) ours = daemon_busy_; + return daemon::shouldConfirmStop(stops, ours, rpcBusyWhileAlive()); } void App::renderDaemonStopConfirm() diff --git a/src/app.h b/src/app.h index 507b702..500ed7e 100644 --- a/src/app.h +++ b/src/app.h @@ -22,6 +22,7 @@ #include "data/wallet_state.h" #include "rpc/connection.h" #include "daemon/stop_delivery.h" +#include "daemon/daemon_busy_detect.h" #include "services/network_refresh_service.h" #include "services/wallet_security_controller.h" #include "services/wallet_security_workflow.h" @@ -191,11 +192,19 @@ public: */ std::vector tailDaemonDebugLog(int maxLines) const; - // True when the daemon's debug.log shows an in-progress Sapling witness-cache rebuild (best-effort - // heuristic). Stopping the daemon during one discards it and forces a multi-minute redo next launch. - bool daemonWitnessRebuildActive() const; - // Whether beginShutdown() should pause and confirm before stopping the daemon (rebuild in progress). + // Whether beginShutdown() should pause and confirm before stopping the daemon: this quit would stop it, + // and it is rebuilding witnesses (its own log says so) or has been alive but too busy to answer RPC for + // a while. Stopping mid-rebuild throws the rebuild away, and a node killed then replays and redoes it. bool shouldConfirmDaemonStop() const; + // The node is alive and its RPC has only timed out / been refused as busy for >= 30s (a rebuild that + // logged no marker, e.g. an external node whose output we can't read). + bool rpcBusyWhileAlive() const; + // Record how an RPC to the node just went, for rpcBusyWhileAlive(): busy kinds (ReplyTimeout / + // QueueFull) start the clock, an answer stops it, anything else leaves it alone. + void noteDaemonRpcResult(bool answered, rpc::RpcFailureKind kind); + // Called by main.cpp: brings the window back (restore + raise) when a quit is deferred for the confirm + // modal while minimized or behind other windows — the modal only renders in a visible frame. + void setRaiseWindowCallback(std::function cb) { raise_window_ = std::move(cb); } // The "node is rebuilding — stop anyway / keep running / cancel" modal, rendered from render(). void renderDaemonStopConfirm(); @@ -1152,6 +1161,15 @@ private: void setDaemonStatus(std::string s); mutable std::string daemon_mem_diag_; // diagnostic info for daemon memory detection size_t daemon_output_offset_ = 0; // for incremental output parsing (rescan detection) + // What the node's own output says it is doing (witness rebuild / startup replay). Fed on the UI thread + // from the captured output; node_rebuilding_ is its snapshot for the shutdown thread's stop loop. + daemon::DaemonBusyTracker daemon_busy_; + std::atomic node_rebuilding_{false}; + std::atomic daemon_busy_reset_pending_{false}; // set from any thread; applied by feedDaemonBusy() + std::chrono::steady_clock::time_point rpc_busy_since_{}; // epoch = not busy + std::function raise_window_; + void feedDaemonBusy(const std::string& chunk); + void resetDaemonBusy(); // Export/Import state char export_result_[256] = {0}; // SECRET exported key — fixed buffer so it can be sodium_memzero'd diff --git a/src/app_network.cpp b/src/app_network.cpp index 4218e98..bbad5ab 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -450,6 +450,7 @@ void App::tryConnect() return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr, connectKind]() { + noteDaemonRpcResult(connected, connectKind); // a busy node (timeouts / queue full) feeds the quit guard if (connected) { VERBOSE_LOGF("[connect #%d] Connected successfully%s\n", attempt, warmingUp ? " (daemon warming up)" : ""); @@ -1794,6 +1795,8 @@ void App::refreshCoreData() // RPCs failing together means the daemon connection is dead (a busy daemon // fails them individually, not both at once). Warmup is excluded — both fail // with -28 there legitimately, and counting it would cause a reconnect loop. + noteDaemonRpcResult(result.blockchainOk || result.balanceOk, + result.blockchainOk ? result.balanceErrKind : result.blockchainErrKind); constexpr int kCoreFailuresBeforeDisconnect = 3; if (!state_.warming_up) { if (!result.balanceOk && !result.blockchainOk) { diff --git a/src/daemon/daemon_busy_detect.h b/src/daemon/daemon_busy_detect.h new file mode 100644 index 0000000..92c226a --- /dev/null +++ b/src/daemon/daemon_busy_detect.h @@ -0,0 +1,145 @@ +#pragma once + +// Is the node busy with something that stopping it would throw away? Tracks dragonxd's own log markers, +// in order, for two independent states: +// - rebuilding: a witness-cache rebuild (BuildWitnessCache). It holds cs_main for up to an hour on a +// big wallet, its second phase logs nothing and ignores shutdown, and a node killed mid-rebuild redoes +// it (after replaying everything since its last flush) on the next start; +// - replaying: the startup replay ("Activating best chain..."). Stopping there is safe — the daemon +// checks for shutdown between steps and flushes — it just takes a moment. +// A rebuild can start in the middle of a replay, so its end must not clear `replaying`, and the state is +// order-based: it never compares timestamps (POSIX nodes log to stdout without them). Pure and +// header-only, so the test target can feed it lines. + +#include +#include +#include + +namespace dragonx { +namespace daemon { + +/** + * "YYYY-MM-DD HH:MM:SS ..." (dragonxd's debug.log prefix, which is UTC) -> seconds since the epoch, or 0 + * when the line has no such prefix. Days-from-civil arithmetic, not mktime(): mktime reads local time. + */ +inline std::int64_t parseDaemonLogUtc(const std::string& line) +{ + int y = 0, mo = 0, d = 0, h = 0, mi = 0, s = 0; + if (line.size() < 19 || std::sscanf(line.c_str(), "%4d-%2d-%2d %2d:%2d:%2d", &y, &mo, &d, &h, &mi, &s) != 6) + return 0; + if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 60) return 0; + // Howard Hinnant's days_from_civil. + const int yy = y - (mo <= 2 ? 1 : 0); + const int era = (yy >= 0 ? yy : yy - 399) / 400; + const unsigned yoe = static_cast(yy - era * 400); + const unsigned doy = (153u * static_cast(mo + (mo > 2 ? -3 : 9)) + 2u) / 5u + static_cast(d) - 1u; + const unsigned doe = yoe * 365u + yoe / 4u - yoe / 100u + doy; + const std::int64_t days = static_cast(era) * 146097 + static_cast(doe) - 719468; + return days * 86400 + h * 3600 + mi * 60 + s; +} + +class DaemonBusyTracker { +public: + // Feed raw output: any chunking, a line split across two chunks is reassembled. + void feed(const std::string& chunk) + { + for (char c : chunk) { + if (c == '\n') { + feedLine(carry_); + carry_.clear(); + } else if (c != '\r') { + carry_.push_back(c); + if (carry_.size() > 64 * 1024) carry_.clear(); // not a log line; don't grow without bound + } + } + } + + void feedLine(const std::string& line) + { + // A new daemon process, or the end of the last one: forget everything. + if (contains(line, "DragonX version ") || contains(line, "Hush version ") || contains(line, "Shutdown: done")) { + reset(); + return; + } + if (contains(line, "init message: Activating best chain")) { + replaying_ = true; + replayStartUtc_ = parseDaemonLogUtc(line); + lastMarker_ = line; + return; + } + if (contains(line, "init message: Full Node Done Loading") || contains(line, "init message: Done loading")) { + replaying_ = false; + lastMarker_ = line; + return; + } + const size_t read = line.find("Reading blocks for witness rebuild: "); + if (read != std::string::npos || contains(line, "Building Witnesses for block ")) { + if (!rebuilding_) rebuildStartUtc_ = parseDaemonLogUtc(line); + rebuilding_ = true; + if (read != std::string::npos) { + long done = 0, total = 0; + if (std::sscanf(line.c_str() + read, "Reading blocks for witness rebuild: %ld / %ld", &done, &total) == 2) { + readDone_ = done; + readTotal_ = total; + } + } + lastMarker_ = line; + return; + } + // The rebuild's end. UpdateTip is logged under cs_main, so one after a rebuild marker means the + // rebuild has released it (exceptions and non-bulk paths print no end line). + if (rebuilding_ && (contains(line, "note witness cache(s) to height") || + contains(line, "aborting witness rebuild") || + contains(line, "aborted during witness rebuild") || + contains(line, "aborting building witnesses") || + contains(line, "UpdateTip: "))) { + rebuilding_ = false; + readDone_ = readTotal_ = 0; + rebuildStartUtc_ = 0; + lastMarker_ = line; + } + } + + void reset() + { + rebuilding_ = replaying_ = false; + readDone_ = readTotal_ = 0; + rebuildStartUtc_ = replayStartUtc_ = 0; + lastMarker_.clear(); + } + + bool rebuilding() const { return rebuilding_; } + bool replaying() const { return replaying_; } + bool busy() const { return rebuilding_ || replaying_; } + long readDone() const { return readDone_; } // the last Phase-1 "reading blocks" progress, 0 if none + long readTotal() const { return readTotal_; } + std::int64_t rebuildStartUtc() const { return rebuildStartUtc_; } // 0: unknown (no timestamps) + std::int64_t replayStartUtc() const { return replayStartUtc_; } + const std::string& lastMarker() const { return lastMarker_; } + +private: + static bool contains(const std::string& s, const char* needle) { return s.find(needle) != std::string::npos; } + + std::string carry_; + bool rebuilding_ = false; + bool replaying_ = false; + long readDone_ = 0; + long readTotal_ = 0; + std::int64_t rebuildStartUtc_ = 0; + std::int64_t replayStartUtc_ = 0; + std::string lastMarker_; +}; + +/** + * The "keep the node running?" guard on quit. Prompt only when this quit would stop the node and it is + * rebuilding witnesses, or has been alive but too busy to answer RPC for a while (a rebuild that logged + * no marker). A replay alone never prompts: stopping it is safe. No daemon version suppresses the prompt + * (v1.3.0 does not checkpoint a witness rebuild; nothing survives a kill). + */ +inline bool shouldConfirmStop(bool stopsDaemon, const DaemonBusyTracker& tracker, bool rpcBusyWhileAlive) +{ + return stopsDaemon && (tracker.rebuilding() || rpcBusyWhileAlive); +} + +} // namespace daemon +} // namespace dragonx diff --git a/src/daemon/embedded_daemon.cpp b/src/daemon/embedded_daemon.cpp index d86df1a..47122e2 100644 --- a/src/daemon/embedded_daemon.cpp +++ b/src/daemon/embedded_daemon.cpp @@ -267,6 +267,7 @@ void EmbeddedDaemon::appendOutput(const char* data, size_t len) { // Caller must hold output_mutex_ process_output_.append(data, len); + output_total_ += len; if (process_output_.size() > MAX_OUTPUT_BYTES + MAX_OUTPUT_BYTES / 4) { // Trim to MAX_OUTPUT_BYTES, cutting at a newline boundary size_t trim_to = process_output_.size() - MAX_OUTPUT_BYTES; diff --git a/src/daemon/embedded_daemon.h b/src/daemon/embedded_daemon.h index 4406bfa..683a5ac 100644 --- a/src/daemon/embedded_daemon.h +++ b/src/daemon/embedded_daemon.h @@ -146,16 +146,26 @@ public: /** * @brief Get new output since a given offset (thread-safe). - * Returns the new text and updates offset to the current size. + * Returns the new text and advances offset. Offsets are absolute positions in everything ever + * appended (start from 0), so trimming old output never shifts them: a reader that falls behind a + * trim only loses the trimmed part, not output that is still buffered. */ std::string getOutputSince(size_t& offset) const { std::lock_guard lk(output_mutex_); - if (offset >= process_output_.size()) { - offset = process_output_.size(); + return outputSince(process_output_, output_total_, offset); + } + + /** getOutputSince() on a buffer holding the LAST buffer.size() of `total` bytes ever appended. */ + static std::string outputSince(const std::string& buffer, size_t total, size_t& offset) + { + const size_t base = total - buffer.size(); // absolute position of buffer[0] + if (offset < base) offset = base; // trimmed before it was read: resume at what's left + if (offset >= total) { + offset = total; return {}; } - std::string result = process_output_.substr(offset); - offset = process_output_.size(); + std::string result = buffer.substr(offset - base); + offset = total; return result; } @@ -361,6 +371,7 @@ private: mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads) mutable std::mutex output_mutex_; // protects process_output_ std::string process_output_; + size_t output_total_ = 0; // bytes ever appended (the absolute end of process_output_) StateCallback state_callback_; // Process handle diff --git a/src/main.cpp b/src/main.cpp index f182a86..b050733 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1319,6 +1319,11 @@ int main(int argc, char* argv[]) // Create application instance dragonx::App app; + // A quit deferred for the "keep the node running?" prompt brings the window back so it can be seen. + app.setRaiseWindowCallback([window]() { + SDL_RestoreWindow(window); + SDL_RaiseWindow(window); + }); if (!app.init()) { fprintf(stderr, "Failed to initialize application!\n"); #ifdef _WIN32 diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 9dd8157..dedbcd8 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -12,6 +12,7 @@ #include "chat/chat_service.h" #include "chat/chat_database.h" #include "daemon/daemon_controller.h" +#include "daemon/daemon_busy_detect.h" #include "daemon/embedded_daemon.h" #include "util/connect_stall.h" #include "util/logger.h" @@ -4016,6 +4017,156 @@ void testDaemonStopExecution() } } +// --------------------------------------------------------------------------------------------------- +// S5: is the node busy with something stopping it would throw away? (DaemonBusyTracker, order-based) +// --------------------------------------------------------------------------------------------------- + +void testParseDaemonLogUtc() +{ + using dragonx::daemon::parseDaemonLogUtc; + // UTC, independent of the machine's timezone (no mktime). + EXPECT_EQ(parseDaemonLogUtc("2026-09-30 16:34:29 Reading blocks for witness rebuild: 5836 / 62583"), + (std::int64_t)1790786069); + EXPECT_EQ(parseDaemonLogUtc("2026-09-30 17:21:56 BuildWitnessCache: rebuilt ..."), (std::int64_t)1790788916); + EXPECT_EQ(parseDaemonLogUtc("2000-02-29 12:00:00 leap day"), (std::int64_t)951825600); + EXPECT_EQ(parseDaemonLogUtc("Reading blocks for witness rebuild: 5836 / 62583"), (std::int64_t)0); // stdout + EXPECT_EQ(parseDaemonLogUtc("2026-13-01 00:00:00 bad month"), (std::int64_t)0); +} + +void testDaemonBusyTracker() +{ + using dragonx::daemon::DaemonBusyTracker; + const std::string ts = "2026-09-30 16:34:29 "; + const std::vector phase1 = { + "Reading blocks for witness rebuild: 836 / 62583", + "Reading blocks for witness rebuild: 5836 / 62583", + "Reading blocks for witness rebuild: 60836 / 62583", + }; + const std::string endLine = + "BuildWitnessCache: rebuilt 13605 note witness cache(s) to height 3326759 in 2820550ms using 24 thread(s)"; + + for (const bool withTs : {true, false}) { // Windows debug.log vs POSIX stdout: identical results + const std::string p = withTs ? ts : ""; + { // The 15s-rule regression: Phase 1, then ~47 min of unrelated lines (Phase 2 logs nothing). + DaemonBusyTracker tr; + for (const auto& l : phase1) tr.feedLine(p + l); + EXPECT_TRUE(tr.rebuilding()); + EXPECT_EQ(tr.readDone(), 60836L); + EXPECT_EQ(tr.readTotal(), 62583L); + EXPECT_EQ(tr.rebuildStartUtc(), withTs ? (std::int64_t)1790786069 : (std::int64_t)0); + for (int i = 0; i < 2000; ++i) { + tr.feedLine(p + "socket sending timeout: 1201s"); + tr.feedLine(p + "TLS: connection from 203.0.113.10:21768 has been established (initiated)"); + tr.feedLine(p + "Using /16 prefix for IP bucketing"); + } + EXPECT_TRUE(tr.rebuilding()); + tr.feedLine(p + endLine); // the real session-1 end line + EXPECT_FALSE(tr.rebuilding()); + } + { // UpdateTip is logged under cs_main: the rebuild released it (no end line on some paths). + DaemonBusyTracker tr; + tr.feedLine(p + phase1[0]); + tr.feedLine(p + "UpdateTip: new best=00ab height=3264178 log2_work=56.8 tx=1 date=x progress=1.0"); + EXPECT_FALSE(tr.rebuilding()); + } + for (const char* abort : {"BuildWitnessCache: shutdown requested, aborting witness rebuild", + "BuildWitnessCache: rescan aborted during witness rebuild", + "aborting building witnesses"}) { + DaemonBusyTracker tr; + tr.feedLine(p + phase1[0]); + tr.feedLine(p + abort); + EXPECT_FALSE(tr.rebuilding()); + } + { // A new process (banner) or a finished shutdown forgets everything. + DaemonBusyTracker a, b, c; + a.feedLine(p + phase1[0]); + a.feedLine(p + "DragonX version v1.3.0-c1040028e"); + EXPECT_FALSE(a.busy()); + b.feedLine(p + "init message: Activating best chain..."); + b.feedLine(p + "Hush version v1.2.0"); + EXPECT_FALSE(b.busy()); + c.feedLine(p + phase1[0]); + c.feedLine(p + "Shutdown: done"); + EXPECT_FALSE(c.busy()); + } + { // Routine per-tx witness seeding and the pre-rescan clear are not a rebuild. + DaemonBusyTracker tr; + for (int i = 0; i < 3; ++i) tr.feedLine(p + "Setting Initial Sapling Witness for tx 00ab, 1 of 5"); + tr.feedLine(p + "Cleared witness data from 12 notes"); + EXPECT_FALSE(tr.rebuilding()); + } + { // The legacy v1.0.2 marker counts. + DaemonBusyTracker tr; + tr.feedLine(p + "Building Witnesses for block 123 0.5 complete, 40 remaining"); + EXPECT_TRUE(tr.rebuilding()); + } + { // The startup replay: UpdateTips don't end it, a rebuild nested inside doesn't either. + DaemonBusyTracker tr; + tr.feedLine(p + "init message: Activating best chain..."); + for (int i = 0; i < 5; ++i) tr.feedLine(p + "UpdateTip: new best=00ab height=3264179"); + EXPECT_TRUE(tr.replaying()); + EXPECT_FALSE(tr.rebuilding()); + tr.feedLine(p + phase1[0]); + EXPECT_TRUE(tr.rebuilding() && tr.replaying()); + tr.feedLine(p + endLine); + EXPECT_FALSE(tr.rebuilding()); + EXPECT_TRUE(tr.replaying()); + tr.feedLine(p + "init message: Full Node Done Loading! :)"); + EXPECT_FALSE(tr.replaying()); + } + } + { // A marker split across two output chunks is still recognised (and \r\n line ends too). + DaemonBusyTracker tr; + tr.feed("noise line\r\nReading blocks for wit"); + EXPECT_FALSE(tr.rebuilding()); + tr.feed("ness rebuild: 836 / 62583\r\nnext"); + EXPECT_TRUE(tr.rebuilding()); + EXPECT_EQ(tr.readDone(), 836L); + } +} + +void testShouldConfirmStop() +{ + using dragonx::daemon::DaemonBusyTracker; + using dragonx::daemon::shouldConfirmStop; + DaemonBusyTracker idle, rebuilding, replaying; + rebuilding.feedLine("Reading blocks for witness rebuild: 836 / 62583"); + replaying.feedLine("init message: Activating best chain..."); + EXPECT_TRUE(shouldConfirmStop(true, rebuilding, false)); // the inverse of the old v1.3.0 bypass + EXPECT_FALSE(shouldConfirmStop(false, rebuilding, false)); // a quit that leaves the node running loses nothing + EXPECT_TRUE(shouldConfirmStop(true, idle, true)); // busy but alive: a rebuild with no marker + EXPECT_FALSE(shouldConfirmStop(true, idle, false)); + EXPECT_FALSE(shouldConfirmStop(true, replaying, false)); // stopping a replay is safe: no prompt +} + +void testOutputSinceAcrossTrim() +{ + using dragonx::daemon::EmbeddedDaemon; + // Offsets are absolute: a reader that is behind when old output is trimmed gets everything still + // buffered (the old index-based offset pointed past it and skipped up to a trim's worth of new text). + std::string all; + for (int i = 0; i < 100; ++i) all += "line " + std::to_string(i) + "\n"; + size_t offset = 0; + std::string got = EmbeddedDaemon::outputSince(all, all.size(), offset); + EXPECT_EQ(got, all); + EXPECT_EQ(offset, all.size()); + // 20 more lines arrive, then the buffer keeps only its last 300 bytes. + std::string more; + for (int i = 100; i < 120; ++i) more += "line " + std::to_string(i) + "\n"; + const std::string full = all + more; + const std::string kept = full.substr(full.size() - 300); + got = EmbeddedDaemon::outputSince(kept, full.size(), offset); + EXPECT_EQ(got, more); // every byte after where we left off, none skipped + EXPECT_EQ(offset, full.size()); + // A reader so far behind that part of its unread text was trimmed resumes at what's left. + size_t stale = 10; + got = EmbeddedDaemon::outputSince(kept, full.size(), stale); + EXPECT_EQ(got, kept); + // Nothing new: empty, offset unchanged. + got = EmbeddedDaemon::outputSince(kept, full.size(), offset); + EXPECT_TRUE(got.empty()); +} + // S3: on Linux a node in its final shutdown phase reads comm "hush-shutoff"; its executable still says // dragonxd, so it must still count as running (it holds the datadir lock until it exits). void testDaemonProcessIdentity() @@ -8604,6 +8755,10 @@ int main() testStopProgressTracker(); testEvaluateStopWait(); testDaemonStopExecution(); + testParseDaemonLogUtc(); + testDaemonBusyTracker(); + testShouldConfirmStop(); + testOutputSinceAcrossTrim(); #ifdef _WIN32 testWindowsStopNeverKillsForeignDaemon(); #endif