Skip to content

NSFont's globalFontMap cache is mutated without locking, so concurrent font creation corrupts the heap #932

Description

@danjboyd

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions