Skip to content

100% CPU busy-loop: ReadMetricsTracker re-arms a 0 ms timer forever while tracking is paused #31205

Closed

Description

@vityas-off

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, walking QTimerInfoList in 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 in master, 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:

  1. Open a chat, let some messages get tracked for read metrics.
  2. Scroll a tracked message out of view — this sets exitGracePending = true.
  3. Before the 300 ms grace period is processed, the tracker becomes paused:
    deactivate the app window, or make the message list not screen-active.
  4. 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 to else if (tracked.viewId)
    and schedules tracked.trackingStarted + kMaxTrackingDuration. That branch has
    no _paused guard.
  • processTransitions() (line ~188) — the block that would finalize and erase the
    item on now >= trackingStarted + kMaxTrackingDuration requires
    !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

  1. ilya-fedin commented on Aug 30, 2026

    @ilya-fedin
    Collaborator
  2. vityas-off commented on Aug 30, 2026

    @vityas-off
    Author

    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 — _tracked read out of the live process, no debugger stop:

    PRE  appActive 0  screenActive 0  paused 1
    TRK  count 5  visible 4  batchNow 40049071
    ITEM msg 72916  trackStarted 39923029  entryPending 0  exitPending 1  exitStart 39980490   (kMax deadline in 163998 ms)
    ITEM msg 72917  trackStarted 39923096  entryPending 0  exitPending 0
    ITEM msg 72918  trackStarted 39923147  entryPending 0  exitPending 0
    ITEM msg 72919  trackStarted 39923183  entryPending 0  exitPending 0
    

    During the spin — 100% of one core:

    TIMEOUT_USEC 0                                              <<< the main loop stops sleeping
    TIMER ... INTERVAL 0  ID 1459617817  TYPE 0  OBJ 0x5591d81bd8f0
      OBJVT ... NEXT 40227930  TMO 0
      callback: HistoryView::ReadMetricsTracker::ReadMetricsTracker(...)::{lambda()#1}
    PRE  appActive 0  screenActive 0  paused 1
    TRK  count 2  visible 4  batchNow 40217073
    ITEM msg 72916  trackStarted 39923029  entryPending 0  exitPending 1  exitStart 39980490   (kMax deadline overdue by 4901 ms)
    ITEM msg 72920  trackStarted 0  entryPending 1
    

    Message 72916 is exactly the combination the analysis predicted: viewId != 0 and exitGracePending == true while _paused == true. Its exitGracePending was set at 39980490 and is never cleared:

    • the exit-grace branch of processTransitions() requires !_paused;
    • its kMaxTrackingDuration branch requires !exitGracePending;
    • the third branch of restartTimer() has no _paused guard, so it keeps proposing trackingStarted + kMaxTrackingDuration, which by then is in the past.

    trackingStarted + kMaxTrackingDuration is 40223029, and NEXT 40227930 is crl::now() at the moment the timer was last armed with TMO 0 — i.e. callOnce(std::max(nearest - now, 0)) with nearest - now == -4901. Every dispatch re-arms it, QTimerInfoList::timerWait() returns 0, idleTimerSourcePrepare reports ready with *timeout = 0, and ppoll stops 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 exitGracePending one is immortal.

    How the state forms. startBatch() / push() / endBatch() are all called from HistoryInner::paintEvent, so exitGracePending can only be set by a repaint. Repaints keep happening while the app is deactivated: in this capture _batchNow advanced 40049071 → 40070097 → 40087528 → … → 40386089 with _paused == 1 the 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 from trackingStarted + 5 min until 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, _tracked dumps, perf profile, stacks) are kept if any of it is useful.

  3. vityas-off commented on Sep 3, 2026

    @vityas-off
    Author
    Image

    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!

  4. ilya-fedin commented on Sep 3, 2026

    @ilya-fedin
    Collaborator

    @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.

  5. vityas-off commented on Sep 3, 2026

    @vityas-off
    Author

    @ilya-fedin but that issue doesn’t seem to have anything to do with GPU usage or spinners. Am i wrong?

  6. ilya-fedin commented on Sep 3, 2026

    @ilya-fedin
    Collaborator

    @vityas-off read the comments in the linked issue

  7. ilya-fedin commented on Sep 3, 2026

    @ilya-fedin
    Collaborator

    @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?

  8. vityas-off commented on Sep 3, 2026

    @vityas-off
    Author

    @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 — no update()/repaint/anim:: in history_view_read_metrics_tracker.{h,cpp} at all. endBatch() can set exitGracePending while paused, both branches of processTransitions() that could clear it are gated on !_paused / !exitGracePending, and the third branch of restartTimer() has no _paused guard, so it keeps re-arming on an already-past trackingStarted + 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 way processTransitions() already does:

    -             } else if (tracked.viewId) {
    +             } else if (tracked.viewId
    +                     && !tracked.entryGracePending
    +                     && !tracked.exitGracePending) {
                          updateNearest(tracked.trackingStarted + kMaxTrackingDuration);

    Records without a pending grace period still finalize on kMaxTrackingDuration exactly as before; a grace-pending record simply stops proposing a deadline nobody can act on, and refreshPaused() re-arms the timer on unpause.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions