Resolving LLDB "Couldn't IRGen Expression" Errors When Building XCFrameworks Using Carthage

26 May 2021

Pre-building your application's dependencies using Carthage and checking them in to source control has numerous advantages over not doing so. Local build times for developers as well as CI build times are vastly improved, we guarantee the consistency of built dependencies across build machines, and we don't have to dirty up our projects with a hideously over-engineered dependency manager like CocoaPods. However, doing so can lead to LLDB problems for projects developed on more than one machine i.e by development teams larger than one.

The Problem

When attempting to debug a project with pre-built dependencies, you are likely to come across the following error:

(lldb) po view

error: virtual filesystem overlay file

'/Users/​auserwhoisnotyou/​Library/​Caches/org.carthage.CarthageKit/​DerivedData/​12.4_12D4e/hopoate/​1.7.0/​Build/​Intermediates.noindex/​Hopoate.build/Release-iphonesimulator/​Hopoate iOS.build/​all-product-headers.yaml' not found

error: couldn't IRGen expression. Please check the above error messages for possible root causes.

The reason for this error is actually quite simple. LLDB is attempting to use the location on the machine that built the dependencies to lookup debug symbols for our dependency, as absolute paths are stored in the binary Swift module. Solve this, and the problem goes away, right?

Not quite. Even if we fix this, LLDB is still likely to lookup debug information for pre-built dependencies in the wrong location. LLDB's debug-symbol lookup ordering can be broadly described as:

  1. next to the executable/dylib
  2. through any custom .dSYM location mechanism
  3. through Spotlight, using the UUID of the executable/dylib. Note that Spotlight doesn't index /tmp

The dSYMs for our dependencies are not located next to the dylibs themselves as they are not copied in to the application bundle. As we have no custom lookup mechanism specified, LLDB resorts to performing a Spotlight search using the dependency UUIDs. This is where it will often fail, preventing the debug session from operating using symbols.

In summary, we have two problems to solve. We must prevent search paths from being hardcoded in to our built frameworks, and we must provide a way for LLDB to find the dSYMs for our built frameworks during debugging.

The Solution

To solve this debug nightmare, we require a three step solution. The first step ensures portable debug information is generated when building our XCFramework dependencies. Step 2 provides a way for LLDB to locate the correct dSYMs, and the final step automates the whole process to ensure a consistent debugging experience across development machines. Let's get started!

1. Portable Debug Information

This one is actually fairly simple. Start by creating an XCConfig file that will be used to provide build settings to Carthage. It should look like this:

SWIFT_SERIALIZE_DEBUGGING_OPTIONS=NO
OTHER_SWIFT_FLAGS=$(inherited) -Xfrontend -no-serialize-debugging-options

The -no-serialize-debugging-options option prevents information about Swift module search paths being included in the generated .swiftModule. We also set SWIFT_SERIALIZE_DEBUGGING_OPTIONS to NO to prevent Xcode from passing -serialize-debugging-options. Save this as carthage.xcconfig in your repository root directory.

Next, we need to make Carthage use this configuration. We create a shell script named carthage.sh to achieve this:

#!/bin/bash

export XCODE_XCCONFIG_FILE=$PWD/carthage.xcconfig

carthage_command="carthage $@ --platform iOS --use-xcframeworks --no-use-binaries"
$carthage_command

This creates a carthage command using the arguments passed to the script, and exports our config file to XCODE_XCCONFIG_FILE so that Carthage can find it. This allows us to change our Carthage update step from carthage update --platform iOS --use-xcframeworks --no-use-binaries to ./carthage.sh update. A tidier command and portable debug information. Nice!

2. Custom .dSYM Location Mechanism

Note that, as mentioned earlier, LLDB can be configured to look in a custom location for dSYMs before resorting to using Spotlight. The LLDB Documentation for the macOS DebugSymbols.framework includes a section on File Mapped UUID Directories, and this is the technique that we will use. Put simply, each dependency executable has a UUID associated with it that you can find quite easily using dwarfdump:

$ xcrun dwarfdump --uuid ./Carthage/Build/Hopoate.xcframework/ios-arm64/Hopoate.framework/Hopoate 
UUID: AFD8D983-33F0-3110-B71B-07BF1550C026 (arm64) ./Carthage/Build/Hopoate.xcframework/ios-arm64/Hopoate.framework/Hopoate

We can use the UUID from dwarfdump to create a series of directories that result in the final 12-byte chunk of the UUID being used as a file that sym-links back to the location of the corresponding executable's dSYM. Remember that as our built dependencies are checked in to source control, these paths can be created relative to the repository root directory, making them portable across machines. For our example above, the directory structure would be AFD8/​D983/​33F0/​3110/​B71B/​07BF1550C026, with 07BF1550C026 being the file linked to the appropriate dSYM.

