perf(mining): sample daemon memory off the UI thread

The daemon's RSS was read on the UI thread: a scan of every /proc entry
on Linux (1-7 ms), a forked ps on macOS (up to ~30 ms). The mining poll
probed every 1-5 s on every tab, and the Mining tab's memory bar probed
again every 1.5 s.

util::BackgroundSampler now runs the probe on one lazily started thread
and caches the result. getDaemonMemoryUsageMB() returns the last sample
and asks for a new one at most every 3 s, and only while the Mining tab
memory bar or the Peers tab Memory cell is drawn: nothing on other tabs
or while minimized. EmbeddedDaemon::memoryProbe() hands the sampler a
pid snapshot (on Windows the value read from the owned process handle),
so nothing off-thread touches daemon state. The mining poll does no I/O
any more; it carries the last sample. Both shutdown paths stop the
sampler.

Fixed along the way: an unreadable /proc made the directory iterator
throw on the UI thread; the scan now just stops.

User-visible:
- the Peers tab Memory cell keeps updating during sync and shows an
  external daemon's memory (it showed the mining poll's value, which
  pauses during sync and covers only the embedded daemon);
- the Mining tab memory bar refreshes every ~3 s (was 1.5 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 21:09:48 -05:00
co-authored by Claude Opus 5.5
parent 2618744e59
commit 296c04386c
11 changed files with 312 additions and 50 deletions
+84
View File
@@ -71,6 +71,7 @@
#include "util/pool_registry.h"
#include "util/daemon_updater.h"
#include "util/http_download.h"
#include "util/background_sampler.h"
#include "util/lite_server_probe.h"
#include "util/wallet_file_probe.h"
#include "wallet/lite_connection_service.h"
@@ -3488,6 +3489,30 @@ void testPosixRequestedStopIsNotACrash()
std::error_code ec; std::filesystem::remove(script, ec);
}
// C103: EmbeddedDaemon::memoryProbe (POSIX: place after testPosixRequestedStopIsNotACrash, it uses
// writeFakeDaemonScript).
void testEmbeddedDaemonMemoryProbe()
{
using dragonx::daemon::EmbeddedDaemon;
{
EmbeddedDaemon idle;
EXPECT_FALSE(static_cast<bool>(idle.memoryProbe())); // no process: empty probe
EXPECT_NEAR(idle.getMemoryUsageMB(), 0.0, 1e-9);
}
const auto script = writeFakeDaemonScript("od_fake_daemon_mem.sh", "sleep 2\nexit 0\n");
EmbeddedDaemon d;
d.setSkipPortCheck(true);
EXPECT_TRUE(d.start(script.string()));
auto probe = d.memoryProbe();
EXPECT_TRUE(static_cast<bool>(probe));
double mb = 0.0;
std::thread t([&probe, &mb] { mb = probe(); }); // runs off the creating thread, as in the app
t.join();
EXPECT_TRUE(mb > 0.0); // the bash stand-in's process group
d.stop(5000);
std::error_code ec; std::filesystem::remove(script, ec);
}
// S4: SIGTERM is graceful on POSIX (dragonxd treats it like RPC stop), and stop() reaps the clean
// exit it leads to at once instead of waiting out the 10s SIGKILL timer (~11s before the fix).
void testPosixStopSigtermIsGraceful()
@@ -4522,6 +4547,63 @@ void testLowestBlockSearch()
EXPECT_TRUE(p == BlockProbe::Unknown);
}
// C103: the sampler (pure threading, no OS probes).
void testBackgroundSampler()
{
using dragonx::util::BackgroundSampler;
using Clock = BackgroundSampler::Clock;
using std::chrono::milliseconds;
auto waitFor = [](const std::function<bool()>& cond) {
for (int i = 0; i < 400 && !cond(); ++i) std::this_thread::sleep_for(milliseconds(5));
return cond();
};
{ BackgroundSampler idle; EXPECT_TRUE(idle.due(Clock::now(), milliseconds(1000))); } // no thread: no hang
BackgroundSampler s;
const auto t0 = Clock::now();
std::atomic<int> calls{0};
EXPECT_TRUE(s.requestIfStale([&calls] { ++calls; return 42.0; }, t0, milliseconds(1000)));
EXPECT_TRUE(waitFor([&s] { return s.hasSample(); }));
EXPECT_NEAR(s.value(), 42.0, 1e-9);
EXPECT_EQ(calls.load(), 1);
EXPECT_FALSE(s.due(t0 + milliseconds(500), milliseconds(1000))); // throttled
EXPECT_FALSE(s.requestIfStale([&calls] { ++calls; return 1.0; }, t0 + milliseconds(500), milliseconds(1000)));
EXPECT_TRUE(s.requestIfStale([&calls] { ++calls; return 7.0; }, t0 + milliseconds(1000), milliseconds(1000)));
EXPECT_TRUE(waitFor([&s] { return s.value() == 7.0; }));
EXPECT_EQ(calls.load(), 2);
// Coalescing: nothing is accepted while a probe runs; reset() discards its result.
std::atomic<bool> release{false}, running{false};
EXPECT_TRUE(s.requestIfStale([&release, &running] {
running = true;
while (!release) std::this_thread::sleep_for(milliseconds(1));
return 9.0;
}, t0 + milliseconds(5000), milliseconds(1000)));
EXPECT_TRUE(waitFor([&running] { return running.load(); }));
EXPECT_FALSE(s.due(t0 + milliseconds(60000), milliseconds(1000)));
EXPECT_FALSE(s.requestIfStale([&calls] { ++calls; return 1.0; }, t0 + milliseconds(60000), milliseconds(1000)));
s.reset();
release = true;
EXPECT_TRUE(waitFor([&s, t0] { return s.due(t0 + milliseconds(5000), milliseconds(1000)); })); // after reset: at once
EXPECT_FALSE(s.hasSample());
EXPECT_NEAR(s.value(), 0.0, 1e-9);
EXPECT_EQ(calls.load(), 2);
// A throwing probe leaves no sample and the thread keeps working.
EXPECT_TRUE(s.requestIfStale([]() -> double { throw std::runtime_error("probe failed"); },
t0 + milliseconds(5000), milliseconds(1000)));
EXPECT_TRUE(waitFor([&s, t0] { return s.due(t0 + milliseconds(7000), milliseconds(1000)); }));
EXPECT_FALSE(s.hasSample());
EXPECT_TRUE(s.requestIfStale([] { return 5.0; }, t0 + milliseconds(7000), milliseconds(1000)));
EXPECT_TRUE(waitFor([&s] { return s.hasSample(); }));
EXPECT_NEAR(s.value(), 5.0, 1e-9);
s.stop();
s.stop(); // idempotent
EXPECT_FALSE(s.requestIfStale([] { return 1.0; }, t0 + milliseconds(60000), milliseconds(1000)));
EXPECT_NEAR(s.value(), 5.0, 1e-9); // last value kept
}
void testConnectRetryBackoff()
{
using dragonx::rpc::ConnectRetryBackoff;
@@ -12885,6 +12967,7 @@ int main()
testFetchBackoff();
testHttpGetCancel();
testLowestBlockSearch();
testBackgroundSampler();
testNetworkRefreshService();
testNetworkRefreshSnapshotHelpers();
testParseDaemonRescanOutput();
@@ -12955,6 +13038,7 @@ int main()
testDaemonCrashDetected();
testPosixStopReturnsAfterCleanExit();
testPosixRequestedStopIsNotACrash();
testEmbeddedDaemonMemoryProbe();
testPosixStopSigtermIsGraceful();
testPosixDestructorLeavesProcessRunning();
testPosixStopSessionDetach();