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