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
+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