Repository navigation
100% CPU busy-loop: ReadMetricsTracker re-arms a 0 ms timer forever while tracking is paused #31205
Description
Note on provenance: I hit this bug as a user (fans spinning up on an idle
machine) and had an AI agent run the whole investigation — gdb snapshots, perf
profiles, walkingQTimerInfoListin memory. The analysis below is its output,
not mine; I don't understand this part of the codebase at all and can't defend
the details. But the raw evidence is reproducible and the code path is right
there inmaster, so it seemed worth filing rather than sitting on. Happy to
collect more data if something is missing or wrong.
Steps to reproduce
No manual repro — it happens on its own a few times a day. From the code path
(below), the conditions look like:
- Open a chat, let some messages get tracked for read metrics.
- Scroll a tracked message out of view — this sets
exitGracePending = true. - Before the 300 ms grace period is processed, the tracker becomes paused:
deactivate the app window, or make the message list not screen-active. - Stay paused until
kMaxTrackingDuration(5 min) elapses for that message.
From that moment the main thread pegs one core until the app is activated again.
Observed 5 times in ~21 hours on this machine, always with the window in the
background.
Expected behaviour
Idle/backgrounded Telegram Desktop should not burn CPU.
Actual behaviour
The main thread busy-loops at 100% of one core. On a 32-thread CPU this is ~3%
overall, so it is invisible in a system monitor's application list — the only
symptom is the fans.
The loop: ReadMetricsTracker owns a base::Timer that gets re-armed with a
timeout of 0 forever. That makes QTimerInfoList::timerWait() return 0, so
idleTimerSourcePrepare() reports ready with *timeout = 0, GLib's
timeout_usec becomes 0, ppoll() returns immediately, and the dispatcher never
sleeps.
Where it comes from — history_view_read_metrics_tracker.cpp:292:
_timer.callOnce(std::max(nearest - now, crl::time(0)));A nearest deadline that is already in the past yields a 0 ms timeout. That is
fine as long as the firing callback removes whatever produced the stale deadline
— onTimeout() → sync() → processTransitions() → restartTimer(). But one
combination of flags is scheduled by restartTimer() and never cleaned up by
processTransitions():
For a tracked item with viewId set and exitGracePending == true, while
_paused == true:
restartTimer()(line ~276) — branch 1 is skipped, branch 2 is skipped because
it is guarded by!_paused, so it falls through toelse if (tracked.viewId)
and schedulestracked.trackingStarted + kMaxTrackingDuration. That branch has
no_pausedguard.processTransitions()(line ~188) — the block that would finalize and erase the
item onnow >= trackingStarted + kMaxTrackingDurationrequires
!tracked.exitGracePending, so it is skipped.- The exit-grace block itself (line ~181) is guarded by
!_paused, so it is
skipped too.
The deadline is therefore permanently in the past, restartTimer() keeps
returning it, and callOnce(0) fires again immediately. Note the timer starts as
a normal future deadline while paused — the spin begins exactly when
kMaxTrackingDuration elapses, and ends when the app becomes active again (or
the tracker is destroyed).
Evidence
Same offending timer in all captured episodes (three shown, snapshot taken with
gdb attached to the live process, QTimerInfoList walked by hand and the
std::function target resolved through the debug symbols):
TIMER 0x5591d1bb51c0 TIMEOUT 1520117337748 INTERVAL 0 ID 184549430 OBJ 0x5591c3e35760
vtable for base::Timer +0x10
std::_Function_handler<void (), HistoryView::ReadMetricsTracker::ReadMetricsTracker(
gsl::not_null<PeerData*>)::{lambda()#1}>::_M_invoke(std::_Any_data const&)
base::Timer::_timeout = 0
INTERVAL 0, _timeout == 0, and the deadline equal to the current time. Across
consecutive snapshots of the same spin the base::Timer object stays the same
(0x5591c3e35760) while the Qt timer id changes every time (184549430 →
318767120 → 2113929264), which is Timer::start() cancelling and re-creating the
timer on every iteration.
GLib main context during the spin vs. when healthy:
spin: timeout_usec 0 [Qt] GIdleTimerSource flags 0x31 (READY)
healthy: timeout_usec 682000 [Qt] GIdleTimerSource flags 0x21
perf profile of the spinning thread:
idleTimerSourceDispatch → QTimerInfoList::activateTimers() → Core::Sandbox::notify
→ QObject::event → base::Timer::timerEvent (timer.cpp:122)
→ base::Timer::start (timer.cpp:52) → crl::now()
Reading the tracker's own fields next to the timer in one snapshot confirms the
paused state the analysis needs:
_lastActivity 2068236 _appActive 0 _screenActive 0 _paused 1 _pausedSince 2068303
What has not been observed directly is the contents of _tracked at the moment
of the spin, so the specific exitGracePending + expired kMaxTrackingDuration
combination is read off the source rather than seen in memory. Everything else
above is from the captures.
master is unchanged in this area at the time of writing — the same lines are
present in history_view_read_metrics_tracker.cpp (file added in 5a39484,
"Better post views tracking").
Possibly the same underlying issue as #31029, which was closed as
cant reproduce.
Operating system
KDE Linux (immutable, Arch-based), Wayland, kernel 7.1.9
Version of Telegram Desktop
7.1.3
Installation source
Flatpak (org.telegram.desktop, Qt 6.11.1, org.gnome.Platform//50)
Crash ID
No response
Logs
No response
Activity
ilya-fedin commentedon Aug 30, 2026
#31192
vityas-off commentedon Aug 30, 2026
Caught it live, with a before/after pair on the same message. Same disclaimer as the original report: this is an AI agent's investigation run on my machine, not my own analysis.
State at the time: a channel chat was the open one, and I clicked another application's window, which covered the Telegram window. The window stayed mapped, just occluded — not minimized. The app was deactivated throughout:
_appActive == 0,_screenActive == 0,_paused == 1.~3 minutes before the spin —
_trackedread out of the live process, no debugger stop:During the spin — 100% of one core:
Message 72916 is exactly the combination the analysis predicted:
viewId != 0andexitGracePending == truewhile_paused == true. ItsexitGracePendingwas set at 39980490 and is never cleared:processTransitions()requires!_paused;kMaxTrackingDurationbranch requires!exitGracePending;restartTimer()has no_pausedguard, so it keeps proposingtrackingStarted + kMaxTrackingDuration, which by then is in the past.trackingStarted + kMaxTrackingDurationis 40223029, andNEXT 40227930iscrl::now()at the moment the timer was last armed withTMO 0— i.e.callOnce(std::max(nearest - now, 0))withnearest - now == -4901. Every dispatch re-arms it,QTimerInfoList::timerWait()returns 0,idleTimerSourcePreparereports ready with*timeout = 0, andppollstops sleeping. The Qt timer id changes on every iteration while the owning object address stays the same, which is the timer being recreated in a loop.Everything else in the same chat is handled correctly, which I think is worth noting: 72917–72919 reached their kMax deadline and were finalized and erased — that branch does run while paused — and 72920 sits harmlessly in entry grace. Only the
exitGracePendingone is immortal.How the state forms.
startBatch()/push()/endBatch()are all called fromHistoryInner::paintEvent, soexitGracePendingcan only be set by a repaint. Repaints keep happening while the app is deactivated: in this capture_batchNowadvanced 40049071 → 40070097 → 40087528 → … → 40386089 with_paused == 1the whole time. A covered-but-mapped window still gets frame callbacks, and on this desktop the compositor also renders it for taskbar thumbnails. So there is no narrow race: any repaint that happens while tracking is paused, and that drops a tracked post out of the visible range, strands that post permanently. It then busy-loops fromtrackingStarted + 5 minuntil the app is activated again.This also corrects a guess in the original report — I suggested scrolling right before backgrounding. That is not needed; the window merely has to keep painting while deactivated.
Raw snapshots (glib source list, the timer walk,
_trackeddumps,perfprofile, stacks) are kept if any of it is useful.vityas-off commentedon Sep 3, 2026
The bug still seems to be reproducible for me in 7.1.5.
@Aokromes
Could you please clarify whether it was expected to be fixed, or if the issue was closed for another reason? If the issue linked above was meant to indicate that this is a duplicate, I think there may have been a mix-up, as it appears to be a different issue.
Thanks!
ilya-fedin commentedon Sep 3, 2026
@vityas-off the issue is closed as a duplicate, I provided link to the issue that will remain open. I don't know about any plans to fix this though.
vityas-off commentedon Sep 3, 2026
@ilya-fedin but that issue doesn’t seem to have anything to do with GPU usage or spinners. Am i wrong?
ilya-fedin commentedon Sep 3, 2026
@vityas-off read the comments in the linked issue
ilya-fedin commentedon Sep 3, 2026
@vityas-off can you move your comment from #31192 to here so that the talk whether it's a duplicate or not remains in one place?
vityas-off commentedon Sep 3, 2026
@ilya-fedin sure, moving it here (AI agent analysis again, not mine):
Same commit, different defect. #31192 is about redraws; this one is a 0 ms timer loop inside
ReadMetricsTracker, which never paints anything — noupdate()/repaint/anim::inhistory_view_read_metrics_tracker.{h,cpp}at all.endBatch()can setexitGracePendingwhile paused, both branches ofprocessTransitions()that could clear it are gated on!_paused/!exitGracePending, and the third branch ofrestartTimer()has no_pausedguard, so it keeps re-arming on an already-pasttrackingStarted + kMaxTrackingDuration.Arguably the nastier of the two: it needs no visible window and no user in front of it. In the capture above it ate 100% of a core with the window occluded and the app deactivated, rendering zero frames, and it does not stop by itself — it only ends when the window is activated again, so on a machine left alone it just burns a core (and battery) indefinitely.
Unlike the redraw regression in #31192, the fix looks like a three-word guard — making
restartTimer()filter the same wayprocessTransitions()already does:Records without a pending grace period still finalize on
kMaxTrackingDurationexactly as before; a grace-pending record simply stops proposing a deadline nobody can act on, andrefreshPaused()re-arms the timer on unpause.