fix(app): advance logic timers by wall-clock time

Logic timers advanced by io.DeltaTime, which update() clamps to 0.25 s
so animations don't jump after a pause. That matched real time only
while the main loop rendered continuously: with frames seconds apart, a
timer fed the clamped delta runs up to 8x slow. Affected: the refresh
scheduler, the seed-migration poll, the chat fast scan, the encryption
check, the loading and shutdown timers, the lock-screen countdowns, the
Settings debug gate and the mining benchmark.

- App::logicDeltaTime(): steady-clock seconds since the previous
  update(), capped at 6 s so a sleep/resume fires each timer once
  instead of replaying the gap. Visual interpolation keeps the clamped
  io.DeltaTime.
- The refresh timers tick before the RPC results are drained, so a
  hold that a callback arms (the connect-retry backoff) starts fresh
  instead of being charged the gap before it existed.
- The shutdown timer is measured from shutdown_start_time_:
  beginShutdown runs from SDL event handling, so an accumulated delta
  would count the idle gap before the close request.
- The lock-screen error and lockout countdowns subtract only the time
  since they were armed: a failed unlock arms them from an RPC result,
  which can land on the frame that ends an idle gap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 19:51:02 -05:00
co-authored by Claude Opus 5.5
parent ffce7cad24
commit 402a3053dc
5 changed files with 51 additions and 13 deletions
+24 -9
View File
@@ -746,10 +746,19 @@ void App::update()
{
PERF_SCOPE("Update.Total");
ImGuiIO& io = ImGui::GetIO();
// Clamp the frame delta: after a long pause (window minimized, or the machine slept) NewFrame reports
// a huge DeltaTime that would fire every refresh/animation timer at once. Every timer reads
// io.DeltaTime, so one clamp here bounds them all (also caps the real-clock delta fed while minimized).
// Clamp the frame delta for visual interpolation: after a long pause (window minimized, an idle
// stretch, or the machine slept) NewFrame reports a large DeltaTime that would make animations jump.
if (io.DeltaTime > 0.25f) io.DeltaTime = 0.25f;
// Logic timers use real elapsed time instead (see logicDeltaTime()), capped so a sleep/resume fires
// each timer at most once rather than replaying the whole gap.
{
const auto now = std::chrono::steady_clock::now();
logic_dt_ = (last_update_time_ == std::chrono::steady_clock::time_point{})
? 0.0f
: std::min(std::chrono::duration<float>(now - last_update_time_).count(), kMaxLogicDeltaSeconds);
if (logic_dt_ < 0.0f) logic_dt_ = 0.0f;
last_update_time_ = now;
}
// Full UI screenshot sweep: demo state is injected once and must stay frozen. Skip every live
// op (refresh/connect/pumps) so a real daemon can't clobber it — on Windows a running node's
@@ -768,6 +777,11 @@ void App::update()
last_interaction_ = std::chrono::steady_clock::now();
}
// Advance the refresh timers by the real time since the last update BEFORE the RPC results are
// applied, so a hold one of those callbacks arms now (e.g. the connect-retry backoff) starts fresh
// instead of being charged the idle gap that elapsed before it existed.
network_refresh_.tick(logic_dt_);
// Drain completed RPC results back onto the main thread
if (worker_) {
worker_->drainResults();
@@ -901,7 +915,7 @@ void App::update()
pumpWalletRestore();
// While confirming the sweep, poll the tx confirmations + legacy balance every ~5s.
if (show_seed_migration_ && seed_migration_step_ == SeedMigrationStep::Confirming) {
seed_migration_poll_timer_ -= ImGui::GetIO().DeltaTime;
seed_migration_poll_timer_ -= logic_dt_;
if (seed_migration_poll_timer_ <= 0.0f) {
seed_migration_poll_timer_ = 5.0f;
pollSweepStatus();
@@ -923,7 +937,6 @@ void App::update()
}
using RefreshTimer = services::NetworkRefreshService::Timer;
network_refresh_.tick(io.DeltaTime);
// Wipe a secret (seed/private key) from the clipboard once its auto-clear delay elapses.
pumpSecretClipboardClear();
@@ -1368,7 +1381,7 @@ void App::update()
// (slower, 10–15s) Transactions cadence — so incoming messages surface in a few seconds. The
// normal harvest only re-scans on a new block. Self-gated (no-op without a chat identity /
// connection / on lite); the in-flight guard keeps overlapping RPCs from stacking.
chat_fast_scan_accum_ += ImGui::GetIO().DeltaTime;
chat_fast_scan_accum_ += logic_dt_;
const float chatPoll = settings_ ? settings_->getChatPollRateSec() : 2.5f; // user-configurable
if (chat_fast_scan_accum_ >= chatPoll) {
chat_fast_scan_accum_ = 0.0f;
@@ -1993,7 +2006,7 @@ void App::render()
} else if (pageNeedsWalletData && (!daemonReady || (state_.connected && !state_.encryption_state_known))) {
// Track how long we've been waiting for encryption state
if (state_.connected && !state_.encryption_state_known) {
encryption_check_timer_ += ImGui::GetIO().DeltaTime;
encryption_check_timer_ += logic_dt_;
if (encryption_check_timer_ >= 15.0f) {
DEBUG_LOGF("[App] Encryption state check timed out after 15s — assuming unencrypted\n");
state_.encryption_state_known = true;
@@ -6424,7 +6437,9 @@ void App::renderShutdownScreen()
float v = ui::schema::UI().drawElement("components.shutdown", key).size;
return v >= 0 ? v : fb;
};
shutdown_timer_ += ImGui::GetIO().DeltaTime;
// Elapsed since beginShutdown, from the wall clock: beginShutdown runs from SDL event handling, so an
// accumulated delta would count the idle gap before the close request as shutdown time.
shutdown_timer_ = std::chrono::duration<float>(std::chrono::steady_clock::now() - shutdown_start_time_).count();
const std::string curShut = shutdown_status_.get(); // one consistent snapshot per frame (M-05)
// After a few seconds, offer to close the wallet and leave the node running: stopping can take as long
@@ -6746,7 +6761,7 @@ void App::renderLoadingOverlay(float contentH)
return v >= 0 ? v : fb;
};
loading_timer_ += ImGui::GetIO().DeltaTime;
loading_timer_ += logic_dt_;
ImDrawList* dl = ImGui::GetWindowDrawList();
ImVec2 wp = ImGui::GetWindowPos();