NSPasteboard crashes due to unsafe, internal concurrent memory mutation when handling file promises

This is a public reposting of FB14885505, in case it’s helpful to anyone else or especially in case someone else has seen this too and knows how to work around it.

NSPasteboard mutates itself simultaneously from the main thread and the global concurrent Dispatch pool, w.r.t. to its internal type cache. This is surprisingly trivial to reproduce (sample code below) by just dropping, e.g. a file promise (such as by opening a PNG in Preview, revealing the thumbnails sidebar, and then dragging the thumbnail onto the sample project’s window).

import SwiftUI

struct ContentView: View {
    var body: some View {
        Rectangle().onDrop(of: NSImage.imageTypes, isTargeted: nil) { _ in
            let pb = NSPasteboard(name: .drag)

            _ = pb.pasteboardItems // Seems to be necessary for the crash.

            _ = NSImage.imageTypes // Not strictly necessary for the crash, but seems to make it more likely. 🤷‍♂️

            return true
        }
    }
}

Judging from the callstack that runs in the concurrent pool, this is specific to file promises (and that seems to match my experience – it only crashes for some test cases, all of which involve file promises being present in the drag pasteboard at the time of the drop).

Since this bug causes semi-random memory corruption, it manifests in a large number of ways – not all of which are all that helpful. But at least one case I’ve seen a few times is helpful, as it clearly shows the offending internal NSPasteboard code running concurrently with itself, e.g.:

Main queue / thread:
#0	0x00007ff80108dab5 in _platform_bzero$VARIANT$Haswell ()
#1	0x000000010df8774d in GuardMalloc_mallocInternal ()
#2	0x00007ff90792542f in stack_logging_lite_malloc ()
#3	0x00007ff800ea8733 in _malloc_zone_malloc_instrumented_or_legacy ()
#4	0x00007ff800f39a72 in _vasprintf ()
#5	0x00007ff800f16922 in asprintf ()
#6	0x00007ff80125655a in -[NSObject(NSObject) __dealloc_zombie] ()
#7	0x00007ff8020e039a in -[NSConcreteMapTable dealloc] ()
#8	0x00007ff804876983 in -[NSPasteboard _updateTypeCacheIfNeeded] ()
#9	0x00007ff8048763df in -[NSPasteboard _typesAtIndex:combinesItems:] ()
#10	0x00007ff804aad148 in NSCoreDragReceiveMessageProc ()
#11	0x00007ff807517b1a in CallReceiveMessageCollectionWithMessage ()
#12	0x00007ff8075124fa in DoMultipartDropMessage ()
#13	0x00007ff8075122ce in DoDropMessage ()
#14	0x00007ff8075159a9 in CoreDragMessageHandler ()
#15	0x00007ff8011d776b in __CFMessagePortPerform ()
#16	0x00007ff80113e5b7 in __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__ ()
#17	0x00007ff80113e4ee in __CFRunLoopDoSource1 ()
#18	0x00007ff80113d166 in __CFRunLoopRun ()
#19	0x00007ff80113c112 in CFRunLoopRunSpecific ()
#20	0x00007ff80bb55a09 in RunCurrentEventLoopInMode ()
#21	0x00007ff80bb55646 in ReceiveNextEventCommon ()
#22	0x00007ff80bb55561 in _BlockUntilNextEventMatchingListInModeWithFilter ()
#23	0x00007ff8047acc61 in _DPSNextEvent ()
#24	0x00007ff8050c0dc0 in -[NSApplication(NSEventRouting) _nextEventMatchingEventMask:untilDate:inMode:dequeue:] ()
#25	0x00007ff80479e075 in -[NSApplication run] ()
#26	0x00007ff804771ff3 in NSApplicationMain ()
#27	0x00007ff90dc24557 in ___lldb_unnamed_symbol57096 ()
#28	0x00007ff90e31fe64 in ___lldb_unnamed_symbol104448 ()
#29	0x00007ff90e6e63ff in static SwiftUI.App.main() -> () ()
#30	0x000000010dfa5cce in static NSPasteboardItem_CrashApp.$main() ()
#31	0x000000010dfa5d69 in main at /Users/SadPanda/Documents/NSPasteboardItem Crash/NSPasteboardItem Crash/NSPasteboardItem_CrashApp.swift:11
#32	0x00007ff800cd5366 in start ()

