Repository navigation
fix(reader): stop UTF-8 splits from freezing Reader mode - #417
Merged
Merged
Conversation
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.
There was a problem hiding this comment.
🟡 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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

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 byte0x80and ending with a dangling0xe2.Two defects combined:
markdown_renderer.appendWrappedLinesand 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.containsEmojiandsplitRunsusedstd.unicode.Utf8Iterator, which assumes valid input and usescatch unreachable. In a release build a malformed lead byte yields a zero-length step, so the loop never advances. A debug build panics withUtf8InvalidStartByte.Solution
src/ui/utf8_text.zigadds 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_renderusesdecodeAtfor emoji detection and run splitting.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 byjust lint) rejectsUtf8Iterator{…}literals andUtf8View.initUncheckedinsrc/, so the unchecked iterator cannot come back.docs/ARCHITECTURE.md,docs/development.md, andCLAUDE.mddocument 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 sameUtf8InvalidStartByteas the frozen app).Context
The frozen instance was recovered without a restart by patching
makeTextTexturein memory to skip the emoji pass and jumping the program counter out of the loop.Test plan
printf '─%.0s' {1..300}; echo). The app stays responsive and the rule wraps cleanly with no replacement glyphs at the line edges.