Skip to content
This repository was archived by the owner on Nov 1, 2021. It is now read-only.

Commit ff61f58

Browse files
committed
Workaround for caching with iOS 8 and NSURLSession.
Observationally, we've seen the NSURL loading system fail to insert items into the NSURLCache when using NSURLSession and iOS 7/8. NSURLConnection works fine on all these, and NSURLSession works fine on iOS 9. Best guess is there's a bug in the NSURLProtocol implementation that fails to insert into the cache. So, here we are creating a workaround for that specific case. CocoaSPDY will buffer the data internally, up to a certain size limit based on the cache size, and will manually insert into the cache when the response is done. This could potentially be expanded in the future for unclaimed, finished push responses, but for now those are excluded. This change also adds a few additional caching-related unit tests.
1 parent 11d02ec commit ff61f58

File tree

4 files changed

+239
-14
lines changed

4 files changed

+239
-14
lines changed

SPDY/SPDYStream.m

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ @implementation SPDYStream
7272

7373
NSURLRequest *_pushRequest; // stored because we need a strong reference, _request is weak.
7474
NSHTTPURLResponse *_response;
75+
76+
NSURLCacheStoragePolicy _cachePolicy;
77+
NSMutableData *_cacheDataBuffer; // only used for manual caching.
78+
NSInteger _cacheMaxItemSize;
7579
}
7680

7781
- (instancetype)initWithProtocol:(SPDYProtocol *)protocol
@@ -332,6 +336,9 @@ - (void)_close
332336
[self markUnblocked]; // just in case. safe if already unblocked.
333337
_metadata.blockedMs = _blockedElapsed * 1000;
334338

339+
// Manually make the willCacheResponse callback when needed
340+
[self _storeCacheResponse];
341+
335342
[_client URLProtocolDidFinishLoading:_protocol];
336343

337344
if (_delegate && [_delegate respondsToSelector:@selector(streamClosed:)]) {
@@ -585,11 +592,24 @@ - (void)didReceiveResponse
585592
return;
586593
}
587594

588-
if (_client) {
589-
NSURLCacheStoragePolicy cachePolicy = SPDYCacheStoragePolicy(_request, _response);
590-
[_client URLProtocol:_protocol
591-
didReceiveResponse:_response
592-
cacheStoragePolicy:cachePolicy];
595+
_cachePolicy = SPDYCacheStoragePolicy(_request, _response);
596+
[_client URLProtocol:_protocol
597+
didReceiveResponse:_response
598+
cacheStoragePolicy:_cachePolicy];
599+
600+
// Prepare for manual caching, if needed
601+
NSURLCache *cache = _protocol.associatedSession.configuration.URLCache;
602+
if (_cachePolicy != NSURLCacheStorageNotAllowed && // cacheable?
603+
[self _shouldUseManualCaching] && // hack needed and NSURLSession used?
604+
cache != nil && // cache configured (NSURLSession-specific)?
605+
_local) { // not a push request?
606+
607+
// The NSURLCache has a heuristic to limit the max size of items based on the capacity of the
608+
// cache. This is our attempt to mimic that behavior and prevent unlimited buffering of large
609+
// responses. These numbers were found by manually experimenting and are only approximate.
610+
// See SPDYNSURLCachingTest testResponseNearItemCacheSize_DoesUseCache.
611+
_cacheDataBuffer = [[NSMutableData alloc] init];
612+
_cacheMaxItemSize = MAX(cache.memoryCapacity * 0.05, cache.diskCapacity * 0.01);
593613
}
594614
}
595615

@@ -689,7 +709,7 @@ - (void)didLoadData:(NSData *)data
689709
NSUInteger inflatedLength = DECOMPRESSED_CHUNK_LENGTH - _zlibStream.avail_out;
690710
inflatedData.length = inflatedLength;
691711
if (inflatedLength > 0) {
692-
[_client URLProtocol:_protocol didLoadData:inflatedData];
712+
[self _didLoadDataChunk:inflatedData];
693713
}
694714

