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/async_task_manager.h
src/util/fetch_backoff.h src/util/fetch_backoff.h
src/util/coalescing_writer.h src/util/coalescing_writer.h
src/util/background_sampler.h
src/util/amount_format.h src/util/amount_format.h
src/util/base64.h src/util/base64.h
src/util/single_instance.h src/util/single_instance.h
+23 -19
View File
@@ -6178,26 +6178,28 @@ bool App::stopDaemonForBootstrap()
double App::getDaemonMemoryUsageMB() const double App::getDaemonMemoryUsageMB() const
{ {
// The Mining tab reads this every frame, but the probe is expensive (Linux embedded/external paths // Never probes on the calling (UI) thread. The probe (a /proc scan on Linux, a forked `ps` on macOS, a
// scan /proc; macOS forks a shell). Throttle to ~1.5s so a 60Hz caller doesn't hammer the OS. // process snapshot for an external daemon) runs on daemon_mem_sampler_'s thread. This returns the last
const double now = ImGui::GetTime(); // completed sample (0 until the first lands, tens of ms after the first ask) and asks for a fresh one
if (daemon_mem_probe_at_ > 0.0 && now - daemon_mem_probe_at_ < 1.5) // when the last request is older than 3 s.
return daemon_mem_cached_mb_; constexpr auto kMaxAge = std::chrono::milliseconds(3000);
daemon_mem_probe_at_ = now; const auto now = std::chrono::steady_clock::now();
if (daemon_mem_sampler_.due(now, kMaxAge)) {
double result = 0.0; util::BackgroundSampler::Probe probe;
// If we have an embedded daemon with a tracked process handle, use it if (daemon_controller_ && daemon_controller_->isRunning()) {
// directly — more reliable than a process scan since we own the handle. // The embedded daemon we own: its tracked process group first, a process scan if that reads 0.
if (daemon_controller_ && daemon_controller_->isRunning()) { daemon_mem_diag_ = "embedded";
double mb = daemon_controller_->memoryUsageMB(); probe = [own = daemon_controller_->memoryProbe()]() {
daemon_mem_diag_ = "embedded"; const double mb = own ? own() : 0.0;
result = (mb > 0.0) ? mb : util::Platform::getDaemonMemoryUsageMB(); return mb > 0.0 ? mb : util::Platform::getDaemonMemoryUsageMB();
} else { };
daemon_mem_diag_ = "process scan"; } else {
result = util::Platform::getDaemonMemoryUsageMB(); // external daemon 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 daemon_mem_sampler_.value();
return result;
} }
// ============================================================================ // ============================================================================
@@ -7401,6 +7403,7 @@ void App::shutdown()
if (settings_) { if (settings_) {
settings_->save(); settings_->save();
} }
daemon_mem_sampler_.stop();
if (daemon_controller_) { if (daemon_controller_) {
stopEmbeddedDaemon(StopMode::Fallback); // on the UI thread: bounded, never killed stopEmbeddedDaemon(StopMode::Fallback); // on the UI thread: bounded, never killed
} }
@@ -7428,6 +7431,7 @@ void App::shutdown()
if (http_worker_) { if (http_worker_) {
http_worker_->stop(); // http_cancel_ was set in beginShutdown(): an in-flight GET aborts within ~1 s 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) // Disconnect RPC after worker is fully stopped (safe — no curl in flight)
if (rpc_) { if (rpc_) {
rpc_->disconnect(); rpc_->disconnect();
+6 -4
View File
@@ -38,6 +38,7 @@
#include "services/wallet_security_controller.h" #include "services/wallet_security_controller.h"
#include "services/wallet_security_workflow.h" #include "services/wallet_security_workflow.h"
#include "util/async_task_manager.h" #include "util/async_task_manager.h"
#include "util/background_sampler.h"
#include "util/coalescing_writer.h" #include "util/coalescing_writer.h"
#include "util/fetch_backoff.h" #include "util/fetch_backoff.h"
#include "util/pool_stats_service.h" #include "util/pool_stats_service.h"
@@ -957,10 +958,11 @@ private:
std::int64_t sb_unconf_key_ts_ = -1; std::int64_t sb_unconf_key_ts_ = -1;
std::size_t sb_unconf_key_n_ = 0; std::size_t sb_unconf_key_n_ = 0;
int sb_unconf_count_ = 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 // Daemon-memory probe is expensive (/proc scan on Linux, popen on macOS): it runs on this sampler's own
// Mining tab's per-frame read doesn't hammer the OS. See App::getDaemonMemoryUsageMB(). // thread, at most once per 3 s and only while a reader asks (Mining tab memory bar, Peers tab Memory cell).
mutable double daemon_mem_cached_mb_ = 0.0; // See App::getDaemonMemoryUsageMB(). Mutable: that accessor is const. Probes capture values only, so any
mutable double daemon_mem_probe_at_ = 0.0; // member order is safe; shutdown() stops it.
mutable util::BackgroundSampler daemon_mem_sampler_;
// ── Chat note buffer (BOTH variants) ──────────────────────────────────────────────────────── // ── Chat note buffer (BOTH variants) ────────────────────────────────────────────────────────
// Each chat message is a shielded tx that spends a note; its change needs a few confirmations before // 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; if (state_.sync.syncing && !includeLocalHashrate) return;
ui::NavPage tracePage = current_page_; ui::NavPage tracePage = current_page_;
// Capture daemon memory outside (may be accessed on main thread). Only for a poll that runs: the probe // Daemon memory is sampled off the UI thread by getDaemonMemoryUsageMB() (Mining / Peers tab); the poll
// reads /proc (or the Windows process table). // only carries the last sample into state.mining.daemon_memory_mb (no I/O here).
double daemonMemMb = 0.0; const double daemonMemMb = daemon_mem_sampler_.value();
if (daemon_controller_) {
daemonMemMb = daemon_controller_->memoryUsageMB();
}
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Mining, *w, auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Mining, *w,
[this, rpc, daemonMemMb, doSlowRefresh, includeLocalHashrate, tracePage]() -> rpc::RPCWorker::MainCb { [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; 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 std::vector<std::string> DaemonController::recentLines(std::size_t count) const
{ {
return daemon_ ? daemon_->getRecentLines(count) : std::vector<std::string>{}; return daemon_ ? daemon_->getRecentLines(count) : std::vector<std::string>{};
+2
View File
@@ -6,6 +6,7 @@
#include <algorithm> #include <algorithm>
#include <cstddef> #include <cstddef>
#include <deque> #include <deque>
#include <functional>
#include <memory> #include <memory>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -106,6 +107,7 @@ public:
int crashCount() const; int crashCount() const;
int lastBlockHeight() const; int lastBlockHeight() const;
double memoryUsageMB() 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::vector<std::string> recentLines(std::size_t count) const;
std::string outputSince(std::size_t& offset) 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 double EmbeddedDaemon::getMemoryUsageMB() const
{ {
if (process_handle_ == nullptr) return 0.0; if (process_handle_ == nullptr) return 0.0;
PROCESS_MEMORY_COUNTERS pmc; PROCESS_MEMORY_COUNTERS pmc;
ZeroMemory(&pmc, sizeof(pmc)); ZeroMemory(&pmc, sizeof(pmc));
pmc.cb = sizeof(pmc); pmc.cb = sizeof(pmc);
@@ -849,6 +849,16 @@ double EmbeddedDaemon::getMemoryUsageMB() const
return 0.0; 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() void EmbeddedDaemon::drainOutput()
{ {
// Read new content from debug.log (tail-follow approach) // 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; 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__ #ifdef __APPLE__
// macOS: use ps to read RSS for the daemon process and its children // macOS: use ps to read RSS for the daemon process and its children
// in the same process group. // in the same process group.
char cmd[128]; 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"); FILE* fp = popen(cmd, "r");
if (!fp) return 0.0; if (!fp) return 0.0;
double total_rss_mb = 0.0; double total_rss_mb = 0.0;
@@ -1341,20 +1353,17 @@ double EmbeddedDaemon::getMemoryUsageMB() const
pclose(fp); pclose(fp);
return total_rss_mb; return total_rss_mb;
#else #else
// Linux: The tracked PID is often a bash wrapper script; the real daemon // Linux: sum VmRSS for every /proc/<pid> whose process group is pgid. The error_code overloads: an
// (dragonxd) is a child in the same process group. Sum VmRSS for every // unreadable /proc ends the scan instead of throwing (the old range-for constructor threw).
// process whose PGID matches our tracked PID.
double total_rss_mb = 0.0; double total_rss_mb = 0.0;
std::error_code ec;
// Iterate /proc/<pid>/stat for all numeric entries for (fs::directory_iterator it("/proc", ec), end; !ec && it != end; it.increment(ec)) {
for (const auto& entry : fs::directory_iterator("/proc")) { const std::string name = it->path().filename().string();
if (!entry.is_directory()) continue; if (name.empty() || !std::isdigit(static_cast<unsigned char>(name[0]))) continue;
const std::string name = entry.path().filename().string();
if (name.empty() || !std::isdigit(name[0])) continue;
// Read process group from /proc/<pid>/stat (field 5 is pgrp) // Read process group from /proc/<pid>/stat (field 5 is pgrp)
std::string statPath = entry.path().string() + "/stat"; const std::string base = it->path().string();
FILE* sf = fopen(statPath.c_str(), "r"); FILE* sf = fopen((base + "/stat").c_str(), "r");
if (!sf) continue; if (!sf) continue;
char statLine[512]; char statLine[512];
@@ -1372,11 +1381,10 @@ double EmbeddedDaemon::getMemoryUsageMB() const
char state; char state;
int ppid; int ppid;
if (sscanf(p, " %c %d %d", &state, &ppid, &pgrp) != 3) continue; 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 // This process belongs to our group — read its VmRSS
std::string statusPath = entry.path().string() + "/status"; FILE* f = fopen((base + "/status").c_str(), "r");
FILE* f = fopen(statusPath.c_str(), "r");
if (!f) continue; if (!f) continue;
char line[256]; char line[256];
while (fgets(line, sizeof(line), f)) { while (fgets(line, sizeof(line), f)) {
@@ -1389,10 +1397,23 @@ double EmbeddedDaemon::getMemoryUsageMB() const
} }
fclose(f); fclose(f);
} }
return total_rss_mb; return total_rss_mb;
#endif // __APPLE__ / Linux #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 bool EmbeddedDaemon::isRunning() const
{ {
+10
View File
@@ -128,8 +128,18 @@ public:
/** /**
* @brief Get daemon process memory usage (RSS) in MB * @brief Get daemon process memory usage (RSS) in MB
* @return Memory usage in MB, or 0 if unavailable * @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; 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 * @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"), drawStatCell(dl, leftX, ry, capFont, sub1, TR("peers_version"),
state.daemon_version > 0 ? buf : nullptr, OnSurface()); 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 > 0) {
if (memMb >= 1024.0) if (memMb >= 1024.0)
snprintf(buf, sizeof(buf), "%.1f GB", 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/pool_registry.h"
#include "util/daemon_updater.h" #include "util/daemon_updater.h"
#include "util/http_download.h" #include "util/http_download.h"
#include "util/background_sampler.h"
#include "util/lite_server_probe.h" #include "util/lite_server_probe.h"
#include "util/wallet_file_probe.h" #include "util/wallet_file_probe.h"
#include "wallet/lite_connection_service.h" #include "wallet/lite_connection_service.h"
@@ -3488,6 +3489,30 @@ void testPosixRequestedStopIsNotACrash()
std::error_code ec; std::filesystem::remove(script, ec); 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 // 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). // exit it leads to at once instead of waiting out the 10s SIGKILL timer (~11s before the fix).
void testPosixStopSigtermIsGraceful() void testPosixStopSigtermIsGraceful()
@@ -4522,6 +4547,63 @@ void testLowestBlockSearch()
EXPECT_TRUE(p == BlockProbe::Unknown); 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() void testConnectRetryBackoff()
{ {
using dragonx::rpc::ConnectRetryBackoff; using dragonx::rpc::ConnectRetryBackoff;
@@ -12885,6 +12967,7 @@ int main()
testFetchBackoff(); testFetchBackoff();
testHttpGetCancel(); testHttpGetCancel();
testLowestBlockSearch(); testLowestBlockSearch();
testBackgroundSampler();
testNetworkRefreshService(); testNetworkRefreshService();
testNetworkRefreshSnapshotHelpers(); testNetworkRefreshSnapshotHelpers();
testParseDaemonRescanOutput(); testParseDaemonRescanOutput();
@@ -12955,6 +13038,7 @@ int main()
testDaemonCrashDetected(); testDaemonCrashDetected();
testPosixStopReturnsAfterCleanExit(); testPosixStopReturnsAfterCleanExit();
testPosixRequestedStopIsNotACrash(); testPosixRequestedStopIsNotACrash();
testEmbeddedDaemonMemoryProbe();
testPosixStopSigtermIsGraceful(); testPosixStopSigtermIsGraceful();
testPosixDestructorLeavesProcessRunning(); testPosixDestructorLeavesProcessRunning();
testPosixStopSessionDetach(); testPosixStopSessionDetach();