feat(daemon): recognise our detached node, tail POSIX debug.log, more RPC threads

S12 - our node across sessions:
- The node the wallet launches is recorded in <datadir>/obsidiandragon-
  dragonxd.pid (pid + process start time + exe). A later session that
  finds it still running (after "Leave running" / keep_daemon_running)
  stops it gracefully on quit and waits for that exact process (no time
  limit, "Leave running" offered); never signals or kills it. A reused
  pid or a reboot never matches; stale records are deleted.
- stop_external_daemon is now honoured when this session only connected
  (no controller); it was ignored.

S13 - the node's own log on every platform:
- No -printtoconsole on Linux/macOS: the node writes its timestamped
  debug.log (which outlives the GUI) and we tail it next to the stderr
  pipe. Tail path from the datadir or the isolated override (Windows had
  %APPDATA% hard-coded, wrong for migrate-to-seed starts).
- After the node shrinks debug.log at startup, resume at this run's
  banner instead of re-reading the old run's tail.
- A node we didn't launch (left running, or found running) feeds the
  busy tracker from its debug.log, tied to the live process (our record,
  or POSIX hushd.pid) and only from lines it wrote. A GUI attaching to a
  node mid-rebuild now says "Node busy: rebuilding note witnesses" and
  asks before stopping it.
- A start that only finds a running node no longer resets the tracker;
  a found busy node shows the busy overlay at once.

