Crash analysis

The messagesViewQueue Crash

A macOS Telegram build died sixteen times in twenty-two minutes, every time the same chat was opened. The crash report named one Foundation method and five bare hex offsets. This is the path from those offsets to a four-line patch.

+00Symptom

One chat, no way back in

Every other conversation opened fine. This one killed the app the instant it appeared — and because the client restores the last chat on launch, it killed the app again on every restart. There is no escape from inside the UI.

Sixteen .ips reports had piled up in ~/Library/Logs/DiagnosticReports. All sixteen carried an identical signature, which is the first useful fact: this is deterministic, not a race.

+01The report

An uncaught exception on a background queue

EXC_BREAKPOINT with SIGTRAP is the shape a C++ terminate takes on arm64. Reading up the stack tells you what actually happened.

Thread queue: messagesViewQueue

CoreFoundation   __exceptionPreprocess
libobjc.A.dylib  objc_exception_throw
Foundation       blockForLocation
Foundation       -[NSConcreteMutableAttributedString addAttribute:value:range:]
Telegram         0x1b0111c
Telegram         0x39148c
Telegram         0xa56a54
Telegram         0x15b67cc
Telegram         0x15f7b4
libdispatch      _dispatch_call_block_and_release
libdispatch      _dispatch_lane_serial_drain

An attributed string is being given an attribute over a range it does not contain. Foundation raises NSRangeException. The throw happens on messagesViewQueue — a background dispatch queue, with no handler anywhere above it — so NSApplicationUncaughtExceptionHandler takes the process down.

Which leaves the actual question: which five functions are those?

+02A wrong turn

The plausible answer that the stack refuses

The client is open source, so the first move is to read it. Message text layout runs through ChatMessageItem.applyMessageEntities, and nearly every addAttribute there is already guarded by string.range.intersection(…). Exactly one call on that path is not: a syntax-highlighting helper that pours a highlighter's attribute ranges into the message text at an offset, unchecked.

Neat story. It is also wrong, and the stack says so.

That helper calls addAttribute from inside an enumerateAttributes block — so a Foundation frame for enumerateAttributesInRange:options:usingBlock: has to sit between the block and its caller. There is no such frame. addAttribute is called directly by Telegram code, whose caller is also Telegram code.

A hypothesis that explains the bug but contradicts the stack isn't a lead, it's a distraction. I lost about an hour to this one before going back and reading the frames properly.

+03Symbolication

A stripped binary still talks

The App Store build ships without debug symbols — 194 MB of executable and roughly four thousand symbols, none of them Swift functions. atos returns offsets from whatever unrelated symbol happens to be nearest, which is worse than nothing because it looks like an answer.

But the crash report records the image UUID, and it matches the installed binary exactly:

report  arm64  d6f8450d-fcbd-3c80-992b-306e6c1a8b17
binary  arm64  D6F8450D-FCBD-3C80-992B-306E6C1A8B17

So the offsets are real addresses in a file sitting on disk. Symbol names are gone; the instructions are not.

+04The instructions

Seven lines that name the function

Disassembling around the innermost offset gives a complete calling convention read-out. Every register here is load-bearing.

+32:  mov  x22, x1            ; arg 2
+28:  mov  x21, x2            ; arg 3
+36:  ldr  x23, [x0, #0x18]   ; arg 1 — existential, load payload
+64:  bl   Swift._bridgeAnythingToObjectiveC
+80:  mov  x0, x20            ; receiver = swiftself
+84:  mov  x2, x24            ; attribute key, from a global
+88:  mov  x3, x23            ; bridged value
+92:  mov  x4, x22            ; range.location — arg 2, untouched
+96:  mov  x5, x21            ; range.length   — arg 3, untouched
+100: bl   objc_msgSend
+104: ← the return address in the crash report
+108: bl   swift_unknownObjectRelease
+112: …                       ; loads a second attribute key

Read it back out as a signature. The receiver arrives in x20, which is Swift's self register — so this is a method, and its self is the attributed string. Argument one is an existential passed indirectly and bridged to AnyObject: an Any. Arguments two and three land in x4 and x5 — the two halves of an NSRangewith no arithmetic between the parameter and the call. Argument four is the colour. And immediately after the crashing message send, the function loads a second attribute key: it does this twice.

One function in the codebase has that shape.

+05Source

Four lines in a shared helper

// packages/TGUIKit/Sources/Extensions.swift

func add(link:Any, for range:NSRange, color: NSColor = presentation.colors.link) {
    self.addAttribute(NSAttributedString.Key.link,            value: link,  range: range)
    self.addAttribute(NSAttributedString.Key.foregroundColor, value: color, range: range)
}

An Any, an NSRange, a colour, two addAttribute calls, and not one line of validation between the caller's range and Foundation.

It has 61 call sites. Twenty of them pass a range with no bounds check of any kind, several on the message-layout path.

+06Reproduction

Which ranges actually throw

Rather than reason about it, apply ranges to an eleven-character string and watch. The results are more specific than expected.

RangeResult
{0, 5}ok
{3, 0}ok
{11, 0}ok
{100, 0}ok
{NSNotFound, 0}ok
{100, 3}throws
{8, 50}throws
{−4, 6}throws
{NSNotFound, 5}throws
NSMutableRLEArray objectAtIndex:effectiveRange:: Out of bounds

That string is the same frame the crash report carries one line below addAttribute. The mechanism is confirmed, not inferred.

And the surprise is in the top half of the table: an empty range is harmless at any location, NSNotFound included. Which quietly explains something about the codebase — those many if range.location != NSNotFound guards scattered around this helper were never the protection that was missing. range(of:) returns {NSNotFound, 0}, and {NSNotFound, 0} never throws. The dangerous range has non-zero length and runs off the end.

+07The fix

Clamp at the choke point

func add(link:Any, for range:NSRange, color: NSColor = presentation.colors.link) {
+   guard range.location != NSNotFound, range.location >= 0, range.length >= 0 else {
+       return
+   }
+   let range = self.trimRange(range)
+   guard range.length > 0 else {
+       return
+   }
    self.addAttribute(NSAttributedString.Key.link,            value: link,  range: range)
    self.addAttribute(NSAttributedString.Key.foregroundColor, value: color, range: range)
}

trimRange already exists in the same file, so the clamp uses the codebase's own vocabulary rather than importing a new idiom. The explicit checks have to come first, though: trimRange clamps from above but not below, and computing location + length on {NSNotFound, n} would trap on Int overflow before any range logic ran. The last guard drops empty ranges, which are legal but pointless.

Worst case after this change is a link that loses its styling.

+08Standing

What is established, and what is not

Established verified

  • The report's image UUID matches the shipping binary, so the offsets are trustworthy.
  • The throwing call is reached by a direct branch, with no Foundation frame between — which rules out every closure-based candidate.
  • The disassembled signature matches add(link:for:color:) on every register: swiftself receiver, bridged existential, range forwarded untouched, second attribute key loaded after the return address.
  • The exception text was reproduced locally and matches the report.
  • The patch survives all nine range shapes above; valid ranges are unaffected.

Not established open

  • Which of the twenty unguarded call sites produces the bad range. The build is stripped and the crash report does not narrow the caller further.
  • Whether anything else fails once this crash stops masking it. No evidence of it — all sixteen reports are identical — but absence of evidence is what it is.

The fix sits at the shared choke point all 61 callers pass through, which is where the process actually dies. Finding the specific caller would sharpen the report; it would not change the patch.