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
+32
View File
@@ -141,6 +141,38 @@ inline bool shouldConfirmStop(bool stopsDaemon, const DaemonBusyTracker& tracker
return stopsDaemon && (tracker.rebuilding() || rpcBusyWhileAlive);
}
/**
* Where to start feeding a DaemonBusyTracker from the last `window` of the debug.log of a node someone else
* started at `processStartUtc` (UTC seconds). Only lines that process wrote count: a node started with
* -printtoconsole leaves an old debug.log behind, whose last run may have died mid-rebuild. Returns the
* offset of that process's startup banner in the window; 0 when its banner is older than the window
* (`windowStartsMidFile`) and the window's first stamped line postdates the process start; npos when the
* log isn't that process's.
*/
inline size_t externalLogFeedOffset(const std::string& window, bool windowStartsMidFile, std::int64_t processStartUtc)
{
size_t lastBanner = std::string::npos;
std::int64_t lastBannerAt = 0, firstStamp = 0;
size_t pos = 0;
while (pos < window.size()) {
size_t end = window.find('\n', pos);
if (end == std::string::npos) end = window.size();
const std::string line = window.substr(pos, end - pos);
const std::int64_t at = parseDaemonLogUtc(line);
if (at > 0 && firstStamp == 0) firstStamp = at;
if (line.find("DragonX version ") != std::string::npos || line.find("Hush version ") != std::string::npos) {
lastBanner = pos;
lastBannerAt = at;
}
pos = end + 1;
}
if (processStartUtc <= 0) return std::string::npos;
const std::int64_t since = processStartUtc - 5; // the log's whole seconds vs the process's start time
if (lastBanner != std::string::npos) return (lastBannerAt > 0 && lastBannerAt >= since) ? lastBanner : std::string::npos;
if (windowStartsMidFile && firstStamp > 0 && firstStamp >= since) return 0;
return std::string::npos;
}
// What the "node is starting / not answering" overlay should say, most specific first.
enum class DaemonStatusKind {
Rebuilding, // its own log says it's rebuilding note witnesses
+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);
+96 -3
View File
@@ -4,7 +4,9 @@
#pragma once
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <functional>
#include <memory>
@@ -324,6 +326,93 @@ public:
return comm == "dragonxd" || exeName == "dragonxd";
}
// ---- Ownership record --------------------------------------------------------------------------
// <datadir>/obsidiandragon-dragonxd.pid names the node this wallet launched, so a later session that
// finds it still running (after "Leave running") knows it is ours: it may stop it gracefully and wait
// for it, never kill it. The start token (when that process started) defeats PID reuse and makes a
// record from before a reboot stale.
struct OwnershipRecord {
long pid = 0;
std::string startToken;
std::string exePath;
};
static std::string formatOwnershipRecord(const OwnershipRecord& r)
{
return "pid=" + std::to_string(r.pid) + "\nstart=" + r.startToken + "\nexe=" + r.exePath + "\n";
}
static bool parseOwnershipRecord(const std::string& text, OwnershipRecord& out)
{
OwnershipRecord r;
size_t pos = 0;
while (pos < text.size()) {
size_t end = text.find('\n', pos);
if (end == std::string::npos) end = text.size();
std::string line = text.substr(pos, end - pos);
pos = end + 1;
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.rfind("pid=", 0) == 0) r.pid = std::strtol(line.c_str() + 4, nullptr, 10);
else if (line.rfind("start=", 0) == 0) r.startToken = line.substr(6);
else if (line.rfind("exe=", 0) == 0) r.exePath = line.substr(4);
}
if (r.pid <= 0 || r.startToken.empty()) return false;
out = r;
return true;
}
/** Linux /proc/<pid>/stat: the process state and its start time (clock ticks after boot, field 22). */
static bool parseProcStatStart(const std::string& line, char& state, unsigned long long& startTicks)
{
const size_t close = line.rfind(')');
if (close == std::string::npos) return false;
// state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt cmajflt utime stime cutime cstime
// priority nice num_threads itrealvalue starttime
return std::sscanf(line.c_str() + close + 1,
" %c %*d %*d %*d %*d %*d %*u %*u %*u %*u %*u %*u %*u %*d %*d %*d %*d %*d %*d %llu",
&state, &startTicks) == 2;
}
/**
* dragonxd shrinks a large debug.log at startup (it keeps the tail), so the file can become smaller
* than where we were reading. Resume at THIS run's startup banner — the first banner line stamped at or
* after `launchUtc` (UTC) — not at 0, which would replay the previous run's lines (old rebuild markers,
* old corruption errors) as if they were new. No such banner yet: skip to the end (it follows).
*/
static size_t shrinkResumeOffset(const std::string& content, std::int64_t launchUtc);
/** When process `pid` started, as an opaque token; "" if it isn't running (or is a zombie). */
static std::string processStartToken(long pid);
/** When process `pid` started, in UTC seconds; 0 if unknown. */
static std::int64_t processStartUtc(long pid);
/** Is process `pid` a dragonxd (by name / executable)? */
static bool processIsDaemon(long pid);
static std::string ownershipRecordPath(); // <datadir>/obsidiandragon-dragonxd.pid
static void writeOwnershipRecord(const std::string& path, const OwnershipRecord& r);
/** The record at `path`, if its process is still running; a stale or unreadable record is deleted. */
static bool liveOwnershipRecord(const std::string& path, OwnershipRecord* out = nullptr);
/**
* Does this DRAGONX.conf text set `key` on an active line ("key=value", spaces allowed around '=';
* '#' starts a comment)? Used before adding a flag of our own: a command-line flag overrides the conf,
* so the user's own setting must win.
*/
static bool confSetsKey(const std::string& confText, const std::string& key)
{
size_t pos = 0;
while (pos < confText.size()) {
size_t end = confText.find('\n', pos);
if (end == std::string::npos) end = confText.size();
std::string line = confText.substr(pos, end - pos);
pos = end + 1;
const size_t hash = line.find('#');
if (hash != std::string::npos) line.erase(hash);
const size_t eq = line.find('=');
if (eq == std::string::npos) continue;
size_t b = line.find_first_not_of(" \t\r");
size_t e = line.find_last_not_of(" \t\r", eq == 0 ? 0 : eq - 1);
if (b == std::string::npos || b >= eq || e == std::string::npos || e < b) continue;
if (line.compare(b, e - b + 1, key) == 0) return true;
}
return false;
}
/** Decision returned by evaluateDatadirLockGate(): whether start() may spawn now. */
struct StartLockGateDecision {
bool proceed = true; // false => bail before spawning
@@ -378,20 +467,24 @@ private:
#ifdef _WIN32
HANDLE process_handle_ = nullptr;
HANDLE stdout_read_ = nullptr; // pipe handle (Linux-style pipe, unused when tailing debug.log)
std::string debug_log_path_; // path to daemon's debug.log for output tailing
size_t debug_log_offset_ = 0; // read offset into debug.log
std::string launch_cmd_; // full command line used to launch daemon (for diagnostics)
std::string launch_binary_; // binary path used (for diagnostics)
std::string launch_workdir_; // working directory used (for diagnostics)
#else
pid_t process_pid_ = 0;
int stdout_fd_ = -1;
int stdout_fd_ = -1; // the child's stdout+stderr (crash diagnostics; the log itself is debug.log)
void tailDebugLog();
// Waits up to timeout_ms for `pid` to exit, draining its output; true once reaped. Only valid
// after monitor_thread_ is joined, so that stop() is the sole reaper.
bool reapWithin(pid_t pid, int timeout_ms, int& status);
#endif
// The node's output is its <datadir>/debug.log (timestamped; it outlives us if the node is left running).
std::string debug_log_path_;
size_t debug_log_offset_ = 0; // read offset into debug.log
std::int64_t launch_utc_ = 0; // when we launched it (to find this run's banner after a log shrink)
std::thread monitor_thread_;
std::atomic<bool> should_stop_{false};
std::atomic<bool> stop_requested_{false}; // a stop was asked for: the next exit is not a crash