S14: launch our node with -rpcthreads=16 unless DRAGONX.conf sets it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 12:06:52 -05:00
co-authored by Claude Opus 5.5
parent e20b70d73f
commit a3361020d1
7 changed files with 696 additions and 85 deletions
+135
View File
@@ -4466,6 +4466,138 @@ void testDaemonProcessIdentity()
EXPECT_FALSE(EmbeddedDaemon::isDaemonProcessIdentity("dragonx-cli", "dragonx-cli"));
}
// S14: -rpcthreads=16 only when DRAGONX.conf doesn't set it (a command-line flag would override the user's).
void testConfSetsKey()
{
using dragonx::daemon::EmbeddedDaemon;
EXPECT_FALSE(EmbeddedDaemon::confSetsKey("", "rpcthreads"));
EXPECT_FALSE(EmbeddedDaemon::confSetsKey("rpcuser=u\nrpcpassword=p\n", "rpcthreads"));
EXPECT_TRUE(EmbeddedDaemon::confSetsKey("rpcuser=u\nrpcthreads=4\n", "rpcthreads"));
EXPECT_TRUE(EmbeddedDaemon::confSetsKey(" rpcthreads = 4\r\n", "rpcthreads")); // spaces, CRLF
EXPECT_TRUE(EmbeddedDaemon::confSetsKey("rpcthreads=4", "rpcthreads")); // no final newline
EXPECT_FALSE(EmbeddedDaemon::confSetsKey("#rpcthreads=4\n", "rpcthreads")); // commented out
EXPECT_FALSE(EmbeddedDaemon::confSetsKey(" # rpcthreads=4\n", "rpcthreads"));
EXPECT_FALSE(EmbeddedDaemon::confSetsKey("rpcthreadsx=4\nxrpcthreads=4\n", "rpcthreads"));
EXPECT_FALSE(EmbeddedDaemon::confSetsKey("rpcthreads\n", "rpcthreads")); // not a key=value line
EXPECT_FALSE(EmbeddedDaemon::confSetsKey("rpcworkqueue=64 # rpcthreads=4\n", "rpcthreads"));
}
// S12: the ownership record names the node we launched, by pid AND start time, so a later session can
// recognise it after "Leave running" — and a reused pid, or a record from before a reboot, never matches.
void testOwnershipRecord()
{
using dragonx::daemon::EmbeddedDaemon;
using Rec = EmbeddedDaemon::OwnershipRecord;
Rec r; r.pid = 4242; r.startToken = "boot-1/123456"; r.exePath = "/opt/dragonx/dragonxd";
Rec back;
EXPECT_TRUE(EmbeddedDaemon::parseOwnershipRecord(EmbeddedDaemon::formatOwnershipRecord(r), back));
EXPECT_EQ(back.pid, 4242L);
EXPECT_EQ(back.startToken, r.startToken);
EXPECT_EQ(back.exePath, r.exePath);
EXPECT_TRUE(EmbeddedDaemon::parseOwnershipRecord("pid=7\r\nstart=x\r\n", back) && back.pid == 7);
EXPECT_FALSE(EmbeddedDaemon::parseOwnershipRecord("", back));
EXPECT_FALSE(EmbeddedDaemon::parseOwnershipRecord("pid=7\n", back)); // no start token
EXPECT_FALSE(EmbeddedDaemon::parseOwnershipRecord("start=x\npid=0\n", back)); // no pid
EXPECT_FALSE(EmbeddedDaemon::parseOwnershipRecord("garbage", back));
char state = 0;
unsigned long long start = 0;
EXPECT_TRUE(EmbeddedDaemon::parseProcStatStart(
"4242 (drag on) x) S 1 4242 4242 0 -1 4194560 100 0 0 0 7 3 0 0 20 0 9 0 98765 1000 50", state, start));
EXPECT_TRUE(state == 'S');
EXPECT_EQ(start, 98765ULL);
EXPECT_FALSE(EmbeddedDaemon::parseProcStatStart("garbage", state, start));
#ifdef _WIN32
const long self = static_cast<long>(GetCurrentProcessId());
#else
const long self = static_cast<long>(getpid());
#endif
const std::string mine = EmbeddedDaemon::processStartToken(self);
EXPECT_FALSE(mine.empty());
EXPECT_EQ(EmbeddedDaemon::processStartToken(self), mine); // stable
EXPECT_TRUE(EmbeddedDaemon::processStartToken(0).empty());
#ifndef _WIN32
{ // an exited (reaped) process has no token: its pid may be anyone's now
const pid_t child = fork();
if (child == 0) _exit(0);
int status = 0;
waitpid(child, &status, 0);
EXPECT_TRUE(EmbeddedDaemon::processStartToken(static_cast<long>(child)).empty());
}
#endif
// A record for a live process is kept; one whose pid is alive but started at another time (a reused
// pid) is stale and deleted, as is an unreadable one. Temp paths only (never the real datadir).
const fs::path dir = makeTempDir();
const std::string path = (dir / "obsidiandragon-dragonxd.pid").string();
Rec live; live.pid = self; live.startToken = mine; live.exePath = "x";
EmbeddedDaemon::writeOwnershipRecord(path, live);
Rec got;
EXPECT_TRUE(EmbeddedDaemon::liveOwnershipRecord(path, &got));
EXPECT_EQ(got.pid, self);
EXPECT_TRUE(fs::exists(path));
Rec reused = live; reused.startToken = mine + "0";
EmbeddedDaemon::writeOwnershipRecord(path, reused);
EXPECT_FALSE(EmbeddedDaemon::liveOwnershipRecord(path));
EXPECT_FALSE(fs::exists(path));
{ std::ofstream(path) << "garbage"; }
EXPECT_FALSE(EmbeddedDaemon::liveOwnershipRecord(path));
EXPECT_FALSE(fs::exists(path));
EXPECT_FALSE(EmbeddedDaemon::liveOwnershipRecord(path)); // missing: just false
std::error_code ec;
fs::remove_all(dir, ec);
}
// S13: the node writes its timestamped debug.log on every platform, which we tail. Fixed epochs (UTC).
void testDebugLogTailRules()
{
using dragonx::daemon::EmbeddedDaemon;
using dragonx::daemon::externalLogFeedOffset;
const std::int64_t t0 = 1790000000; // 2026-09-21 13:46:40 UTC
auto stamp = [](std::int64_t utc) {
const std::time_t tt = static_cast<std::time_t>(utc);
std::tm tm{};
#ifdef _WIN32
gmtime_s(&tm, &tt);
#else
gmtime_r(&tt, &tm);
#endif
char buf[32];
std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S ", &tm);
return std::string(buf);
};
const std::string oldRun = stamp(t0 - 3600) + "DragonX version v1.3.0\n" +
stamp(t0 - 3500) + "Reading blocks for witness rebuild: 5000 / 62583\n";
const std::string newRun = stamp(t0 + 1) + "DragonX version v1.3.0\n" + stamp(t0 + 2) + "init message: Loading\n";
// A shrink at startup: resume at THIS run's banner, never replay the old run's rebuild lines.
EXPECT_EQ(EmbeddedDaemon::shrinkResumeOffset(oldRun + newRun, t0), oldRun.size());
EXPECT_EQ(EmbeddedDaemon::shrinkResumeOffset(oldRun, t0), oldRun.size()); // banner not written yet: skip
EXPECT_EQ(EmbeddedDaemon::shrinkResumeOffset("", t0), size_t(0));
// A node someone else started at t0: only its own lines count.
EXPECT_EQ(externalLogFeedOffset(oldRun + newRun, false, t0), oldRun.size());
EXPECT_TRUE(externalLogFeedOffset(oldRun, false, t0) == std::string::npos); // a stale log (-printtoconsole node)
const std::string midRun = stamp(t0 + 600) + "Reading blocks for witness rebuild: 9000 / 62583\n";
EXPECT_EQ(externalLogFeedOffset("ing a partial line\n" + midRun, true, t0), size_t(0)); // banner before the window
EXPECT_TRUE(externalLogFeedOffset(oldRun.substr(30), true, t0) == std::string::npos); // ...but old lines: no
EXPECT_TRUE(externalLogFeedOffset(midRun, false, t0) == std::string::npos); // whole file, no banner: can't tell
EXPECT_TRUE(externalLogFeedOffset(oldRun + newRun, false, 0) == std::string::npos);
#ifdef _WIN32
const long self = static_cast<long>(GetCurrentProcessId());
#else
const long self = static_cast<long>(getpid());
#endif
const std::int64_t started = EmbeddedDaemon::processStartUtc(self);
const std::int64_t nowUtc = static_cast<std::int64_t>(std::time(nullptr));
EXPECT_TRUE(started > 0 && started <= nowUtc + 2 && started > nowUtc - 3600); // this test started recently
EXPECT_FALSE(EmbeddedDaemon::processIsDaemon(self));
EXPECT_FALSE(EmbeddedDaemon::processIsDaemon(0));
}
void testDatadirLockGate()
{
using dragonx::daemon::EmbeddedDaemon;
@@ -9067,6 +9199,9 @@ int main()
testDaemonShutdownPolicy();
testDatadirLockGate();
testDaemonProcessIdentity();
testConfSetsKey();
testOwnershipRecord();
testDebugLogTailRules();
testStopDeliveryPolicy();
testProcStatParse();
testStopProgressTracker();