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 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 00:02:27 -05:00
co-authored by Claude Opus 5.5
parent 821bebd13e
commit 6b14098bb2
8 changed files with 395 additions and 54 deletions
+48 -45
View File
@@ -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<daemon::StopUserChoice>(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<std::string> 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()
+22 -4
View File
@@ -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<std::string> 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<void()> 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<bool> node_rebuilding_{false};
std::atomic<bool> 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<void()> 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
+3
View File
@@ -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) {
+145
View File
@@ -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 <cstdint>
#include <cstdio>
#include <string>
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<unsigned>(yy - era * 400);
const unsigned doy = (153u * static_cast<unsigned>(mo + (mo > 2 ? -3 : 9)) + 2u) / 5u + static_cast<unsigned>(d) - 1u;
const unsigned doe = yoe * 365u + yoe / 4u - yoe / 100u + doy;
const std::int64_t days = static_cast<std::int64_t>(era) * 146097 + static_cast<std::int64_t>(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
+1
View File
@@ -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;
+16 -5
View File
@@ -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<std::mutex> 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
+5
View File
@@ -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
+155
View File
@@ -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<std::string> 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