Skip to content

Guard the global appearance behind one lock - #458

Merged
tannevaled merged 1 commit into
mainfrom
guard-the-appearance-globals
Sep 6, 2026
Merged

Guard the global appearance behind one lock#458
tannevaled merged 1 commit into
mainfrom
guard-the-appearance-globals

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

The metric scale, the touch density, the active font and the OpenType size a
host last asked for are package globals. A host sets them; every widget
reads them, on every metric it lays out. Nothing synchronised the two.

Found by -race from an application whose tests set the scale in one test while
another was constructing a ListBox. It is not a test artefact — moving a
window between displays is exactly this pairing: the scale set from whatever
thread the display-change event arrives on, while the UI is laying out at the
old one. What a race costs here is not a crash but silence: chrome laid out at
one scale around type measured at another.

One RWMutex for the four, because they are one fact — how big is everything.
SetMetricScale re-renders the font, so the scale and the font change together,
and two locks taken in two orders is how a toolkit deadlocks on a display
change.

The invariant, written down in appearance.go: no call holds the lock
across another call that takes it. SetMetricScale drops it before
rescaleText, which installs a face through SetFont, which takes it again;
CurrentFont reads the scale once and passes it to scaledDefaultFont rather
than reading it again, because RLock is not safely reentrant either — a writer
arriving between two nested RLocks deadlocks both.

Evidence

TestAppearanceSurvivesConcurrentUse runs three writers and six readers over
the four globals.

races result
without this change 8 FAIL
with it 0 ok

Full suite green, plain and under -race.

🤖 Generated with Claude Code

The metric scale, the touch density, the active font and the OpenType size
a host last asked for are package globals: a host SETS them, and every
widget READS them on every metric it lays out. Nothing synchronised the
two.

Found by -race from an application whose tests set the scale in one test
while another was constructing a list. It is not a test artefact: moving a
window between displays is exactly this pairing — the scale set from
whatever thread the display-change event arrives on, while the UI is laying
out at the old one. What a race costs here is not a crash but silence,
chrome laid out at one scale around type measured at another.

One RWMutex for the four, because they are one fact — "how big is
everything" — SetMetricScale re-renders the font so the scale and the font
change together, and two locks taken in two orders is how a toolkit
deadlocks on a display change.

The invariant, written down in appearance.go: no call holds the lock across
another call that takes it. SetMetricScale drops it before rescaleText,
which installs a face through SetFont, which takes it again; CurrentFont
reads the scale once and passes it to scaledDefaultFont rather than reading
it again, because RLock is not safely reentrant either — a writer arriving
between two nested RLocks deadlocks both.

The test fails without the change: eight races over one run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 41f1bec into main Sep 6, 2026
1 check passed
@tannevaled
tannevaled deleted the guard-the-appearance-globals branch September 6, 2026 16:19
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