I opened up the ChatGPT app on my Mac this morning, and noticed something strange: the contents of the update notification was rendering upside-down.
This wasnβt the first time. Users have been reporting similar bugs in the official ChatGPT Mac app for a long time:
Not sure if this is just a bug or a #StrangerThings Easter egg, but @ChatGPTβs macOS app just went full Upside Down on me. π
β Thomas Mirnig (@ThomasMirnig)
A notable characteristic of this bug is that it happens intermittently. Every now and then, certain strips of text in the chat bar or in a notification would display upside down. Not flipped positioning, but rather each individual label rendering as invertedβ¦
I opened up the ChatGPT app on my Mac this morning, and noticed something strange: the contents of the update notification was rendering upside-down.
This wasnβt the first time. Users have been reporting similar bugs in the official ChatGPT Mac app for a long time:
Not sure if this is just a bug or a #StrangerThings Easter egg, but @ChatGPTβs macOS app just went full Upside Down on me. π
β Thomas Mirnig (@ThomasMirnig)
A notable characteristic of this bug is that it happens intermittently. Every now and then, certain strips of text in the chat bar or in a notification would display upside down. Not flipped positioning, but rather each individual label rendering as inverted. Only some elements were affected, while other views and buttons rendered fine.
Since I had the bug in front of me, I figured I might as well take a stab at fixing it.
Attaching the Debugger
I attached LLDB to the running ChatGPT process and poked at the problematic CALayer. The upside-down text was rendered by a SwiftUI internal layer class called CGDrawingLayer.
(lldb) po (BOOL)[(CALayer*)0x98b357360 isGeometryFlipped]
NO
(lldb) po (BOOL)[[(CALayer*)0x98b357360 superlayer] isGeometryFlipped]
YES
The layer hierarchy looked correct: parent flipped, child unflipped, which is standard for AppKit.
Unlike iOS where all views use a top-left origin, AppKitβs default coordinate system has its origin at the bottom-left, with Y increasing upward. Views can override isFlipped to return YES for a top-left origin instead. When a view is layer-backed, AppKit needs to reconcile these coordinate systems with Core Animation, which always uses bottom-left origin.
AppKit handles this by setting each layerβs geometryFlipped property based on whether the viewβs coordinate system differs from its ancestorβs. I used Hopper to disassemble AppKitβs _updateLayerGeometryFromView and found the calculation:
geometryFlipped = self.isFlipped XOR ancestor.isFlipped
So YES XOR YES = NO. The math checked out.
So given that, Iβd expect this to quickly fix the problem:
(lldb) expr [(CALayer*)0x98b357360 setNeedsDisplay]
(lldb) expr [(CALayer*)0x98b357360 displayIfNeeded]
The text rendered correctly. Forcing a redraw fixed it. This told me the layer configuration was fine now. The problem was in the initial draw.
I dug deeper and found something strange. The view hierarchy was fully connected:
(lldb) expr -l objc -O -- [[[[(CALayer*)0x8fba682a0 superlayer] delegate] superview] superview]
<AppKitPlatformViewHost: 0x8fb51c000> β
But the layer hierarchy was not:
(lldb) expr -l objc -O -- [[(CALayer*)0x8fba682a0 superlayer] superlayer]
nil β BUG!
This was the clue. When you call addSubview(), the viewβs superview is set immediately. But the backing layer tree is still in flux during the transaction. If a layer is displayed before its ancestor chain has fully settled, you have a race condition.
In a properly connected hierarchy, an NSHostingView (flipped) inside another flipped view yields YES XOR YES = NO. The child layer doesnβt need its own flip since the parent already handles it. Core Animation then uses this to set up the CGContext CTM (current transformation matrix) when drawing.
But when superlayer.superlayer is nil, thereβs no ancestor to XOR against. The calculation falls back to just the viewβs own flip state, producing the wrong geometryFlipped value. This propagates to the CTM:
Connected hierarchy:
βββββββββββββββββββββββββββββββββββ
β superlayer.superlayer (flipped) β
β ββ superlayer (flipped) β β YES XOR YES = NO
β ββ CGDrawingLayer β β geometryFlipped = NO β
βββββββββββββββββββββββββββββββββββ β CTM d = +2 (correct)
Disconnected hierarchy:
βββββββββββββββββββββββββββββββββββ
β superlayer.superlayer = nil β
β ββ superlayer (flipped) β β YES XOR ??? = ???
β ββ CGDrawingLayer β β geometryFlipped = wrong
βββββββββββββββββββββββββββββββββββ β CTM d = -2 (upside-down)
The d component of the CTM controls Y-axis scaling. A positive value means Y increases upward (standard for text). A negative value inverts the Y-axis, rendering every glyph upside-down.
Finding the Pattern
With the core issue identified, I was still pretty far from identifying the root cause. Debugging a compiled app where the bug only happens intermittently is tedious. Fortunately, Stephan Casas (OpenAI) had posted a repo reproducing this exact issue. The repo demonstrates embedding SwiftUI views inside an NSTextField using NSTextAttachmentCell, and exhibits the same intermittent upside-down rendering.
This gave me a minimal reproduction to work with, rather than poking at ChatGPTβs binary.
The Smoking Gun
I needed to catch the exact moment things went wrong. I added a swizzle for CALayer.display that logged the layer hierarchy state at first display:
@objc func swizzled_display() {
let className = String(cString: object_getClassName(self))
if className.contains("CGDrawingLayer") && self.contents == nil {
let grandparentExists = (self.superlayer?.superlayer != nil)
NSLog("CGDrawingLayer FIRST DISPLAY [%@]", grandparentExists ? "OK" : "BAD")
if !grandparentExists {
NSLog("π¨ LAYER HIERARCHY NOT CONNECTED!")
NSLog(" superlayer.superlayer is nil")
}
}
self.swizzled_display() // call original
}
On a bad launch, the logs confirmed my suspicion:
CGDrawingLayer FIRST DISPLAY [BAD]
π¨ LAYER HIERARCHY NOT CONNECTED!
superlayer.superlayer is nil
The layer was being displayed before its ancestor chain was connected.
The Code Smell
Looking at the repro code more carefully, I found two architectural mistakes:
1. Mutating attributedStringValue during draw()
override func draw(_ dirtyRect: NSRect) {
self.reformatAttributedString(forRect: dirtyRect) // Mutation!
super.draw(dirtyRect)
}
This triggered text system relayout during the draw pass.
2. Calling addSubview from NSTextAttachmentCell.draw()
The cellβs draw(withFrame:in:) method was adding the hosting view to the view hierarchy. This method is meant for drawing content into a graphics context, not for managing view hierarchies.
Together, these created a cascade:
draw()mutatesattributedStringValue- Text system relayouts
HostingCell.draw()gets calledaddSubview()is called- SwiftUI creates new
CGDrawingLayers CA::Transaction::commit()triggers display- Display runs before layer hierarchy is connected
- Wrong CTM β upside-down text
Validating the Theory
I extended the swizzle to log more details and compared good vs bad launches:
Good launch:
CGDrawingLayer FIRST DISPLAY [OK]
gp_exists=1 superlayer.flip=1
Layer addresses: 0x89e0dd6c0, 0x89e0dd650, 0x89e0dd5e0
Bad launch:
CGDrawingLayer FIRST DISPLAY [BAD]
gp_exists=0 superlayer.flip=0
Layer addresses: 0x89e89d730, 0x89e89d7a0, 0x89e89d810
The CTM values confirmed the theory:
- Good:
d = +2(unflipped, correct for text) - Bad:
d = -2(flipped, text renders upside-down)
I also found a natural 100% repro: make the window small enough to cause text truncation, close the app, and relaunch. The truncation forces complex layout recalculation during the initial draw, widening the race window.
The Quick Workaround
My first fix was to defer addSubview using CATransaction.setCompletionBlock:
override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {
let frame = cellFrame
CATransaction.setCompletionBlock { [weak self] in
guard let self = self, self.contentHost.superview == nil else { return }
controlView.addSubview(self.contentHost)
self.contentHost.frame = frame
}
}
This worked. By the time the completion block ran, the layer hierarchy was connected. But this is ultimately a hacky solution, since I was still triggering view hierarchy changes from a draw() context, it was just happening later.
The Proper Fix
The practical problem with NSTextAttachmentCell is that it only gives you a concrete frame in draw(withFrame:in:). If you want to host a live view (not just draw into a context), you end up managing that view from within the draw callbackβexactly where you donβt want to mutate hierarchies.
NSTextAttachmentViewProvider (macOS 12+) solves this by design. Its loadView() method is called as part of layout and view resolution, not from the drawing callback:
class HostingAttachmentViewProvider<Content: View>: NSTextAttachmentViewProvider {
private let contentBuilder: () -> Content
override func loadView() {
// TextKit calls this and handles addSubview for us
self.view = NSHostingView(rootView: contentBuilder())
}
}
class HostingAttachment<Content: View>: NSTextAttachment {
override func viewProvider(
for parentView: NSView?,
location: NSTextLocation,
textContainer: NSTextContainer?
) -> NSTextAttachmentViewProvider? {
return HostingAttachmentViewProvider(...)
}
}
With this approach:
- No manual
addSubview()- TextKit handles view insertion - TextKit inserts the view during layout/attachment resolution, avoiding the race condition
- No draw-time mutations
This fix is showcased in the demo below:

