Skip to content

TextMate: fall back to a full TextView redraw when the visible-line cache is stale#595

Merged
danipen merged 3 commits into
AvaloniaUI:masterfrom
alvaro-berruezo-unity3d:fix/textmate-syntax-highlight-race
Jun 5, 2026
Merged

TextMate: fall back to a full TextView redraw when the visible-line cache is stale#595
danipen merged 3 commits into
AvaloniaUI:masterfrom
alvaro-berruezo-unity3d:fix/textmate-syntax-highlight-race

Conversation

@alvaro-berruezo-unity3d

@alvaro-berruezo-unity3d alvaro-berruezo-unity3d commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Summary

TextMateColoringTransformer.ModelTokensChanged clamps its partial redraw range against the cached visible-line indices. When SetModel resets those indices to (-1, -1) and _areVisualLinesValid to false, any tokenizer event that arrives before the first layout pass refills the cache produces an inverted redraw range. _textView.Redraw(offset, length) is then called with a negative length and paints nothing, so the newly-tokenized lines stay unstyled until the next layout pass eventually triggers a fresh render (typically a scroll or a click).

This PR detects that state — either areVisualLinesValid was false at capture time, or the clamped range came out inverted — and falls back to a full _textView.Redraw() instead of the broken partial one. The fallback is slightly heavier but only fires in the narrow window before the first layout pass; the partial-redraw path still runs in the common case.

How this was found

We reproduce this in a Unity Version Control diff viewer that hosts AvaloniaEdit.TextMate. Under rapid file switching the cache is stale frequently enough that the natural race produced an observable "missing syntax highlighting on one side of the diff" symptom; instrumentation logs showed ~160 invalid-range redraw events per session before the fix. A deterministic synthesis that suppresses TextView_VisualLinesChanged's cache update on one side reproduces the failure 100% — with this PR's fallback applied, the same synthesis renders both sides correctly highlighted.

Edit history

An earlier version of this PR also included a defensive _model.InvalidateLine(0) after _model.SetGrammar(grammar) in TextMateColoringTransformer.SetGrammar, intended to work around TMModel.SetGrammar's same-grammar early-return. After looking into TextMateSharp 2.0.4 more carefully, that workaround turned out to be unnecessary: the underlying tokenizer-thread race it was defending against is explicitly fixed by 2.0.4's TokenizerThread rewrite (danipen/TextMateSharp PRs #114, #115, #121). That commit has been reverted in this branch; only the redraw-range fallback remains.

Notes

  • The redraw-range bug is purely local to AvaloniaEdit.TextMate and independent of any TextMateSharp version — applies to all consumers regardless of which TextMateSharp package they're on.
  • No tests added: TextMateInstallationTests currently covers disposal semantics, but exercising the redraw clamping deterministically would require a fairly elaborate harness (a real grammar, a tokenizer thread, a controlled layout-pass scheduler). Happy to add unit tests if there's a pattern the maintainers prefer.
  • Verified against the current master head.

TMModel.SetGrammar early-returns when the same IGrammar instance is
passed, skipping its internal InvalidateLine(0). That's the common case
when Installation reloads a cached grammar after a document swap, and it
leaves the model with no invalid lines to process. TransformLine then
sees null tokens for the visible lines and they render plain, until
something else triggers re-tokenization.

Explicitly invalidate line 0 right after _model.SetGrammar(grammar) so
the tokenizer always has work to do on the freshly-bound model. The
extra invalidation is cheap on the common path (same content + same
grammar tokenizes to the same state) but plugs the gap where the
caller's intent — "use this grammar for this content" — could be
silently dropped.
…ache is stale

ModelTokensChanged is invoked from the tokenizer's background thread and
captures _firstVisibleLineIndex/_lastVisibleLineIndex under lock to use
inside the UI-thread dispatch callback. Those cached indices are reset
to -1 (and _areVisualLinesValid to false) by SetModel, and only refilled
when TextView_VisualLinesChanged fires after a layout pass.

If a tokenizer event arrives before the first layout pass on a freshly
bound model, the captured indices are still (-1, -1), so the redraw
range collapses to firstLineIndexToRedraw=Math.Max(firstChanged, -1) and
lastLineIndexToRedrawLine=Math.Min(lastChanged, -1)=-1, clamped to 0.
_textView.Redraw(offset, length) is then called with a negative length
and paints nothing. The newly-tokenized lines stay un-styled until the
next layout pass (a scroll, a click, etc.) eventually triggers a fresh
render.

Detect that case — either the visible-line cache wasn't valid at capture
time, or the clamped range came out inverted — and fall back to a full
_textView.Redraw() instead of the broken partial one. The fallback is
slightly heavier but only fires in the narrow window before the first
layout pass; the partial-redraw path still runs in the common case.
@udlose

udlose commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

@danipen do you still need to release the multiple race condition fixes and memory leak fixes i added in TextMateSharp? i dont think they're included in the latest version of AvaloniaEdit, right?

@danipen

danipen commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Let me check...

@danipen

danipen commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Yes, you're right. It's worth trying TextMateSharp v2.0.4 first, to see if all the race condition changes fixes this issue. If it's fixed we can discard it. If not, I'm fine merging it.

This reverts commit 72328dc.

The defensive InvalidateLine(0) added in 72328dc was meant to work around
a case where TMModel.SetGrammar early-returns on same-grammar calls and
leaves the model without any invalid lines to tokenize. After looking
into it, that path only mattered because the underlying tokenizer-thread
race in TextMateSharp could leave the model un-tokenized in the first
place. TextMateSharp 2.0.4 rewrites the tokenizer thread lifecycle and
explicitly fixes those races (danipen/TextMateSharp PRs AvaloniaUI#114, AvaloniaUI#115,
AvaloniaUI#121), so the local InvalidateLine is no longer load-bearing.

The full TextView redraw fallback in ModelTokensChanged (43c7713) is
independent of TextMateSharp - it addresses a clamping bug local to
AvaloniaEdit.TextMate where the redraw range collapses to an inverted
interval when the visible-line cache is stale - and stays.
@alvaro-berruezo-unity3d alvaro-berruezo-unity3d changed the title TextMate: fix random missing syntax highlighting on freshly-bound models TextMate: fall back to a full TextView redraw when the visible-line cache is stale Jun 5, 2026
@alvaro-berruezo-unity3d

Copy link
Copy Markdown
Contributor Author

TextMateSharp 2.0.4 removes the need for one of the fixes on the PR. The _model.InvalidateLine(0) call in TextMateColoringTransformer is not needed anymore

I updated the changes, leaving only the necessary fix

@danipen danipen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

@danipen danipen merged commit be976ea into AvaloniaUI:master Jun 5, 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.

3 participants