Skip to content

fix(FUN-8): copy style/spacer atom per cell in tableWithEnvironment:rows:error:#240

Open
kostub wants to merge 6 commits into
masterfrom
em/2026-06-11-issues/t16
Open

fix(FUN-8): copy style/spacer atom per cell in tableWithEnvironment:rows:error:#240
kostub wants to merge 6 commits into
masterfrom
em/2026-06-11-issues/t16

Conversation

@kostub

@kostub kostub commented Jun 12, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes FUN-8: +[MTMathAtomFactory tableWithEnvironment:rows:error:] allocated a single leading atom — an MTMathStyle (matrix/cases) or an ordinary spacer MTMathAtom (eqalign/split/aligned) — and inserted that same object reference into every table cell. Any client that mutated one cell's leading atom would silently mutate every other cell in the table.

The two inserted atoms are not the same kind of thing, so they get different treatment:

MTMathStyle (matrix / pmatrix / … / cases) — made immutable, sharing is now safe

An MTMathStyle is a non-rendering control marker: the typesetter reads only its style, never its nucleus, type, or fontStyle, and scripts are already forbidden via -scriptsAllowed. Storing any of that state on it is meaningless. Rather than defensively copying it, we make the class genuinely immutable:

  • -setNucleus:, -setType:, and -setFontStyle: now throw on any value other than the one -copyWithZone: legitimately assigns (empty nucleus, kMTMathAtomStyle, kMTFontStyleDefault), so deep copying still works.
  • style was already readonly; sub/superscripts already throw.

With the atom immutable, the same instance can be shared across every matrix/cases cell with no aliasing risk, so the per-cell [style copy] is removed — sharing is intentional and documented.

Ordinary spacer (eqalign / split / aligned) — copied per cell

The relation spacer is a plain kMTMathAtomOrdinary atom (empty nucleus) whose only job is to give the following =/relation its left inter-element spacing. It is a fully mutable atom and its nucleus renders, so it genuinely must not be shared — each cell gets [spacer copy].

No public LaTeX-level behaviour changes: MTTypesetter re-copies via finalized, so rendering is unaffected.

Test plan

  • testTableSpacerCellsAreIndependent — mutates one aligned cell's spacer and asserts the other cells are unaffected. Exercises the real failure mode (object sharing → mutation leak); fails when [spacer copy] is reverted.
  • testStyleAtomIsImmutable — asserts nucleus/type/fontStyle/subscript are all rejected on a style atom, while the copy-compatible values stay allowed and [style copy] still works.
  • Full swift test suite: 294 tests, 0 failures.

…ows:error:

MTMathAtomFactory was inserting the same MTMathStyle/spacer object reference
into every cell of matrix, cases, and aligned environments. Any mutation of
one cell's leading atom would silently alias all other cells. Fix by calling
[atom copy] at each insert site (3 one-line edits) so each cell owns an
independent, deeply-copied atom. Adds a regression test asserting object
non-identity across cells for all three affected environments.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request addresses an object aliasing issue in MTMathAtomFactory.m where the same style or spacer atom was inserted into multiple table cells. The code has been updated to insert a copy of the atom instead, ensuring each cell has an independent instance. Additionally, a new test suite testTableCellsHaveIndependentLeadingAtoms has been added to verify this fix across matrix, cases, and aligned environments. No review comments were provided, so there is no feedback to address.

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.

@kostub

kostub commented Jun 12, 2026

Copy link
Copy Markdown
Owner Author

EM-REVIEW v1

Verdict: No blocking issues. Ready to merge.

Reviewed the full diff and surrounding source in MTMathAtomFactory.m and MTMathList.m, and ran the regression test under SPM (including a revert-the-fix mutation check).

