Summary
When text is replaced programmatically via tv.text = value while async syntax highlighting is in progress for the previous content, a RedBlackTree crash fires in DEBUG builds:
X is out of bounds. Valid range is 0-0.
(where X is a character position from the previous file's content)
Root Cause
The TextInputView.string setter and setState(_:) handle in-flight async syntax highlight operations differently:
setState(_:) — safe path:
Replaces stringView as a new object, which triggers lineControllerStorage.stringView.didSet → lineControllers.removeAll(). This releases all old LineController instances. Their pending syntaxHighlight(_:completion:) operations hold [weak self] references to the highlighter — once the controller is released, self is nil and the completion bails out early. No stale state survives.
string.set — unsafe path:
set {
if newValue != stringView.string {
stringView.string = newValue // mutates the existing StringView in place
languageMode.parse(newValue) // sync parse
lineManager.rebuild() // creates all-new DocumentLineNodeIDs
// ...
invalidateLines() // marks controllers invalid but does NOT cancel or remove them
layoutManager.layoutIfNeeded()
}
}
stringView.string = newValue mutates the contents of the existing StringView object — it does not replace the object itself — so lineControllerStorage.stringView.didSet is never triggered. Old LineController instances remain in the dictionary, keyed by the old DocumentLineNodeID UUIDs.
lineManager.rebuild() creates all-new line nodes with new UUIDs, so those old controllers are now orphaned (no future getOrCreateLineController call will return them). But they are not released — they remain alive in lineControllerStorage with a nonzero ARC count.
Any in-flight DispatchQueue.main.async completion from an old controller therefore fires with a live self, calls redisplayLineFragments(), and then calls delegate?.lineControllerDidInvalidateLineWidthDuringAsyncSyntaxHighlight(self), triggering a layout pass. That layout accesses the freshly rebuilt lineManager using stale character-offset assumptions from the old file, hitting the out-of-bounds guard.
Proposed Fix
Add lineControllerStorage.removeAllLineControllers() in the string setter, mirroring what setState(_:) achieves via the stringView cascade:
set {
if newValue != stringView.string {
stringView.string = newValue
languageMode.parse(newValue)
lineControllerStorage.removeAllLineControllers() // ← add this line
lineManager.rebuild()
// ... rest unchanged
}
}
This releases all old controllers immediately. Their [weak self] completions see nil and return early — same behaviour as the setState path.
Reproduction
- Open any multi-line file with a Tree-sitter language mode active (async syntax highlighting in flight).
- Programmatically replace the text via
tv.text = newContent while highlighting operations are queued.
- Crash fires in DEBUG. Release builds return
nil gracefully and continue.
The specific trigger in our app was a "reload from disk" flow that called tv.text = reloadedString on a file that was already being syntax-highlighted. File content is not special — any reload of a sufficiently long file is enough to have an in-flight operation at the moment of replacement.
Notes
- Only reproduces in DEBUG builds (the
fatalError guard in RedBlackTree.node(containingLocation:) is #if DEBUG).
- No stack trace attached — the fix routes around the crash before we could capture one. Happy to provide one if useful; it would require temporarily reverting the workaround.
setState(_:) is the safe API and we now use it exclusively for all programmatic content changes. Filing this so the string setter has the same safety guarantee.
Summary
When text is replaced programmatically via
tv.text = valuewhile async syntax highlighting is in progress for the previous content, aRedBlackTreecrash fires in DEBUG builds:(where X is a character position from the previous file's content)
Root Cause
The
TextInputView.stringsetter andsetState(_:)handle in-flight async syntax highlight operations differently:setState(_:)— safe path:Replaces
stringViewas a new object, which triggerslineControllerStorage.stringView.didSet → lineControllers.removeAll(). This releases all oldLineControllerinstances. Their pendingsyntaxHighlight(_:completion:)operations hold[weak self]references to the highlighter — once the controller is released,selfis nil and the completion bails out early. No stale state survives.string.set— unsafe path:stringView.string = newValuemutates the contents of the existingStringViewobject — it does not replace the object itself — solineControllerStorage.stringView.didSetis never triggered. OldLineControllerinstances remain in the dictionary, keyed by the oldDocumentLineNodeIDUUIDs.lineManager.rebuild()creates all-new line nodes with new UUIDs, so those old controllers are now orphaned (no futuregetOrCreateLineControllercall will return them). But they are not released — they remain alive inlineControllerStoragewith a nonzero ARC count.Any in-flight
DispatchQueue.main.asynccompletion from an old controller therefore fires with a liveself, callsredisplayLineFragments(), and then callsdelegate?.lineControllerDidInvalidateLineWidthDuringAsyncSyntaxHighlight(self), triggering a layout pass. That layout accesses the freshly rebuiltlineManagerusing stale character-offset assumptions from the old file, hitting the out-of-bounds guard.Proposed Fix
Add
lineControllerStorage.removeAllLineControllers()in thestringsetter, mirroring whatsetState(_:)achieves via thestringViewcascade:This releases all old controllers immediately. Their
[weak self]completions see nil and return early — same behaviour as thesetStatepath.Reproduction
tv.text = newContentwhile highlighting operations are queued.nilgracefully and continue.The specific trigger in our app was a "reload from disk" flow that called
tv.text = reloadedStringon a file that was already being syntax-highlighted. File content is not special — any reload of a sufficiently long file is enough to have an in-flight operation at the moment of replacement.Notes
fatalErrorguard inRedBlackTree.node(containingLocation:)is#if DEBUG).setState(_:)is the safe API and we now use it exclusively for all programmatic content changes. Filing this so thestringsetter has the same safety guarantee.