Skip to content

RedBlackTree out-of-bounds crash: string setter missing lineControllerStorage cleanup causes stale async completions to fire against rebuilt lineManager #424

@ryanmarimon

Description

@ryanmarimon

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

  1. Open any multi-line file with a Tree-sitter language mode active (async syntax highlighting in flight).
  2. Programmatically replace the text via tv.text = newContent while highlighting operations are queued.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions