diff --git a/iosMath/lib/MTMathAtomFactory.m b/iosMath/lib/MTMathAtomFactory.m index 962770d..ea91fe2 100644 --- a/iosMath/lib/MTMathAtomFactory.m +++ b/iosMath/lib/MTMathAtomFactory.m @@ -11,6 +11,12 @@ #import "MTMathAtomFactory.h" #import "MTMathListBuilder.h" +#import + +// Lock protecting the two genuinely-mutable symbol tables: `commands` (in +// +supportedLatexSymbols) and `textToCommands` (in +textToLatexSymbolNames). +// Read-only tables are guarded only by dispatch_once and need no run-time lock. +static os_unfair_lock gSymbolTableLock = OS_UNFAIR_LOCK_INIT; NSString *const MTSymbolMultiplication = @"\u00D7"; NSString *const MTSymbolDivision = @"\u00F7"; @@ -163,9 +169,13 @@ + (nullable MTMathAtom *)atomForLatexSymbolName:(NSString *)symbolName // Switch to the canonical name symbolName = canonicalName; } - - NSDictionary* commands = [self supportedLatexSymbols]; - MTMathAtom* atom = commands[symbolName]; + + // commands is a genuinely-mutable dict (addLatexSymbol: writes it), so guard. + NSMutableDictionary* commands = [self supportedLatexSymbols]; + MTMathAtom* atom = nil; + os_unfair_lock_lock(&gSymbolTableLock); + atom = commands[symbolName]; + os_unfair_lock_unlock(&gSymbolTableLock); if (atom) { // Return a copy of the atom since atoms are mutable. return [atom copy]; @@ -178,32 +188,43 @@ + (nullable NSString*) latexSymbolNameForAtom:(MTMathAtom*) atom if (atom.nucleus.length == 0) { return nil; } - NSDictionary*>* dict = [MTMathAtomFactory textToLatexSymbolNames]; + // textToLatexSymbolNames is a genuinely-mutable dict (addLatexSymbol: writes it), and the + // per-nucleus `inner` dict is mutated in place by addLatexSymbol:, so the lock must cover the + // FULL read — both the outer dict[...] lookup and the inner[...] lookups (incl. the Bin + // fallback). Resolve `name` to a local under the lock, then copy it out before unlocking. + NSMutableDictionary* dict = [MTMathAtomFactory textToLatexSymbolNames]; + NSString* name = nil; + os_unfair_lock_lock(&gSymbolTableLock); NSDictionary* inner = dict[atom.nucleus]; - if (!inner) { - return nil; - } - NSString* name = inner[@(atom.type)]; - if (name) { - return name; - } - // -[MTMathList finalized] reclassifies leading/orphan/trailing Bin atoms to Un. The - // forward table only ever registers atoms as Bin, so a (nucleus, Un) - // lookup must fall back to the Bin cell to recover the canonical name. - if (atom.type == kMTMathAtomUnaryOperator) { - return inner[@(kMTMathAtomBinaryOperator)]; + if (inner) { + name = inner[@(atom.type)]; + // -[MTMathList finalized] reclassifies leading/orphan/trailing Bin atoms to Un. The + // forward table only ever registers atoms as Bin, so a (nucleus, Un) + // lookup must fall back to the Bin cell to recover the canonical name. + if (!name && atom.type == kMTMathAtomUnaryOperator) { + name = inner[@(kMTMathAtomBinaryOperator)]; + } } - return nil; + // `name` is an immutable NSString stored in the dict; copy guarantees we don't hold a + // reference into the mutable container after unlocking. + NSString* result = [name copy]; + os_unfair_lock_unlock(&gSymbolTableLock); + return result; } + (void)addLatexSymbol:(NSString *)name value:(MTMathAtom *)atom { NSParameterAssert(name); NSParameterAssert(atom); + // Ensure both tables are initialized before taking the lock (dispatch_once + // inside each accessor guarantees a single init; no deadlock since the tokens + // are method-local and the lock is not held during the once-block). NSMutableDictionary* commands = [self supportedLatexSymbols]; - commands[name] = atom; + NSMutableDictionary*>* dict = [self textToLatexSymbolNames]; + + os_unfair_lock_lock(&gSymbolTableLock); + commands[name] = [atom copy]; // copy on write — symmetric with the read side if (atom.nucleus.length != 0) { - NSMutableDictionary*>* dict = [self textToLatexSymbolNames]; NSMutableDictionary* inner = dict[atom.nucleus]; if (!inner) { inner = [NSMutableDictionary dictionaryWithCapacity:1]; @@ -216,12 +237,16 @@ + (void)addLatexSymbol:(NSString *)name value:(MTMathAtom *)atom inner[typeKey] = name; } } + os_unfair_lock_unlock(&gSymbolTableLock); } + (NSArray *)supportedLatexSymbolNames { - NSDictionary* commands = [MTMathAtomFactory supportedLatexSymbols]; - return commands.allKeys; + NSMutableDictionary* commands = [MTMathAtomFactory supportedLatexSymbols]; + os_unfair_lock_lock(&gSymbolTableLock); + NSArray* keys = commands.allKeys; + os_unfair_lock_unlock(&gSymbolTableLock); + return keys; } + (nullable MTAccent*) accentWithName:(NSString*) accentName @@ -272,7 +297,8 @@ + (MTFontStyle)fontStyleWithName:(NSString *)fontName { + (NSDictionary*) textStyles { static NSDictionary* textStyles = nil; - if (!textStyles) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ textStyles = @{ @"text": @(kMTTextStyleRoman), @"textrm": @(kMTTextStyleRoman), @@ -281,7 +307,7 @@ + (MTFontStyle)fontStyleWithName:(NSString *)fontName { @"textsf": @(kMTTextStyleSansSerif), @"texttt": @(kMTTextStyleTypewriter), }; - } + }); return textStyles; } @@ -367,14 +393,15 @@ + (nullable MTMathAtom *)tableWithEnvironment:(NSString *)env rows:(NSArray* matrixEnvs = nil; - if (!matrixEnvs) { + static dispatch_once_t matrixEnvsOnce; + dispatch_once(&matrixEnvsOnce, ^{ matrixEnvs = @{ @"matrix" : @[], @"pmatrix" : @[ @"(", @")"], @"bmatrix" : @[ @"[", @"]"], @"Bmatrix" : @[ @"{", @"}"], @"vmatrix" : @[ @"vert", @"vert"], @"Vmatrix" : @[ @"Vert", @"Vert"], }; - } + }); if ([matrixEnvs objectForKey:env]) { // it is set to matrix as the delimiters are converted to latex outside the table. table.environment = @"matrix"; @@ -493,7 +520,8 @@ + (nullable MTMathAtom *)tableWithEnvironment:(NSString *)env rows:(NSArray*) supportedLatexSymbols { static NSMutableDictionary* commands = nil; - if (!commands) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ commands = [NSMutableDictionary dictionaryWithDictionary:@{ // Greek characters @"alpha" : [MTMathAtom atomWithType:kMTMathAtomVariable value:@"\u03B1"], @@ -883,15 +911,15 @@ + (nullable MTMathAtom *)tableWithEnvironment:(NSString *)env rows:(NSArray*>*) textToLatexSymbolNames { static NSMutableDictionary*>* textToCommands = nil; - if (!textToCommands) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ NSDictionary* commands = [self supportedLatexSymbols]; textToCommands = [NSMutableDictionary dictionaryWithCapacity:commands.count]; for (NSString* command in commands) { @@ -957,14 +986,15 @@ + (NSDictionary*) aliases } inner[typeKey] = command; } - } + }); return textToCommands; } + (NSDictionary*) accents { static NSDictionary* accents = nil; - if (!accents) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ accents = @{ @"grave" : @"\u0300", @"acute" : @"\u0301", @@ -979,14 +1009,15 @@ + (NSDictionary*) aliases @"widehat" : @"\u0302", @"widetilde" : @"\u0303", }; - } + }); return accents; } + (NSDictionary*) accentValueToName { static NSDictionary* accentToCommands = nil; - if (!accentToCommands) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ NSDictionary* accents = [self accents]; NSMutableDictionary* mutableDict = [NSMutableDictionary dictionaryWithCapacity:accents.count]; for (NSString* command in accents) { @@ -1007,14 +1038,15 @@ + (NSDictionary*) accentValueToName mutableDict[acc] = command; } accentToCommands = [mutableDict copy]; - } + }); return accentToCommands; } +(NSDictionary *) delimiters { static NSDictionary* delims = nil; - if (!delims) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ delims = @{ @"." : @"", // . means no delimiter @"(" : @"(", @@ -1049,14 +1081,15 @@ + (NSDictionary*) accentValueToName @"lfloor" : @"\u230A", @"rfloor" : @"\u230B", }; - } + }); return delims; } + (NSDictionary*) delimValueToName { static NSDictionary* delimToCommands = nil; - if (!delimToCommands) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ NSDictionary* delims = [self delimiters]; NSMutableDictionary* mutableDict = [NSMutableDictionary dictionaryWithCapacity:delims.count]; for (NSString* command in delims) { @@ -1077,7 +1110,7 @@ + (NSDictionary*) delimValueToName mutableDict[delim] = command; } delimToCommands = [mutableDict copy]; - } + }); return delimToCommands; } @@ -1085,7 +1118,8 @@ + (NSDictionary*) delimValueToName +(NSDictionary *) fontStyles { static NSDictionary* fontStyles = nil; - if (!fontStyles) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ // \text* commands are handled by the parser via the textStyles // dictionary, so they do NOT appear here. fontStyles = @{ @@ -1106,7 +1140,7 @@ + (NSDictionary*) delimValueToName @"mathbfit": @(kMTFontStyleBoldItalic), @"bm": @(kMTFontStyleBoldItalic), }; - } + }); return fontStyles; } @@ -1115,7 +1149,8 @@ + (NSDictionary*) delimValueToName + (NSDictionary*) stackCommands { static NSDictionary* stackCommands = nil; - if (!stackCommands) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ // Each command maps to a single stretchy cap glyph (a Unicode codepoint). The // typesetter walks the cap's OpenType h_variants first; if no variant is wide // enough, it falls back to the font's HorizontalGlyphAssembly (parts + connector @@ -1142,7 +1177,7 @@ + (NSDictionary*) delimValueToName @"stackrel": [[MTMathStackCommandSpec alloc] initWithOver:nil under:nil displayClass:kMTMathAtomRelation argRoles:@[@(kMTStackArgOver), @(kMTStackArgBase)] inheritsClass:NO], @"stackbin": [[MTMathStackCommandSpec alloc] initWithOver:nil under:nil displayClass:kMTMathAtomBinaryOperator argRoles:@[@(kMTStackArgOver), @(kMTStackArgBase)] inheritsClass:NO], }; - } + }); return stackCommands; } @@ -1192,7 +1227,8 @@ + (MTMathAtomType) inheritedDisplayClassForBase:(MTMathList*)base + (NSDictionary*) stackCommandReverseTable { static NSDictionary* reverseTable = nil; - if (!reverseTable) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ NSDictionary* forward = [self stackCommands]; NSMutableDictionary* mutable = [NSMutableDictionary dictionaryWithCapacity:forward.count]; for (NSString* cmd in forward) { @@ -1201,7 +1237,7 @@ + (MTMathAtomType) inheritedDisplayClassForBase:(MTMathList*)base mutable[key] = cmd; } reverseTable = [mutable copy]; - } + }); return reverseTable; } diff --git a/iosMath/lib/MTMathListBuilder.m b/iosMath/lib/MTMathListBuilder.m index f524067..eb6f8cb 100644 --- a/iosMath/lib/MTMathListBuilder.m +++ b/iosMath/lib/MTMathListBuilder.m @@ -607,10 +607,11 @@ - (BOOL) expectCharacter:(unichar) ch - (NSString*) readCommand { static NSSet* singleCharCommands = nil; - if (!singleCharCommands) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ NSArray* singleChars = @[ @'{', @'}', @'$', @'#', @'%', @'_', @'|', @' ', @',', @'>', @';', @'!', @'\\' ]; singleCharCommands = [[NSSet alloc] initWithArray:singleChars]; - } + }); if ([self hasCharacters]) { // Check if we have a single character command. unichar ch = [self getNextCharacter]; @@ -848,13 +849,14 @@ - (MTMathAtom*) atomForCommand:(NSString*) command - (MTMathList*) stopCommand:(NSString*) command list:(MTMathList*) list stopChar:(unichar) stopChar { static NSDictionary* fractionCommands = nil; - if (!fractionCommands) { + static dispatch_once_t fractionCommandsOnce; + dispatch_once(&fractionCommandsOnce, ^{ fractionCommands = @{ @"over" : @[], @"atop" : @[], @"choose" : @[ @"(", @")"], @"brack" : @[ @"[", @"]"], @"brace" : @[ @"{", @"}"]}; - } + }); if ([command isEqualToString:@"right"]) { if (!_currentInnerAtom) { NSString* errorMessage = @"Missing \\left"; @@ -1009,7 +1011,8 @@ - (MTMathAtom*) buildTable:(NSString*) env firstList:(MTMathList*) firstList row + (NSDictionary*) spaceToCommands { static NSDictionary* spaceToCommands = nil; - if (!spaceToCommands) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ spaceToCommands = @{ @3 : @",", @4 : @">", @@ -1018,7 +1021,7 @@ + (NSDictionary*) spaceToCommands @18 : @"quad", @36 : @"qquad", }; - } + }); return spaceToCommands; } @@ -1093,14 +1096,15 @@ + (NSDictionary*) spaceToCommands + (NSDictionary*) styleToCommands { static NSDictionary* styleToCommands = nil; - if (!styleToCommands) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ styleToCommands = @{ @(kMTLineStyleDisplay) : @"displaystyle", @(kMTLineStyleText) : @"textstyle", @(kMTLineStyleScript) : @"scriptstyle", @(kMTLineStyleScriptScript) : @"scriptscriptstyle", }; - } + }); return styleToCommands; } diff --git a/iosMath/render/MTFontManager.m b/iosMath/render/MTFontManager.m index 5160bd0..b13ba31 100644 --- a/iosMath/render/MTFontManager.m +++ b/iosMath/render/MTFontManager.m @@ -11,6 +11,7 @@ #import "MTFontManager.h" #import "MTFont+Internal.h" +#import const int kDefaultFontSize = 20; @@ -23,7 +24,9 @@ NSString *const MTFontNameFiraMath = @"firamath"; NSString *const MTFontNameNotoSansMath = @"notosansmath"; -@interface MTFontManager () +@interface MTFontManager () { + os_unfair_lock _cacheLock; +} @property (nonatomic, nonnull) NSMutableDictionary* nameToFontMap; @@ -48,6 +51,7 @@ - (instancetype)initPrivate self = [super init]; if (self) { self.nameToFontMap = [[NSMutableDictionary alloc] init]; + _cacheLock = OS_UNFAIR_LOCK_INIT; } return self; } @@ -55,12 +59,14 @@ - (instancetype)initPrivate - (nullable MTFont *)fontWithName:(NSString *)name size:(CGFloat)size { if (!name) { return nil; } // nil name cannot key the cache dictionary + os_unfair_lock_lock(&_cacheLock); MTFont* f = self.nameToFontMap[name]; if (!f) { f = [[MTFont alloc] initFontWithName:name size:size]; - if (!f) { return nil; } // unknown/unloadable font — do not cache - self.nameToFontMap[name] = f; + if (f) { self.nameToFontMap[name] = f; } } + os_unfair_lock_unlock(&_cacheLock); + if (!f) { return nil; } // unknown/unloadable font — do not cache if (f.fontSize == size) { return f; } else { diff --git a/iosMathTests/MTConcurrencyTest.m b/iosMathTests/MTConcurrencyTest.m new file mode 100644 index 0000000..e1ecbab --- /dev/null +++ b/iosMathTests/MTConcurrencyTest.m @@ -0,0 +1,238 @@ +// +// MTConcurrencyTest.m +// iosMath +// +// SEC-3: Thread-safety tests for symbol/lookup tables and font cache. +// Verifies: dispatch_once lazy inits, guarded mutable symbol tables, +// copy-on-write in +addLatexSymbol:value:, and guarded font cache. +// + +#import +#import "MTMathAtomFactory.h" +#import "MTMathListBuilder.h" +#import "MTFontManager.h" +#import "MTFont.h" + +// Number of concurrent workers for stress tests. +static const NSUInteger kConcurrencyDegree = 32; +// Number of iterations per worker. +static const NSUInteger kIterationsPerWorker = 200; + +@interface MTConcurrencyTest : XCTestCase +@end + +@implementation MTConcurrencyTest + +// --------------------------------------------------------------------------- +// Test 1: Concurrent first-touch of all the factory/builder lookup tables. +// --------------------------------------------------------------------------- +// Many threads simultaneously call the accessor methods whose static variables +// were previously guarded only by `if (!table) { … }`. After the fix all 17 +// sites use dispatch_once; this stress test will TSan-detect any residual race. +- (void)testConcurrentTableFirstTouch +{ + dispatch_group_t group = dispatch_group_create(); + dispatch_queue_t q = dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0); + + for (NSUInteger i = 0; i < kConcurrencyDegree; i++) { + dispatch_group_async(group, q, ^{ + for (NSUInteger j = 0; j < kIterationsPerWorker; j++) { + // Factory tables + // Public symbol table accessors (touch the lazy-init paths) + XCTAssertGreaterThan([MTMathAtomFactory supportedLatexSymbolNames].count, 0u); + // Builder tables (exercised indirectly via parse) + XCTAssertNotNil([MTMathListBuilder buildFromString:@"x + y"]); + // A lookup that touches aliases + commands + XCTAssertNotNil([MTMathAtomFactory atomForLatexSymbolName:@"alpha"]); + XCTAssertNotNil([MTMathAtomFactory atomForLatexSymbolName:@"land"]); // alias + XCTAssertNotNil([MTMathAtomFactory accentWithName:@"hat"]); + XCTAssertNotNil([MTMathAtomFactory boundaryAtomForDelimiterName:@"("]); + XCTAssertNotNil([MTMathAtomFactory stackAtomForCommand:@"overrightarrow"]); + XCTAssertNotNil([MTMathAtomFactory stackCommandSpec:@"overset"]); + // Reverse lookup + MTMathAtom* atom = [MTMathAtom atomWithType:kMTMathAtomRelation value:@"←"]; + (void)[MTMathAtomFactory latexSymbolNameForAtom:atom]; + } + }); + } + + long result = dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, 30LL * NSEC_PER_SEC)); + XCTAssertEqual(result, 0, @"Concurrent first-touch should complete without deadlock/crash"); +} + +// --------------------------------------------------------------------------- +// Test 2: Concurrent reads + addLatexSymbol writes. +// --------------------------------------------------------------------------- +// Writers call +addLatexSymbol:value: with unique names; readers call +// atomForLatexSymbolName:, latexSymbolNameForAtom:, and supportedLatexSymbolNames +// concurrently. Without the lock, NSMutableDictionary mutate+read is a data race +// (undefined behavior, crash on corrupt internal state). After the fix it is safe. +- (void)testConcurrentAddAndLookupSymbol +{ + dispatch_group_t group = dispatch_group_create(); + dispatch_queue_t q = dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0); + + NSUInteger writerCount = kConcurrencyDegree / 4; + NSUInteger readerCount = kConcurrencyDegree - writerCount; + + // A nucleus shared by both the writers and the reverse-lookup reader. The writers below + // register names whose atoms all carry this nucleus, so -addLatexSymbol: mutates the SAME + // per-nucleus `inner` NSMutableDictionary that latexSymbolNameForAtom: reads. This is what + // exercises the inner read/write race in latexSymbolNameForAtom: — querying a different + // nucleus (e.g. "→") would touch a different inner dict and never collide. Under TSan the + // pre-fix code (which read inner[...] after unlocking) flags here. + NSString* const sharedNucleus = @"__testSharedNucleus"; + + // Writer threads. All writers share one nucleus + type (kMTMathAtomRelation), so they all + // mutate the SAME inner-dict cell `inner[@(kMTMathAtomRelation)]`. The names alternate + // between two equal-length, low-sorting strings: equal-length + non-descending compare makes + // -addLatexSymbol: actually execute the in-place `inner[typeKey] = name` write on every + // iteration (so the writer genuinely mutates the cell the reader is reading), maximizing the + // race window the early-unlock bug would expose. + NSString* const writeA = @"__a"; + NSString* const writeB = @"__b"; + for (NSUInteger i = 0; i < writerCount; i++) { + dispatch_group_async(group, q, ^{ + for (NSUInteger j = 0; j < kIterationsPerWorker; j++) { + NSString* name = (j & 1) ? writeA : writeB; + MTMathAtom* atom = [MTMathAtom atomWithType:kMTMathAtomRelation value:sharedNucleus]; + [MTMathAtomFactory addLatexSymbol:name value:atom]; + } + }); + } + + // Reader threads: lookup existing and possibly newly-added symbols + for (NSUInteger i = 0; i < readerCount; i++) { + dispatch_group_async(group, q, ^{ + for (NSUInteger j = 0; j < kIterationsPerWorker; j++) { + // Lookup a well-known symbol (always present) + MTMathAtom* atom = [MTMathAtomFactory atomForLatexSymbolName:@"alpha"]; + XCTAssertNotNil(atom, @"Known symbol 'alpha' must be resolvable during concurrent mutation"); + + // Enumerate all known symbol names (touches the live dict) + NSArray* names = [MTMathAtomFactory supportedLatexSymbolNames]; + XCTAssertGreaterThan(names.count, 0u); + + // Reverse lookup on the SAME nucleus the writers mutate — concurrent read of the + // inner dict that addLatexSymbol: is mutating in place. This is the case that the + // earlier "→" reader missed. + MTMathAtom* rel = [MTMathAtom atomWithType:kMTMathAtomRelation value:sharedNucleus]; + (void)[MTMathAtomFactory latexSymbolNameForAtom:rel]; + } + }); + } + + long result = dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, 30LL * NSEC_PER_SEC)); + XCTAssertEqual(result, 0, @"Concurrent addLatexSymbol + lookup must not crash"); +} + +// --------------------------------------------------------------------------- +// Test 3: Added symbols are resolvable after concurrent insertion. +// --------------------------------------------------------------------------- +// Verifies that every symbol added in test 2's writers can actually be found +// afterward (proves the write was durable, not lost due to a clobber). +- (void)testAddedSymbolsAreResolvable +{ + NSMutableArray* names = [NSMutableArray array]; + NSUInteger count = 50; + for (NSUInteger i = 0; i < count; i++) { + NSString* name = [NSString stringWithFormat:@"__verifyResolvable_%lu", (unsigned long)i]; + MTMathAtom* atom = [MTMathAtom atomWithType:kMTMathAtomOrdinary value:@"α"]; + [MTMathAtomFactory addLatexSymbol:name value:atom]; + [names addObject:name]; + } + + for (NSString* name in names) { + MTMathAtom* found = [MTMathAtomFactory atomForLatexSymbolName:name]; + XCTAssertNotNil(found, @"Symbol '%@' should be resolvable after insertion", name); + XCTAssertEqualObjects(found.nucleus, @"α"); + } +} + +// --------------------------------------------------------------------------- +// Test 4: Copy-on-write regression. +// --------------------------------------------------------------------------- +// +addLatexSymbol:value: must copy the atom on write so that a subsequent +// mutation of the caller's atom does NOT affect the stored table entry. +- (void)testAddLatexSymbolCopiesAtomOnWrite +{ + NSString* symName = @"__testCopyOnWrite_SEC3"; + MTMathAtom* original = [MTMathAtom atomWithType:kMTMathAtomOrdinary value:@"A"]; + [MTMathAtomFactory addLatexSymbol:symName value:original]; + + // Mutate the original after insertion + original.nucleus = @"B"; + + // The stored copy must still have the original nucleus + MTMathAtom* retrieved = [MTMathAtomFactory atomForLatexSymbolName:symName]; + XCTAssertNotNil(retrieved, @"Symbol must be found after insertion"); + XCTAssertEqualObjects(retrieved.nucleus, @"A", + @"Stored atom must be a copy — post-insertion mutation of the caller's atom " + "must not affect the table (copy-on-write)"); +} + +// --------------------------------------------------------------------------- +// Test 5: Concurrent font cache access. +// --------------------------------------------------------------------------- +// Many threads call -fontWithName:size: simultaneously. Before the fix, the +// check-then-act on nameToFontMap is a data race. After the fix it is guarded. +- (void)testConcurrentFontCacheAccess +{ + dispatch_group_t group = dispatch_group_create(); + dispatch_queue_t q = dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0); + + for (NSUInteger i = 0; i < kConcurrencyDegree; i++) { + dispatch_group_async(group, q, ^{ + for (NSUInteger j = 0; j < kIterationsPerWorker; j++) { + MTFont* font = [[MTFontManager fontManager] + fontWithName:MTFontNameLatinModern size:20]; + XCTAssertNotNil(font, @"Font must be non-nil under concurrent access"); + XCTAssertEqualWithAccuracy(font.fontSize, 20.0, 0.001); + + // Also exercise the size-variant branch (size != cached size) + MTFont* font2 = [[MTFontManager fontManager] + fontWithName:MTFontNameLatinModern size:14]; + XCTAssertNotNil(font2); + XCTAssertEqualWithAccuracy(font2.fontSize, 14.0, 0.001); + } + }); + } + + long result = dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, 30LL * NSEC_PER_SEC)); + XCTAssertEqual(result, 0, @"Concurrent fontWithName: must not crash"); +} + +// --------------------------------------------------------------------------- +// Test 6: Concurrent parser invocations (exercises builder tables end-to-end). +// --------------------------------------------------------------------------- +- (void)testConcurrentParsing +{ + NSArray* expressions = @[ + @"\\frac{1}{2}", + @"\\sqrt{x^2 + y^2}", + @"\\sum_{i=0}^{n} i", + @"\\int_0^\\infty e^{-x} dx", + @"\\alpha + \\beta = \\gamma", + @"\\overset{\\text{def}}{=}", + @"\\overrightarrow{AB}", + @"\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}", + ]; + + dispatch_group_t group = dispatch_group_create(); + dispatch_queue_t q = dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0); + + for (NSUInteger i = 0; i < kConcurrencyDegree; i++) { + dispatch_group_async(group, q, ^{ + for (NSUInteger j = 0; j < kIterationsPerWorker; j++) { + NSString* expr = expressions[j % expressions.count]; + MTMathList* list = [MTMathListBuilder buildFromString:expr]; + XCTAssertNotNil(list, @"Parse of '%@' must succeed under concurrency", expr); + } + }); + } + + long result = dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, 30LL * NSEC_PER_SEC)); + XCTAssertEqual(result, 0, @"Concurrent parsing must not crash"); +} + +@end