test(daemon): add F1/F2 process-lifecycle integration tests; fix clobbered start error

Links the real EmbeddedDaemon into the ObsidianDragonTests target (its deps were
already present) and adds two POSIX integration tests that exercise the actual
fork/exec/waitpid fixes headlessly:

- testExecFailureReported (F2): start() against a non-executable file must fail with a
  precise "not executable or wrong architecture" reason.
- testDaemonCrashDetected (F1): a short-lived child that exits abnormally is still
  detected (crash_count_ increments) while isRunning() is hammered from the test
  thread — a regression test for the reap race.

Writing the F2 test surfaced a real bug: start()'s failure branch called
setState(State::Error, "Failed to start dragonxd process"), and setState stores the
Error message into last_error_ — clobbering the precise message startProcess() had
just set, so getLastError()/the UI only ever saw the generic string. Fixed to pass the
preserved detail to setState, so the precise reason survives and now also reaches the
state callback (crash panel / status).

ctest 1/1, green including the two new integration tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 13:11:15 -05:00
parent ff5f5ddf23
commit d188a08db7
4 changed files with 71 additions and 5 deletions

View File

@@ -2634,6 +2634,59 @@ void testPlatformEnsureDirectory()
}
}
#ifndef _WIN32
// Integration tests that drive the REAL EmbeddedDaemon fork/exec/waitpid paths (POSIX only).
void testExecFailureReported()
{
using dragonx::daemon::EmbeddedDaemon;
namespace fsn = std::filesystem;
// A present-but-non-executable file: execv() must fail, and the F2 self-pipe handshake
// must report it as a start FAILURE with a precise reason — not a transient "Running".
fsn::path bin = fsn::temp_directory_path() / "od_fake_daemon_bin";
{ std::ofstream(bin.string(), std::ios::binary) << "this is not an executable"; }
fsn::permissions(bin, fsn::perms::owner_read, fsn::perm_options::replace); // 0400, no +x
EmbeddedDaemon d;
d.setSkipPortCheck(true); // bypass the port + datadir-lock gates so we reach startProcess()
EXPECT_TRUE(!d.start(bin.string()));
EXPECT_TRUE(d.getLastError().find("not executable or wrong architecture") != std::string::npos);
EXPECT_TRUE(!d.isRunning());
std::error_code ec; fsn::remove(bin, ec);
}
void testDaemonCrashDetected()
{
using dragonx::daemon::EmbeddedDaemon;
namespace fsn = std::filesystem;
// A tiny script that ignores the injected daemon args, lives briefly, then exits abnormally
// — standing in for a daemon that crashes. is_script detection runs it via /bin/bash.
fsn::path script = fsn::temp_directory_path() / "od_fake_daemon.sh";
{ std::ofstream(script.string()) << "#!/bin/bash\nsleep 0.2\nexit 7\n"; }
fsn::permissions(script, fsn::perms::owner_all, fsn::perm_options::replace); // +x
EmbeddedDaemon d;
d.setSkipPortCheck(true);
EXPECT_TRUE(d.start(script.string()));
EXPECT_TRUE(d.isRunning()); // reads the atomic state_, not a racy waitpid()
// Hammer isRunning() the way the UI thread does while the child exits and monitorProcess()
// reaps it. Pre-fix (F1), isRunning()'s own waitpid() could steal the reap and hide the
// crash; with the fix the monitor is the sole reaper and always sees it.
for (int i = 0; i < 400 && d.getCrashCount() == 0; ++i) {
(void)d.isRunning();
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
EXPECT_TRUE(d.getCrashCount() >= 1); // the unexpected exit was detected and counted
EXPECT_TRUE(!d.isRunning()); // state_ flipped to Error
d.stop(); // join the monitor thread cleanly
std::error_code ec; fsn::remove(script, ec);
}
#endif // !_WIN32
void testDatadirLockGate()
{
using dragonx::daemon::EmbeddedDaemon;
@@ -6799,6 +6852,10 @@ int main()
testWalletSecurityWorkflowExecutor();
testDaemonShutdownPolicy();
testDatadirLockGate();
#ifndef _WIN32
testExecFailureReported();
testDaemonCrashDetected();
#endif
testPlatformEnsureDirectory();
testVerifySaplingParams();
testConnectHasStalled();