Files
ObsidianDragon/src/daemon/embedded_daemon.h
DanS b3444e0a89 fix(daemon): harden startup process lifecycle (crash race, exec failure, datadir lock)
Three verified daemon-startup edge-case fixes in the embedded-daemon process
lifecycle (all in embedded_daemon.{cpp,h}):

- F1: EmbeddedDaemon::isRunning() (POSIX) now reads the atomic state_ instead of
  calling waitpid(WNOHANG) from the UI thread, which raced monitorProcess()'s own
  reap. waitpid is one-shot: whichever thread won consumed the exit status; if
  isRunning() won, the monitor never saw the crash, so crash_count_/State::Error
  and the 3-strike restart cap were silently lost. monitorProcess() is now the sole
  reaper (predicate Running || Stopping keeps stop()'s wait loops correct). Mirrors
  the existing XmrigManager::isRunning() fix.

- F2: startProcess() (POSIX) adds a close-on-exec self-pipe exec handshake. On a
  non-executable / wrong-arch / corrupt binary, execv fails in the child and the
  parent now learns synchronously (reads errno vs EOF), reaps the zombie, sets a
  precise last_error_ ("not executable or wrong architecture"), and returns false
  -- instead of reporting State::Running for a daemon that never started. Uses
  pipe()+FD_CLOEXEC (not pipe2) so the branch stays shared with macOS. Parent-side
  setpgid is now best-effort + logged.

- F4: start() gates on a lingering datadir lock after the port check. A graceful
  shutdown releases the RPC port ~90s before the datadir .lock, so a rapid
  stop->start spawned a daemon that died on the lock and, three times in ~12s,
  tripped the 3-strike crash cap before the lock cleared. start() now polls
  isDaemonProcessRunning() with a bounded ~300ms wait and bails with a distinct
  non-crash Error (no crash_count_ bump) that the connect loop retries once the
  lock clears. Isolated migrate-to-seed starts (skip_port_check_ / -datadir
  override) are exempt.

Adds the testDatadirLockGate unit test (pure evaluateDatadirLockGate matrix) to
test_phase4.cpp. Plan and progress tracked in docs/daemon-startup-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 10:32:30 -05:00

316 lines
12 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
#include <string>
#include <functional>
#include <memory>
#include <atomic>
#include <thread>
#include <mutex>
#include <set>
#include <vector>
#ifdef _WIN32
#include <windows.h>
#else
#include <sys/types.h>
#endif
namespace dragonx {
namespace daemon {
/**
* @brief Manages the embedded dragonxd process
*
* Handles starting, stopping, and monitoring the dragonxd daemon process.
* Ports functionality from Qt version's connection.cpp.
*/
class EmbeddedDaemon {
public:
enum class State {
Stopped,
Starting,
Running,
Stopping,
Error
};
using StateCallback = std::function<void(State, const std::string&)>;
EmbeddedDaemon();
~EmbeddedDaemon();
// Non-copyable
EmbeddedDaemon(const EmbeddedDaemon&) = delete;
EmbeddedDaemon& operator=(const EmbeddedDaemon&) = delete;
/**
* @brief Start the embedded dragonxd daemon
* @param binary_path Path to dragonxd binary (auto-detect if empty)
* @return true if process started successfully
*/
bool start(const std::string& binary_path = "");
/**
* @brief Stop the running daemon
* @param wait_ms Maximum milliseconds to wait for graceful shutdown
*/
void stop(int wait_ms = 5000);
/**
* @brief Check if daemon process is running
*/
bool isRunning() const;
/**
* @brief Get current daemon state
*/
State getState() const { return state_; }
/**
* @brief Get daemon process memory usage (RSS) in MB
* @return Memory usage in MB, or 0 if unavailable
*/
double getMemoryUsageMB() const;
/**
* @brief Get last error message
*/
const std::string& getLastError() const { return last_error_; }
/**
* @brief Get dragonxd process output (thread-safe copy)
*/
std::string getOutput() const {
std::lock_guard<std::mutex> lk(output_mutex_);
return process_output_;
}
/**
* @brief Get new output since a given offset (thread-safe).
* Returns the new text and updates offset to the current size.
*/
std::string getOutputSince(size_t& offset) const {
std::lock_guard<std::mutex> lk(output_mutex_);
if (offset >= process_output_.size()) {
offset = process_output_.size();
return {};
}
std::string result = process_output_.substr(offset);
offset = process_output_.size();
return result;
}
/**
* @brief Get current output size (thread-safe, no copy)
*/
size_t getOutputSize() const {
std::lock_guard<std::mutex> lk(output_mutex_);
return process_output_.size();
}
/**
* @brief Get last N lines of daemon output (thread-safe snapshot)
*/
std::vector<std::string> getRecentLines(int maxLines = 8) const;
/**
* @brief Extract the latest block height from daemon output (thread-safe).
* Parses the last "height=N" from UpdateTip lines without copying
* the entire output buffer. Returns -1 if no UpdateTip found.
*/
int getLastBlockHeight() const {
std::lock_guard<std::mutex> lk(output_mutex_);
// Search backwards from the end for "height="
size_t pos = process_output_.rfind("height=");
if (pos == std::string::npos) return -1;
pos += 7; // skip "height="
int h = 0;
for (size_t i = pos; i < process_output_.size(); ++i) {
char c = process_output_[i];
if (c >= '0' && c <= '9') h = h * 10 + (c - '0');
else break;
}
return h > 0 ? h : -1;
}
/**
* @brief Whether start() detected an existing daemon on the RPC port.
* When true the wallet should connect to it instead of showing an error.
*/
bool externalDaemonDetected() const { return external_daemon_detected_; }
// Clear the adopted-external latch before relaunching our OWN process (e.g. a wallet switch that
// stopped the adopted daemon), so the freshly spawned daemon is treated as owned. start() also
// clears it on the port-free fall-through, but only if not short-circuited by an early guard.
void clearExternalDaemonDetected() { external_daemon_detected_ = false; }
/**
* @brief Set callback for state changes
*/
void setStateCallback(StateCallback cb) { state_callback_ = cb; }
/**
* @brief Find dragonxd binary in standard locations
* @return Path to binary, or empty if not found
*/
static std::string findDaemonBinary();
/**
* @brief Check whether anything is listening on the default RPC port.
* Useful for detecting an externally-started daemon.
*/
static bool isRpcPortInUse();
/**
* @brief Get the chain parameters for dragonxd
* @return Command line arguments for dragonx chain
*/
static std::vector<std::string> getChainParams();
/**
* @brief Set debug logging categories (appended as -debug= flags on next start)
*/
void setDebugCategories(const std::set<std::string>& cats) { debug_categories_ = cats; }
const std::set<std::string>& getDebugCategories() const { return debug_categories_; }
/**
* @brief Set maximum peer connections (0 = use daemon default)
*/
void setMaxConnections(int v) { max_connections_ = v; }
/**
* @brief Request a blockchain rescan on the next daemon start
*/
void setRescanOnNextStart(bool v) { rescan_on_next_start_ = v; }
bool rescanOnNextStart() const { return rescan_on_next_start_.load(); }
// Active wallet file (multi-wallet). Passed to the daemon as -wallet=<name> so it loads
// <datadir>/<name>; empty or "wallet.dat" keeps the daemon default (no arg). Must be a plain
// filename in the datadir — the daemon rejects paths.
void setWalletFile(const std::string& v) { wallet_file_ = v; }
std::string walletFile() const { return wallet_file_; }
/**
* @brief Request a wallet repair (-zapwallettxes=2) on the next daemon start. This deletes all
* wallet transaction/note records and rebuilds them from the chain (keys are kept); the
* daemon implicitly rescans afterwards. One-shot, like the rescan flag.
*/
void setZapOnNextStart(bool v) { zap_on_next_start_ = v; }
bool zapOnNextStart() const { return zap_on_next_start_.load(); }
// -salvagewallet: attempt to recover keys from a corrupt wallet.dat on startup (implies -rescan in
// the daemon). One-shot, consumed on the next start. Used to repair a wallet a switch flagged corrupt.
void setSalvageOnNextStart(bool v) { salvage_on_next_start_ = v; }
bool salvageOnNextStart() const { return salvage_on_next_start_.load(); }
/**
* @brief One-shot isolated-datadir override for the NEXT start(): run the daemon against a
* different datadir (with its own DRAGONX.conf) plus the given extra args. Used by the
* "migrate to a seed wallet" flow to mint a fresh mnemonic wallet in a throwaway datadir
* without touching the real one. Consumed on the next start(); later starts are normal.
* The caller must serialize this with start() (no concurrent starts).
*/
void setNextStartOverride(const std::string& datadir, std::vector<std::string> extraArgs) {
override_datadir_ = datadir;
override_extra_args_ = std::move(extraArgs);
}
void clearNextStartOverride() { override_datadir_.clear(); override_extra_args_.clear(); }
/**
* @brief Skip the "default RPC port already in use → external daemon" bail in start().
* Set true only for an isolated instance running on its OWN (non-default) port
* alongside the main daemon (migrate-to-seed flow).
*/
void setSkipPortCheck(bool v) { skip_port_check_ = v; }
/**
* @brief True while ANY dragonxd process is running (by process name), regardless of who started it.
* Unlike isRpcPortInUse()/isRunning(), this reflects the actual PROCESS still being alive — a
* graceful shutdown stops accepting RPC (port reads "free") but keeps the datadir lock until the
* process exits, which can take up to ~90s. Use this to know a stopped node has FULLY released
* the datadir before starting a replacement. Matches the daemon binary name on all platforms.
*/
static bool isDaemonProcessRunning();
/** Decision returned by evaluateDatadirLockGate(): whether start() may spawn now. */
struct StartLockGateDecision {
bool proceed = true; // false => bail before spawning
const char* errorMessage = ""; // set (a string literal) when proceed == false
};
/**
* @brief Pure decision for start(): bail because a previous dragonxd still holds the
* shared datadir lock? Isolated instances (skip_port_check_ / an active -datadir
* override) are exempt — they run their own throwaway datadir+port and can coexist
* with the main daemon. Does no process/fs I/O itself (the caller does the probing),
* so it is directly unit-testable; defined inline so tests need only this header.
*/
static StartLockGateDecision evaluateDatadirLockGate(bool skipPortCheck,
bool isolatedOverride,
bool stillRunningAfterWait)
{
if (skipPortCheck || isolatedOverride) return {true, ""};
if (stillRunningAfterWait) {
return {false,
"A previous dragonxd is still shutting down and holding the data "
"directory lock. Retrying shortly…"};
}
return {true, ""};
}
/** @brief Is an arbitrary TCP port currently in use on localhost? (used to pick a free port) */
static bool tcpPortInUse(int port);
/** Get number of consecutive daemon crashes (resets on successful start or manual reset) */
int getCrashCount() const { return crash_count_.load(); }
/** Reset crash counter (call on successful connection or manual restart) */
void resetCrashCount() { crash_count_ = 0; }
private:
void setState(State s, const std::string& message = "");
void monitorProcess();
void drainOutput(); // read any pending stdout into process_output_
void appendOutput(const char* data, size_t len); // append + trim (caller holds output_mutex_)
bool startProcess(const std::string& binary_path, const std::vector<std::string>& args);
std::atomic<State> state_{State::Stopped};
std::atomic<bool> external_daemon_detected_{false};
std::string last_error_;
mutable std::mutex output_mutex_; // protects process_output_
std::string process_output_;
StateCallback state_callback_;
// Process handle
#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;
#endif
std::thread monitor_thread_;
std::atomic<bool> should_stop_{false};
std::set<std::string> debug_categories_;
int max_connections_ = 0; // 0 = daemon default
std::string wallet_file_; // -wallet=<name> for the active wallet; empty/"wallet.dat" = default
std::atomic<int> crash_count_{0}; // consecutive crash counter
std::atomic<bool> rescan_on_next_start_{false}; // -rescan flag for next start
std::atomic<bool> zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start
std::atomic<bool> salvage_on_next_start_{false}; // -salvagewallet flag for next start
std::string override_datadir_; // one-shot: -datadir for the next start
std::vector<std::string> override_extra_args_; // one-shot: extra args for the next start
bool skip_port_check_ = false; // isolated instance on a non-default port
};
} // namespace daemon
} // namespace dragonx