iOS 13 NSKeyedUnarchiver EXC_BAD_ACCESS

Hi all,


I've spent the better part of a day and half now trying to debug this issue I'm seeing when trying to unarchive a Data blob stored locally (this issue also appears when retrieving it via iCloud, but since they run through the same codepath, I assume they're related).


Background

I originally built this app four years ago, and for reasons that have since been lost to time (but probably because I was more of a novice back then), I relied on the AutoCoding library to get objects in my data model to automagically adopt NSCoding (although I did implement the protocol myself in some places -- like I said, I was an novice) and FCFileManager for saving these objects to the local documents directory. The data model itself is fairly simple: custom NSObjects that have various properties of NSString, NSArray, and other custom NSObject classes (but I will note there are a number of circular references; most of them declared as strong and nonatomic in header files). This combination has (and still does) work well in the production version of the app.


However, in a future update, I'm planning on adding saving/loading files from iCloud. While I've been building that out, I've been looking to trim down my list of third-party dependencies and update older code to iOS 13+ APIs. It so happens that FCFileManager relies on the now-deprecated +[NSKeyedUnarchiver unarchiveObjectWithFile:] and +[NSKeyedArchiver archiveRootObject: toFile:], so I've focused on rewriting what I need from that library using more modern APIs.


I was able to get saving files working pretty easily using this:

@objc static func save(_ content: NSCoding, at fileName: String, completion: ((Bool, Error?) -> ())?) {
        CFCSerialQueue.processingQueue.async { // my own serial queue
            measureTime(operation: "[LocalService Save] Saving") { // just measures the time it takes for the logic in the closure to process
                do {
                    let data: Data = try NSKeyedArchiver.archivedData(withRootObject: content, requiringSecureCoding: false)
                    // targetDirectory here is defined earlier in the class as the local documents directory
                    try data.write(to: targetDirectory!.appendingPathComponent(fileName), options: .atomicWrite)
                    if (completion != nil) {
                        completion!(true, nil)
                    }
                } catch {
                    if (completion != nil) {
                        completion!(false, error)
                    }
                }
            }
        }
    }


And this works great -- pretty fast and can still be loaded by FCFileManager's minimal wrapper around +[NSKeyedUnarchiver unarchiveObjectWithFile:].


Problem

But loading this file _back_ from the local documents directory has proved to be a massive challenge. Here's what I'm working with right now:

    @objc static func load(_ fileName: String, completion: @escaping ((Any?, Error?) -> ())) {
        CFCSerialQueue.processingQueue.async {// my own serial queue
            measureTime(operation: "[LocalService Load] Loading") {
                do {
                    // targetDirectory here is defined earlier in the class as the local documents directory
                    let combinedUrl: URL = targetDirectory!.appendingPathComponent(fileName) 
                    if (FileManager.default.fileExists(atPath: combinedUrl.path)) {
                        let data: Data = try Data(contentsOf: combinedUrl)
                        let obj: Any? = try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data)
                        completion(obj, nil)
                    } else {
                        completion(nil, ServiceError.generic(message: "Data not found at URL \(combinedUrl)"))
                    }
                } catch {
                    completion(nil, error)
                }
            }
        }
    }


I've replaced FCFileManager's +[NSKeyedUnarchiver unarchiveObjectWithFile:] with the new +[NSKeyedUnarchiver unarchiveTopLevelObjectWithData:], but I'm running into EXC_BAD_ACCESS code=2 crashes when getting execution flowing through that line. The stacktrace is never particularly helpful; it's usually ~1500 frames long and jumping between various custom -[NSObject initWithCoder:] implementations. Here's an example (comments added for context, clarity, and conciseness):


@implementation Game
@synthesize AwayKStats,AwayQBStats,AwayRB1Stats,AwayRB2Stats,AwayWR1Stats,AwayWR2Stats,AwayWR3Stats,awayTOs,awayTeam,awayScore,awayYards,awayQScore,awayStarters,gameName,homeTeam,hasPlayed,homeYards,HomeKStats,superclass,HomeQBStats,HomeRB1Stats,HomeRB2Stats,homeStarters,HomeWR1Stats,HomeWR2Stats,HomeWR3Stats,homeScore,homeQScore,homeTOs,numOT,AwayTEStats,HomeTEStats, gameEventLog,HomeSStats,HomeCB1Stats,HomeCB2Stats,HomeCB3Stats,HomeDL1Stats,HomeDL2Stats,HomeDL3Stats,HomeDL4Stats,HomeLB1Stats,HomeLB2Stats,HomeLB3Stats,AwaySStats,AwayCB1Stats,AwayCB2Stats,AwayCB3Stats,AwayDL1Stats,AwayDL2Stats,AwayDL3Stats,AwayDL4Stats,AwayLB1Stats,AwayLB2Stats,AwayLB3Stats,homePlays,awayPlays,playEffectiveness, homeStarterSet, awayStarterSet;

