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
+261 -44
View File
@@ -6,16 +6,22 @@
// binary discovery, process spawn, stdout/stderr monitoring, crash recovery.
#include "embedded_daemon.h"
#include "daemon_busy_detect.h"
#include "../config/version.h"
#include "../resources/embedded_resources.h"
#include "../util/platform.h"
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <chrono>
#include <cstring>
#include <ctime>
#include <algorithm>
#include <fstream>
#include <iterator>
#include <limits>
#include <vector>
#include "../util/logger.h"
@@ -33,6 +39,7 @@
#include <signal.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <errno.h>
#include <sys/socket.h>
#include <netinet/in.h>
@@ -217,11 +224,11 @@ std::vector<std::string> EmbeddedDaemon::getChainParams()
DEBUG_LOGF("[INFO] Using %s\n", dbcache_arg.c_str());
}
// No -printtoconsole on any platform: the node writes its timestamped <datadir>/debug.log, which we
// tail (and which keeps going if the node is left running after we quit); its stdout/stderr stay piped
// on POSIX for crash diagnostics.
return {
"-tls=only",
#ifndef _WIN32
"-printtoconsole",
#endif
"-clientname=ObsidianDragon",
"-ac_name=DRAGONX",
"-ac_algo=randomx",
@@ -570,6 +577,19 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
args.push_back("-maxconnections=" + std::to_string(max_connections_));
}
// More RPC workers for a node we launch: dragonxd's default 8 workers + 16-deep queue fill up with
// requests waiting behind a long cs_main holder (a witness rebuild), after which even "stop" is refused.
// The GUI itself no longer floods it; this is headroom for dragonx-cli and other clients — a delay,
// not a fix. Not for an isolated start, and DRAGONX.conf wins (a flag would override the user's own).
if (override_datadir_.empty()) {
std::string conf;
{
std::ifstream in((fs::path(util::Platform::getDragonXDataDir()) / "DRAGONX.conf").string(), std::ios::binary);
if (in) conf.assign(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
}
if (!confSetsKey(conf, "rpcthreads")) args.push_back("-rpcthreads=16");
}
// Host a RandomX stratum pool from this node (-stratum). Only v1.3.0+ daemons implement it; older
// ones ignore the unknown flag (no fatal arg check), and the Settings toggle is gated on daemon
// version, so this is only enabled against a daemon that supports it. Without -stratumallowip the
@@ -624,6 +644,18 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
args.push_back("-datadir=" + override_datadir_);
}
for (const auto& a : override_extra_args_) args.push_back(a);
const bool isolated = !override_datadir_.empty();
// Tail this node's debug.log — in the isolated datadir for an isolated start — from its current end.
{
debug_log_path_ = (fs::path(isolated ? override_datadir_ : util::Platform::getDragonXDataDir()) /
"debug.log").string();
std::error_code ec;
const auto size = fs::file_size(debug_log_path_, ec);
debug_log_offset_ = ec ? 0 : static_cast<size_t>(size);
launch_utc_ = static_cast<std::int64_t>(std::time(nullptr));
DEBUG_LOGF("[INFO] Will tail daemon output from: %s (offset %zu)\n", debug_log_path_.c_str(),
debug_log_offset_);
}
override_datadir_.clear();
override_extra_args_.clear();
@@ -642,6 +674,20 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
setState(State::Running, "dragonxd started");
DEBUG_LOGF("[INFO] dragonxd process started successfully\\n");
// Record it as ours, so a later session recognises it if it is left running (main datadir only).
if (!isolated) {
#ifdef _WIN32
const long pid = process_handle_ ? static_cast<long>(GetProcessId(process_handle_)) : 0;
#else
const long pid = static_cast<long>(process_pid_);
#endif
OwnershipRecord rec;
rec.pid = pid;
rec.startToken = processStartToken(pid);
rec.exePath = daemon_path;
if (pid > 0 && !rec.startToken.empty()) writeOwnershipRecord(ownershipRecordPath(), rec);
}
// Start monitor thread (if not already running)
should_stop_ = false;
@@ -703,29 +749,6 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
}
}
// Determine debug.log path for output tailing.
// The daemon writes to %APPDATA%\Hush\DRAGONX\debug.log when -printtoconsole is NOT used.
{
char appdata[MAX_PATH];
if (SUCCEEDED(SHGetFolderPathA(NULL, CSIDL_APPDATA, NULL, 0, appdata))) {
debug_log_path_ = std::string(appdata) + "\\Hush\\DRAGONX\\debug.log";
} else {
const char* home = getenv("APPDATA");
if (home) {
debug_log_path_ = std::string(home) + "\\Hush\\DRAGONX\\debug.log";
}
}
// Record current file size so we only read NEW output from this launch
if (!debug_log_path_.empty() && fs::exists(debug_log_path_)) {
debug_log_offset_ = static_cast<size_t>(fs::file_size(debug_log_path_));
} else {
debug_log_offset_ = 0;
}
DEBUG_LOGF("[INFO] Will tail daemon output from: %s (offset %zu)\n",
debug_log_path_.c_str(), debug_log_offset_);
}
// Launch daemon windowless. Use CREATE_NO_WINDOW (NOT CREATE_NEW_CONSOLE): CREATE_NEW_CONSOLE
// allocates a console window that briefly flashes on screen before SW_HIDE can hide it, which is
// visible as a console-window flash on wallet launch. CREATE_NO_WINDOW gives the console child no
@@ -850,7 +873,13 @@ void EmbeddedDaemon::drainOutput()
// Witness …" lines the warmup progress bar parses only exist in debug.log on Windows. On a shrink,
// restart from the new beginning; the parser is monotonic and converges as it reaches current output.
if (currentSize < debug_log_offset_) {
debug_log_offset_ = 0;
std::string content(currentSize, '\0');
DWORD got = 0;
LARGE_INTEGER zero{};
SetFilePointerEx(hFile, zero, NULL, FILE_BEGIN);
if (currentSize > 0 && ReadFile(hFile, &content[0], static_cast<DWORD>(currentSize), &got, NULL))
content.resize(got);
debug_log_offset_ = shrinkResumeOffset(content, launch_utc_);
}
if (currentSize <= debug_log_offset_) {
CloseHandle(hFile);
@@ -1381,12 +1410,24 @@ bool EmbeddedDaemon::isRunning() const
return (s == State::Running || s == State::Stopping);
}
void EmbeddedDaemon::drainOutput()
void EmbeddedDaemon::tailDebugLog()
{
if (stdout_fd_ < 0) return;
if (debug_log_path_.empty()) return;
const int fd = open(debug_log_path_.c_str(), O_RDONLY | O_CLOEXEC);
if (fd < 0) return; // not created yet
struct stat st{};
if (fstat(fd, &st) != 0) { close(fd); return; }
const size_t size = static_cast<size_t>(st.st_size);
if (size < debug_log_offset_) { // shrunk at startup: resume at this run's banner (see shrinkResumeOffset)
std::string content(size, '\0');
const ssize_t got = size ? pread(fd, &content[0], size, 0) : 0;
content.resize(got > 0 ? static_cast<size_t>(got) : 0);
debug_log_offset_ = shrinkResumeOffset(content, launch_utc_);
}
char buffer[4096];
for (;;) {
ssize_t n = read(stdout_fd_, buffer, sizeof(buffer) - 1);
while (debug_log_offset_ < size) {
const size_t want = std::min(sizeof(buffer) - 1, size - debug_log_offset_);
const ssize_t n = pread(fd, buffer, want, static_cast<off_t>(debug_log_offset_));
if (n <= 0) break;
buffer[n] = '\0';
{
@@ -1394,7 +1435,27 @@ void EmbeddedDaemon::drainOutput()
appendOutput(buffer, static_cast<size_t>(n));
}
VERBOSE_LOGF("[dragonxd] %s", buffer);
debug_log_offset_ += static_cast<size_t>(n);
}
close(fd);
}
void EmbeddedDaemon::drainOutput()
{
if (stdout_fd_ >= 0) {
char buffer[4096];
for (;;) {
ssize_t n = read(stdout_fd_, buffer, sizeof(buffer) - 1);
if (n <= 0) break;
buffer[n] = '\0';
{
std::lock_guard<std::mutex> lk(output_mutex_);
appendOutput(buffer, static_cast<size_t>(n));
}
VERBOSE_LOGF("[dragonxd] %s", buffer);
}
}
tailDebugLog();
}
static std::string describeExitStatus(int status)
@@ -1591,8 +1652,6 @@ void EmbeddedDaemon::endStop(bool keepWatching)
void EmbeddedDaemon::monitorProcess()
{
char buffer[4096];
while (!should_stop_) {
// Check if process exited (non-blocking waitpid captures exit status)
if (process_pid_ > 0) {
@@ -1617,23 +1676,181 @@ void EmbeddedDaemon::monitorProcess()
}
}
// Read process output (non-blocking)
ssize_t bytes_read = read(stdout_fd_, buffer, sizeof(buffer) - 1);
if (bytes_read > 0) {
buffer[bytes_read] = '\0';
{
std::lock_guard<std::mutex> lk(output_mutex_);
appendOutput(buffer, static_cast<size_t>(bytes_read));
}
VERBOSE_LOGF("[dragonxd] %s", buffer);
}
// Read process output (non-blocking): the stdout/stderr pipe and the node's debug.log
drainOutput();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
#endif // _WIN32
std::string EmbeddedDaemon::processStartToken(long pid)
{
if (pid <= 0) return {};
#ifdef _WIN32
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, static_cast<DWORD>(pid));
if (!h) return {};
std::string token;
DWORD code = 0;
FILETIME created{}, exited{}, kernel{}, user{};
if (GetExitCodeProcess(h, &code) && code == STILL_ACTIVE &&
GetProcessTimes(h, &created, &exited, &kernel, &user)) {
token = std::to_string((static_cast<unsigned long long>(created.dwHighDateTime) << 32) |
created.dwLowDateTime);
}
CloseHandle(h);
return token;
#elif defined(__linux__)
std::ifstream f("/proc/" + std::to_string(pid) + "/stat");
std::string line;
if (!f || !std::getline(f, line)) return {};
char state = 0;
unsigned long long startTicks = 0;
if (!parseProcStatStart(line, state, startTicks) || state == 'Z' || state == 'X') return {};
std::string bootId; // start ticks count from boot: tie them to this boot
std::ifstream b("/proc/sys/kernel/random/boot_id");
if (b) std::getline(b, bootId);
return bootId + "/" + std::to_string(startTicks);
#elif defined(__APPLE__)
struct proc_bsdinfo info{};
if (proc_pidinfo(static_cast<int>(pid), PROC_PIDTBSDINFO, 0, &info, sizeof(info)) != sizeof(info)) return {};
if (info.pbi_status == 5) return {}; // SZOMB
return std::to_string(info.pbi_start_tvsec) + "." + std::to_string(info.pbi_start_tvusec);
#else
return {};
#endif
}
std::int64_t EmbeddedDaemon::processStartUtc(long pid)
{
if (pid <= 0) return 0;
#ifdef _WIN32
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, static_cast<DWORD>(pid));
if (!h) return 0;
std::int64_t utc = 0;
FILETIME created{}, exited{}, kernel{}, user{};
if (GetProcessTimes(h, &created, &exited, &kernel, &user)) {
const unsigned long long t = (static_cast<unsigned long long>(created.dwHighDateTime) << 32) |
created.dwLowDateTime; // 100ns since 1601
if (t > 116444736000000000ULL) utc = static_cast<std::int64_t>((t - 116444736000000000ULL) / 10000000ULL);
}
CloseHandle(h);
return utc;
#elif defined(__linux__)
std::ifstream f("/proc/" + std::to_string(pid) + "/stat");
std::string line;
char state = 0;
unsigned long long startTicks = 0;
if (!f || !std::getline(f, line) || !parseProcStatStart(line, state, startTicks)) return 0;
std::ifstream s("/proc/stat"); // "btime <boot time, UTC seconds>"
std::string key;
long long btime = 0;
while (s >> key) {
if (key == "btime") { s >> btime; break; }
s.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
}
const long hz = sysconf(_SC_CLK_TCK);
if (btime <= 0 || hz <= 0) return 0;
return static_cast<std::int64_t>(btime + static_cast<long long>(startTicks / static_cast<unsigned long long>(hz)));
#elif defined(__APPLE__)
struct proc_bsdinfo info{};
if (proc_pidinfo(static_cast<int>(pid), PROC_PIDTBSDINFO, 0, &info, sizeof(info)) != sizeof(info)) return 0;
return static_cast<std::int64_t>(info.pbi_start_tvsec);
#else
return 0;
#endif
}
bool EmbeddedDaemon::processIsDaemon(long pid)
{
if (pid <= 0) return false;
#ifdef _WIN32
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, static_cast<DWORD>(pid));
if (!h) return false;
char path[MAX_PATH];
bool is = false;
// A device path (\Device\HarddiskVolume3\...\dragonxd.exe); only its file name matters.
const DWORD len = GetProcessImageFileNameA(h, path, MAX_PATH);
if (len > 0) {
std::string name(path, len);
const size_t slash = name.find_last_of("\\/");
if (slash != std::string::npos) name.erase(0, slash + 1);
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) { return std::tolower(c); });
is = name == "dragonxd.exe";
}
CloseHandle(h);
return is;
#elif defined(__linux__)
const fs::path proc = fs::path("/proc") / std::to_string(pid);
std::ifstream f((proc / "comm").string());
std::string comm;
if (f) std::getline(f, comm);
std::error_code ec;
return isDaemonProcessIdentity(comm, fs::read_symlink(proc / "exe", ec).filename().string());
#elif defined(__APPLE__)
char path[PROC_PIDPATHINFO_MAXSIZE];
if (proc_pidpath(static_cast<int>(pid), path, sizeof(path)) <= 0) return false;
return isDaemonProcessIdentity("", fs::path(path).filename().string());
#else
return false;
#endif
}
std::string EmbeddedDaemon::ownershipRecordPath()
{
return (fs::path(util::Platform::getDragonXDataDir()) / "obsidiandragon-dragonxd.pid").string();
}
size_t EmbeddedDaemon::shrinkResumeOffset(const std::string& content, std::int64_t launchUtc)
{
size_t pos = 0;
while (pos < content.size()) {
size_t end = content.find('\n', pos);
if (end == std::string::npos) end = content.size();
const std::string line = content.substr(pos, end - pos);
if ((line.find("DragonX version ") != std::string::npos || line.find("Hush version ") != std::string::npos)) {
const std::int64_t at = parseDaemonLogUtc(line);
if (at > 0 && at >= launchUtc - 5) return pos; // 5s: the log's second vs our clock read
}
pos = end + 1;
}
return content.size();
}
void EmbeddedDaemon::writeOwnershipRecord(const std::string& path, const OwnershipRecord& r)
{
const std::string tmp = path + ".tmp";
{
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
if (!out) return;
out << formatOwnershipRecord(r);
if (!out) return;
}
std::error_code ec;
fs::rename(tmp, path, ec);
if (ec) fs::remove(tmp, ec);
else DEBUG_LOGF("[INFO] Recorded our dragonxd (pid %ld) in %s\n", r.pid, path.c_str());
}
bool EmbeddedDaemon::liveOwnershipRecord(const std::string& path, OwnershipRecord* out)
{
std::string text;
{
std::ifstream in(path, std::ios::binary);
if (!in) return false;
text.assign(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
}
OwnershipRecord r;
if (parseOwnershipRecord(text, r) && processStartToken(r.pid) == r.startToken) {
if (out) *out = r;
return true;
}
std::error_code ec; // that process is gone (or the pid now belongs to another): the record is stale
if (fs::remove(path, ec)) DEBUG_LOGF("[INFO] Removed stale dragonxd record (pid %ld)\n", r.pid);
return false;
}
bool EmbeddedDaemon::isRpcPortInUse()
{
int port = std::atoi(DRAGONX_DEFAULT_RPC_PORT);