Crash analysis
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.
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.
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?
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.
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.
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 NSRange — with 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.
// 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.
Rather than reason about it, apply ranges to an eleven-character string and watch. The results are more specific than expected.
| Range | Result |
|---|---|
| {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 |
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.
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.
add(link:for:color:) on every register: swiftself receiver, bridged existential, range forwarded untouched, second attribute key loaded after the return address.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.