-(id)initWithCoder:(NSCoder *)aDecoder {
    self = [super init];
    if (self) {
        // ...lots of other decoding...

        // stack trace says the BAD_ACCESS is flowing through these decoding lines
        // @property (atomic) Team *homeTeam;
        homeTeam = [aDecoder decodeObjectOfClass:[Team class] forKey:@"homeTeam"];
        // @property (atomic) Team *awayTeam;
        // there's no special reason for this line using a different decoding method;
        // I was just trying to test out both
        awayTeam = [aDecoder decodeObjectForKey:@"awayTeam"]; 

        // ...lots of other decoding...
    }
    return self;
}


Each Game object has a home and away Team; each team has an NSMutableArray of Game objects called gameSchedule, defined as such:

@property (strong, atomic) NSMutableArray<game*> *gameSchedule;


Here's Team's initWithCoder: implementation:

-(id)initWithCoder:(NSCoder *)coder {
    self = [super initWithCoder:coder];
    if (self) {
        if (teamHistory.count > 0) {
           if (teamHistoryDictionary == nil) {
               teamHistoryDictionary = [NSMutableDictionary dictionary];
           }
           if (teamHistoryDictionary.count < teamHistory.count) {
               for (int i = 0; i < teamHistory.count; i++) {
                   [teamHistoryDictionary setObject:teamHistory[i] forKey:[NSString stringWithFormat:@"%ld",(long)([HBSharedUtils currentLeague].baseYear + i)]];
               }
           }
        }

        if (state == nil) {
           // set the home state here
        }

        if (playersTransferring == nil) {
           playersTransferring = [NSMutableArray array];
        }

        if (![coder containsValueForKey:@"projectedPollScore"]) {
           if (teamOLs != nil && teamQBs != nil && teamRBs != nil && teamWRs != nil && teamTEs != nil) {
               FCLog(@"[Team Attributes] Adding Projected Poll Score to %@", self.abbreviation);
               projectedPollScore = [self projectPollScore];
           } else {
               projectedPollScore = 0;
           }
        }

        if (![coder containsValueForKey:@"teamStrengthOfLosses"]) {
           [self updateStrengthOfLosses];
        }

        if (![coder containsValueForKey:@"teamStrengthOfSchedule"]) {
           [self updateStrengthOfSchedule];
        }

        if (![coder containsValueForKey:@"teamStrengthOfWins"]) {
           [self updateStrengthOfWins];
        }
    }
    return self;
}


Pretty simple other than for the backfilling of some properties. However, this class imports AutoCoding, which hooks into -[NSObject initWithCoder:] like so:


- (void)setWithCoder:(NSCoder *)aDecoder
{
    BOOL secureAvailable = [aDecoder respondsToSelector:@selector(decodeObjectOfClass:forKey:)];
    BOOL secureSupported = [[self class] supportsSecureCoding];
    NSDictionary *properties = self.codableProperties;
    for (NSString *key in properties)
    {
        id object = nil;
        Class propertyClass = properties[key];
        if (secureAvailable)
        {
            object = [aDecoder decodeObjectOfClass:propertyClass forKey:key]; // where the EXC_BAD_ACCESS seems to be coming from
        }
        else
        {
            object = [aDecoder decodeObjectForKey:key];
        }
        if (object)
        {
            if (secureSupported && ![object isKindOfClass:propertyClass] && object != [NSNull null])
            {
                [NSException raise:AutocodingException format:@"Expected '%@' to be a %@, but was actually a %@", key, propertyClass, [object class]];
            }
            [self setValue:object forKey:key];
        }
    }
}

- (instancetype)initWithCoder:(NSCoder *)aDecoder
{
    [self setWithCoder:aDecoder];
    return self;
}


