Skip to content

fix(reader): stop UTF-8 splits from freezing Reader mode - #417

Merged
forketyfork merged 2 commits into
mainfrom
fix/reader-utf8-freeze
Oct 28, 2026
Merged

forketyfork merged 2 commits into
mainfrom
fix/reader-utf8-freeze

Conversation

@forketyfork

Copy link
Copy Markdown
Owner

TLDR

When Reader mode showed a long line of special characters (for example ────), the app stopped and used 100% CPU. The Reader cut the text in the middle of a character. Then a loop that looks for emoji did not move forward on the broken bytes. Now the Reader cuts text only between characters, and the loop always moves forward, also on broken bytes.

Issue

Opening Reader mode froze the whole app. A sample of the frozen process showed the main thread spinning in text_render.makeTextTexture → unicode.utf8Decode. Inspecting it with lldb showed the text being rendered: 155 bytes of ─ (U+2500, e2 94 80), starting with a stray continuation byte 0x80 and ending with a dangling 0xe2.

Two defects combined:

  1. Byte-based wrapping. markdown_renderer.appendWrappedLines and the Reader's table-cell wrappers (wrappedLineCount, renderWrappedTableCellRuns) measured columns in bytes and split over-long tokens at byte offsets, cutting multi-byte characters apart. Non-ASCII lines also wrapped at roughly a third of the intended width.
  2. Unchecked UTF-8 iteration. containsEmoji and splitRuns used std.unicode.Utf8Iterator, which assumes valid input and uses catch unreachable. In a release build a malformed lead byte yields a zero-length step, so the loop never advances. A debug build panics with Utf8InvalidStartByte.

Solution

  • src/ui/utf8_text.zig adds lenient UTF-8 traversal (decodeAt, columnCount, prefixLen). A malformed sequence is consumed one byte at a time as U+FFFD, the same way SDL_ttf renders it, so iteration always advances.
  • text_render uses decodeAt for emoji detection and run splitting.
  • All three wrappers count columns in codepoints and split tokens with prefixLen, so chunks never end inside a character. The table line counter and the table renderer stay consistent, so row heights match what is drawn.
  • scripts/check-unchecked-utf8.sh (run by just lint) rejects Utf8Iterator{…} literals and Utf8View.initUnchecked in src/, so the unchecked iterator cannot come back.
  • docs/ARCHITECTURE.md, docs/development.md, and CLAUDE.md document the module and the rule.

Regression tests cover paragraph wrapping of a long ─ rule, the table line count, and emoji detection over malformed bytes. All three failed before the fix (the emoji test crashed with the same Utf8InvalidStartByte as the frozen app).

Context

The frozen instance was recovered without a restart by patching makeTextTexture in memory to skip the emoji pass and jumping the program counter out of the loop.

Test plan

  • Open Reader mode on a terminal whose history contains a long box-drawing rule (e.g. printf '─%.0s' {1..300}; echo). The app stays responsive and the rule wraps cleanly with no replacement glyphs at the line edges.
  • In Reader mode, check that non-ASCII prose (Cyrillic, accented text) wraps at the same width as ASCII prose.
  • Check a markdown table whose cells hold long non-ASCII words: cells wrap without broken glyphs and row heights fit their content.

Issue: Opening Reader mode froze the app at 100% CPU. The main thread spun
in the emoji-detection loop while rendering a long box-drawing rule from
terminal output.

Solution: The Reader wrapped text by byte count and split long tokens at
byte offsets, cutting multi-byte characters. The emoji pass then walked the
broken slice with std.unicode.Utf8Iterator, whose `catch unreachable` makes a
release build take a zero-length step forever. Wrapping now counts and
splits by codepoint, and UI text iteration uses a decoder that steps over
malformed bytes as U+FFFD. A lint check bans the unchecked iterator.
@forketyfork
forketyfork marked this pull request as ready for review October 28, 2026 13:33
@forketyfork
forketyfork requested a balanced review from Copilot October 28, 2026 13:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Both wrapping paths repeatedly count entire remaining tokens, causing quadratic work that can stall the UI on long unbroken text.

1 open issue
What changed in this PR

This PR addresses Reader mode freezes caused by splitting UTF-8 characters during wrapping and unchecked iteration over malformed text. It adds shared UTF-8 helpers used by text rendering and markdown layout.

Changes:

  • Add lenient decoding that always advances, including over malformed bytes.
  • Wrap paragraphs and table cells by codepoints, with regression tests.
  • Add a lint guard against unchecked UTF-8 iteration and document the new helpers.

Build, tests, and lint were not run during this review.

File Description
src/​ui/​utf8_text.zig Adds lenient decoding, column helpers, and unit tests.
src/​ui/​text_render.zig Makes emoji detection and run splitting tolerate malformed UTF-8.
src/​ui/​components/​reader_overlay.zig Updates table wrapping and line counts to use codepoints.
src/​ui/​components/​markdown_renderer.zig Splits long tokens at codepoint boundaries and adds regression coverage.
src/​main.zig Registers the UTF-8 helper tests.
scripts/​check-unchecked-utf8.sh Detects unchecked UTF-8 iterator usage.
justfile Includes the new check in linting.
docs/​development.md Documents the lint guard and safe traversal helpers.
docs/​ARCHITECTURE.md Adds the UTF-8 utility to the module inventory.
CLAUDE.md Documents safe UTF-8 traversal and wrapping requirements.

Review effort: Balanced

Comment thread src/ui/components/markdown_renderer.zig Resolved
Issue: Review on #417 (discussion_r4401308950) noted that wrapping re-counted
the whole remaining token after every emitted chunk, making long unbroken
tokens quadratic in paragraph layout and on every rendered table frame.

Solution: Count a token's columns once before chunking it and subtract the
chunk width as chunks are consumed. Each chunk of an over-long token spans
exactly the wrap width, so the running count stays exact and wrapping is
linear again.
@forketyfork
forketyfork merged commit 31c93e2 into main Oct 28, 2026
4 checks 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