NSTextView crash with interaction between inserted .link attribute in text storage and NSSpellChecker

I have been getting crash reports from users of my Mac app on Sonoma 14.0 and 14.1 when typing into an NSTextView subclass. The crash logs I have show involvement of the spell-checking system - NSTestCheckingController, NSSpellChecker, and NSCorrectionPanel. The crash is because of an exception being thrown. The throwing method is either [NSString getParagraphStart:end:contentsEnd:forRange:] or [NSTextStorage ensureAttributesAreFixedInRange:].

I have not yet reproduced the crash. I have tried modifying the reference finding process to simply link every word, via NSStringEnumerationByWords.

The text view in question recognizes certain things in the entered text and adds hyperlinks to the text while the user is typing. It re-parses and re-adds the links on every key press (via overriding the didChangeText method), on a background thread.

From user reports, I have learned that:

  • The crash only occurs on macOS 14.0 and 14.1, not on previous versions
  • The call stack always involves the spell checker, and sometimes involves adding recognized links to the text storage (the call to DispatchQueue.main.async in the code below)
  • The crash stops happening if the user turns off the system spell checker in System Settings -> Keyboard -> Edit on an Input Source -> Correct Spelling Automatically switch
  • The crash does not happen when there are no links in the text view.

Here is the relevant code:

extension NSMutableAttributedString {
    func batchUpdates(_ updates: () -> ()) {
        self.beginEditing()
        updates()
        self.endEditing()
    }
}

class MyTextView : NSTextView {
  func didChangeText() {
    super.didChangeText()
    findReferences()
  }

  var parseToken: CancelationToken? = nil
  let parseQueue = DispatchQueue(label: "com.myapp.ref_parser")
  private func findReferences() {
    guard let storage = self.textStorage else { return }
    self.parseToken?.requestCancel()
    let token = CancelationToken()
    self.parseToken = token
    let text = storage.string
    self.parseQueue.async {
        if token.cancelRequested { return }
        
        let refs = RefParser.findReferences(inText: text, cancelationToken: token)

        DispatchQueue.main.async {
            if !token.cancelRequested {
                storage.batchUpdates {
                  var linkRanges: [NSRange] = []
                  storage.enumerateAttribute(.link, in: NSRange(location: 0, length: storage.length)) { linkValue, linkRange, stop in
                      if let linkUrl = linkValue as? NSURL {
                          linkRanges.append(linkRange)
                      }
                  }
                  
                  for rng in linkRanges {
                      storage.removeAttribute(.link, range: rng)
                  }
                  
                  for r in refs {
                      storage.addAttribute(.link, value: r.url, range: r.range)
                  }
                }
                
                self.verseParseToken = nil
            }
        }
    }
  }
}

I've filed this as FB13306015 if any engineers see this. Can anyone

  • Clarification: the url property on the objects in refs are just NSURL, nothing fancy.

Add a Comment