I did some code tracing and found that execution flows through line 12 of this snippet. Based on some logging I added, it seems like propertyClass somehow gets deallocated before getting passed to -[NSCoder decodeObjectOfClass:forKey:]. However, Xcode shows that propertyClass has a value when the crash occurs (see screenshot: https://imgur.com/a/J0mgrvQ


The property in question in that frame is defined:

@property (strong, nonatomic) Record *careerFgMadeRecord;

and has the following properties itself:


@interface Record : NSObject
@property (strong, nonatomic) NSString *title;
@property (nonatomic) NSInteger year;
@property (nonatomic) NSInteger statistic;
@property (nonatomic) Player *holder;
@property (nonatomic) HeadCoach *coachHolder;
// … some functions
@end


This class also imports AutoCoding, but has no custom initWithCoder: or setWithCoder: implementation.


Curiously, replacing the load method I’ve written with FCFileManager’s version also crashes in the same fashion, so this could be more of an issue with how the data is being archived than how it’s being retrieved. But again, this all works fine when using FCFileManager’s methods to load/save files, so my guess is that there’s some lower-level difference between the implementation of archives in iOS 11 (when FCFileManager was last updated) and iOS 12+ (when the NSKeyedArchiver APIs were updated).


Per some suggestions I've found online (like this one), I also tried this:

    @objc static func load(_ fileName: String, completion: @escaping ((Any?, Error?) -> ())) {
        CFCSerialQueue.processingQueue.async {
            measureTime(operation: "[LocalService Load] Loading") {
                do {
                    let combinedUrl: URL = targetDirectory!.appendingPathComponent(fileName)
                    if (FileManager.default.fileExists(atPath: combinedUrl.path)) {
                        let data: Data = try Data(contentsOf: combinedUrl)
                        let unarchiver: NSKeyedUnarchiver = try NSKeyedUnarchiver(forReadingFrom: data)
                        unarchiver.requiresSecureCoding = false;
                        let obj: Any? = try unarchiver.decodeTopLevelObject(forKey: NSKeyedArchiveRootObjectKey)
                        completion(obj, nil)
                    } else {
                        completion(nil, ServiceError.generic(message: "Data not found at URL \(combinedUrl)"))
                    }
                } catch {
                    completion(nil, error)
                }
            }
        }
    }

However, this still throws the same EXC_BAD_ACCESS when trying to decode.


Does anyone have any insight into where I might be going wrong here? I’m sure it’s something simple, but I just can’t seem to figure it out. I have no problem providing more source code if needed to dive deeper.


Thanks for your help!

Replies

unarchiveTopLevelObjectWithData is deprecated.


https://developer.apple.com/documentation/foundation/nskeyedunarchiver?language=objc


Also, when you use the newer

+ (id)unarchivedObjectOfClass:(Class)cls 
fromData:(NSData *)data
error:(NSError * _Nullable *)error;


or


+ (id)unarchivedObjectOfClasses:(NSSet<Class> *)classes 
fromData:(NSData *)data
error:(NSError * _Nullable *)error;


You need to specify every class in the object. So if you have an array that contains some dictionaries, for example, you need to indicate both the array and dictionary in classes.

Thanks for the advice, @janabanana! Those other methods specifying specific classes to load didn't work to eliminate the EXC_BAD_ACCESS, so now I'm trying loading data using -[NSKeyedUnarchiver decodeTopLevelObjectForKey:] like this:

    @objc static func load(_ fileUrl: URL, completion: @escaping ((Any?, Error?) -> ())) {
        CFCSerialQueue.processingQueue.async {
            measureTime(operation: "[iCloudService Load] Loading") {
                do {
                    try validateTargetDirectory()
                    if (FileManager.default.fileExists(atPath: fileUrl.path)) {
                        let data: Data = try Data(contentsOf: fileUrl)
                        let unarchiver: NSKeyedUnarchiver = try NSKeyedUnarchiver(forReadingFrom: data)
                        unarchiver.requiresSecureCoding = false;
                        let obj: Any? = try unarchiver.decodeTopLevelObject(forKey: NSKeyedArchiveRootObjectKey)
                        completion(obj, nil)
                    } else {
                        completion(nil, ServiceError.generic(message: "Data not found at URL \(fileUrl)"))
                    }
                } catch {
                    completion(nil, error)
                }
            }
        }
    }

and made some adjustments to saving as well:


    @objc static func save(_ content: NSCoding, at fileName: String, completion: ((Bool, Error?) -> ())?) {
        CFCSerialQueue.processingQueue.async {
            measureTime(operation: "[iCloudService Save] Saving") {
                do {
                    try validateTargetDirectory()
                    try validateSourceDirectory()

                    // writing to both local documents directory and iCloud in sequence here
                    let data: Data = try NSKeyedArchiver.archivedData(withRootObject: content, requiringSecureCoding: false)
                    try data.write(to: sourceDirectory!.appendingPathComponent(CFC_LOCAL_FILE_NAME), options: .atomicWrite)
                  
                    let combinedUrl: URL = targetDirectory!.appendingPathComponent(fileName)
                    try? FileManager.default.removeItem(at: combinedUrl)
                    try data.write(to: targetDirectory!.appendingPathComponent(fileName), options: .atomicWrite) //saving the file directly to icloud

                    if (completion != nil) {
                        completion!(true, nil)
                    }
                } catch {
                    if (completion != nil) {
                        completion!(false, error)
                    }
                }
            }
        }
    }


I've written a unit test for this, and it looks like when the target file is saved in the local documents directory, it gets decoded properly when loaded via the above load(). But when I try the flow out on device and the target file is saved in the app's iCloud container/directory, the decoding continues to fails with an EXC_BAD_ACCESS, but now in an even weirder place, where it looks like a Double pointer in some Swift code is getting deallocated early despite a valid value always being passed to the method in question:


private static func formatStatValue(value: Double, stat: FCCombineStat) -> String {
        switch (stat) {
            case .benchReps:
                return String(format: "%.0f reps", value)
            case .fortyYardDash:
                return String(format: "%.2f sec", value)
            case .threeCone:
                return String(format: "%.2f sec", value)
            case .vertical:
                return String(format: "%.1f\"", value)
            case .broadJump: // EXC_BAD_ACCCESS occurring on next line, seemingly because value has been deallocated?
                return String(format: "%.0f\'%.0f\"", value / 12.0, value.truncatingRemainder(dividingBy: 12.0))
            default:
                return String(format: "%.1f", value)
        }
    }

Got any ideas on what this could be? Could the issue be further up the stack with something that's been deallocated?


Beyond where the crash has shifted to, is there something that changes in NSKeyedArchiver when writing to the iCloud documents directory that I should be aware of? I worked my way through the docs but didn't find anything specific.


Thanks for your help!