EGGER APPS

The Egger Apps Blog

31 Jan 2014

Cocoa: Asynchronous Host name lookups

Cocoa offers a lot of high level APIs for all kinds of network operations, so chances are you never need to perform a DNS lookup yourself — until you start to do any custom network programming.

The easiest way to do name lookups is using the getaddrinfo API. It's not a Cocoa API, but it's completely cross-platform, and it works on all flavors of UNIX. After reading lots of man pages, you might come up with a simple Objective C wrapper looking similar to the following:

+(NSArray*)addressesForHost:(NSString*)host port:(NSNumber*)port error:(NSError**)outError
{
    struct addrinfo hints = {.ai_family=PF_UNSPEC;.ai_socktype=SOCK_STREAM;.ai_protocol=IPPROTO_TCP};
    struct addrinfo *res;
    int gai_error = getaddrinfo(host.UTF8String, port.stringValue.UTF8String, &hints, &res);
    if (gai_error) {
        if (outError) *outError = [NSError errorWithDomain:@"MyDomain" code:gai_error userInfo:@{NSLocalizedDescriptionKey:@(gai_strerror(gai_error))}];
        return nil;
    }
    NSMutableArray *addresses = [NSMutableArray array];
    struct addrinfo *ai = res;
    do {
        NSData *address = [NSData dataWithBytes:ai->ai_addr length:ai->ai_addrlen];
        [addresses addObject:address];
    } while (ai = ai->ai_next);
    freeaddrinfo(res);
    return [addresses copy];
}

This will convert a given host name & port into one or more data objects containing a struct sockaddr. These structures are suitable for passing to connect; if you want to extract the IP address as an NSString, you could use a method like this:

+(NSString*)stringForAddress:(NSData*)addressData error:(NSError**)outError {
    char hbuf[NI_MAXHOST];
    int gai_error = getnameinfo(addressData.bytes, (socklen_t)addressData.length, hbuf, NI_MAXHOST, NULL, 0, NI_NUMERICHOST);
    if (gai_error) {
        if (outError) *outError = [NSError errorWithDomain:@"MyDomain" code:gai_error userInfo:@{NSLocalizedDescriptionKey:@(gai_strerror(gai_error))}];
        return nil;
    }
    return [NSString stringWithUTF8String:hbuf];
}

This method works great, but it has a very significant disadvantage: it will block execution while performing a DNS lookup. DNS lookups can take anywhere from a few milliseconds up to ten seconds or longer. If you want to make a fast and responsive application, there's no way a 10 second freeze is acceptable.

The obvious solution is to perform the lookup on a background thread, so that your UI doesn't freeze. But even if you use a background thread, you can't cancel a lookup that's in progress. You could of course just kill the thread, but that is a bad idea and will probably leak memory and file descriptors.

On Linux, there's an asynchronous API you can use, getaddrinfo_a. Unfortunately that API isn't part of OS X or iOS. But we have Core Foundation! The CFNetwork API provides CFHost, a set of functions for non-blocking DNS lookups.

There is some good sample code for CFHost available from Apple. It shows how to use CFHost for fully asynchronous name lookups using delegates. However, in the use case I'm looking at, that's overkill. All I want to do is look up a name in a background thread, and have the possibility of cancelling the lookup from the main thread, without killing the secondary thread.

Let's first declare a class named DNSResolver:

#import <Foundation/Foundation.h>
@interface DNSResolver : NSObject
    @property NSString *hostname;
    @property NSArray *addresses;
    @property NSError *error;
    @property BOOL shouldCancel, done;
    -(BOOL)lookup;
@end

I want to use this class as follows:

DNSResolver *resolver = [[DNSResolver alloc] init];
resolver.hostname = @"example.com";
BOOL lookupSuccessful = [resolver lookup];
// now either resolver.addresses or resolver.error should be set

Now this is still a synchronous method, as the calling thread will be blocked while the lookup occurs. But the difference is that we can cancel the lookup from another thread, by setting shouldCancel to YES.

If you look at the CFHost documentation, you'll see that it's not trivial to implement the lookup method. I don't want to bore you, so I'll just paste the implementation I came up with:

-(BOOL)lookup {
    // sanity check
    if (!self.hostname) {
        self.error = [NSError errorWithDomain:@"MyDomain" code:1 userInfo:@{NSLocalizedDescriptionKey:@"No hostname provided."}];
        return NO;
    }
    // set up the CFHost object
    CFHostRef host = CFHostCreateWithName(kCFAllocatorDefault, (__bridge CFStringRef)self.hostname);
    CFHostClientContext ctx = {.info = (__bridge void*)self};
    CFHostSetClient(host, DNSResolverHostClientCallback, &ctx);
    CFRunLoopRef runloop = CFRunLoopGetCurrent();
    CFHostScheduleWithRunLoop(host, runloop, CFSTR("DNSResolverRunLoopMode"));
    // start the name resolution
    CFStreamError error;
    Boolean didStart = CFHostStartInfoResolution(host, kCFHostAddresses, &error);
    if (!didStart) {
        self.error = [NSError errorWithDomain:@"MyDomain" code:1 userInfo:@{NSLocalizedDescriptionKey:@"CFHostStartInfoResolution failed."}];
        return NO;
    }
    // run the run loop for 50ms at a time, always checking if we should cancel
    while(!self.shouldCancel && !self.done) {
        CFRunLoopRunInMode(CFSTR("DNSResolverRunLoopMode"), 0.05, true);
    }
    if (self.shouldCancel) {
        CFHostCancelInfoResolution(host, kCFHostAddresses);
        self.error = [NSError errorWithDomain:@"MyDomain" code:1 userInfo:@{NSLocalizedDescriptionKey:@"Name look up cancelled."}];
    }
    if (!self.error) {
        Boolean hasBeenResolved;
        CFArrayRef addressArray = CFHostGetAddressing(host, &hasBeenResolved);
        if (hasBeenResolved) {
            self.addresses = [(__bridge NSArray*)addressArray copy];
        } else {
            self.error = [NSError errorWithDomain:@"MyDomain" code:1 userInfo:@{NSLocalizedDescriptionKey:@"Name look up failed"}];
        }
    }
    // clean up the CFHost object
    CFHostSetClient(host, NULL, NULL);
    CFHostUnscheduleFromRunLoop(host, runloop, CFSTR("DNSResolverRunLoopMode"));
    CFRelease(host);
    return self.error ? NO : YES;
}

we also need to provide a callback, which I implemented as follows:

void DNSResolverHostClientCallback ( CFHostRef theHost, CFHostInfoType typeInfo, const CFStreamError *error, void *info) {
    DNSResolver *self = (__bridge DNSResolver*)info;
    if (error->domain || error->error) self.error = [NSError errorWithDomain:@"MyDomain" code:1 userInfo:@{NSLocalizedDescriptionKey:@"Name look up failed"}];
    self.done = YES;
}

Now we have a class that we can use on a background thread to look up host names. It's easy to use, because it blocks on the calling thread, but we can still cancel the lookup safely from another thread. The class, including a small sample XCode project, will be available soon on Github.