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
parent 45b652f514
commit b3444e0a89
4 changed files with 655 additions and 19 deletions

View File

@@ -3,6 +3,7 @@
#include "chat/chat_service.h"
#include "chat/chat_database.h"
#include "daemon/daemon_controller.h"
#include "daemon/embedded_daemon.h"
#include "data/transaction_history_cache.h"
#include "data/address_book.h"
#include "data/wallet_index.h"
@@ -2477,6 +2478,28 @@ void testDaemonShutdownPolicy()
EXPECT_TRUE(bootstrap.disconnectRpc);
}
void testDatadirLockGate()
{
using dragonx::daemon::EmbeddedDaemon;
// Normal start, no lingering daemon after the bounded wait → proceed.
auto clear = EmbeddedDaemon::evaluateDatadirLockGate(false, false, false);
EXPECT_TRUE(clear.proceed);
// A previous dragonxd still alive after the wait → bail with a distinct, non-crash msg.
auto locked = EmbeddedDaemon::evaluateDatadirLockGate(false, false, true);
EXPECT_TRUE(!locked.proceed);
EXPECT_TRUE(std::string(locked.errorMessage).find("data directory lock") != std::string::npos);
// Isolated instance via skip_port_check_ is exempt even if a sibling dragonxd is running.
auto skipPort = EmbeddedDaemon::evaluateDatadirLockGate(true, false, true);
EXPECT_TRUE(skipPort.proceed);
// Isolated instance via -datadir override is exempt even if a sibling is running.
auto isolated = EmbeddedDaemon::evaluateDatadirLockGate(false, true, true);
EXPECT_TRUE(isolated.proceed);
}
void testDaemonLifecycleExecution()
{
using dragonx::daemon::DaemonController;
@@ -6619,6 +6642,7 @@ int main()
testWalletSecurityWorkflow();
testWalletSecurityWorkflowExecutor();
testDaemonShutdownPolicy();
testDatadirLockGate();
testDaemonLifecycleExecution();
testDaemonLifecycleAdapters();
testConsoleTextLayout();