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
+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