Summary
NSFont keeps a process-global font cache that is read and mutated with no synchronization, so creating and releasing fonts on more than one thread corrupts the heap.
On macOS, NSFont instances are immutable and font lookup is safe from any thread, so code written against Cocoa crashes under GNUstep. This looks like the same class of defect as gnustep/libs-base#312 (NSCache is not thread-safe), which was resolved by giving the class an internal lock.
Environment
- GNUstep Base
1.31, GUI 0.32, Back 0.32 (X11 backend)
libs-gui at f03c34410
- clang /
libobjc2 / libdispatch
- Debian 13, kernel 6.12.101, x86_64
The unguarded state
Source/NSFont.m declares the cache at line 209:
/* Cache all created fonts for reuse. */
static NSMapTable* globalFontMap = 0;
It is read and mutated from instance initialisation and from -dealloc:
NSMapGet(globalFontMap, (void *)key) — line 842, in -initWithName:matrix:fix:screenFont:role:
NSMapInsert(globalFontMap, (void *)key, (void *)self) — line 881, same method
NSMapRemove(globalFontMap, (void *)key) — line 904, in -dealloc
There is no locking anywhere in the file — grep -c 'NSLock\|@synchronized\|lock]' Source/NSFont.m returns 0. One thread can therefore remove a dying font's entry while another inserts or looks one up.
Two further process-global structures in the same file look similarly exposed:
static NSFont *placeHolder (line 200), the shared instance that +fontWithName:size: sends -initWithName:... to (lines 724, 762)
font_roles[role].cachedFont (lines 351, 415, 430), the cache behind +systemFontOfSize: and friends, assigned with ASSIGN and torn down with DESTROY in setNSFont
Observed crashes
A Markdown renderer building attributed strings on several dispatch threads crashed at three different sites across runs, which is the signature of corrupted shared state rather than a fault at one call site:
#0 NSMapRemove () from libgnustep-base.so.1.31
#0 objc_msgSend_fpret () from libobjc.so.4.6
#1 libgnustep-gui.so.0.32
#2 -[NSFontManager convertFont:toHaveTrait:]
#0 objc_retain () / objc_release () from libobjc.so.4.6
#1 libgnustep-base.so.1.31
Crash rate before any mitigation: 6 crashes in 12 consecutive test-suite runs. After serialising every code path that builds AppKit text objects behind one process-wide lock: 0 crashes in 16 runs.
Reproducer
A standalone program whose worker threads only call +[NSFont fontWithName:size:] crashes 10/10 runs. It is attached to the companion backend issue, gnustep/libs-back#239, because its own crash lands in libfontconfig via libgnustep-back rather than in globalFontMap.
Two control variants from that issue localise the problem and are relevant here too:
| Variant |
Result |
| Concurrent create and release |
10 / 10 crashed |
Same, with @synchronized ([NSFont class]) around the font work |
0 / 10 crashed |
| Fonts pre-created and retained on the main thread, so workers only hit the cache |
0 / 10 crashed |
The cache-hit read path is fine; creation and destruction are what break.
To be clear about what is and is not demonstrated: the unguarded globalFontMap above is a source-inspection finding, consistent with the NSMapRemove backtrace, but the minimal reproducer does not by itself prove that particular race — it crashes in the backend first.
Suggested fix
Give the font cache the same treatment NSCache received: a static lock held across the cache check, backend construction, and insert in -initWithName:matrix:fix:screenFont:role:, and across the NSMapRemove in -dealloc. The placeHolder initialisation path and the font_roles cache would need the same guard.
Note that the backend also crashes on its own under this workload (gnustep/libs-back#239), so locking here may serialise enough to hide that rather than fix it.
Summary
NSFontkeeps a process-global font cache that is read and mutated with no synchronization, so creating and releasing fonts on more than one thread corrupts the heap.On macOS,
NSFontinstances are immutable and font lookup is safe from any thread, so code written against Cocoa crashes under GNUstep. This looks like the same class of defect as gnustep/libs-base#312 (NSCache is not thread-safe), which was resolved by giving the class an internal lock.Environment
1.31, GUI0.32, Back0.32(X11 backend)libs-guiatf03c34410libobjc2/libdispatchThe unguarded state
Source/NSFont.mdeclares the cache at line 209:It is read and mutated from instance initialisation and from
-dealloc:NSMapGet(globalFontMap, (void *)key)— line 842, in-initWithName:matrix:fix:screenFont:role:NSMapInsert(globalFontMap, (void *)key, (void *)self)— line 881, same methodNSMapRemove(globalFontMap, (void *)key)— line 904, in-deallocThere is no locking anywhere in the file —
grep -c 'NSLock\|@synchronized\|lock]' Source/NSFont.mreturns 0. One thread can therefore remove a dying font's entry while another inserts or looks one up.Two further process-global structures in the same file look similarly exposed:
static NSFont *placeHolder(line 200), the shared instance that+fontWithName:size:sends-initWithName:...to (lines 724, 762)font_roles[role].cachedFont(lines 351, 415, 430), the cache behind+systemFontOfSize:and friends, assigned withASSIGNand torn down withDESTROYinsetNSFontObserved crashes
A Markdown renderer building attributed strings on several dispatch threads crashed at three different sites across runs, which is the signature of corrupted shared state rather than a fault at one call site:
Crash rate before any mitigation: 6 crashes in 12 consecutive test-suite runs. After serialising every code path that builds AppKit text objects behind one process-wide lock: 0 crashes in 16 runs.
Reproducer
A standalone program whose worker threads only call
+[NSFont fontWithName:size:]crashes 10/10 runs. It is attached to the companion backend issue, gnustep/libs-back#239, because its own crash lands inlibfontconfigvialibgnustep-backrather than inglobalFontMap.Two control variants from that issue localise the problem and are relevant here too:
@synchronized ([NSFont class])around the font workThe cache-hit read path is fine; creation and destruction are what break.
To be clear about what is and is not demonstrated: the unguarded
globalFontMapabove is a source-inspection finding, consistent with theNSMapRemovebacktrace, but the minimal reproducer does not by itself prove that particular race — it crashes in the backend first.Suggested fix
Give the font cache the same treatment
NSCachereceived: a static lock held across the cache check, backend construction, and insert in-initWithName:matrix:fix:screenFont:role:, and across theNSMapRemovein-dealloc. TheplaceHolderinitialisation path and thefont_rolescache would need the same guard.Note that the backend also crashes on its own under this workload (gnustep/libs-back#239), so locking here may serialise enough to hide that rather than fix it.