First, I set up a complex (broken) layout, producing a wide race window. I then restart the app, triggering the bug. Switching to the new approach (NSTextAttachmentViewProvider) resolves the bug and layout issues.
Back to ChatGPT
With the reproduction demo solved, I went back to ChatGPT to figure out if this was the exact same bug.
Turns out, ChatGPT behaves quite differently. Setting a symbolic breakpoint on -[NSView addSubview:] and logging backtraces revealed that ChatGPT wasnβt using NSTextAttachmentCell at all. There were no addSubview calls happening during draw(). This was likely the same underlying bug, the layer hierarchy race condition, but triggered through a different code path.
This made it harder to debug, since the bug wasnβt 100% reproducible in the app. It mostly occurred in the update prompt banner, and only intermittently.
After getting my hands on an older version of the app, I cleared the Sparkle SULastCheckTime defaults key to force the update prompt to appear:
defaults delete com.openai.chat SULastCheckTime
Then I launched with the debugger attached, breakpoint armed, and waited for the update banner to render. When it did, I captured the backtrace:
* frame #0: AppKit`-[NSView addSubview:]
frame #1: ChatGPT`___lldb_unnamed_symbol106841 + 852
frame #2: ChatGPT`___lldb_unnamed_symbol106825 + 704
frame #3: ChatGPT`MarkdownTextBlock.updateView(_:context:) + 924
frame #4: SwiftUI`PlatformViewRepresentableAdaptor.updateViewProvider(_:context:)
...
frame #20: SwiftUI`NSHostingView._willUpdateConstraintsForSubtree()
frame #22: AppKit`-[NSView _updateConstraintsForSubtreeIfNeeded...]
...
frame #94: QuartzCore`CA::Transaction::run_commit_handlers(CATransactionPhase)
frame #95: QuartzCore`CA::Transaction::commit()
There it was. MarkdownTextBlock, an NSViewRepresentable that renders the "Update available" markdown, was calling addSubview from within its updateView(_:context:) method. And this was happening during the constraint update phase, inside CA::Transaction::commit().
Disassembling the unnamed symbols revealed what was happening: updateView calls a helper that iterates through text attachments (embedded views in the markdown), calculates their frames with CGRectOffset, and adds each one as a subview.
The cascade is slightly different, but leads to the same race:
CA::Transaction::commit()starts- Constraint update phase runs (
_willUpdateConstraintsForSubtree) - SwiftUI calls
MarkdownTextBlock.updateView(_:context:) updateViewiterates text attachments and callsaddSubview()for each- View hierarchy connects immediately
- Transaction continues to display phase
CGDrawingLayer.display()runs before layer hierarchy is connected- Wrong CTM β upside-down text
Visualizing both paths:
Repro (NSTextAttachmentCell)
draw()
βββ addSubview()
βββ view.superview = β (immediate)
βββ layer.superlayer.superlayer = nil (not yet attached)
βββ layoutSubtreeIfNeeded()
βββ creates CGDrawingLayer
βββ displayIfNeeded()
βββ π displays with nil grandparent β wrong CTM
ChatGPT (NSViewRepresentable)
CA::Transaction::commit()
βββ constraint update phase
β βββ updateView()
β βββ addSubview()
β βββ view.superview = β (immediate)
β βββ layer.superlayer.superlayer = nil (not yet attached)
βββ display phase
βββ π CGDrawingLayer.display() may run before
layer hierarchy connects β wrong CTM
Same underlying race condition, but with a different trigger.
While the trigger isnβt draw(), itβs the same class of problem. SwiftUI may call updateView() during constraint update phases, which happen inside CA::Transaction::commit(). Adding subviews at that moment creates the same timing hazard: the layer hierarchy may not be fully connected when Core Animation runs the display phase.
The fix would be the same as the reproduction demo: if MarkdownTextBlockView uses TextKit internally, switch to NSTextAttachmentViewProvider for embedded views. If attachments are managed manually, defer addSubview calls using CATransaction.setCompletionBlock, or restructure it entirely to let SwiftUI manage the attachment views.
Lessons Learned
This wasnβt a framework bug. Core Animationβs deferred rendering pipeline is by design. It would be expensive to rebuild and flush the entire render tree synchronously on every addSubview(), so CA batches changes into transactions. The timing sensitivity only manifests when you add views during phases where you shouldnβt.
View and layer hierarchies connect at different times. addSubview() updates the view hierarchy immediately, but backing layers and their rendered state are driven by Core Animation transactions. A layer can end up displaying before all of its ancestor state (flips, transforms, or hosting containers) has fully settled.
1.
Donβt modify the view hierarchy during draw or layout passes. The core mistake of the reproduction demo was using draw() for more than rendering into a graphics context.
1.
For NSViewRepresentable, create your view hierarchy in makeNSView(). In the ChatGPT app, the core mistake was modifying the view hierarchy in updateNSView(). SwiftUI may call updateView() during constraint updates, so modifying the view hierarchy there risks a race condition. If you must add views dynamically, defer the addition to after the current transaction commits.
Code
The demo and fix for the reproduction repository are available in this PR.
β Thomas Mirnig (