Skip to content

Fix NSRangeException crash in updateCodeBlockSelection after document swap - #151

Merged
luca-chen198 merged 1 commit into
nodes-app:mainfrom
wishworldbetter:fix/codeblock-selection-stale-range-crash
Aug 12, 2026
Merged

Fix NSRangeException crash in updateCodeBlockSelection after document swap#151
luca-chen198 merged 1 commit into
nodes-app:mainfrom
wishworldbetter:fix/codeblock-selection-stale-range-crash

Conversation

@wishworldbetter

Copy link
Copy Markdown
Contributor

Crash

A macOS app embedding this package crashes with SIGABRT via an uncaught NSRangeException from -[NSString substringWithRange:]. Faulting stack from a real .ips crash report:

Foundation  -[NSString substringWithRange:]
mm          closure #2 in NativeTextViewCoordinator.updateCodeBlockSelection(textView:parsed:)
libswiftCore Sequence.compactMap
mm          NativeTextViewCoordinator.updateCodeBlockSelection(textView:parsed:)
mm          closure #7 in NativeTextViewWrapper.updateNSView(_:context:)   (dispatched async to main)

Root cause

In Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift, updateCodeBlockSelection reads cachedCodeBlockTokens — token ranges produced by a previous parse — and calls nsText.substring(with: token.contentRange) against the text view's current string.

When the host swaps documents and the new text is shorter than the old one, an async-queued update from updateNSView can run with the stale cached tokens. Their ranges exceed the new string's length, so substringWithRange: raises NSRangeException and the process aborts. Any document-switch path can trigger it.

Reproduction sketch

  1. Embed the editor in a host app.
  2. Open a document that has a fenced code block near its end.
  3. Switch to a shorter document. The queued updateCodeBlockSelection call still holds the old parse's token cache, whose ranges are out of bounds for the new string → crash.

Fix

Add a bounds guard at the top of the compactMap closure that skips tokens whose range or contentRange extends past the current string length. Stale tokens are only possible for one async hop after a document swap; the next parse refreshes the cache and the copy-button overlays come back with correct geometry.

🤖 Generated with Claude Code

… swap

updateCodeBlockSelection reads cachedCodeBlockTokens (ranges from a
previous parse) and calls substring(with:) against the text view's
CURRENT string. When the host swaps documents and the new text is
shorter than the old one, an async-queued update from updateNSView can
still run with the stale cached tokens; their ranges exceed the new
string's length, so -[NSString substringWithRange:] raises an
NSRangeException and the process aborts (SIGABRT).

Guard each cached token's range and contentRange against the current
string length and skip stale ones — the next parse refreshes the cache
and the copy-button overlays reappear with correct geometry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@luca-chen198 luca-chen198 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@luca-chen198
luca-chen198 merged commit cdaae40 into nodes-app:main Aug 12, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants