views:

100

answers:

2

I've noticed some weird behavior with NSBundle when using it in a command-line program. If, in my program, I take an existing bundle and make a copy of it and then try to use pathForResource to look up something in the Resources folder, nil is always returned unless the bundle I'm looking up existed before my program started. I created a sample app that replicates the issue and the relevant code is:

int main(int argc, char *argv[]) 
{ 
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init]; 
    NSString *exePath = [NSString stringWithCString:argv[0]
                                           encoding:NSASCIIStringEncoding]; 
    NSString *path = [exePath stringByDeletingLastPathComponent]; 
    NSString *templatePath = [path stringByAppendingPathComponent:@"TestApp.app"];

    // This call works because TestApp.app exists before this program is run 
    NSString *resourcePath = [NSBundle pathForResource:@"InfoPlist" 
                                                ofType:@"strings"
                                           inDirectory:templatePath]; 
    NSLog(@"NOCOPY: %@", resourcePath); 

    NSString *copyPath = [path stringByAppendingPathComponent:@"TestAppCopy.app"]; 
    [[NSFileManager defaultManager] removeItemAtPath:copyPath 
                                               error:nil]; 
    if ([[NSFileManager defaultManager] copyItemAtPath:templatePath 
                                                toPath:copyPath 
                                                 error:nil]) 
    { 
        // This call will fail if TestAppCopy.app does not exist before 
        // this program is run
        NSString *resourcePath2 = [NSBundle pathForResource:@"InfoPlist"
                                                     ofType:@"strings"
                                                inDirectory:copyPath]; 
        NSLog(@"COPY: %@", resourcePath2); 
        [[NSFileManager defaultManager] removeItemAtPath:copyPath 
                                                   error:nil]; 
    } 
    [pool release]; 
} 

For the purpose of this test app, let's assume that TestApp.app already exists in the same directory as my test app. If I run this, the 2nd NSLog call will output: COPY: (null)

Now, if I comment out the final removeItemAtPath call in the if statement so that when my program exits TestAppCopy.app still exists and then re-run, the program will work as expected.

I've tried this in a normal Cocoa application and I can't reproduce the behavior. It only happens in a shell tool target. Can anyone think of a reason why this is failing?

BTW: I'm trying this on 10.6.4 and I haven't tried on any other versions of Mac OS X.

+1  A: 

That sounds like a bug in the Foundation. The one key difference between a command line tool like that one and a Cocoa application is the run loop. Try refactoring the above into something like:

@interface Foo:NSObject
@end
@implementation Foo
- (void) doIt { .... your code from main() here .... }
@end

... main(...) {
    Foo *f = [Foo new];
    [f performSelector: @selector(doIt) withObject: nil afterDelay: 0.1 ...];
    [[NSRunLoop currentRunLoop] run];
    return 0; // not reached, I'd bet.
}

And see if that "fixes" it. It might. It might not (there are couple of other significant differences, obviously). In any case, do please file a bug via http://bugreport.apple.com/ and add the bug # as a comment.

bbum
Thanks for the reply bbum. The bug has been entered as rdar://8535620. I'll give this bounty a couple of more days and if no one else replies with an answer, I'll award you the bounty.
Dustin
I forgot to add that I tried your suggestion but it didn't change the behavior.
Dustin
+3  A: 

I can confirm that it is a bug in CoreFoundation, not Foundation. The bug is due to CFBundle code relying on a directory contents cache containing stale data. The code apparently assumes that neither the bundle directories nor their immediate parent directories will change during application runtime.

The CoreFoundation call corresponding to +[NSBundle pathForResource:ofType:inDirectory:] is CFBundleCopyResourceURLInDirectory(), and it exhibits the same misbehavior. (This is unsurprising, as -pathForResource:ofType:inDirectory: itself uses this call.)

The problem ultimately lies with _CFBundleCopyDirectoryContentsAtPath(). This is called during bundle loading and during all resource lookup. It caches information about the directories it looks up in contentsCache.

Here's the problem: When it comes time to get the contents of TestAppCopy.app, the cached contents of the directory containing TestApp.app don't include TestAppCopy.app. Because the cache ostensibly has the contents of that directory, only the cached contents are searched for TestAppCopy.app. When TestAppCopy.app is not found, the function takes that as a definitive "this path does not exist" and doesn't bother trying to open the directory:

__CFSpinLock(&CFBundleResourceGlobalDataLock);
if (contentsCache) dirDirContents = (CFArrayRef)CFDictionaryGetValue(contentsCache, dirName);
if (dirDirContents) {
    Boolean foundIt = false;
    CFIndex dirDirIdx, dirDirLength = CFArrayGetCount(dirDirContents);
    for (dirDirIdx = 0; !foundIt && dirDirIdx < dirDirLength; dirDirIdx++) if (kCFCompareEqualTo == CFStringCompare(name, CFArrayGetValueAtIndex(dirDirContents, dirDirIdx), kCFCompareCaseInsensitive)) foundIt = true;
    if (!foundIt) tryToOpen = false;
}
__CFSpinUnlock(&CFBundleResourceGlobalDataLock);

So, the contents array remains empty, gets cached for this path, and lookup continues. We now have cached the (incorrectly empty) contents of TestAppCopy.app, and as lookup drills down into this directory, we keep hitting bad cached information. Language lookup takes a stab when it finds nothing and hopes there's an en.lproj hanging around, but we still won't find anything, because we're looking in a stale cache.

CoreFoundation includes SPI functions to flush the CFBundle caches. The only place public API calls into them in CoreFoundation is __CFBundleDeallocate(). This flushes all cached information about the bundle's directory itself, but not its parent directory: _CFBundleFlushContentsCacheForPath(), which actually removes the data from the cache, removes only keys matching an anchored, case-insensitive search for the bundle path.

It would seem the only public way a client of CoreFoundation could flush bad information about TestApp.app's parent directory would be to make the parent directory a bundle directory (so TestApp.app lived alongside Contents), create a CFBundle for the parent bundle directory, then release that CFBundle. But, it seems that if you made the mistake of trying to work with the TestAppCopy.app bundle prior to flushing it, the bad data about TestAppCopy.app would not be flushed.

Jeremy W. Sherman
Great answer! Thanks. As I stated above, I entered a bug for it so hopefully it will get fixed at some point.
Dustin