fix(FUN-4): prevent infinite loop in glyph assembly on degenerate extenders#239
Conversation
MTGlyphAssemblyTest covers three cases: - zero-advance extender with flanking fixed parts never exits in the original loop (hang-class bug); the new test asserts the method returns a non-empty best-effort assembly. - positive-advance extender reaches the requested height via normal branch A exit. - tall \left(...\right) with a bundled font (regression guard: no behaviour change on valid MATH data). Two test-only headers are added to expose private seams: - MTTypesetter+Testing.h — declares initWithFont:style:cramped:spaced: and constructGlyphWithParts:height:glyphs:offsets:height: - MTGlyphPart+Testing.h — redeclares writable properties so tests can build synthetic MTGlyphPart instances. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…nerate extenders The loop `for (int numExtenders = 0; true; numExtenders++)` in -[MTTypesetter constructGlyphWithParts:height:glyphs:offsets:height:] only exits when the assembled minHeight (or maxHeight via spread-delta) reaches the requested glyphHeight. A font plist that supplies an extender part with fullAdvance == 0 (or whose advance is fully cancelled by connector overlap) gives minOffsetDelta <= 0 each iteration, so minHeight never grows. With a sufficiently large target neither branch A nor branch B fires and the loop spins forever on the UI thread. Fix: two-layer guard, both local to constructGlyphWithParts:. 1. No-progress guard: after both normal exit branches fail, if neither minHeight nor maxHeight grew by at least 0.01 pt vs the previous iteration, the font data is degenerate. Return the best-effort assembly (most extenders we can usefully add) rather than looping. Note: checking minHeight alone is insufficient. Some valid fonts (e.g. arrowright in Latin Modern Math) have extenders where endConnector == startConnector == fullAdvance, so extender-to-extender joints contribute zero to minOffset and minHeight stays flat while maxHeight grows each iteration until branch B fires. The guard therefore requires BOTH minHeight AND maxHeight to stall. 2. Hard iteration cap (belt-and-suspenders): `numExtenders <= 10000` caps the loop unconditionally, covering degenerate arithmetic that the progress check might miss. 10 000 extenders is far beyond any realistic delimiter stretch at any font size. Net change: ~18 LOC added, 1 LOC changed (for-header), 0 removed. One file (MTTypesetter.m). No header or API changes. Valid fonts are byte-for-byte unaffected. Fixes FUN-4. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
There was a problem hiding this comment.
Code Review
This pull request implements a loop-termination guard in MTTypesetter.m to prevent infinite loops on the UI thread when handling malformed font MATH data with zero-advance extenders, supported by new unit tests. The review feedback identifies a high-severity memory accumulation risk where running up to 10,000 iterations can allocate millions of autoreleased objects, and suggests reducing the iteration cap to 1,000 and using an @autoreleasepool block to mitigate potential out-of-memory crashes.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| // Well-formed fonts are unaffected: a valid extender produces either growing | ||
| // minHeight (branch A path) or growing maxHeight (branch B path), so neither | ||
| // guard is ever reached. | ||
| static const int kMaxExtenders = 10000; |
There was a problem hiding this comment.
Memory Accumulation Risk on High Iteration Counts
In the worst-case scenario (e.g., if a font has a very small but positive advance, or if an extremely large glyphHeight is requested), the loop can run up to kMaxExtenders times.
Because the loop builds the entire glyph and offset arrays from scratch in each iteration, the number of allocations grows quadratically:
For NSNumber and NSMutableArray instances) being accumulated on the UI thread's autorelease pool. This will cause a massive memory spike and likely trigger an Out-Of-Memory (OOM) crash or a severe UI freeze.
Recommendations:
- Reduce
kMaxExtenders: A limit of1000is still extremely generous (equivalent to several times the height of any iPad screen) but limits the maximum allocations to a safe level (~1 million objects in the absolute worst case). - Use
@autoreleasepool: Wrap the body of theforloop in an@autoreleasepoolblock to ensure that temporary arrays and numbers from previous iterations are drained immediately rather than accumulating.
static const int kMaxExtenders = 1000;|
EM-REVIEW v1 Code review: FUN-4 glyph-assembly infinite-loop guard. Reviewed head cfc939b. Severity: BLOCKING (1 blocking issue). BLOCKING - New iOS test not wired into iosMath.xcodeproj, so it never runs in CI. Non-blocking:
No regression risk to the normal path: well-formed fonts hit branch A or B as before; new code runs only after both branches fail, which valid data never reaches. Required before merge: add MTGlyphAssemblyTest.m to iosMath.xcodeproj (PBXFileReference + PBXBuildFile + iosMathTests group + Sources build phase) and confirm xcodebuild test -project iosMath.xcodeproj -scheme iosMath runs the 3 new tests. Not approving. |
Add the glyph-assembly loop-termination tests to the iosMathTests target so they run under the iOS Xcode test job, not only SPM swift test. Without this the infinite-loop regression guard was silently skipped on iOS. Co-Authored-By: Claude Opus 4.8 <[email protected]>
…ding the typesetter loop Replace the in-loop infinite-loop guard in constructGlyphWithParts: with validation at the two boundaries where a degenerate assembly can enter: - math_table_to_plist.py: refuse to emit a plist whose extender part has a non-positive advance (names the offending glyph), so bad font data never ships. - MTFontMathTable: reject such an assembly at load time (return nil) for any plist not produced by the script. Callers already fall back to the largest discrete variant when no assembly is available, so the typesetter never sees degenerate parts and cannot hang. This drops the subtle two-layer no-progress heuristic, the test-only private headers, and the synthetic test in favor of a one-time check that fails fast with a clear diagnostic. None of the 8 bundled fonts contain a zero-advance extender, so behavior is unchanged for all real fonts. Tests live in MTTypesetterTest.m (already wired into iosMath.xcodeproj) so they run in both the SPM and iOS xcodebuild CI lanes. Co-Authored-By: Claude Opus 4.8 <[email protected]>
…rning nil Per review: a zero-advance extender is malformed plist data, the same category as an invalid plist version — which MTFontMathTable already rejects by throwing. Treat it consistently and fail loud rather than silently mis-rendering. - Validate all v_assembly/h_assembly entries in -initWithFont:mathTable: and throw NSException (naming the offending glyph) if an extender has a non-positive advance. Validating at load — not lazily in getGlyphAssemblyFromTable: — keeps the exception out of the per-glyph typesetting loop (UI thread, not wrapped in @Try) and fails deterministically before any rendering. - Drop the lazy nil guard added previously. - Tests updated to assert construction throws for a degenerate extender. Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
/gemini review |
…s/t12 # Conflicts: # iosMathTests/MTTypesetterTest.m
There was a problem hiding this comment.
Code Review
This pull request introduces validation for glyph assemblies to prevent infinite loops in the renderer caused by extender parts with non-positive advance values. It adds a validation check in the Python plist generator script (math_table_to_plist.py), implements a corresponding runtime check in the Objective-C MTFontMathTable class, and includes comprehensive unit tests to verify this behavior. There are no review comments, so no additional feedback is provided.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Summary
Fixes FUN-4 (issues.md#L213).
The loop
for (int numExtenders = 0; true; numExtenders++)in-[MTTypesetter constructGlyphWithParts:height:glyphs:offsets:height:]only exits when the assembled
minHeight(branch A) ormaxHeight(branch B)reaches the requested
glyphHeight. A font plist that supplies an extenderpart with
fullAdvance == 0givesminOffsetDelta <= 0each iteration:minHeightnever grows,maxHeightstalls or shrinks, and the loop spinsforever on the UI thread — hanging the app.
Fix (1 file, ~18 LOC, no API change)
Two-layer guard inside
constructGlyphWithParts::No-progress guard — after both normal exit branches fail, if neither
minHeightnormaxHeightgrew by >= 0.01 pt vs the previous iteration,return the best-effort assembly rather than looping. Both heights must stall
to trigger the guard; checking only
minHeightwould falsely fire on validfonts (e.g.
arrowrightin Latin Modern Math) where extender-to-extenderjoints contribute zero to
minOffsetbutmaxHeightgrows normally untilbranch B fires.
Hard iteration cap (
numExtenders <= 10000) as belt-and-suspendersagainst any degenerate arithmetic the progress check might miss.
Valid fonts are byte-for-byte unaffected.
Tests
New
MTGlyphAssemblyTest(3 tests, all green):testConstructGlyphWithZeroAdvanceExtenderTerminates— synthetic partswith a zero-advance extender flanked by fixed parts (the exact hang
configuration); asserts the method returns a non-empty best-effort
assembly. Without the fix this hangs and is caught by the XCTest timeout.
testConstructGlyphWithPositiveAdvanceExtenderProducesAdequateHeight—normal positive-advance extender reaches the requested height via branch A.
testTallDelimiterRendersWithBundledFont— end-to-end regression guard:a tall
\left( \frac{\frac{1}{2}}{\frac{3}{4}} \right)renders withpositive dimensions using a bundled font (no behaviour change on valid data).
Two test-only headers (
MTGlyphPart+Testing.h,MTTypesetter+Testing.h)expose private seams to the test target; they are not part of the public API.
Test plan
swift build— cleanswift test— 295 tests, 0 failures🤖 Generated with Claude Code