The Preference Pane Update Problem

The NSBundle class in Cocoa is pretty nice. It’s an easy interface to access everything in a bundle, from Info.plist keys to localized strings, contained resources to loading executable code. It’s nice, but there is an issue with how it works that will cause problems whenever a user updates a preference pane by double-clicking a new version and have System Preference install it over the older one.

The symptoms

Try this experiment: take any non-Apple preference pane out there, install it, then open the content of the installed package and delete its main nib file (or all nib files if you want). The next time you open it, you’ll see a blank preference pane, perhaps with a few errors on the console or more drastic problems (as you should expect after breaking it).

Now say you want to reinstall the non-broken version of this preference pane. You double-click on it, System Preferences asks you to if you want to replace the current version with the new one, and click yes. Once it installed the new preference pane, System Preferences opens it and you’re presented with… exactly the same blank pane as before (!).

Surprised? If you quit then reopen System Preference and open your panel, the new version will now work flawlessly. “Good, looks like it was just a fluke!” But in reality, every time you redo those steps the same events happens. What’s the matter?

And the root of the problem is… NSBundle!

Here is my understanding of what’s happening. System Preferences when it launches checks for every preference pane installed in its usual directories. It creates an instance of NSBundle for each of them to get their localized name and icon. That’s a good practice, as the Bundle Programming Guide tells you:

However, if you plan to retrieve more than one resource file, it is always faster to use a bundle object. Bundle objects cache search information as they go, so subsequent searches are usually faster.

Upon upgrading a new preference pane, it replaces the old bundle with the new one. This creates a problem: all the cached information from the old bundle is still kept in the NSBundle instance it created at launch time, making it out of sync with the actual content of the bundle.

The NSBundle instance your preference pane receives through its initWithBundle: method is thus contaminated by this outdated cached information. At this point, any use of this NSBundle instance to access the bundle’s content may fail or return outdated data, or work fine depending on whatever was present in the old version of the preference pane and the nature of the requested content.

Initially, I thought this was limited to returning the old version of the infoDictionary, because I was checking the version number in Magic Launch and displaying it in the panel, making that pretty obvious. But while working on bigger changes to the preference pane, I noticed the problem was more serious and applied to basically everything accessed through NSBundle.

Attempt at a workaround

Basically, all we need is a way to clear NSBundle’s cache. Unfortunately, there is no API to do that. Instead, let’s try create a new instance:

- (id)initWithBundle:(NSBundle *)bundle {
    NSBundle *newBundle = [NSBundle bundleWithPath:[bundle bundlePath]];
    [super initWithBundle:newBundle];
}

Looks right, but doesn’t work. NSBundle keeps track of all its instances and bundleWithPath: will return the same instance if one already leads to the same path. So we end up getting the same instance as before, with the same cached data and the same problems. Not good.

One could try to load every resource directly by path, bypassing NSBundle cache, but this soon become tedious, and in my case I have code shared between the Magic Launch Agent application and the preference pane that depends on NSBundle.

A similar but somewhat better solution would be to create a NSBundle wrapper class that would implement its own logic for retrieving resources and everything, but could be passed to the rest of Cocoa as an authentic NSBundle thanks to Objective-C dynamic binding. It’s rather hard work to duplicate everything in NSBundle, and then you have to forward any call to unimplemented method to the wrapped NSBundle for private methods or categories Apple might have in there to work.

That second solution would work, but I’m not feeling like writing all that code at the risk of introducing hard to notice bugs, and possibly forward-compatibility problems, just to fix the first landing after an upgrade done incorrectly by System Preferences.

The killer solution

The only acceptable solution I’ve found is this one: detect a version mismatch between the NSBundle instance and the actual code of the preference pane, and when there is a mismatch, just kill System Preferences and restart it. That’s rather drastic, I know, but it’s still better than having a half working, possibly buggy preference pane when first opening it after an upgrade.

What the user sees is the System Preference window momentarily disappearing, then reappearing and instantly switching to the newly upgraded preference pane.

It’s implemented like this:

- (id)initWithBundle:(NSBundle *)bundle {
    static NSString *const expectedVersion = @"1.3";
    NSString *bundleVersion = [[bundle infoDictionary] 
      objectForKey:(NSString *)kCFBundleVersionKey];

    if (![bundleVersion isEqual:expectedVersion]) {
        NSLog(@"Magic Launch version mismatch, restarting System Preferences...");
        MLReopenPreferences(bundle);
        exit(0);
    }
}

(I’ll leave the implementation of MLReopenPreferences above for a future post.)

What’s nice about this solution is that it’s forward compatible: if/when NSBundle or System Preferences get fixed, there will no longer be a version mismatch, and no version mismatch means no more killing of System Preferences.


Follow-ups


Comments

Alexandre Cossette

Did you create a bug on the Apple site? (https://developer.apple.com/bugreporter/)

Michel Fortin

@Alexandre: Yes. rdar://7890447

System Preferences provides NSBundle with out of date cached data

Summary:

After upgrading a preference pane by opening it with System Preferences, the NSBundle instance received by the preference pane’s main class in initWithBundle: still contains cached data from the previous version of the preference pane (because it’s a the same path), which has the wrong infoDictionary and prevents loading new resources not present in the previous version of the preference pane.

Steps to Reproduce:

You need two versions of the same preference pane, the second one having a different Info.plist file or a resource file not present in the first version. Both versions of the preference pane have the same filename.

  1. Install a custom preference pane for System Preference.
  2. Double-click on a different version of that same preference pane, System Preference will prompt you to replace the first version by the second version, click Ok.
  3. System Preferences automatically loads the new preference pane.
  4. The initWithBundle: method of the preference pane’s main class should call the following methods on the preference pane’s NSBundle instance and check the result:
    • infoDictionary
    • pathForResource:ofType:
    • loadNibNamed:

Expected Results:

The three methods above should return values associated with the second version of the preference pane, with no trace of the first version left.

Actual Results:

The three methods above behave in part as if they were called for the previous version of the preference pane.

  • infoDictionary returns the dictionary for the first version of the preference pane.
  • pathForResource:ofType: fails if the resource was not present in the first version of the preference pane.
  • loadNibNamed: fails if the nib file was not present in the first version of the preference pane.

Notes:

Presumably, System Preferences should clear all the cached data in the preference pane’s NSBundle instance after installing a new version. There is a workaround of some sort, but it’s rather drastic and contorted: in each preference pane, detect this condition and force restart System Preferences when it happens. This is highly visible to the user and it breaks the navigation history in System Preferences, but it’s a lesser evil than having a buggy preference pane after upgrading.

Dave Keck

I recently started using _CFBundleFlushBundleCaches() to work around this problem. It’s a private function, but much better than the alternatives, in my opinion.

I’ve tested this technique on 10.5 and 10.6, both of which work as expected.

The header for this function can be found here and the source here.

Because it’s a private function, I weak-link the symbol and check for its existence at runtime:

extern void _CFBundleFlushBundleCaches(CFBundleRef bundle) __attribute__((weak_import));

if (_CFBundleFlushBundleCaches != NULL)
{
    _CFBundleFlushBundleCaches(cfBundle);
}

Thanks for the article by the way - it helped me arrive at this solution.


  • © 2003–2017 Michel Fortin.