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:
+48
-45
@@ -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,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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user