(a) Correctness - PASS

  • All three insert sites now use [style copy] / [spacer copy], giving each cell an independent atom (matrix ~L388, eqalign/split/aligned spacer ~L424, cases ~L475).
  • copy invokes the existing deep copyWithZone:. The base MTMathAtom copyWithZone: uses [[self class] allocWithZone:], so [style copy] correctly returns an MTMathStyle and the subclass override (MTMathList.m L894-899) preserves _style. The spacer is a plain MTMathAtom, copied correctly.
  • No render-path behavior change: MTMathAtom finalized already calls [self copy] (MTMathList.m L277) and MTMathTable finalized re-finalizes every cell (L1073-1078), so the typesetter never saw the aliasing. The bug was only observable to clients mutating the unfinalized list.

(b) Regression test - PASS (genuinely catches the bug)

  • testTableCellsHaveIndependentLeadingAtoms asserts XCTAssertNotEqual on distinct-cell leading atoms across matrix (3 pairs), cases (3 pairs), aligned (1 pair). Cell-structure indexing is correct: cases wraps the table in an MTInner with comma spacer at innerList index 0 and table at index 1 (matches L483); aligned spacer lives at column-1 atoms[0] (matches L420-424).
  • Verified empirically: test PASSES on PR head; reverting just the aligned spacer site to insertAtom:spacer made it FAIL with both pointers identical (0x10e7240300600000), confirming it reproduces FUN-8. Restored after.

(c) Wiring - PASS

  • Added to already-wired MTMathListBuilderTest.m; no new test file. Types used (MTInner, MTMathTable, kMTMathAtomInner) already appear elsewhere in this file and resolve via existing imports. No Xcode project changes required. Compiles and runs under swift test.

(d) Leaks / over-copy - PASS

  • Exactly one copy per cell, ARC-managed. No over-copy. Original template allocation now unused as inserted object but harmless.

Minor (non-blocking)

  • XCTAssertNotEqual on object pointers works (scalar compare) but XCTAssertNotIdentical is the semantically precise API for distinct instances. Optional polish only.

kostub and others added 4 commits June 12, 2026 15:11
Per-cell atom independence is an object-identity property; XCTAssertNotIdentical
expresses that intent more precisely than XCTAssertNotEqual. Available since
Xcode 12.5; the project builds exclusively on Xcode 16.2 / Swift 6.0, so there
is no availability risk.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Mutate one cell's leading atom and assert the other cells are
unaffected, reproducing the actual aliasing failure mode rather than
just checking that the objects are distinct instances.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…al semantics

A style atom is a non-rendering control marker (style is readonly, scripts
already throw), so storing a nucleus on it is meaningless. Override
-setNucleus: to throw on a non-empty value (empty stays allowed so
-copyWithZone: keeps working), completing its immutability.

Reflect the asymmetry in the regression test:
- matrix/cases insert the now-immutable MTMathStyle, so only object sharing
  can go wrong -> assert distinct instances.
- eqalign/aligned insert a plain ordinary spacer whose nucleus renders, so
  exercise the real leak: mutate one cell's spacer, assert others unaffected.

Add testStyleAtomRejectsNucleus covering the new enforcement.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…cases cells

A style atom is a non-rendering control marker — the typesetter reads only
its `style`, never nucleus/type/fontStyle, and scripts are already forbidden.
Lock down -setType:/-setFontStyle: (in addition to -setNucleus:), each
permitting only the value -copyWithZone: assigns. With the atom now immutable,
sharing one instance across all matrix/cases cells is safe, so drop the
per-cell [style copy] there. The eqalign/aligned spacer is a plain mutable
ordinary atom, so it still needs a per-cell copy.

Tests:
- testTableSpacerCellsAreIndependent: mutate one cell's spacer, assert the
  others are unaffected (the real leak; only the mutable spacer needs copying).
- testStyleAtomIsImmutable: assert nucleus/type/fontStyle/subscript are
  rejected while the copy-compatible values stay allowed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@kostub

kostub commented Jun 28, 2026

Copy link
Copy Markdown
Owner Author

/gemini review

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request aims to fix an aliasing bug in table cell creation by copying the mutable spacer atom and making MTMathStyle immutable by overriding its setters to throw exceptions. However, the review feedback correctly notes that throwing exceptions in these setters violates the Liskov Substitution Principle (LSP) and introduces runtime crash risks. It is recommended to keep MTMathStyle mutable and copy it upon insertion instead, and to remove the corresponding unit tests that assert on these exceptions.

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.