695715
// This can happen if the decompressed data is size N * DECOMPRESSED_CHUNK_LENGTH,
@@ -711,7 +731,69 @@ - (void)didLoadData:(NSData *)data
711731
}
712732
} else {
713733
NSData *dataCopy = [[NSData alloc] initWithBytes:data.bytes length:dataLength];
714-
[_client URLProtocol:_protocol didLoadData:dataCopy];
734+
[self _didLoadDataChunk:dataCopy];
735+
}
736+
}
737+
738+
- (void)_didLoadDataChunk:(NSData *)data
739+
{
740+
[_client URLProtocol:_protocol didLoadData:data];
741+
742+
if (_cacheDataBuffer) {
743+
NSUInteger bufferSize = _cacheDataBuffer.length + data.length;
744+
if (bufferSize < _cacheMaxItemSize) {
745+
[_cacheDataBuffer appendData:data];
746+
} else {
747+
// Throw away anything already buffered, it's going to be too big
748+
_cacheDataBuffer = nil;
749+
}
750+
}
751+
}
752+
753+
// NSURLSession on iOS 8 and iOS 7 does not cache items. Seems to be a bug with its interation
754+
// with NSURLProtocol. This flag represents whether our workaround, to insert into the cache
755+
// ourselves, is turned on.
756+
- (bool)_shouldUseManualCaching
757+
{
758+
NSInteger osVersion = 0;
759+
NSProcessInfo *processInfo = [NSProcessInfo processInfo];
760+
if ([processInfo respondsToSelector:@selector(operatingSystemVersion)]) {
761+
osVersion = [processInfo operatingSystemVersion].majorVersion;
762+
}
763+
764+
// iOS 8 and earlier and using NSURLSession
765+
return (osVersion <= 8 && _protocol.associatedSession != nil && _protocol.associatedSessionTask != nil);
766+
}
767+
768+
- (void)_storeCacheResponse
769+
{
770+
if (_cacheDataBuffer == nil) {
771+
return;
772+
}
773+
774+
NSCachedURLResponse *cachedResponse;
775+
cachedResponse = [[NSCachedURLResponse alloc] initWithResponse:_response
776+
data:_cacheDataBuffer
777+
userInfo:nil
778+
storagePolicy:_cachePolicy];
779+
NSURLCache *cache = _protocol.associatedSession.configuration.URLCache;
780+
NSURLSessionDataTask *dataTask = (NSURLSessionDataTask *)_protocol.associatedSessionTask;
781+
782+
// Make "official" willCacheResponse callback to app, bypassing the NSURL loading system.
783+
id<NSURLSessionDataDelegate> delegate = (id)_protocol.associatedSession.delegate;
784+
if ([delegate respondsToSelector:@selector(URLSession:dataTask:willCacheResponse:completionHandler:)]) {
785+
NSOperationQueue *queue = _protocol.associatedSession.delegateQueue;
786+
[(queue) ?: [NSOperationQueue mainQueue] addOperationWithBlock:^{
787+
[delegate URLSession:_protocol.associatedSession dataTask:dataTask willCacheResponse:cachedResponse completionHandler:^(NSCachedURLResponse * __nullable cachedResponse) {
788+
// This block may execute asynchronously at any time. No need to come back to the SPDY/NSURL thread
789+
if (cachedResponse) {
790+
[cache storeCachedResponse:cachedResponse forDataTask:dataTask];
791+
}
792+
}];
793+
}];
794+
} else {
795+
// willCacheResponse delegate not implemented. Default behavior is to cache.
796+
[cache storeCachedResponse:cachedResponse forDataTask:dataTask];
715797
}
716798
}
717799

SPDYUnitTests/SPDYIntegrationTestHelper.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@
2929
- (BOOL)didLoadFromNetwork;
3030
- (BOOL)didLoadFromCache;
3131
- (BOOL)didGetResponse;
32+
- (BOOL)didLoadData;
3233
- (BOOL)didGetError;
3334
- (BOOL)didCacheResponse;
3435

3536
- (void)reset;
3637
- (void)loadRequest:(NSURLRequest *)request;
38+
- (void)provideResponseWithStatus:(NSUInteger)status cacheControl:(NSString *)cacheControl date:(NSDate *)date dataChunks:(NSArray *)dataChunks;
3739
- (void)provideResponseWithStatus:(NSUInteger)status cacheControl:(NSString *)cacheControl date:(NSDate *)date;
3840
- (void)provideBasicUncacheableResponse;
3941
- (void)provideBasicCacheableResponse;