Let's see how we can automate the process to generate these file mapped directories. Create a script named create-uuid-dirs.sh and populate it with the following:

#!/bin/bash

# Find all executables embedded in our built XCFrameworks
executable_paths=`ls -l Carthage/Build/*xcframework/*/*.framework/* | grep -Ev '\.h$' | grep "\-rwx" | awk '{print $NF}'`

for executable_path in $executable_paths
do
	# $executable_path will resemble something like Carthage/Build/Hopoate.xcframework/ios-arm64/Hopoate.framework/Hopoate

	# Get the executable name from the last path component
	executable_name=$(echo $executable_path | cut -d'/' -f 6)

	# Get the path to the framework architecture directory inside the parent xcframework directory, relative to the Carthage root directory
	architecture_directory_path=$(echo $executable_path | cut -d'/' -f 2,3,4)

	# Create the path to the architecture's dSYM file that was created when the XCFramework was built
	dSYM_path=$architecture_directory_path/dSYMS/$executable_name.framework.dSYM/Contents/Resources/DWARF/$executable_name

	# Extract the UUID from the executable.
	dwarf_dump=`xcrun dwarfdump --uuid $executable_path`
	UUID=${dwarf_dump:6:36}

	# Create the UUID directory structure required by LLDB's File Mapped UUID Directories
	directory_path=Carthage/dSYMs/UUIDs/${UUID:0:4}/${UUID:4:4}/${UUID:9:4}/${UUID:14:4}/${UUID:19:4}

	# Create the directories
	mkdir -p $directory_path

	# The final leaf node to be sym-linked to the dSYM file
	final_node=${UUID:24:12}

	# Sym-link back to the executable's dSYM
	ln -s ../../../../../../../$dSYM_path $directory_path/$final_node
done

There's rather a lot here, so let's walk through it piece by piece.

In order to find all of the individual architecture frameworks that exist within our built XCFrameworks, we use the ls command: ls -l Carthage/​Build/​*xcframework/​*/*.framework/*. This finds everything inside the individual frameworks, not just executables, so we pass it to grep "\-rwx", narrowing our results to just the executables. In some cases, a framework vendor may have left a public header as executable, so to avoid those we grep -Ev '\.h$'. All we want is the paths to the executables from ls's output, so we use awk '{print $NF}' to grab the last part of each line of the output, which are the paths to the executables.

Next, we loop over the paths that we just extracted, and perform a 7-step process:

  1. We extract the executable name in to exectuable_name using cut to take the last component of the executable path, which is the name of the built framework.
  2. Get the path to the architecture directory within the XCFramework, again using cut on the executable path. Store it in architecture_directory​_path.
  3. Calculate the path to the original dSYM using the exectuable_name and architecture_directory​_path created earlier, and store it in dSYM_path.
  4. Use dwarfdump to extract the UUID of the executable.
  5. Use the UUID to create a directory hierarchy in Carthage/dSYMs/UUIDs/ in the structure required by LLDB.
  6. Generate the name of the file that will exist in the final leaf directory of the directories that we just created, and store it in final_node.
  7. Sym-link the final_node file to the dSYM-path we created earlier.

Note that we have created a new directory structure in Carthage/dSYMs/UUIDs/. These should be checked in to source control alongside your built dependencies.

3. Automating dSYM Generation and Lookup

Now that we have our file mapped directory generation script, we need to add a call to it in the carthage.sh script we made earlier:

#!/bin/bash

set -euo pipefail

export XCODE_XCCONFIG_FILE=$PWD/carthage.xcconfig

carthage_command="carthage $@ --platform iOS --use-xcframeworks --no-use-binaries"
$carthage_command

rm -rf Carthage/dSYMs
bash ./create-uuid-dirs.sh

This ensures that whenever a Carthage command is run the existing file mapped directories are removed and new ones are created.

Next, we need to tell LLDB where to look to find our UUID directories. We can do this with a simple Run Script build phase in our Xcode project:

repository_root=$(git rev-parse --show-toplevel)

carthage_uuid_directory_path=$repository_root/Carthage/dSYMs/UUIDs
defaults write com.apple.DebugSymbols DBGFileMappedPaths $carthage_uuid_directory_path

This sets the default value for DBGFileMappedPaths to the root of our generated file mapped directory structure. Adding this as a build phase guarantees that your debug session will look in the correct location for your dependency debug information. You can also verify that this step has set the location correctly by running defaults read com.apple.DebugSymbols DBGFileMappedPaths in your terminal.

Conclusion

Anyone who has worked on a team using Carthage for dependency management will know the pain of the "couldn't IRGen expression" error. Hopefully, with the tips and tricks in this article, you can resolve the issue once and for all. I would like to thank the good folks at Strava, whose article on fixing this problem for non-XCFramework dependencies pointed me in the right direction regarding the use of File Mapped UUID Directories to provide LLDB with the correct debugging information.