iosuiwebviewnsurlprotocol

NSURLProtocol + UIWebView + certain domains = app UI frozen


We're building a browser for iOS. We decided to experiment with using a custom NSURLProtocol subclass in order to implement our own caching scheme and perform user-agent spoofing. It does both of those things quite well...the problem is, navigating to certain sites (msn.com is the worst) will cause the entire app's UI to freeze for up to fifteen seconds. Obviously something is blocking the main thread, but it's not in our code.

This issue only appears with the combination of UIWebView and a custom protocol. If we swap in a WKWebView (which we can't use for various reasons) the problem disappears. Similarly, if we don't register the protocol such that it isn't ever utilized, the problem goes away.

It also doesn't seem to much matter what the protocol does; we wrote a bare-bones dummy protocol that does nothing but forward responses (bottom of post). We dropped that protocol into a bare-bones test browser that doesn't have any of our other code--same result. We also tried using someone else's (RNCachingURLProtocol) and observed the same result. It appears that the simple combination of these two components, with certain pages, causes the freeze. I'm at a loss to attempt to resolve (or even investigate) this and would greatly appreciate any guidance or tips. Thanks!

import UIKit

private let KEY_REQUEST_HANDLED = "REQUEST_HANDLED"

final class CustomURLProtocol: NSURLProtocol {
    var connection: NSURLConnection!

    override class func canInitWithRequest(request: NSURLRequest) -> Bool {
        return NSURLProtocol.propertyForKey(KEY_REQUEST_HANDLED, inRequest: request) == nil
    }

    override class func canonicalRequestForRequest(request: NSURLRequest) -> NSURLRequest {
        return request
    }

    override class func requestIsCacheEquivalent(aRequest: NSURLRequest, toRequest bRequest: NSURLRequest) -> Bool {
        return super.requestIsCacheEquivalent(aRequest, toRequest:bRequest)
    }

    override func startLoading() {
        var newRequest = self.request.mutableCopy() as! NSMutableURLRequest
        NSURLProtocol.setProperty(true, forKey: KEY_REQUEST_HANDLED, inRequest: newRequest)
        self.connection = NSURLConnection(request: newRequest, delegate: self)
    }

    override func stopLoading() {
        connection?.cancel()
        connection = nil
    }

    func connection(connection: NSURLConnection!, didReceiveResponse response: NSURLResponse!) {
        self.client!.URLProtocol(self, didReceiveResponse: response, cacheStoragePolicy: .NotAllowed)
    }

    func connection(connection: NSURLConnection!, didReceiveData data: NSData!) {
        self.client!.URLProtocol(self, didLoadData: data)
    }

    func connectionDidFinishLoading(connection: NSURLConnection!) {
        self.client!.URLProtocolDidFinishLoading(self)
    }

    func connection(connection: NSURLConnection!, didFailWithError error: NSError!) {
        self.client!.URLProtocol(self, didFailWithError: error)
    }
}

Solution

  • I've just checked NSURLProtocol behavior with msn.com, and found that at some point startLoading method called in WebCoreSynchronousLoaderRunLoopMode mode. That causes main thread blocking.

    Looking through CustomHTTPProtocol Apple sample code, I've found comments that describes this problem. Fix is implemented in next way:

    @interface CustomHTTPProtocol () <NSURLSessionDataDelegate>
    
    @property (atomic, strong, readwrite) NSThread * clientThread; ///< The thread on which we should call the client.
    
    /*! The run loop modes in which to call the client.
     *  \details The concurrency control here is complex.  It's set up on the client 
     *  thread in -startLoading and then never modified.  It is, however, read by code 
     *  running on other threads (specifically the main thread), so we deallocate it in 
     *  -dealloc rather than in -stopLoading.  We can be sure that it's not read before 
     *  it's set up because the main thread code that reads it can only be called after 
     *  -startLoading has started the connection running.
     */
    @property (atomic, copy, readwrite) NSArray * modes;
    
    - (void)startLoading
    {
        NSMutableArray *calculatedModes;
        NSString *currentMode;
    
        // At this point we kick off the process of loading the URL via NSURLSession. 
        // The thread that calls this method becomes the client thread.
    
        assert(self.clientThread == nil); // you can't call -startLoading twice
    
        // Calculate our effective run loop modes.  In some circumstances (yes I'm looking at 
        // you UIWebView!) we can be called from a non-standard thread which then runs a 
        // non-standard run loop mode waiting for the request to finish.  We detect this 
        // non-standard mode and add it to the list of run loop modes we use when scheduling 
        // our callbacks.  Exciting huh?
        //
        // For debugging purposes the non-standard mode is "WebCoreSynchronousLoaderRunLoopMode" 
        // but it's better not to hard-code that here.
    
        assert(self.modes == nil);
        calculatedModes = [NSMutableArray array];
        [calculatedModes addObject:NSDefaultRunLoopMode];
        currentMode = [[NSRunLoop currentRunLoop] currentMode];
        if ( (currentMode != nil) && ! [currentMode isEqual:NSDefaultRunLoopMode] ) {
            [calculatedModes addObject:currentMode];
        }
        self.modes = calculatedModes;
        assert([self.modes count] > 0);
    
        // Create new request that's a clone of the request we were initialised with, 
        // except that it has our 'recursive request flag' property set on it.
    
        // ... 
    
        // Latch the thread we were called on, primarily for debugging purposes.
    
        self.clientThread = [NSThread currentThread];
    
        // Once everything is ready to go, create a data task with the new request.
    
        self.task = [[[self class] sharedDemux] dataTaskWithRequest:recursiveRequest delegate:self modes:self.modes];
        assert(self.task != nil);
    
        [self.task resume];
    }
    

    Some Apple engineers have good sense of humor.

    Exciting huh?

    See full apple sample for details.

    Issue is not reproducible with WKWebView, because NSURLProtocol doesn't work with it. See next question for details.