SPDYUnitTests/SPDYIntegrationTestHelper.m

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,19 +43,24 @@ - (instancetype)init
4343

4444
- (BOOL)didLoadFromNetwork
4545
{
46-
return self.didGetResponse && _stream != nil;
46+
return self.didGetResponse && self.didLoadData && _stream != nil;
4747
}
4848

4949
- (BOOL)didLoadFromCache
5050
{
51-
return self.didGetResponse && _stream == nil;
51+
return self.didGetResponse && self.didLoadData && _stream == nil;
5252
}
5353

5454
- (BOOL)didGetResponse
5555
{
5656
return _response != nil;
5757
}
5858

59+
- (BOOL)didLoadData
60+
{
61+
return _data.length > 0;
62+
}
63+
5964
- (BOOL)didGetError
6065
{
6166
return _connectionError != nil;
@@ -100,12 +105,10 @@ - (NSString *)dateHeaderValue:(NSDate *)date
100105
return string;
101106
}
102107

103-
- (void)provideResponseWithStatus:(NSUInteger)status cacheControl:(NSString *)cacheControl date:(NSDate *)date
108+
- (void)provideResponseWithStatus:(NSUInteger)status cacheControl:(NSString *)cacheControl date:(NSDate *)date dataChunks:(NSArray *)dataChunks
104109
{
105110
[SPDYMockSessionManager shared].streamQueuedBlock = ^(SPDYStream *stream) {
106111
_stream = stream;
107-
uint8_t dataBytes[] = {1};
108-
NSData *data = [NSData dataWithBytes:dataBytes length:1];
109112
NSMutableDictionary *headers = [NSMutableDictionary dictionaryWithDictionary:@{
110113
@":status": [@(status) stringValue],
111114
@":version": @"1.1",
@@ -119,10 +122,19 @@ - (void)provideResponseWithStatus:(NSUInteger)status cacheControl:(NSString *)ca
119122

120123
[stream mergeHeaders:headers];
121124
[stream didReceiveResponse];
122-
[stream didLoadData:data];
125+
for (NSData *data in dataChunks) {
126+
[stream didLoadData:data];
127+
}
123128
};
124129
}
125130

131+
- (void)provideResponseWithStatus:(NSUInteger)status cacheControl:(NSString *)cacheControl date:(NSDate *)date
132+
{
133+
uint8_t dataBytes[] = {1};
134+
NSArray *dataChunks = @[ [NSData dataWithBytes:dataBytes length:1] ];
135+
[self provideResponseWithStatus:status cacheControl:cacheControl date:date dataChunks:dataChunks];
136+
}
137+
126138
- (void)provideBasicUncacheableResponse
127139
{
128140
[self provideResponseWithStatus:200 cacheControl:@"no-store, no-cache, must-revalidate" date:[NSDate date]];
@@ -157,6 +169,7 @@ - (NSString *)description
157169
NSDictionary *params = @{
158170
@"didLoadFromNetwork": @([self didLoadFromNetwork]),
159171
@"didGetResponse": @([self didGetResponse]),
172+
@"didLoadData": @([self didLoadData]),
160173
@"didGetError": @([self didGetError]),
161174
@"didCacheResponse": @([self didCacheResponse]),
162175
@"stream": _stream ?: @"<nil>",
@@ -237,6 +250,7 @@ - (instancetype)init
237250
}
238251
return self;
239252
}
253+
240254
- (void)loadRequest:(NSMutableURLRequest *)request
241255
{
242256
[super loadRequest:request];

SPDYUnitTests/SPDYNSURLCachingTest.m

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ + (void)setUp
2727
[SPDYIntegrationTestHelper setUp];
2828

2929
[SPDYURLConnectionProtocol registerOrigin:@"http://example.com"];
30+
31+
[NSURLCache setSharedURLCache:[[NSURLCache alloc] initWithMemoryCapacity:1000000 diskCapacity:10000000 diskPath:nil]];
3032
}
3133

3234
+ (void)tearDown
@@ -105,7 +107,7 @@ - (void)testCacheableResponse_DoesInsertAndLoadFromCache
105107
}
106108
}
107109