Comment thread iosMath/lib/MTMathList.m
Comment on lines +901 to +934
// A style atom is a non-rendering control marker: the typesetter reads only its `style`, never
// its nucleus/type/fontStyle, and scripts are already forbidden via -scriptsAllowed. Make it
// fully immutable so the same instance can be shared across table cells without aliasing bugs.
// Each setter permits only the value that the inherited -copyWithZone: assigns (so copying keeps
// working) and rejects anything else.
- (void)setNucleus:(NSString *)nucleus
{
if (nucleus.length > 0) {
@throw [[NSException alloc] initWithName:@"Error"
reason:@"Nucleus cannot be set on a style atom; it is a non-rendering control marker."
userInfo:nil];
}
[super setNucleus:nucleus];
}

- (void)setType:(MTMathAtomType)type
{
if (type != kMTMathAtomStyle) {
@throw [[NSException alloc] initWithName:@"Error"
reason:@"The type of a style atom cannot be changed."
userInfo:nil];
}
[super setType:type];
}

- (void)setFontStyle:(MTFontStyle)fontStyle
{
if (fontStyle != kMTFontStyleDefault) {
@throw [[NSException alloc] initWithName:@"Error"
reason:@"Font style cannot be set on a style atom; it is a non-rendering control marker."
userInfo:nil];
}
[super setFontStyle:fontStyle];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Overriding setters to throw exceptions in a subclass of a mutable class (MTMathAtom) violates the Liskov Substitution Principle (LSP) and introduces a significant risk of runtime crashes.

For example, any generic list traversal or manipulation code that dynamically updates properties (such as fontStyle or nucleus) on all atoms in a list will crash with an unhandled exception if it encounters an MTMathStyle atom:

for (MTMathAtom *atom in list.atoms) {
    atom.fontStyle = kMTFontStyleBold; // Will crash here!
}

Instead of enforcing runtime immutability via exceptions, MTMathStyle should remain a standard mutable atom. To prevent the cell-sharing aliasing bug, simply copy the MTMathStyle instance when inserting it into the table cells in MTMathAtomFactory.m, just like you did for the spacer atom:

[row[j] insertAtom:[style copy] atIndex:0];

Comment on lines +1430 to +1446
// FUN-8: a style atom is a non-rendering control marker (the typesetter reads only its `style`).
// It must be fully immutable so matrix/cases can share one instance across all cells safely.
- (void) testStyleAtomIsImmutable
{
MTMathStyle *style = [[MTMathStyle alloc] initWithStyle:kMTLineStyleText];
// nucleus, type and fontStyle are all meaningless for a style atom and must be rejected.
XCTAssertThrows(style.nucleus = @"x", @"style atom must reject a non-empty nucleus");
XCTAssertThrows(style.type = kMTMathAtomOrdinary, @"style atom must reject a type change");
XCTAssertThrows(style.fontStyle = kMTFontStyleBold, @"style atom must reject a font style");
// Scripts are already forbidden for style atoms via -scriptsAllowed.
XCTAssertThrows(style.subScript = [MTMathListBuilder buildFromString:@"x"], @"style atom must reject a subscript");
// The values copyWithZone: assigns must remain allowed so deep copying still works.
XCTAssertNoThrow(style.nucleus = @"");
XCTAssertNoThrow(style.type = kMTMathAtomStyle);
XCTAssertNoThrow(style.fontStyle = kMTFontStyleDefault);
XCTAssertNoThrow([style copy]);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Since we should remove the setter overrides that throw exceptions on MTMathStyle to avoid violating the Liskov Substitution Principle, this test asserting those exceptions are thrown is no longer needed and should be removed.

Code-review follow-up: superScript is gated by the same -scriptsAllowed
predicate as subScript, but assert it explicitly so the immutability
contract is fully covered.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
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.

1 participant