Posts

Doing neurosurgery on SwiftData

by George Bougakov · ·

One big benefit of SwiftUI and SwiftData is that it does a lot of stuff for you, and abstracts pretty much everything, so you can just kick back and make cool apps. The flip side of this is that it is a pain in the back to debug — be it the dreaded “Swift compiler is unable to type-check this expression in a reasonable time” or something else. In my case, my app started randomly crashing out of nowhere with an EXC_BREAKPOINT error code somewhere in SwiftData code.

Since you are here, I will assume that the same (or something similar) is happening right now to you. My sincere condolences, we will get through this together.

This post is going to be less of a comprehensive debugging manual and more of a case study, which hopefully might help with the general direction on where to dig, because there is barely any information on debugging this, apart from some open Apple Developer Forums threads.

Paul Hudson has a few useful articles on debugging SwiftData, and his SwiftData by Example tutorials were immensely helpful for me, however here we will try to dig a bit deeper. So, without further ado, let’s look at the anamnesis.

Our patient

Naam is a domain management app, that has a somewhat convoluted data schema structured in a way to reduce unnecessary repetition of code. Here is an extremely simplified version of the data schema:

struct Service: Codable {
    let name: String
    let logo: String?
    let url: URL
}

struct DNSData: Codable {
    let nameServers: [String]
    let dnsProvider: Service
    let mailProvider: Service?
}

@Model
final class Domain {
    var name: String
    var expiry: Date
    var dnsData: DNSData
    
    init(name: String, expiry: Date, dnsData: DNSData) {
        self.name = name
        self.expiry = expiry
        self.dnsData = dnsData
    }
}

The domain has a few parameters, like its name (e.g. example.com), expiry date, and also data that can be fetched from the DNS, like the nameservers, DNS provider and mail provider. Since we want to display providers nicely in the UI, they have some properties like a name, logo, and link.

However, while all domains functioning on the internet will have at least one nameserver, and as a consequence a DNS provider, not all domains have a mail provider (for example, example.com does not).

gbgk@aluminium ~ % host -t MX example.com
example.com mail is handled by 0 .

Due to this, we will mark mailProvider optional.

Symptoms

After launching the app and adding some domains, the app randomly crashes. There appears to be nothing specific causing the issue, and there is no helpful error message.

Let’s start the examination

Since clicking through the stack frames does not yield anything remotely useful, the next step is to detach the debugger and generate a proper crash report.

How do I get a crash report?
  1. In the Xcode console, next to (lldb) type detach and hit enter.
  2. Copy the crash report from your development device using this manual from Apple, or if you are using a simulator, open Console.app and select Crash reports in the sidebar.
Thread 0 Crashed::  Dispatch queue: NSManagedObjectContext 0x60000350c9a0
0   SwiftData                     	       0x1d2b75c80 0x1d2b40000 + 220288
1   SwiftData                     	       0x1d2b772c4 0x1d2b40000 + 225988
2   libswiftCore.dylib            	       0x194c8cbd8 _KeyedDecodingContainerBox.decode<A>(_:forKey:) + 236
3   libswiftCore.dylib            	       0x194c82904 KeyedDecodingContainer.decode(_:forKey:) + 36
4   swiftdatacrashpoc.debug.dylib 	       0x100e98c3c Service.init(from:) + 332
5   swiftdatacrashpoc.debug.dylib 	       0x100e99178 protocol witness for Decodable.init(from:) in conformance Service + 20
6   libswiftCore.dylib            	       0x194efdfe0 dispatch thunk of Decodable.init(from:) + 16
7   SwiftData                     	       0x1d2b767d4 0x1d2b40000 + 223188

Okay, this is something! We can see in frames 2 through 6 references to the decoding of our struct Service which would suggest that SwiftData is having trouble decoding it from whatever format it stores it in.