108-
- (void)testCachedItem_DoesHaveMetadata
110+
- (void)testCachedItem_DoesHaveFreshMetadata
109111
{
110112
for (NSArray *testParams in [self parameterizedTestInputs]) {
111113
GET_TEST_PARAMS;
@@ -124,6 +126,7 @@ - (void)testCachedItem_DoesHaveMetadata
124126
SPDYMetadata *metadata = [SPDYProtocol metadataForResponse:testHelper.response];
125127
XCTAssertNotNil(metadata, @"%@", testHelper);
126128
XCTAssertEqual(metadata.loadSource, SPDYLoadSourceNetwork, @"%@", testHelper);
129+
XCTAssertEqual(metadata.streamId, 1, @"%@", testHelper);
127130

128131
// Now make request again. Should pull from cache.
129132
[testHelper reset];
@@ -136,6 +139,7 @@ - (void)testCachedItem_DoesHaveMetadata
136139
metadata = [SPDYProtocol metadataForResponse:testHelper.response];
137140
XCTAssertNotNil(metadata, @"%@", testHelper);
138141
XCTAssertEqual(metadata.loadSource, shouldPullFromCache ? SPDYLoadSourceCache : SPDYLoadSourceNetwork, @"%@", testHelper);
142+
XCTAssertEqual(metadata.streamId, shouldPullFromCache ? 0 : 1, @"%@", testHelper);
139143

140144
// Special logic for metadata provided by SPDYProtocolContext
141145
if ([testHelper isMemberOfClass:[SPDYURLSessionIntegrationTestHelper class]]) {
@@ -144,6 +148,7 @@ - (void)testCachedItem_DoesHaveMetadata
144148

145149
XCTAssertNotNil(metadata2, @"%@", testHelper);
146150
XCTAssertEqual(metadata2.loadSource, shouldPullFromCache ? SPDYLoadSourceCache : SPDYLoadSourceNetwork, @"%@", testHelper);
151+
XCTAssertEqual(metadata2.streamId, 0, @"%@", testHelper);
147152
}
148153
}
149154
}
@@ -476,4 +481,126 @@ - (void)testPOSTRequest_DoesNotUseCache
476481
}
477482
}
478483

