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>
This commit is contained in:
2026-08-02 10:32:30 -05:00
co-authored by Claude Opus 4.8
parent 45b652f514
commit b3444e0a89
4 changed files with 655 additions and 19 deletions
+26
View File
@@ -235,6 +235,32 @@ public:
*/
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);