Dispatch concurrent queue (default QoS):
#0	0x00007ff80111b45c in -[__NSSetM addObject:] ()
#1	0x00007ff80487692e in -[NSPasteboard _updateTypeCacheIfNeeded] ()
#2	0x00007ff8048763df in -[NSPasteboard _typesAtIndex:combinesItems:] ()
#3	0x00007ff804aa9597 in -[NSPasteboard _canRequestDataForType:index:usesPboardTypes:combinesItems:] ()
#4	0x00007ff804fdd161 in -[NSPasteboard _dataForType:index:usesPboardTypes:combinesItems:securityScoped:] ()
#5	0x00007ff804aa7c4b in -[NSPasteboardItem dataForType:] ()
#6	0x00007ff8055804af in -[NSFilePromiseReceiver receivePromisedFilesAtDestination:options:operationQueue:reader:] ()
#7	0x00007ff90e787b51 in ___lldb_unnamed_symbol131674 ()
#8	0x00007ff90e9bc340 in ___lldb_unnamed_symbol148147 ()
#9	0x00007ff8020d00ba in __NSBLOCKOPERATION_IS_CALLING_OUT_TO_A_BLOCK__ ()
#10	0x00007ff8020cffb8 in -[NSBlockOperation main] ()
#11	0x00007ff8020cff4b in __NSOPERATION_IS_INVOKING_MAIN__ ()
#12	0x00007ff8020cf1ec in -[NSOperation start] ()
#13	0x00007ff8020cef0d in __NSOPERATIONQUEUE_IS_STARTING_AN_OPERATION__ ()
#14	0x00007ff8020cedde in __NSOQSchedule_f ()
#15	0x000000010e68ce7d in _dispatch_block_async_invoke2 ()
#16	0x000000010e67ca7b in _dispatch_client_callout ()
#17	0x000000010e67fa09 in _dispatch_continuation_pop ()
#18	0x000000010e67eae8 in _dispatch_async_redirect_invoke ()
#19	0x000000010e6906a9 in _dispatch_root_queue_drain ()
#20	0x000000010e6911ba in _dispatch_worker_thread2 ()
#21	0x000000010dfb832f in _pthread_wqthread ()
#22	0x000000010dfbebeb in start_wqthread ()

There doesn’t appear to be any workaround (short of not supporting drops at all!).

The more complicated the drop handler the more likely it is to promptly crash upon drop – in my real code with a non-trivial handler, it’s virtually guaranteed to crash on the second drop containing a file promise, while in the vastly reduced sample code (above) it can take dozens of drops before it finally crashes outright.

I have not directly tested whether this NSPasteboard bug occurs in the absence of SwiftUI, so I don’t strictly know if the root cause is in AppKit or SwiftUI. However, since most SwiftUI apps don’t support drag-and-drop, but plenty of AppKit ones do and manage to not crash when given the exact same test cases, I do strongly suspect SwiftUI is causing this somehow.

☝️ You may wonder why I’m directly accessing the drag pasteboard rather than using the NSItemProviders provided by SwiftUI. It’s because that API is horribly broken – in many cases the provided NSItemProvider(s) are duds that contain no actual data. So I have to use the drag pasteboard directly in order to stand any chance of supporting drag & drop.

Also, the NSItemProvider-based API is harder to use and doesn’t support important aspects of drag-and-drop, like file promises (although, with NSPasteboard apparently corrupting itself when file promises are received, I guess none of Apple’s APIs do anymore 😔).


Follow-up (September 12th, 2024)

I actually received a response from Apple, from a real human (or at least a convincing AI). Ultimately their response didn’t help as it contained some mistakes, but I’m hopeful there’ll be more follow-up and a productive conclusion. And in the interim, they did assert a few things which are important to know, and are not otherwise documented by Apple:

  • SwiftUI’s onDrop(of:isTargeted:perform:) method makes no claims or promises as to what thread / queue it executes the closure on, and in fact according to the anonymous Apple engineer it never executes the closure on the main thread.

    Now, while that may be the intent, the reality of that is wrong – in my experience it always executes the closure on the main thread (which makes a lot of sense to me as drag-and-drop event handling in AppKit has always been on the main thread in practice).

    Nonetheless, Apple says one cannot rely on the current behaviour and should in fact assume it never executes on the main thread (though in practice that means you have to check, not assume, since if you blindly do something like DispatchQueue.main.sync { … } in your drop handler your code will deadlock, today).
  • NSPasteboard is not safe to use outside the main thread / queue.

    This isn’t documented anywhere public – not in NSPasteboard‘s documentation itself, nor the ancient Application Kit Framework Thread Safety documentation.

    I wouldn’t be surprised if it’s broadly true, as the AppKit APIs involving it always seemed main-thread centric anyway (all the handlers and delegate methods involving pasteboards are invoked on the main thread, in my experience). And it’s generally best to assume everything in AppKit is main-thread-only unless it’s explicitly documented otherwise.

    However, it’s important to note that Apple’s own code doesn’t follow this rule – e.g. NSFilePromiseReceiver, internally, uses NSPasteboard from the global concurrent queue.

Even though Apple’s initial response to this bug report hasn’t been all that fruitful, I do want to emphasise the fact that they did respond, which was a pleasant surprise and very much appreciated.

Leave a Comment