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()