Skip to content

Fix infinite loop when a text run shapes to no glyphs - #22119

Merged
MrJul merged 3 commits into
AvaloniaUI:mainfrom
Gillibald:fix/runs-that-shape-to-no-glyphs
Sep 1, 2026
Merged

Fix infinite loop when a text run shapes to no glyphs#22119
MrJul merged 3 commits into
AvaloniaUI:mainfrom
Gillibald:fix/runs-that-shape-to-no-glyphs

Conversation

@Gillibald

@Gillibald Gillibald commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What does the pull request do?

Fixes an infinite loop and unbounded allocation in TextLayout when a text run shapes to no glyphs.

What is the current behavior?

A shaper hides the default ignorables it substitutes for line breaks behind the font's space glyph, and deletes them when the font has none. A run holding nothing but a line break then shapes to an empty glyph buffer while still owning its characters.

TextFormatterImpl.ShapeTogether splits the shaped buffer by glyph count, so it skips that run and rolls its characters into previousLength for a following run to absorb. When no such run follows they are lost: the line reports a length of 0 with no line break, TextLayout.CreateTextLines never advances _textSourceLength, and it formats the same line forever until the process runs out of memory.

Line breaks are the common trigger, not the only one. Any run that is entirely default-ignorable takes the same path - with the headless BareMinimum.ttf, U+200D, U+200B and U+FE0F each shape to a run of length 1 with zero glyphs.

GlyphRun also indexes its glyph list without checking it is non-empty, so hit-testing a run with characters and no glyphs throws IndexOutOfRangeException.

What is the updated/expected behavior with this PR?

  • ShapeTogether splits on text length, so a run that shapes to no glyphs keeps its characters and the layout advances. Text starting with a line break lays out as an empty first line followed by the wrapped text.
  • GlyphRun hit-testing handles an empty glyph list, degenerating to a single zero-width cluster.
  • GlyphRun.InkBounds answers empty without building a platform glyph run. TextLineImpl reads it for every shaped run in a line, and the Skia implementation creates an SKFont and measures glyph widths in its constructor.

Checklist

Breaking changes

None. No public API is added or changed.

Obsoletions / Deprecations

None.

Fixed issues

Fixes #22004

🤖 Generated with Claude Code

A shaper hides the default ignorables it substitutes for line breaks
behind the font's space glyph, and deletes them when the font has no
such glyph. A run holding nothing but a line break then shapes to an
empty glyph buffer while still owning its characters.

- TextFormatter drops that run today, so the line covers no text and
  the layout loop never advances past it - measuring a wrapping
  TextBlock whose text starts with a newline never returns.
- GlyphRun indexes its glyph list without checking it is non-empty, so
  hit-testing such a run throws IndexOutOfRangeException.

Covers all three layers: GlyphRun hit-testing, the formatted line, and
the TextLayout repro from the issue. The layout test bounds maxLines so
a regression fails instead of hanging the test run.

The Skia tests embed the headless platform's BareMinimum.ttf, which has
four glyphs and no space, rather than a copy of it.
ShapeTogether split the shaped buffer by glyph count, so a run whose
glyphs were all deleted by the shaper was skipped and its characters
rolled into previousLength for a following run to absorb. When no such
run follows, the characters are lost: the line reports a length of 0
and TextLayout formats the same line forever.

Splitting by text length preserves the case the accumulation exists for
- a run whose glyphs merged into a neighbouring cluster splits off no
text either - and stops discarding characters that shaped to nothing.

That makes zero-glyph glyph runs reachable for the first time, so:

- GetDistanceFromCharacterHit, FindGlyphIndex and FindNearestCharacterHit
  handle an empty glyph list. There is no cluster to snap to and the run
  sits at a single position, so it degenerates to one zero-width cluster.
- InkBounds answers empty directly instead of building a platform glyph
  run. TextLineImpl reads it for every shaped run in a line, and the Skia
  implementation creates an SKFont and measures glyph widths in its
  constructor - wasted on a run that marks nothing.

Fixes AvaloniaUI#22004
@MrJul

MrJul commented Sep 1, 2026

Copy link
Copy Markdown
Member

This also supersedes #21686, right?

Comment thread src/Avalonia.Base/Media/GlyphRun.cs Outdated
new Size(Metrics.WidthIncludingTrailingWhitespace, Metrics.Height));

public Rect InkBounds => PlatformImpl.Item.Bounds;
// A run with no glyphs marks nothing, so its ink bounds are empty by definition. Answering

@MrJul MrJul Sep 1, 2026

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.

Other added comments genuinely help to understand the algorithm, but this one is a bit verbose for its purpose. Please reduce the clanker's comment here: "only allocate the platform impl if needed", or something along those lines.

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.2.999-cibuild0069256-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@Gillibald

Gillibald commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

This also supersedes #21686, right?

Update:

I had a deeper look at that issue, and it is not related.

@Gillibald
Gillibald force-pushed the fix/runs-that-shape-to-no-glyphs branch from 41fd133 to 697b533 Compare September 1, 2026 10:22

@MrJul MrJul 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!

@MrJul
MrJul enabled auto-merge September 1, 2026 10:37
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.2.999-cibuild0069264-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@MrJul
MrJul added this pull request to the merge queue Sep 1, 2026
Merged via the queue into AvaloniaUI:main with commit 2e7d2c5 Sep 1, 2026
10 checks passed
MrJul pushed a commit to MrJul/Avalonia that referenced this pull request Sep 2, 2026
* Add failing tests for runs that shape to no glyphs

A shaper hides the default ignorables it substitutes for line breaks
behind the font's space glyph, and deletes them when the font has no
such glyph. A run holding nothing but a line break then shapes to an
empty glyph buffer while still owning its characters.

- TextFormatter drops that run today, so the line covers no text and
  the layout loop never advances past it - measuring a wrapping
  TextBlock whose text starts with a newline never returns.
- GlyphRun indexes its glyph list without checking it is non-empty, so
  hit-testing such a run throws IndexOutOfRangeException.

Covers all three layers: GlyphRun hit-testing, the formatted line, and
the TextLayout repro from the issue. The layout test bounds maxLines so
a regression fails instead of hanging the test run.

The Skia tests embed the headless platform's BareMinimum.ttf, which has
four glyphs and no space, rather than a copy of it.

* Keep text runs that shape to no glyphs

ShapeTogether split the shaped buffer by glyph count, so a run whose
glyphs were all deleted by the shaper was skipped and its characters
rolled into previousLength for a following run to absorb. When no such
run follows, the characters are lost: the line reports a length of 0
and TextLayout formats the same line forever.

Splitting by text length preserves the case the accumulation exists for
- a run whose glyphs merged into a neighbouring cluster splits off no
text either - and stops discarding characters that shaped to nothing.

That makes zero-glyph glyph runs reachable for the first time, so:

- GetDistanceFromCharacterHit, FindGlyphIndex and FindNearestCharacterHit
  handle an empty glyph list. There is no cluster to snap to and the run
  sits at a single position, so it degenerates to one zero-width cluster.
- InkBounds answers empty directly instead of building a platform glyph
  run. TextLineImpl reads it for every shaped run in a line, and the Skia
  implementation creates an SKFont and measures glyph widths in its
  constructor - wasted on a run that marks nothing.

Fixes AvaloniaUI#22004

* Adjust GlyphRun.InkBounds xml comment
@MrJul MrJul added backported-12.1.x and removed backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch labels Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Measuring a TextBlock with leading-newline text and TextWrapping=Wrap hangs and leaks memory until OOM

3 participants