484+
- (void)testResponseNearItemCacheSize_DoesUseCache
485+
{
486+
for (NSArray *testParams in [self parameterizedTestInputs]) {
487+
GET_TEST_PARAMS;
488+
NSLog(@"- using %@, policy %tu", [testHelper class], cachePolicy);
489+
490+
[self resetSharedCache];
491+
492+
NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:[NSURL URLWithString:@"http://example.com/test/path"]];
493+
request.cachePolicy = cachePolicy;
494+
495+
// Note: per observational manual testing, NSURL system has a much lower limit for item size.
496+
// Seems to be less than 1% of max capacity.
497+
// Base memory capacity 1000000, base disk capacity 10000000:
498+
// - Response size 50000 is ok.
499+
// - Response size 50001 is not ok.
500+
// - If memory capacity goes from 1000000 to 999999, 50000 is no longer ok.
501+
// - If disk capacity goes from 10000000 to 6000000, 50000 is still ok.
502+
// - If disk capacity goes from 10000000 to 5000000, 50000 is no longer ok.
503+
// So we're walking the line here with little understanding of Apple's heuristic. I thought
504+
// 10% was the limit, but apparently its somewhere less than 1% depending on memory/disk
505+
// setttings.
506+
// This test may need to change if this starts failing.
507+
NSArray *dataChunks = @[
508+
[NSMutableData dataWithLength:30000],
509+
[NSMutableData dataWithLength:20000],
510+
];
511+
512+
[testHelper provideResponseWithStatus:200 cacheControl:@"public, max-age=1200" date:[NSDate date] dataChunks:dataChunks];
513+
[testHelper loadRequest:request];
514+
515+
XCTAssertTrue(testHelper.didLoadFromNetwork, @"%@", testHelper);
516+
XCTAssertTrue(testHelper.didCacheResponse, @"%@", testHelper);
517+
518+
// Now make request again. Should pull from cache.
519+
[testHelper reset];
520+
[testHelper loadRequest:request];
521+
522+
XCTAssertEqual(testHelper.didLoadFromCache, shouldPullFromCache, @"%@", testHelper);
523+
}
524+
}
525+
526+
- (void)testResponseExceedsItemCacheSize_DoesNotUseCache
527+
{
528+
for (NSArray *testParams in [self parameterizedTestInputs]) {
529+
GET_TEST_PARAMS;
530+
NSLog(@"- using %@, policy %tu", [testHelper class], cachePolicy);
531+
532+
[self resetSharedCache];
533+
534+
NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:[NSURL URLWithString:@"http://example.com/test/path"]];
535+
request.cachePolicy = cachePolicy;
536+
537+
// 10% of cache. Limit is 10%. Disk capacity is set to 10M.
538+
NSArray *dataChunks = @[
539+
[NSMutableData dataWithLength:500000],
540+
[NSMutableData dataWithLength:500000],
541+
];
542+
543+
[testHelper provideResponseWithStatus:200 cacheControl:@"public, max-age=1200" date:[NSDate date] dataChunks:dataChunks];
544+
[testHelper loadRequest:request];
545+
546+
XCTAssertTrue(testHelper.didLoadFromNetwork, @"%@", testHelper);
547+
XCTAssertFalse(testHelper.didCacheResponse, @"%@", testHelper);
548+
}
549+
}
550+
551+
- (void)testCacheDoesStoreAndLoadCorrectData
552+
{
553+
for (NSArray *testParams in [self parameterizedTestInputs]) {
554+
GET_TEST_PARAMS;
555+
NSLog(@"- using %@, policy %tu", [testHelper class], cachePolicy);
556+
557+
[self resetSharedCache];
558+
559+
NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:[NSURL URLWithString:@"http://example.com/test/path"]];
560+
request.cachePolicy = cachePolicy;
561+
562+
uint8_t buffer1[50] = {0};
563+
uint8_t buffer2[3] = {0};
564+
uint8_t buffer3[50] = {0};
565+
const NSUInteger expectedLength = sizeof(buffer1) + sizeof(buffer2) + sizeof(buffer3);
566+
uint8_t finalBuffer[expectedLength];
567+
568+
int i = 0;
569+
for (int j = 0; j < sizeof(buffer1); j++) {
570+
finalBuffer[i] = i % 256;
571+
buffer1[j] = i++ % 256;
572+
}
573+
for (int j = 0; j < sizeof(buffer2); j++) {
574+
finalBuffer[i] = i % 256;
575+
buffer2[j] = i++ % 256;
576+
}
577+
for (int j = 0; j < sizeof(buffer3); j++) {
578+
finalBuffer[i] = i % 256;
579+
buffer3[j] = i++ % 256;
580+
}
581+
NSData *finalData = [NSData dataWithBytes:finalBuffer length:expectedLength];
582+
583+
NSArray *dataChunks = @[
584+
[NSMutableData dataWithBytes:buffer1 length:sizeof(buffer1)],
585+
[NSMutableData dataWithBytes:buffer2 length:sizeof(buffer2)],
586+
[NSMutableData dataWithBytes:buffer3 length:sizeof(buffer3)],
587+
];
588+
589+
[testHelper provideResponseWithStatus:200 cacheControl:@"public, max-age=1200" date:[NSDate date] dataChunks:dataChunks];
590+
[testHelper loadRequest:request];
591+
592+
XCTAssertTrue(testHelper.didLoadFromNetwork, @"%@", testHelper);
593+
XCTAssertTrue(testHelper.didCacheResponse, @"%@", testHelper);
594+
595+
// Now make request again. Should pull from cache.
596+
[testHelper reset];
597+
[testHelper loadRequest:request];
598+
599+
XCTAssertEqual(testHelper.didLoadFromCache, shouldPullFromCache, @"%@", testHelper);
600+
XCTAssertEqual(testHelper.data.length, expectedLength, @"%@", testHelper);
601+
602+
XCTAssertEqualObjects(testHelper.data, finalData, @"%@", testHelper);
603+
}
604+
}
605+
479606
@end

0 commit comments

Comments
 (0)