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()
|
||||
|
||||
Reference in New Issue
Block a user