diff --git a/src/app.cpp b/src/app.cpp index 649dd25..0bd0c9a 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -944,6 +944,7 @@ void App::update() // The node is too busy to answer: send it nothing that needs cs_main (it would only queue behind the // rest and hold one of its few RPC slots) until the canary gets through. pumpRpcBusyCanary() runs it. pumpRpcBusyCanary(); + pumpExternalDaemonLog(); const bool rpcBusy = rpc_busy_gate_->busy(); // Keep the "node starting / warming up / busy" overlay current between connect attempts and polls, @@ -5430,8 +5431,11 @@ bool App::startEmbeddedDaemon() }); } - resetDaemonBusy(); // a new process: whatever the last one was doing is over - return daemon_controller_->start(settings_.get()); + // Only a process we actually launched starts a new story: a start that finds a node already running (and + // launches nothing) must not wipe what the tracker knows about that node from its debug.log. + const bool launched = daemon_controller_->start(settings_.get()); + if (launched) resetDaemonBusy(); // a new process: whatever the last one was doing is over + return launched; } // Drives one stop session for DaemonController::executeDaemonStop: a dedicated RPC client for "stop" (not @@ -5441,8 +5445,10 @@ class AppStopRuntime final : public daemon::DaemonController::StopRuntime { public: using DC = daemon::DaemonController; - AppStopRuntime(App& app, daemon::EmbeddedDaemon* owned, std::vector credentials) - : app_(app), owned_(owned), creds_(std::move(credentials)), t0_(std::chrono::steady_clock::now()) + AppStopRuntime(App& app, daemon::EmbeddedDaemon* owned, std::vector credentials, + daemon::EmbeddedDaemon::OwnershipRecord recorded = {}) + : app_(app), owned_(owned), creds_(std::move(credentials)), recorded_(std::move(recorded)), + t0_(std::chrono::steady_clock::now()) { std::lock_guard lk(app_.stop_client_mutex_); app_.active_stop_client_ = &client_; @@ -5460,6 +5466,9 @@ public: bool processAlive() override { if (owned_) return owned_->pollOwnedProcess(); + // The node an earlier session of ours left running: that exact process (pid + start time). + if (recorded_.pid > 0) + return daemon::EmbeddedDaemon::processStartToken(recorded_.pid) == recorded_.startToken; // Not ours: we can only watch for it to go (port and process), never wait on or signal it. return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning(); } @@ -5536,6 +5545,7 @@ private: App& app_; daemon::EmbeddedDaemon* owned_ = nullptr; std::vector creds_; + daemon::EmbeddedDaemon::OwnershipRecord recorded_; // pid > 0: our earlier session's detached node size_t credIndex_ = 0; rpc::RPCClient client_; bool clientOpen_ = false; @@ -5571,12 +5581,15 @@ void App::chooseStopAction(daemon::StopUserChoice choice) daemon::StopOutcome App::stopEmbeddedDaemon(StopMode mode, StopScope scope) { if (scope == StopScope::Policy) { - if (!daemon_controller_) return daemon::StopOutcome::Exited; - auto shutdownDecision = daemon_controller_->shutdownDecision( - false, settings_ && settings_->getStopExternalDaemon()); - if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) { - DEBUG_LOGF("stopEmbeddedDaemon: %s — skipping\n", shutdownDecision.logReason); - return daemon::StopOutcome::StillRunning; + const bool mayStopExternal = mayStopExternalNode(); + if (!daemon_controller_) { + if (!mayStopExternal) return daemon::StopOutcome::Exited; // nothing we may stop + } else { + auto shutdownDecision = daemon_controller_->shutdownDecision(false, mayStopExternal); + if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) { + DEBUG_LOGF("stopEmbeddedDaemon: %s — skipping\n", shutdownDecision.logReason); + return daemon::StopOutcome::StillRunning; + } } } @@ -5590,6 +5603,11 @@ daemon::StopOutcome App::stopEmbeddedDaemon(StopMode mode, StopScope scope) // external node and a node we already let go of are not, and are never waited on, signalled or killed. const bool owned = d && d->beginStop(); if (d && !owned) d->endStop(/*keepWatching=*/false); + // Not launched by this session, but by an earlier one that left it running: we wait for that exact + // process like our own (no time limit when quitting), but still never signal or kill it. + daemon::EmbeddedDaemon::OwnershipRecord recorded; + const bool ours = !owned && daemon::EmbeddedDaemon::liveOwnershipRecord( + daemon::EmbeddedDaemon::ownershipRecordPath(), &recorded); if (!owned && !daemon::EmbeddedDaemon::isRpcPortInUse()) { // Nothing listening for a stop: either it is gone, or it is another dragonxd we can't talk to (one @@ -5624,7 +5642,7 @@ daemon::StopOutcome App::stopEmbeddedDaemon(StopMode mode, StopScope scope) switch (mode) { case StopMode::AppShutdown: policy.userChoices = true; - policy.maxWaitSec = owned ? 0.0 : 120.0; // a node we don't own: watch it go for up to 2 min + policy.maxWaitSec = (owned || ours) ? 0.0 : 120.0; // a node we don't own: watch it go for up to 2 min break; case StopMode::Background: policy.maxWaitSec = 120.0; @@ -5637,15 +5655,19 @@ daemon::StopOutcome App::stopEmbeddedDaemon(StopMode mode, StopScope scope) break; } - DEBUG_LOGF("stopEmbeddedDaemon: %s node, %s\n", owned ? "our own" : "an external", - mode == StopMode::AppShutdown ? "quitting (no time limit; the user may leave it running)" + DEBUG_LOGF("stopEmbeddedDaemon: %s node, %s\n", + owned ? "our own" : ours ? "our earlier session's (left running)" : "an external", + mode == StopMode::AppShutdown + ? (policy.maxWaitSec > 0 ? "quitting (up to 120s for a node we don't own; never killed)" + : "quitting (no time limit; the user may leave it running)") : mode == StopMode::Background ? "background (up to 120s, never killed)" : "fallback (up to 20s, never killed)"); stop_kill_offered_ = false; stop_session_active_ = true; daemon::StopOutcome outcome; { - AppStopRuntime runtime(*this, owned ? d : nullptr, std::move(creds)); + AppStopRuntime runtime(*this, owned ? d : nullptr, std::move(creds), + ours ? recorded : daemon::EmbeddedDaemon::OwnershipRecord{}); AppStopTaskContext task(*this, policy.abandonOnShutdown); outcome = daemon::DaemonController::executeDaemonStop(policy, runtime, task); } @@ -5654,6 +5676,8 @@ daemon::StopOutcome App::stopEmbeddedDaemon(StopMode mode, StopScope scope) // Still running after a background wait: keep watching it (its exit is booked as a clean stop). Left // running on purpose ("Leave running"), exited or killed: let go of it. if (owned) d->endStop(/*keepWatching=*/outcome == daemon::StopOutcome::StillRunning); + if (outcome == daemon::StopOutcome::Exited || outcome == daemon::StopOutcome::Killed) + daemon::EmbeddedDaemon::liveOwnershipRecord(daemon::EmbeddedDaemon::ownershipRecordPath()); // drops it if gone DEBUG_LOGF("stopEmbeddedDaemon: %s\n", outcome == daemon::StopOutcome::Exited ? "the node exited" : outcome == daemon::StopOutcome::Detached ? "left running at the user's request" @@ -6015,10 +6039,11 @@ void App::beginShutdown() DEBUG_LOGF("beginShutdown: starting (daemon_controller_=%s)\n", daemon_controller_ ? "yes" : "no"); - // If no embedded daemon, just mark done — don't stop - // an externally-managed daemon that the user started themselves. - // Worker join + RPC disconnect happen in shutdown(). - if (!daemon_controller_) { + // No controller (this session never launched a node): disconnect only — unless the node is the one our + // earlier session left running, or stop_external_daemon is on. Worker join + RPC disconnect happen in + // shutdown(). + const bool keepRunning = (settings_ && settings_->getKeepDaemonRunning()) || shutdown_keep_daemon_override_; + if (!daemon_controller_ && (keepRunning || !mayStopExternalNode())) { DEBUG_LOGF("beginShutdown: no embedded daemon, disconnecting only\n"); shutdown_status_ = TR("appx_disconnecting"); if (settings_) { @@ -6033,17 +6058,18 @@ void App::beginShutdown() settings_->save(); } - auto shutdownDecision = daemon_controller_->shutdownDecision( - (settings_ && settings_->getKeepDaemonRunning()) || shutdown_keep_daemon_override_, - settings_ && settings_->getStopExternalDaemon()); - if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) { - DEBUG_LOGF("beginShutdown: %s, skipping daemon stop\n", shutdownDecision.logReason); + if (daemon_controller_) { + auto shutdownDecision = daemon_controller_->shutdownDecision(keepRunning, mayStopExternalNode()); + if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) { + DEBUG_LOGF("beginShutdown: %s, skipping daemon stop\n", shutdownDecision.logReason); + shutdown_status_ = shutdownDecision.status; + shutdown_complete_ = true; + return; + } shutdown_status_ = shutdownDecision.status; - shutdown_complete_ = true; - return; + } else { + shutdown_status_ = TR("appx_sending_stop_command_to_daemon"); } - - shutdown_status_ = shutdownDecision.status; DEBUG_LOGF("beginShutdown: spawning shutdown thread for daemon stop\n"); // Run the daemon shutdown on a background thread so the UI @@ -6119,8 +6145,8 @@ static std::string formatBusyDuration(double secs) App::NodeBusyText App::nodeBusyText() const { NodeBusyText out; - // The tracker reads OUR node's output; once that process is gone its last state means nothing. - const bool ours = daemon_controller_ && daemon_controller_->isRunning(); + // Once the node the tracker follows is gone, its last state means nothing. + const bool ours = busyTrackerIsLive(); const bool rebuilding = ours && daemon_busy_.rebuilding(); const bool replaying = ours && daemon_busy_.replaying() && !rebuilding; const bool rpcBusy = rpc_busy_gate_->busy(); @@ -6169,6 +6195,87 @@ App::NodeBusyText App::nodeBusyText() const return out; } +bool App::busyTrackerIsLive() const +{ + return (daemon_controller_ && daemon_controller_->isRunning()) || ext_log_tied_; +} + +long App::externalNodePid() const +{ + daemon::EmbeddedDaemon::OwnershipRecord rec; + if (daemon::EmbeddedDaemon::liveOwnershipRecord(daemon::EmbeddedDaemon::ownershipRecordPath(), &rec)) + return rec.pid; +#ifndef _WIN32 + // POSIX dragonxd writes /hushd.pid (removed on a clean exit): a live dragonxd under that pid uses + // this datadir. (Windows writes none; there only our own record ties a node to this log.) + std::ifstream in((std::filesystem::path(util::Platform::getDragonXDataDir()) / "hushd.pid").string()); + long pid = 0; + if (in >> pid && pid > 0 && daemon::EmbeddedDaemon::processIsDaemon(pid)) return pid; +#endif + return 0; +} + +void App::pumpExternalDaemonLog() +{ + if (!supportsEmbeddedDaemon()) return; + const double now = ImGui::GetTime(); + if (now < ext_log_next_at_) return; + ext_log_next_at_ = now + 2.0; + + auto untie = [this] { + if (!ext_log_tied_) return; + ext_log_tied_ = false; + ext_log_pid_ = 0; + daemon_busy_.reset(); + node_rebuilding_ = false; + busy_first_seen_ = {}; + DEBUG_LOGF("[busy] stopped following the external node's debug.log\n"); + }; + auto readRange = [](const std::string& path, std::uintmax_t from, std::uintmax_t to) { + std::string s; + std::ifstream in(path, std::ios::binary); + if (!in || to <= from) return s; + in.seekg(static_cast(from)); + s.resize(static_cast(to - from)); + in.read(&s[0], static_cast(s.size())); + s.resize(static_cast(in.gcount())); + return s; + }; + + if (daemon_controller_ && daemon_controller_->isRunning()) { untie(); return; } // ours feeds it directly + const long pid = externalNodePid(); + if (pid <= 0) { untie(); return; } + const std::string path = (std::filesystem::path(util::Platform::getDragonXDataDir()) / "debug.log").string(); + std::error_code ec; + const std::uintmax_t size = std::filesystem::file_size(path, ec); + if (ec) { untie(); return; } + + if (!ext_log_tied_ || pid != ext_log_pid_ || size < ext_log_offset_) { + // (Re)tie: this process's lines from the last 2 MB, into a fresh tracker. The banner may be older than + // the window on a long-running node; a rebuild still in progress logged its markers recently. + constexpr std::uintmax_t kWindow = 2u * 1024u * 1024u; + const std::uintmax_t from = size > kWindow ? size - kWindow : 0; + const std::string window = readRange(path, from, size); + const size_t at = daemon::externalLogFeedOffset(window, from > 0, + daemon::EmbeddedDaemon::processStartUtc(pid)); + untie(); + if (at == std::string::npos) return; // not that process's log (it may log to its console) + daemon_busy_.reset(); + ext_log_tied_ = true; + ext_log_pid_ = pid; + ext_log_offset_ = from + window.size(); + DEBUG_LOGF("[busy] following the debug.log of a node this session didn't launch (pid %ld)\n", pid); + feedDaemonBusy(window.substr(at)); + return; + } + if (size > ext_log_offset_) { + const std::uintmax_t to = std::min(size, ext_log_offset_ + 1024u * 1024u); + const std::string chunk = readRange(path, ext_log_offset_, to); + ext_log_offset_ += chunk.size(); + feedDaemonBusy(chunk); + } +} + void App::resetDaemonBusy() { // Any thread (a restart/rescan task starts the node): the UI thread applies it before its next feed. @@ -6197,15 +6304,24 @@ bool App::rpcBusyWhileAlive() const return (daemon_controller_ && daemon_controller_->isRunning()) || daemon::EmbeddedDaemon::isRpcPortInUse(); } +bool App::mayStopExternalNode() const +{ + if (settings_ && settings_->getStopExternalDaemon()) return true; + return daemon::EmbeddedDaemon::liveOwnershipRecord(daemon::EmbeddedDaemon::ownershipRecordPath()); +} + bool App::shouldConfirmDaemonStop() const { - if (!daemon_controller_) return false; - // Only relevant when this shutdown would actually STOP the daemon (embedded, or external with - // stop-on-exit) — a DisconnectOnly shutdown leaves it running and loses nothing. - const auto decision = daemon_controller_->shutdownDecision( - settings_ && settings_->getKeepDaemonRunning(), - settings_ && settings_->getStopExternalDaemon()); - const bool stops = decision.action == daemon::DaemonController::ShutdownAction::StopDaemon; + // Only relevant when this shutdown would actually STOP the daemon (ours, ours left running by an + // earlier session, or external with stop-on-exit) — a DisconnectOnly shutdown loses nothing. + const bool keep = settings_ && settings_->getKeepDaemonRunning(); + bool stops = false; + if (!daemon_controller_) { + stops = !keep && mayStopExternalNode(); + } else { + const auto decision = daemon_controller_->shutdownDecision(keep, mayStopExternalNode()); + stops = decision.action == daemon::DaemonController::ShutdownAction::StopDaemon; + } // No daemon version suppresses this: v1.3.0 does not checkpoint a witness rebuild, and nothing survives // a kill. return stops && nodeBusyForStop(); @@ -6216,7 +6332,7 @@ bool App::nodeBusyForStop() const // The tracker reads OUR node's output; once that process is gone its last state means nothing. An // external node's busyness shows only as RPC that has stopped answering. daemon::DaemonBusyTracker ours; - if (daemon_controller_ && daemon_controller_->isRunning()) ours = daemon_busy_; + if (busyTrackerIsLive()) ours = daemon_busy_; return daemon::shouldConfirmStop(true, ours, rpcBusyWhileAlive()); } diff --git a/src/app.h b/src/app.h index dd491e6..a83c52a 100644 --- a/src/app.h +++ b/src/app.h @@ -203,6 +203,10 @@ public: // The node is rebuilding witnesses (our own node's log says so) or has been too busy to answer for a // while: stopping it now either waits up to an hour or makes it start over. UI thread only. bool nodeBusyForStop() const; + // May quitting (or a default-policy stop) stop a node this session didn't launch? With stop_external_daemon + // on, or when it is the node an earlier session of ours launched and left running (its ownership record + // matches a live process). That one is stopped gracefully and waited for, never killed. + bool mayStopExternalNode() const; // For an operation that would stop the node (restart, rescan, switch, reinstall, bootstrap, ...): when // nodeBusyForStop(), tell the user to try again later and return true; the caller then does nothing. bool refuseIfNodeBusy(); @@ -1198,6 +1202,16 @@ private: rpc::ConnectRetryBackoff canary_backoff_; // a canary refused as queue-full waits before the next std::chrono::steady_clock::time_point canary_retry_at_{}; void pumpRpcBusyCanary(); + // A node this session didn't launch (left running by an earlier session, or found running): feed the busy + // tracker from its /debug.log, while that log is tied to the live process (see + // daemon::externalLogFeedOffset). Our own running node's output feeds the tracker directly. UI thread. + void pumpExternalDaemonLog(); + long externalNodePid() const; // that node: our ownership record, or (POSIX) its hushd.pid; 0 if none + bool busyTrackerIsLive() const; // the tracker describes a node that is running now + bool ext_log_tied_ = false; + long ext_log_pid_ = 0; + std::uintmax_t ext_log_offset_ = 0; + double ext_log_next_at_ = 0.0; void abortRpcBusyCanary(); void feedDaemonBusy(const std::string& chunk); void resetDaemonBusy(); diff --git a/src/app_network.cpp b/src/app_network.cpp index 88070a6..fccf893 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -660,7 +660,11 @@ void App::tryConnect() if (startEmbeddedDaemon()) { VERBOSE_LOGF("[connect #%d] Embedded daemon starting, will retry connection...\n", attempt); } else if (daemon_controller_ && daemon_controller_->externalDaemonDetected()) { - connection_status_ = TR("sb_connecting_generic"); + // A node already running (left running, or started by hand). If our probe reached + // it but got no answer, say what it is doing (its log may show a rebuild) rather + // than a bare "Connecting…" until the next attempt, up to 30s away. + if (rpc::rpcKindMeansBusy(connectKind)) applyDaemonInitStatus(/*reachableButBusy=*/true); + else connection_status_ = TR("sb_connecting_generic"); VERBOSE_LOGF("[connect #%d] External daemon detected, will connect via RPC...\n", attempt); } else { VERBOSE_LOGF("[connect #%d] Failed to start embedded daemon — lastError: %s\n", diff --git a/src/daemon/daemon_busy_detect.h b/src/daemon/daemon_busy_detect.h index 870e9fb..ad30aed 100644 --- a/src/daemon/daemon_busy_detect.h +++ b/src/daemon/daemon_busy_detect.h @@ -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 diff --git a/src/daemon/embedded_daemon.cpp b/src/daemon/embedded_daemon.cpp index 7639e7c..96ea26e 100644 --- a/src/daemon/embedded_daemon.cpp +++ b/src/daemon/embedded_daemon.cpp @@ -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 #include #include #include #include #include +#include #include +#include +#include +#include #include #include "../util/logger.h" @@ -33,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -217,11 +224,11 @@ std::vector EmbeddedDaemon::getChainParams() DEBUG_LOGF("[INFO] Using %s\n", dbcache_arg.c_str()); } + // No -printtoconsole on any platform: the node writes its timestamped /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(in), std::istreambuf_iterator()); + } + 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); + launch_utc_ = static_cast(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(GetProcessId(process_handle_)) : 0; +#else + const long pid = static_cast(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(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(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(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(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(debug_log_offset_)); if (n <= 0) break; buffer[n] = '\0'; { @@ -1394,7 +1435,27 @@ void EmbeddedDaemon::drainOutput() appendOutput(buffer, static_cast(n)); } VERBOSE_LOGF("[dragonxd] %s", buffer); + debug_log_offset_ += static_cast(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 lk(output_mutex_); + appendOutput(buffer, static_cast(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 lk(output_mutex_); - appendOutput(buffer, static_cast(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(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(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(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(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(created.dwHighDateTime) << 32) | + created.dwLowDateTime; // 100ns since 1601 + if (t > 116444736000000000ULL) utc = static_cast((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 " + std::string key; + long long btime = 0; + while (s >> key) { + if (key == "btime") { s >> btime; break; } + s.ignore(std::numeric_limits::max(), '\n'); + } + const long hz = sysconf(_SC_CLK_TCK); + if (btime <= 0 || hz <= 0) return 0; + return static_cast(btime + static_cast(startTicks / static_cast(hz))); +#elif defined(__APPLE__) + struct proc_bsdinfo info{}; + if (proc_pidinfo(static_cast(pid), PROC_PIDTBSDINFO, 0, &info, sizeof(info)) != sizeof(info)) return 0; + return static_cast(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(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(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(in), std::istreambuf_iterator()); + } + 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); diff --git a/src/daemon/embedded_daemon.h b/src/daemon/embedded_daemon.h index 683a5ac..069833c 100644 --- a/src/daemon/embedded_daemon.h +++ b/src/daemon/embedded_daemon.h @@ -4,7 +4,9 @@ #pragma once +#include #include +#include #include #include #include @@ -324,6 +326,93 @@ public: return comm == "dragonxd" || exeName == "dragonxd"; } + // ---- Ownership record -------------------------------------------------------------------------- + // /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//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(); // /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 /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 should_stop_{false}; std::atomic stop_requested_{false}; // a stop was asked for: the next exit is not a crash diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 4ac7988..0a6e3d6 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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(GetCurrentProcessId()); +#else + const long self = static_cast(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(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(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(GetCurrentProcessId()); +#else + const long self = static_cast(getpid()); +#endif + const std::int64_t started = EmbeddedDaemon::processStartUtc(self); + const std::int64_t nowUtc = static_cast(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();