However, if you look closely, you might see that frame 4 references Service.init(from:) — a method that we have not defined for our struct. That’s because when a struct conforms to Codable, the Swift compiler auto-synthesizes this method for us. Let’s write out all decoders manually to see where the problem lies — thankfully with Xcode it’s extremely easy, just type init in the struct code and accept two suggestions — first the one that initializes the struct from properties (init(name: ...) and then the one that creates the struct from a Decoder (init(from decoder: ...)

Let’s do that for both Service and DNSData and re-run the app. And now, look at that, now the debugger is showing us the precise line where the error is occurring:

We can now do some print-debugging by placing print statements before every decoding operation in the DNSData initializer:

init(from decoder: any Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    self.nameServers = try container.decode([String].self, forKey: .nameServers)
    
    print("decode dnsProvider")
    self.dnsProvider = try container.decode(Service.self, forKey: .dnsProvider)
    
    print("decode mailProvider")
    self.mailProvider = try container.decodeIfPresent(Service.self, forKey: .mailProvider)
    
    print("done")
}

After re-running the app we can see that all print statements are executed except done, meaning only decoding of mailProvider fails.

Since mailProvider is optional, the next logical step would be to see if mailProvider not being nil fixes the crash — that’s pretty trivial to check, and we do indeed see that the app does not crash if mailProvider is set to something.

What is quite weird is that mailProvider is being decoded with the decodeIfPresent method, which should return nil if there is no container for key .mailProvider, but it does not happen. If we check decoder.allKeys in the Service initializer, we can see that it returns an empty array. This means that the container is created, but is empty.

That is quite unusual, but actually makes perfect sense if you look at how SwiftData stores our Domain in the SQLite database.

Let’s take an example domain:

Domain(
  name: "example.com",
  expiry: Date(),
  dnsData: DNSData(
    nameServers: ["ns.example.com"],
    dnsProvider: Service(
      name: "Example",
      logo: "example",
      url: URL(string: "https://example.com")!
    ),
    mailProvider: nil
  )
)

SwiftData converts it into this SQL record (written out here as JSON for readability):

{
	"name": "example.com",
	"expiry": "2025-01-01T...",
	"nameServers": "[encoded as binary data]",
	"name1": "Example",
	"logo1": "example",
	"url1": "https://example.com",
	"name2": null,
	"logo2": null,
	"url2": null
}

Which makes sense on the surface, but if you spend a few minutes looking at this JSON, you might realize that there is some ambiguity.

If name2 is NULL, what does it mean? Does it mean that mailProvider.name is nil? It would make sense, if not for the fact that mailProvider.name just cannot be nil, it’s not optional!

Sounds a bit confusing? Well it is! And SwiftData also gets confused, so it tries to decode the abovementioned record into this:

Domain(
  name: "example.com",
  expiry: Date(),
  dnsData: DNSData(
    nameServers: ["ns.example.com"],
    dnsProvider: Service(
      name: "Example",
      logo: "example",
      url: URL(string: "https://example.com")!
    ),
    mailProvider: Service(
      name: nil, // Because name2 is null (uh oh!!!)
      logo: nil, // Because logo2 is null (this is fine, logo is optional)
      url: nil // Because url2 is null (uh oh again!!!)
    )
  )
)

…which breaks our code because name and url are not optional!

So, to summarize, let’s retrace what our app is doing:

  1. When launched, it creates a ModelContainer
  2. Since SwiftData can find the SQLite database with the data, it tries to read it
  3. SwiftData finds the record with a mailProvider equal to nil
  4. SwiftData starts decoding the record, and since structs are flattened in the database, assumes that mailProvider exists (even though it is optional, and that is not guaranteed!)
  5. Decoding initializer of Service receives a container, but since all values in the database are null, the container is empty
  6. The decode tries to read the value from the container, but there is none
  7. Kaboom

Treatment

There are a few options for how we can fix this. The most obvious and blunt one is just not using optional structs, but that is not optimal. Another option is for us to manually check if the container is empty or not before decoding it.

To do that, we first need to explicitly define the CodingKeys of Service. That is quite easy, just type CodingKeys inside the Service struct definition and let Xcode autofill it for you.

After that, replace the decoding of mailProvider in the DNSData initializer with this:

init(from decoder: any Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    
    self.nameServers = try container.decode([String].self, forKey: .nameServers)
    self.dnsProvider = try container.decode(Service.self, forKey: .dnsProvider)

    // Get the containter for mailProvider
    let nestedMailContainer = try container.nestedContainer(keyedBy: Service.CodingKeys.self, forKey: .mailProvider)
    
    // Check if all values in the container are null / the container is empty
    if nestedMailContainer.allKeys.isEmpty {
        self.mailProvider = nil
    } else {
        self.mailProvider = try container.decodeIfPresent(Service.self, forKey: .mailProvider)
    }
}

If you rebuild and launch the app now, it should start without problems!

I have reported the problem to Apple Developer Technical Support but haven’t received any reply yet, however if anything does happen in the future, I will try to update this article with details.

Throughout debugging this problem I have learned quite a bit about debugging SwiftUI apps, and how the internals of SwiftData work, and I hope this article has been useful for you, even if not resolving your issue outright, but at least pointing you in the right direction.

Have you seen other interesting SwiftData behaviors? Feel free to reply with them to this post on Bluesky.

Comments

Reply to this post on Bluesky to post a comment