Skip to content

Fix crash: stale code-block token cache after a programmatic content swap - #162

Open
secondstagehq wants to merge 1 commit into
nodes-app:mainfrom
secondstagehq:fix/stale-codeblock-cache-crash
Open

Fix crash: stale code-block token cache after a programmatic content swap#162
secondstagehq wants to merge 1 commit into
nodes-app:mainfrom
secondstagehq:fix/stale-codeblock-cache-crash

Conversation

@secondstagehq

Copy link
Copy Markdown

Crash

updateCodeBlockSelection cuts each fenced block's code out of the current text with ranges from cachedCodeBlockTokens:

code: nsText.substring(with: token.contentRange)

That cache is only refreshed by the typing/caret delegate paths. A programmatic content swap — a document switch, or the SwiftUI text binding changing externally — goes through rebuildTextStorageAndStyle, which never touches the cache. The deferred refresh in updateNSView:

DispatchQueue.main.async {
    context.coordinator.updateCodeBlockSelection(textView: textView)
}

then runs with no parsed, indexes the new (shorter) string with the previous document's ranges, and -[NSString substringWithRange:] throws NSRangeException. Uncaught ObjC exception in a main-queue dispatch block → abort(). We hit this in production (macOS app embedding the engine, document with a fenced code block replaced by shorter content via sync) — crash report points exactly at NativeTextViewCoordinator.updateCodeBlockSelectionclosure #7 in NativeTextViewWrapper.updateNSView. Reproduced in a unit test (included); still present on 0.12.0 (the file is unchanged).

Fix (two layers)

  1. Root causerebuildTextStorageAndStyle already computes a parse of the incoming text; hand its codeBlockTokensWithIndices to the cache there (and clear the cache in raw source mode), so the deferred refresh always reads ranges parsed from the text it is shown with.
  2. Defence in depthupdateCodeBlockSelection skips any cached block whose range/contentRange no longer fits the current text instead of substringing it, so no caller racing a content mutation can abort the process. The next parse re-delivers the block.

Includes a regression test suite (CodeBlockSelectionStaleCacheTests) that reproduces the abort on unpatched code and verifies both layers. Full package suite passes (298 tests).

The branch is based on the 0.11.0 tag (the version we ship); happy to rebase onto main if you prefer.

🤖 Generated with Claude Code

…tent swap

updateCodeBlockSelection substrings the current text with ranges from
cachedCodeBlockTokens, but only the typing/caret delegate paths refresh
that cache. A programmatic content swap (document switch, external
binding change) goes through rebuildTextStorageAndStyle, which left the
cache untouched; the deferred no-parsed refresh in updateNSView then
indexed the new text with the previous document's ranges and threw
NSRangeException in -[NSString substringWithRange:] — an uncaught ObjC
exception on the main queue, so the app aborts.

Two layers:
- rebuildTextStorageAndStyle now hands its own parse's code-block tokens
  to the cache (and clears it in raw source mode), so the deferred
  refresh always reads ranges parsed from the text it is shown with.
- updateCodeBlockSelection skips any cached block whose range no longer
  fits the current text instead of substringing it, so no caller racing
  a content mutation can abort the process.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant