Published on

Laggy SPM refresh

Authors

I'm going to paint a picture most of you have probably seen: you make any change to your Package.swift file, and unlike literally any other IDE, Xcode freezes, reloads the entire Package.swift file, and only stops the beachball (on any open editor, mind you) a couple minutes later. This is a pretty terrible UX, but has been the status quo since the introduction of SPM.

Can anything be done about it? Probably! It's hard to tell because we don't have access to the source. Here, I'll be trying to patch Xcode to make it a little less terrible.

Investigation

The (still spinning indicator in the project that caused me to write this post on a whim) says "reloading package <package name>." We'll start there to try and find some SPM core library in Xcode.

We get a hit (Xcode 14.3) in PlugIns/IDESwiftPackageCore.framework. This is a private framework, but it's probably the correct private framework for finding out what's going on with SPM.

Copilot keeps suggesting I write something about opening up the application in Hopper, as opposed to my usual Cutter. It seems pretty neat, but costs $100 😱. Additionally, seems that Ghidra is better, I'll stick with Cutter for now.

Anyway, while we wait, we may as well profile Xcode to see what it's doing. I'm using Instruments to profile Xcode, and I attach after triggering a refresh by changing Package.swift.

Looking at instruments, we see a few calls to the IDESwiftPackageCore and SwiftPM modules. The SPMWorkspace class caught my eye. It's referenced in the aforementioned IDESwiftPackageCore as well as IDEFoundation and SwiftPM.Framework. It seems to be the main class for interacting with SPM. A quick google finds an open source SwiftPMWorkspace but no SPMWorkspace. We can't look at source directly, so lets keep looking through the logs.

A few calls elsewhere (on what I assume is a thread waiting for a file access) I find a call to IDESwiftWorkspace.noteOnePackageNeedsReloading, followed by SPMWorkspace.processPackageGraphActionsInBackgroundIfNeeded. This seems to be the trigger for all of our freezes!

Going up the chain in IDESwiftPackageCore, we see that noteOnePackageNeedsReloading is called from packageStructureDidChange_at_ which terminates in a call from SPMPackageDirectoryStructureFileSystemWatcherDelegate.packageStructureFileSystemWatcherDeDidChange_at:.

It seems fairly clear what's causing our block! Now, what to do about it?

Patching

The best option would probably be shoving it on a background thread, but I have no real confidence of my ability to write a good concurrent patch. Another option would be to shift to an explicit refresh model: instead of refreshing on every change, we could refresh on... something else? I'm not sure where the other calls are to the terminal method: reloadPackage. However, a search of Xcode's binaries revealed that there's one more framework which mentions SPMWorkspace: IDEFoundation.

Not much luck here: these methods seem to just deal with setting up the workspace, not refreshing it.

Anyway, the current idea is to just test our above hypothesis by removing the call to reloadPackage. The simplest place to do this is at the top level: SPMPackageDirectoryStructureFileSystemWatcherDelegate.packageStructureFileSystemWatcherDeDidChange_at:. We'll just replace the call with a noop, and see what happens!

Actually getting the patch to take effect (gone wrong)

Feeling happy and victorious, we fire up the patch aaaand... it crashes! "A memory corruption was found in executable text" Right after Xcode's last log message, we see CODE SIGNING: cs_invalid_page(0x111caf000): p=46541[Xcode] final status 0x23006a00, denying page sending SIGKILL

Ah, we have to resign the modified binary. Luckly, we made a backup before modifying it, so we can just resign with the metadata of the backup signature.

Using codesign --preserve-metadata --force -vvv --sign "Apple Development" Contents/PlugIns/IDESwiftPackageCore.framework/Versions/A/IDESwiftPackageCore

We start again, and this time it crashes on launch!

AMFI: disallowing exception handler for 'Xcode' (pid: 48428, team: ''), because the handler was set by non-eligible process 'com.apple.dt.ins' (38100) with Team ID '' (not master-entitled).

At this point, codesigning with my own cert seems a little bleak.

We have one last avenue open to us: we can lean on dyld to load our own code. I'm a little worried about this approach: there are a few ways that it can be blocked and it wouldn't surprise me if Xcode did one of them.

And yup. Injecting a dylib with DYLD_INSERT_LIBRARIES fails

...reason: mapping process is a platform binary, but mapped file is not

Some further inspection casts light on Xcode being hardened and ignoring all dyld environment variables. We could resign / unsign xcode, but then we'd lose apple-only private entitlements that xcode probably has associated with it.

Welp, that's the end of my endeavor. If I wanted to push further, I'd probably resign everything inside Xcode with my own cert and see if anything breaks or uses private entitlements, but there's probably something and it seems like a waste of time. I could also try to disable SIP. That would definitely work, but I'd rather not turn off security for my whole system.

Oh well. Perhaps someone smarter and / or with access to an apple signing certificate will come up with a solution. Until then, time to think about other matters every time Xcode decides to lock the UI on a package refresh.