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();