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
+1
View File
@@ -711,6 +711,7 @@ set(APP_HEADERS
src/util/async_task_manager.h
src/util/fetch_backoff.h
src/util/coalescing_writer.h
src/util/background_sampler.h
src/util/amount_format.h
src/util/base64.h
src/util/single_instance.h
+23 -19
View File
@@ -6178,26 +6178,28 @@ bool App::stopDaemonForBootstrap()
double App::getDaemonMemoryUsageMB() const
{
// The Mining tab reads this every frame, but the probe is expensive (Linux embedded/external paths
// scan /proc; macOS forks a shell). Throttle to ~1.5s so a 60Hz caller doesn't hammer the OS.
const double now = ImGui::GetTime();
if (daemon_mem_probe_at_ > 0.0 && now - daemon_mem_probe_at_ < 1.5)
return daemon_mem_cached_mb_;
daemon_mem_probe_at_ = now;
double result = 0.0;
// If we have an embedded daemon with a tracked process handle, use it
// directly — more reliable than a process scan since we own the handle.
if (daemon_controller_ && daemon_controller_->isRunning()) {
double mb = daemon_controller_->memoryUsageMB();
daemon_mem_diag_ = "embedded";
result = (mb > 0.0) ? mb : util::Platform::getDaemonMemoryUsageMB();
} else {
daemon_mem_diag_ = "process scan";
result = util::Platform::getDaemonMemoryUsageMB(); // external daemon
// Never probes on the calling (UI) thread. The probe (a /proc scan on Linux, a forked `ps` on macOS, a
// process snapshot for an external daemon) runs on daemon_mem_sampler_'s thread. This returns the last
// completed sample (0 until the first lands, tens of ms after the first ask) and asks for a fresh one
// when the last request is older than 3 s.
constexpr auto kMaxAge = std::chrono::milliseconds(3000);
const auto now = std::chrono::steady_clock::now();
if (daemon_mem_sampler_.due(now, kMaxAge)) {
util::BackgroundSampler::Probe probe;
if (daemon_controller_ && daemon_controller_->isRunning()) {
// The embedded daemon we own: its tracked process group first, a process scan if that reads 0.
daemon_mem_diag_ = "embedded";
probe = [own = daemon_controller_->memoryProbe()]() {
const double mb = own ? own() : 0.0;
return mb > 0.0 ? mb : util::Platform::getDaemonMemoryUsageMB();
};
} else {
daemon_mem_diag_ = "process scan";
probe = []() { return util::Platform::getDaemonMemoryUsageMB(); }; // external daemon
}
daemon_mem_sampler_.requestIfStale(std::move(probe), now, kMaxAge);
}
daemon_mem_cached_mb_ = result;
return result;
return daemon_mem_sampler_.value();
}
// ============================================================================
@@ -7401,6 +7403,7 @@ void App::shutdown()
if (settings_) {
settings_->save();
}
daemon_mem_sampler_.stop();
if (daemon_controller_) {
stopEmbeddedDaemon(StopMode::Fallback); // on the UI thread: bounded, never killed
}
@@ -7428,6 +7431,7 @@ void App::shutdown()
if (http_worker_) {
http_worker_->stop(); // http_cancel_ was set in beginShutdown(): an in-flight GET aborts within ~1 s
}
daemon_mem_sampler_.stop(); // joins a probe in flight (tens of ms at most)
// Disconnect RPC after worker is fully stopped (safe — no curl in flight)
if (rpc_) {
rpc_->disconnect();
+6 -4
View File
@@ -38,6 +38,7 @@
#include "services/wallet_security_controller.h"
#include "services/wallet_security_workflow.h"
#include "util/async_task_manager.h"
#include "util/background_sampler.h"
#include "util/coalescing_writer.h"
#include "util/fetch_backoff.h"
#include "util/pool_stats_service.h"
@@ -957,10 +958,11 @@ private:
std::int64_t sb_unconf_key_ts_ = -1;
std::size_t sb_unconf_key_n_ = 0;
int sb_unconf_count_ = 0;
// Daemon-memory probe is expensive (/proc scan on Linux, popen on macOS); throttle it to ~1.5s so the
// Mining tab's per-frame read doesn't hammer the OS. See App::getDaemonMemoryUsageMB().
mutable double daemon_mem_cached_mb_ = 0.0;
mutable double daemon_mem_probe_at_ = 0.0;
// Daemon-memory probe is expensive (/proc scan on Linux, popen on macOS): it runs on this sampler's own
// thread, at most once per 3 s and only while a reader asks (Mining tab memory bar, Peers tab Memory cell).
// See App::getDaemonMemoryUsageMB(). Mutable: that accessor is const. Probes capture values only, so any
// member order is safe; shutdown() stops it.
mutable util::BackgroundSampler daemon_mem_sampler_;
// ── Chat note buffer (BOTH variants) ────────────────────────────────────────────────────────
// Each chat message is a shielded tx that spends a note; its change needs a few confirmations before
+3 -6
View File
@@ -3021,12 +3021,9 @@ void App::refreshMiningInfo()
if (state_.sync.syncing && !includeLocalHashrate) return;
ui::NavPage tracePage = current_page_;
// Capture daemon memory outside (may be accessed on main thread). Only for a poll that runs: the probe
// reads /proc (or the Windows process table).
double daemonMemMb = 0.0;
if (daemon_controller_) {
daemonMemMb = daemon_controller_->memoryUsageMB();
}
// Daemon memory is sampled off the UI thread by getDaemonMemoryUsageMB() (Mining / Peers tab); the poll
// only carries the last sample into state.mining.daemon_memory_mb (no I/O here).
const double daemonMemMb = daemon_mem_sampler_.value();
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Mining, *w,
[this, rpc, daemonMemMb, doSlowRefresh, includeLocalHashrate, tracePage]() -> rpc::RPCWorker::MainCb {
+5
View File
@@ -99,6 +99,11 @@ double DaemonController::memoryUsageMB() const
return daemon_ ? daemon_->getMemoryUsageMB() : 0.0;
}
std::function<double()> DaemonController::memoryProbe() const
{
return daemon_ ? daemon_->memoryProbe() : std::function<double()>{};
}
std::vector<std::string> DaemonController::recentLines(std::size_t count) const
{
return daemon_ ? daemon_->getRecentLines(count) : std::vector<std::string>{};
+2
View File
@@ -6,6 +6,7 @@
#include <algorithm>
#include <cstddef>
#include <deque>
#include <functional>
#include <memory>
#include <string>
#include <vector>
@@ -106,6 +107,7 @@ public:
int crashCount() const;
int lastBlockHeight() const;
double memoryUsageMB() const;
std::function<double()> memoryProbe() const; // EmbeddedDaemon::memoryProbe(); empty without a daemon
std::vector<std::string> recentLines(std::size_t count) const;
std::string outputSince(std::size_t& offset) const;
+41 -20
View File
@@ -839,7 +839,7 @@ static DWORD findProcessByName(const char* name)
double EmbeddedDaemon::getMemoryUsageMB() const
{
if (process_handle_ == nullptr) return 0.0;
PROCESS_MEMORY_COUNTERS pmc;
ZeroMemory(&pmc, sizeof(pmc));
pmc.cb = sizeof(pmc);
@@ -849,6 +849,16 @@ double EmbeddedDaemon::getMemoryUsageMB() const
return 0.0;
}
std::function<double()> EmbeddedDaemon::memoryProbe() const
{
if (process_handle_ == nullptr) return {};
// GetProcessMemoryInfo on the handle we own is one cheap call: read it here and hand back the value, so the
// callable never touches process_handle_ (the monitor can close it). The expensive part on Windows, the
// Toolhelp process scan App falls back to when this reads 0, runs on the sampler thread.
const double mb = getMemoryUsageMB();
return [mb]() { return mb; };
}
void EmbeddedDaemon::drainOutput()
{
// Read new content from debug.log (tail-follow approach)
@@ -1321,15 +1331,17 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
return true;
}
double EmbeddedDaemon::getMemoryUsageMB() const
namespace {
// Sum of the RSS (MB) of every process in process group `pgid`. The tracked pid is often a bash wrapper and the
// real dragonxd a child in the same group (setpgid in startProcess). Runs on a sampler thread
// (util::BackgroundSampler), so the /proc scan / `ps` fork no longer costs the UI anything.
double sampleProcessGroupRssMB(pid_t pgid)
{
if (process_pid_ <= 0) return 0.0;
#ifdef __APPLE__
// macOS: use ps to read RSS for the daemon process and its children
// in the same process group.
char cmd[128];
snprintf(cmd, sizeof(cmd), "ps -o rss= -g %d 2>/dev/null", process_pid_);
snprintf(cmd, sizeof(cmd), "ps -o rss= -g %d 2>/dev/null", static_cast<int>(pgid));
FILE* fp = popen(cmd, "r");
if (!fp) return 0.0;
double total_rss_mb = 0.0;
@@ -1341,20 +1353,17 @@ double EmbeddedDaemon::getMemoryUsageMB() const
pclose(fp);
return total_rss_mb;
#else
// Linux: The tracked PID is often a bash wrapper script; the real daemon
// (dragonxd) is a child in the same process group. Sum VmRSS for every
// process whose PGID matches our tracked PID.
// Linux: sum VmRSS for every /proc/<pid> whose process group is pgid. The error_code overloads: an
// unreadable /proc ends the scan instead of throwing (the old range-for constructor threw).
double total_rss_mb = 0.0;
// Iterate /proc/<pid>/stat for all numeric entries
for (const auto& entry : fs::directory_iterator("/proc")) {
if (!entry.is_directory()) continue;
const std::string name = entry.path().filename().string();
if (name.empty() || !std::isdigit(name[0])) continue;
std::error_code ec;
for (fs::directory_iterator it("/proc", ec), end; !ec && it != end; it.increment(ec)) {
const std::string name = it->path().filename().string();
if (name.empty() || !std::isdigit(static_cast<unsigned char>(name[0]))) continue;
// Read process group from /proc/<pid>/stat (field 5 is pgrp)
std::string statPath = entry.path().string() + "/stat";
FILE* sf = fopen(statPath.c_str(), "r");
const std::string base = it->path().string();
FILE* sf = fopen((base + "/stat").c_str(), "r");
if (!sf) continue;
char statLine[512];
@@ -1372,11 +1381,10 @@ double EmbeddedDaemon::getMemoryUsageMB() const
char state;
int ppid;
if (sscanf(p, " %c %d %d", &state, &ppid, &pgrp) != 3) continue;
if (pgrp != process_pid_) continue;
if (pgrp != pgid) continue;
// This process belongs to our group — read its VmRSS
std::string statusPath = entry.path().string() + "/status";
FILE* f = fopen(statusPath.c_str(), "r");
FILE* f = fopen((base + "/status").c_str(), "r");
if (!f) continue;
char line[256];
while (fgets(line, sizeof(line), f)) {
@@ -1389,10 +1397,23 @@ double EmbeddedDaemon::getMemoryUsageMB() const
}
fclose(f);
}
return total_rss_mb;
#endif // __APPLE__ / Linux
}
} // namespace
std::function<double()> EmbeddedDaemon::memoryProbe() const
{
const pid_t pid = process_pid_; // snapshot on the calling thread; the callable carries only this
if (pid <= 0) return {};
return [pid]() { return sampleProcessGroupRssMB(pid); };
}
double EmbeddedDaemon::getMemoryUsageMB() const
{
const auto probe = memoryProbe();
return probe ? probe() : 0.0;
}
bool EmbeddedDaemon::isRunning() const
{
+10
View File
@@ -128,8 +128,18 @@ public:
/**
* @brief Get daemon process memory usage (RSS) in MB
* @return Memory usage in MB, or 0 if unavailable
*
* Synchronous (Linux scans /proc, macOS forks `ps`): UI code uses memoryProbe() through a
* util::BackgroundSampler instead.
*/
double getMemoryUsageMB() const;
/**
* @brief A self-contained RSS probe (MB) for another thread. Snapshots what it needs on the calling
* thread (the pid; on Windows the value itself: one cheap call on the owned handle); the callable
* touches no EmbeddedDaemon state. Empty when there is no process.
*/
std::function<double()> memoryProbe() const;
/**
* @brief Get last error message
+3 -1
View File
@@ -276,7 +276,9 @@ void RenderPeersTab(App* app)
drawStatCell(dl, leftX, ry, capFont, sub1, TR("peers_version"),
state.daemon_version > 0 ? buf : nullptr, OnSurface());
double memMb = state.mining.daemon_memory_mb;
// The cached off-thread sample (refreshed every ~3 s while this tab or the Mining tab asks). Also
// keeps updating during sync and shows an external daemon's memory (process scan).
double memMb = app->getDaemonMemoryUsageMB();
if (memMb > 0) {
if (memMb >= 1024.0)
snprintf(buf, sizeof(buf), "%.1f GB", memMb / 1024.0);
+134
View File
@@ -0,0 +1,134 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Runs a slow probe (a /proc scan, a forked `ps`) off the UI thread and caches its latest result as an atomic.
// One lazily started thread, latest-wins, no main-thread callback. due / requestIfStale / reset / stop are for
// one owner thread (the UI); value / hasSample are safe from anywhere. Header-only.
#pragma once
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <functional>
#include <mutex>
#include <thread>
#include <utility>
namespace dragonx {
namespace util {
class BackgroundSampler {
public:
using Probe = std::function<double()>;
using Clock = std::chrono::steady_clock;
BackgroundSampler() = default;
~BackgroundSampler() { stop(); }
BackgroundSampler(const BackgroundSampler&) = delete;
BackgroundSampler& operator=(const BackgroundSampler&) = delete;
// True when requestIfStale would accept a probe now (none pending/running, last request >= maxAge ago).
bool due(Clock::time_point now, std::chrono::milliseconds maxAge) const
{
std::lock_guard<std::mutex> lk(mtx_);
return !stopping_ && !busyLocked() && (!requestedOnce_ || now - lastRequestAt_ >= maxAge);
}
// Schedules `probe` unless one is pending/running or the previous request is younger than maxAge. The probe
// runs on the sampler thread: it must capture everything it needs by value (no owner state).
bool requestIfStale(Probe probe, Clock::time_point now, std::chrono::milliseconds maxAge)
{
if (!probe) return false;
{
std::lock_guard<std::mutex> lk(mtx_);
if (stopping_ || busyLocked()) return false;
if (requestedOnce_ && now - lastRequestAt_ < maxAge) return false;
lastRequestAt_ = now; // before starting the thread: a failed start is retried after maxAge
requestedOnce_ = true;
if (!thread_.joinable()) {
try {
thread_ = std::thread(&BackgroundSampler::run, this);
} catch (...) {
return false; // no thread (resource exhaustion): keep the cached value
}
}
pending_ = std::move(probe);
}
cv_.notify_one();
return true;
}
double value() const { return value_.load(std::memory_order_relaxed); } // last completed sample (0 before)
bool hasSample() const { return have_.load(std::memory_order_relaxed); }
// Forget the cached sample (the process changed): value()==0 and the next request is accepted at once.
// A probe already running is discarded when it finishes.
void reset()
{
std::lock_guard<std::mutex> lk(mtx_);
++generation_;
requestedOnce_ = false;
value_.store(0.0, std::memory_order_relaxed);
have_.store(false, std::memory_order_relaxed);
}
// Idempotent. Drops a pending probe and joins the thread (waits for one in flight: tens of ms).
void stop()
{
{
std::lock_guard<std::mutex> lk(mtx_);
stopping_ = true;
pending_ = nullptr;
}
cv_.notify_all();
if (thread_.joinable()) thread_.join();
}
private:
bool busyLocked() const { return running_ || static_cast<bool>(pending_); }
void run()
{
for (;;) {
Probe probe;
std::uint64_t gen = 0;
{
std::unique_lock<std::mutex> lk(mtx_);
cv_.wait(lk, [this] { return stopping_ || static_cast<bool>(pending_); });
if (stopping_) return;
probe = std::move(pending_);
pending_ = nullptr;
running_ = true;
gen = generation_;
}
double v = 0.0;
bool ok = false;
try { v = probe(); ok = true; } catch (...) {}
{
std::lock_guard<std::mutex> lk(mtx_);
running_ = false;
if (ok && gen == generation_) {
value_.store(v, std::memory_order_relaxed);
have_.store(true, std::memory_order_relaxed);
}
}
}
}
mutable std::mutex mtx_;
std::condition_variable cv_;
std::thread thread_;
Probe pending_;
bool running_ = false;
bool stopping_ = false;
bool requestedOnce_ = false;
Clock::time_point lastRequestAt_{};
std::uint64_t generation_ = 0;
std::atomic<double> value_{0.0};
std::atomic<bool> have_{false};
};
} // namespace util
} // namespace dragonx
+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();