Repository navigation
Sustained ~110% CPU: infinite retry loop in SynchronizePeerReadStates for a ghost PeerId(0) sync operation (13.1.0) #194
Description
Environment
- Telegram for macOS 13.1.0 (283357), direct download build
- macOS 27.2 (26B123), Apple Silicon
- Single account, ~1.1 GB postbox
Summary
Immediately after waking the Mac (with a batch of ~20 documents delivered to a channel
overnight and auto-download resuming), Telegram pinned ~1.1 CPU cores indefinitely:
~110% CPU for 8.7 hours straight (~187 core-hours/day), UI idle, app not frontmost,
near-zero network (~1 KB/s). The burn:
- survives app restarts (reproduced 3×, re-arms within a minute of launch),
- survives network disconnect (Wi-Fi off for 60 s — no change),
- only Log Out / Log In clears it (CPU returned to ~0.5% baseline immediately after).
Root cause (found via in-app logging)
Enabling logs via tg://settings/enable_log shows the loop verbatim — a single ghost
operation for PeerId(0) (an invalid peer id — no such peer exists):
[SynchronizePeerReadStates] ... 0:Id(rawValue: 0): begin operation Push(state: nil, thenSync: true)
[SynchronizePeerReadStates] ... 0:Id(rawValue: 0): operation retry Push(state: nil, thenSync: true)
[SynchronizePeerReadStates] ... 0:Id(rawValue: 0): take new operation Push(state: nil, thenSync: true)
... repeats
Measured: 20,009 log lines in 24.08 s = 831 iterations/sec, and 20009/20009 of them are
for PeerId(0) — no other peer participates. CPU 154% while logging was enabled.
The manager (ManagedSynchronizePeerReadStatesContextImpl) restarts a failed operation
immediately, with no backoff — the error handler removes the active operation and calls
self.update() right away (approx. ManagedSynchronizePeerReadStates.swift:97-107 on master).
Since peer 0 can never resolve to an Api.InputPeer, every attempt fails instantly →
infinite tight loop. Each iteration issues postbox transactions and invalidates views,
which produces a whole-system transaction storm.
Evidence
fs_usage (15 s window): 3.23 M fcntl(F_SETLK) calls on postbox/db/db_sqlite-shm
(100–250K/sec — a SQLite transaction-boundary storm), near-zero page reads (all cached),
occasional WAL frames on db_sqlite-wal.
sample/spindump: burn on DispatchQueue "org.telegram.postbox.Postbox" (5.1 s CPU per
10 s), stacks in sqlite3_step / sqlite3VdbeExec / sqlite3VdbeIdxRowid +
swift_allocObject / _swift_release_dealloc / _swift_getGenericMetadata; a second
unnamed serial queue (the manager's Queue()) constantly self-waking via
dispatch_async → _dispatch_event_loop_poke.
critlog (stable/logs/critlog-*.txt, 3 weeks of history): Postbox transaction took … from: … SynchronizePeerReadState.swift:433 (the keepsReadState check) and :366 (push
path) — baseline on normal days is 1–36 such entries/day; on the incident day:
975+. Also elevated: Postbox.swift:3997 (loadedPeerWithId — 380) and
AccountStateManager.swift:1052 (replayFinalState — 141).
Onset sequence in critlog, incident morning:
06:36:12 [MTProtoRust] system woke up, resetting connections
06:37:33 unsentMessageIdsViewSignal started with []
06:38:24 Postbox transaction took 39.9 ms, from: …/SyncCore_RecentDownloadItem.swift:127
06:40:56 Postbox transaction took 17.6 ms, from: …/SynchronizePeerReadState.swift:366 ← first
… continuous until log-out (~15:23)
So the ghost row appeared around wake-up while a document-batch download was resuming.
(Line numbers are from master @ telegram-ios fb0e62ba; the shipped 13.1.0 build may differ
slightly.)
What did NOT help
App restarts ×3; marking the affected chats read/unread; reading the chat from another
device; join/leave of channels; network disconnect; re-sending scheduled messages.
Suggestions
- Discard invalid operations: skip (or assert-and-drop) sync operations whose
peerId.id == 0when enqueueing, and/or when the manager picks them up — a peer that
can't resolve to anInputPeercan never complete. - Add retry backoff in
ManagedSynchronizePeerReadStatesContextImpl(the instant
error →update()restart makes any permanently-failing operation a busy loop). - Investigate what writes the
PeerId(0)row — the onset points at the download-completion
path at wake (SyncCore_RecentDownloadItem).
Proposed patch
A minimal guard in the manager: drop and clear operations for the invalid PeerId(0) the
same way the !keepsReadState path clears non-member channels (best-effort
confirmSynchronizedIncomingReadState; if the transaction fails, the next update() retries):
--- a/submodules/TelegramCore/Sources/State/ManagedSynchronizePeerReadStates.swift
+++ b/submodules/TelegramCore/Sources/State/ManagedSynchronizePeerReadStates.swift
@@ -58,6 +58,14 @@ private final class SynchronizePeerReadStatesContextImpl {
let peerIds = Set(self.currentState.keys).union(Set(self.pendingOperations.keys))
for peerId in peerIds {
+ if peerId.id.rawValue == 0 {
+ Logger.shared.log("SynchronizePeerReadStates", "\(peerId): dropping operation for invalid peer id")
+ let postbox = self.postbox
+ _ = postbox.transaction { transaction -> Void in
+ transaction.confirmSynchronizedIncomingReadState(peerId)
+ }.startStandalone()
+ continue
+ }
var maybeOperation: PeerReadStateSynchronizationOperation?
if let operation = self.currentState[peerId] {
maybeOperation = operationThis is a band-aid for the consumer side. The deeper issues are (2) no backoff on the error
path above, and (3) whatever wrote the PeerId(0) row in the first place.
Workaround
Log out and log back in (rebuilds the account postbox). Restarting the app does not help.
No user data is attached; logs quoted above contain only technical metadata. I can provide
additional sanitized excerpts (fs_usage/spindump/critlog fragments) on request.
Activity
CraftStick commentedon Oct 30, 2026
Thanks for the detailed analysis. I checked the code at the revision the macOS repository pins (submodules/telegram-ios at fb0e62ba), and it matches your description. I did not reproduce the loop itself.
ManagedSynchronizePeerReadStates.swiftthe error handler of an operation (around line 103) logs "operation retry", removes the active operation and callsupdate()right away. There is no delay or retry limit.update()then starts the same operation again for the same peer, becausecurrentStatestill contains it.synchronizePeerReadStateinSynchronizePeerReadState.swiftfirst builds anApi.InputPeerfrom the peer in the postbox (inputPeer(postbox:peerId:), line 12). If the peer cannot be resolved, as forPeerId(0), the signal fails at once, so the operation fails without any network request. That would explain why the loop survives a network disconnect and why traffic stays near zero.So any operation that can never complete turns into a busy loop on the postbox queue, and a backoff in the manager would limit the damage whatever writes the invalid row.
This code is in the Telegram-iOS submodule, so it cannot be changed from this repository. I have not investigated what writes the
PeerId(0)row, so I cannot say whether dropping it in the manager is enough or whether the source should be fixed.igabdullin commentedon Oct 30, 2026
Follow-up: how the ghost operation gets created, the wider class, and a verified fix.
(@CraftStick — your analysis matches;
inputPeer(postbox:peerId:)at line 12 is indeed theinstant, network-free failure point.)
1. Where the row comes from — mechanism, executed against the fb0e62ba sources
There is exactly one writer of a
.Push(thenSync: true)row:synchronizeReadStateTable.setfrom
MessageHistoryTable.applyInteractiveMaxReadIndex(~line 745) — the interactive"mark read" path. The other
.Pushwriter (Postbox.swift ~2405) hardcodesthenSync: false,which the stored byte rules out. Two findings make the creation possible for any peer without
stored read state:
MessageHistoryReadStateTable.applyInteractiveMaxReadIndex(~line 430) returns.Push(thenSync: true)even when the peer has no read state at all — there is nopeer-existence check. The table stores only two bytes (opType, thenSync);
stateisre-derived at read time, which is why the log shows
Push(state: nil, thenSync: true).One
set()with a zero peer index is enough.peerKeepsReadState(nil) == true(NonMemberChannelReadState.swift:21 — "A peer that is notstored keeps today's behaviour"), so the guards in
_internal_applyMaxReadIndexInteractivelyand
synchronizePeerReadStatedo not stop it.Verified with an out-of-tree harness (Postbox builds standalone with
swift build):2. Why the instant retries imply a stored peer row — and what is still unknown
loadedPeerWithIdof a missing row never emits, so for a missing peer the consumer's operationwould hang silently. The observed 831 retries/sec mean
PeerId(0)resolved to a stored recordthat
apiInputPeerrejects. Where that record (and the zero index that read it) came from isstill unidentified: the write path is silent, and every interactive-read caller I checked passes
real message indexes (the macOS notification path is excluded — strict userInfo parser built from
real
message.id, and its legacyNSUserNotificationCenterdelegate is not in use on modernmacOS; the associated-peer branch cannot fire for a peer without an existing read state).
Two directions for settling the root, short of a repro:
postboxLogat the enqueue site when the peer id is zero would name the caller on thenext recurrence (affected users can already produce a live loop trace via
tg://settings/enable_log);(rejecting peers/messages with zero ids at ingestion) would be the true root fix.
3. The class is wider than zero
Any never-completing operation turns into this busy loop. Other entry points: a stored peer
without
accessHash(apiInputPeer→ nil — peers known only indirectly), validationnon-convergence (
localReadMarkIsAheadOfServer/ count mismatch), a permanently rejectingserver. For the record:
error → update()with no delay exists in exactly two files acrossTelegramCore — this manager and
Network/FetchV2.swift(which setsstate = .failedfirst,so it likely does not spin).
4. Proposed fix — three layers
set()): covers every writer, clears legacy rowsDeliberately not proposed:
peerKeepsReadState(nil) == false(looks intentional; peers beingfetched would change semantics) and dropping operations for stored peers without
accessHash(the hash may legitimately arrive later).
5. Layer B — concrete diff (v2)
v1 compiled and behavior-checked in the real macOS app by @CraftStick (comment below); v2
updates it after an edge-case review: the window is now tied to the operation that failed, so
a changed operation for the same peer (a fresh read of the chat) starts immediately instead of
waiting out the old window (verified red/green in our harness).
Exponential per-peer backoff 1s → 32s (cap), reset on completion; peers inside the backoff
window are skipped in
update(). For the incident's loop this is 831 retries/sec → at most oneper second, and it bounds every other never-completing operation the same way.
PR #2418 now carries layers C′ + A + regression tests (their
PostboxTestsBazel target globsTests/**/*.swift, so the tests run in CI as-is).igabdullin commentedon Oct 30, 2026
Addendum: layer B verified in execution, class 1 reproduced with TelegramCore's own code,
and no id validation at peer ingestion
(follow-up to the layer table and the diff in my previous comment).
1. A stored peer without
accessHash— executed, not just read. After getting TelegramCoreto build standalone from this revision (SwiftPM; TelegramApi via its own
generate-swiftpm.sh,FlatSerialization generated with the pinned flatc 24.12.23), the class-1 chain runs end-to-end
on synthetic data — no account, no network:
The operation is queued and can never be sent to the server — with the manager's no-backoff
restart this is the incident's loop for a valid peer id. C′ deliberately does not cover this
case — it only matches the zero peer id; a valid peer without an accessHash is exactly what B
bounds.
2. Layer B verification. A verbatim port of the manager's restart logic (Logger → a counter,
the operation signal replaced by an instantly-failing one modelling the
apiInputPeer == nilcase), same sandbox:
Two takeaways: the patch throttles exactly as designed; and the pure restart machinery is
capable of ~135K retries/sec — the incident's observed 831/sec was limited only by the
per-attempt transaction cost, so a future never-completing operation that fails before doing
any postbox work would spin even harder. This is why B seems worth merging alongside C′+A
rather than treating it as optional polish.
3. Peer ingestion does not validate ids. Reading the merge path:
updatePeers(UpdatePeers.swift ~142) and the public
updatePeersCustomstore whatever peers they aregiven — there is no check that a peer id is not the zero sentinel. So a zero-id peer can be
stored, which is consistent with the incident's stored-peer deduction (an instantly retrying
loop is only possible for a stored row). If field data ever shows zeroed ids arriving in
server responses, an ingestion-level guard would be the true root fix — noting it here as a
direction, not proposing a diff: it sits on a hot path (every server peer update) and belongs
to the maintainers' design space.
(The verification harnesses live outside the repo; happy to share them. Compilation of the B
diff itself still needs the CI/macOS toolchain.)
CraftStick commentedon Oct 30, 2026
Layer B: checked in the macOS app
I applied the diff from your comment (layer B, backoff 1 s → 32 s) to
ManagedSynchronizePeerReadStates.swiftat the pinned submodule revision (fb0e62ba) and built the full macOS app (Xcode 27, arm64, Debug). It compiles without errors or warnings.To see the behaviour I forced every synchronization operation to fail asynchronously (a temporary hook, not part of the diff), the way a failing postbox transaction does, with the same hook in both builds:
With layer B the retries come at +0, 1, 3, 7, 15, 31, 63, 95 s and so on, so the delay doubles up to the 32 s cap.
Normal use is unaffected: with the hook off, opening a chat with unread messages cleared its unread counter. During that, the app logged two real retry errors for one channel, about 1.4 s apart, and then the operation completed.
Not checked: this is a simulated failure, not the real
PeerId(0)row, so it shows that the backoff bounds a never-completing operation but not where the row comes from.igabdullin commentedon Oct 30, 2026
Thanks for running layer B in the real app — that closes the one gap our port couldn't:
compilation in the actual build and normal-use behavior on top of it.
The three retry rates line up on one curve — 831/s in the incident (each attempt pays
postbox transactions), ~17.5K/s with your hook, 134.9K/s with a pure-signal stub in our
port. Same unrestricted loop, differing only in per-attempt cost — which is also why the
incident's rate shouldn't be read as the loop's ceiling.
Your normal-use observation (two retries ~1.4s apart, then the operation completed) is
exactly the evidence that the 1s seed delay doesn't disturb normal synchronization — worth
having on the record, since "won't backoff delay legit syncs?" is the natural review
question and now it has a data answer.
One update before this goes further: reviewing the edge cases, we found and fixed a flaw in
the version you tested — the backoff window was keyed by peer only, so a changed
operation for the same chat (a fresh read) could wait out the old operation's window, up
to the 32s cap. The diff in the comment above is updated to v2: it records which operation
failed and resets the window when the queued operation differs, so a fresh read starts
immediately. Neither of your scenarios is affected — your forced-failure hook kept the
operation constant (no resets), and normal use completes anyway; the changed-operation
case is verified red/green in our harness (old version waits, updated picks up at once).
Practical question: would you fold layer B into PR #2418 as a fourth commit, or keep it as
a separate PR? It's now verified end-to-end by two independent harnesses, so whichever
vehicle the maintainers prefer, the diff is ready.
CraftStick commentedon Oct 30, 2026
A writer of the empty-peer row in the macOS client
Following your analysis (the row is created by an interactive read with a zero peer index), I looked for callers in this repository and found one.
ChatController'supdateChatLocationThreadresetsmaxVisibleIncomingMessageIndextoMessageIndex.absoluteLowerBound()(aMessageIndexofPeerId(0)) and clears the remembered indexes. ThereadHistorysignal fires on that value,chatHistoryKeepsReadStatelets members of channels, forums and monoforums through, and the index reachesapplyMaxReadIndexInteractively. The callers ofupdateChatLocationThreadare the topic separators in a forum shown as a message feed, a new topic and the thread switches in a monoforum.I reproduced the creation of the row with a temporary hook that repeats this sequence on an open chat. The application log then shows, without any other change:
It matches the row from your incident log. In my database there is no stored record for
PeerId(0), so the operation just stays active and does not spin, which is what you predicted ("for a missing peer the consumer's operation would hang silently"). I did not reproduce the 100 % CPU loop, and I did not try the real topic click or monoforum switch.With a guard against the empty peer in
readHistorythe same hook creates no such row. The change is in #206. It removes this one writer on the macOS side; layers C′, A and B from your comments are still needed for rows already stored and for the other never-completing operations.