From 296c04386c572cb7fa1209d36f9a3bd584c6d7df Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 9 Oct 2026 21:09:48 -0500 Subject: [PATCH] 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 --- CMakeLists.txt | 1 + src/app.cpp | 42 +++++----- src/app.h | 10 ++- src/app_network.cpp | 9 +-- src/daemon/daemon_controller.cpp | 5 ++ src/daemon/daemon_controller.h | 2 + src/daemon/embedded_daemon.cpp | 61 +++++++++----- src/daemon/embedded_daemon.h | 10 +++ src/ui/windows/peers_tab.cpp | 4 +- src/util/background_sampler.h | 134 +++++++++++++++++++++++++++++++ tests/test_phase4.cpp | 84 +++++++++++++++++++ 11 files changed, 312 insertions(+), 50 deletions(-) create mode 100644 src/util/background_sampler.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 9bd0283..97ea97d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/src/app.cpp b/src/app.cpp index 92c7513..f644c56 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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(); diff --git a/src/app.h b/src/app.h index 9a28635..3c15c0e 100644 --- a/src/app.h +++ b/src/app.h @@ -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 diff --git a/src/app_network.cpp b/src/app_network.cpp index 3ff1f7b..e27381b 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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 { diff --git a/src/daemon/daemon_controller.cpp b/src/daemon/daemon_controller.cpp index 3391130..518e045 100644 --- a/src/daemon/daemon_controller.cpp +++ b/src/daemon/daemon_controller.cpp @@ -99,6 +99,11 @@ double DaemonController::memoryUsageMB() const return daemon_ ? daemon_->getMemoryUsageMB() : 0.0; } +std::function DaemonController::memoryProbe() const +{ + return daemon_ ? daemon_->memoryProbe() : std::function{}; +} + std::vector DaemonController::recentLines(std::size_t count) const { return daemon_ ? daemon_->getRecentLines(count) : std::vector{}; diff --git a/src/daemon/daemon_controller.h b/src/daemon/daemon_controller.h index 245cac1..78ef21f 100644 --- a/src/daemon/daemon_controller.h +++ b/src/daemon/daemon_controller.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -106,6 +107,7 @@ public: int crashCount() const; int lastBlockHeight() const; double memoryUsageMB() const; + std::function memoryProbe() const; // EmbeddedDaemon::memoryProbe(); empty without a daemon std::vector recentLines(std::size_t count) const; std::string outputSince(std::size_t& offset) const; diff --git a/src/daemon/embedded_daemon.cpp b/src/daemon/embedded_daemon.cpp index b5dbf30..f013dd4 100644 --- a/src/daemon/embedded_daemon.cpp +++ b/src/daemon/embedded_daemon.cpp @@ -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 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(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/ 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//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(name[0]))) continue; // Read process group from /proc//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 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 { diff --git a/src/daemon/embedded_daemon.h b/src/daemon/embedded_daemon.h index d7a88ef..8cfcf23 100644 --- a/src/daemon/embedded_daemon.h +++ b/src/daemon/embedded_daemon.h @@ -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 memoryProbe() const; /** * @brief Get last error message diff --git a/src/ui/windows/peers_tab.cpp b/src/ui/windows/peers_tab.cpp index 32cf0f3..da91c5b 100644 --- a/src/ui/windows/peers_tab.cpp +++ b/src/ui/windows/peers_tab.cpp @@ -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); diff --git a/src/util/background_sampler.h b/src/util/background_sampler.h new file mode 100644 index 0000000..10053bb --- /dev/null +++ b/src/util/background_sampler.h @@ -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 +#include +#include +#include +#include +#include +#include +#include + +namespace dragonx { +namespace util { + +class BackgroundSampler { +public: + using Probe = std::function; + 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 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 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 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 lk(mtx_); + stopping_ = true; + pending_ = nullptr; + } + cv_.notify_all(); + if (thread_.joinable()) thread_.join(); + } + +private: + bool busyLocked() const { return running_ || static_cast(pending_); } + + void run() + { + for (;;) { + Probe probe; + std::uint64_t gen = 0; + { + std::unique_lock lk(mtx_); + cv_.wait(lk, [this] { return stopping_ || static_cast(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 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 value_{0.0}; + std::atomic have_{false}; +}; + +} // namespace util +} // namespace dragonx diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index c56f185..843dcb2 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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(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(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& 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 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 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();