1// Copyright 2015-present 650 Industries. All rights reserved. 2 3#import "EXBuildConstants.h" 4#import "EXErrorRecoveryManager.h" 5#import "EXKernel.h" 6#import "EXScopedBridgeModule.h" 7 8#import <React/RCTAssert.h> 9 10// if the app crashes and it has not yet been 5 seconds since it loaded, don't auto refresh. 11#define EX_AUTO_REFRESH_BUFFER_BASE_SECONDS 5.0 12 13@interface EXErrorRecoveryRecord : NSObject 14 15@property (nonatomic, assign) BOOL isRecovering; 16@property (nonatomic, strong) NSError *error; 17@property (nonatomic, strong) NSDate *dtmLastLoaded; 18@property (nonatomic, strong) NSDictionary *developerInfo; 19 20@end 21 22@implementation EXErrorRecoveryRecord 23 24@end 25 26@interface EXErrorRecoveryManager () 27 28@property (nonatomic, strong) NSMutableDictionary<NSString *, EXErrorRecoveryRecord *> *experienceInfo; 29@property (nonatomic, assign) NSUInteger reloadBufferDepth; 30@property (nonatomic, strong) NSDate *dtmAnyExperienceLoaded; 31 32@end 33 34@implementation EXErrorRecoveryManager 35 36- (instancetype)init 37{ 38 if (self = [super init]) { 39 _reloadBufferDepth = 0; 40 _dtmAnyExperienceLoaded = [NSDate date]; 41 _experienceInfo = [NSMutableDictionary dictionary]; 42 } 43 return self; 44} 45 46- (void)setDeveloperInfo:(NSDictionary *)developerInfo forScopeKey:(NSString *)scopeKey 47{ 48 if (!scopeKey) { 49 NSAssert(scopeKey, @"Cannot associate recovery info with a nil scope key"); 50 } 51 EXErrorRecoveryRecord *record = [self _recordForScopeKey:scopeKey]; 52 if (!record) { 53 record = [[EXErrorRecoveryRecord alloc] init]; 54 @synchronized (_experienceInfo) { 55 _experienceInfo[scopeKey] = record; 56 } 57 } 58 record.developerInfo = developerInfo; 59} 60 61- (NSDictionary *)developerInfoForScopeKey:(NSString *)scopeKey 62{ 63 EXErrorRecoveryRecord *record = [self _recordForScopeKey:scopeKey]; 64 if (record) { 65 return record.developerInfo; 66 } 67 return nil; 68} 69 70- (void)setDeveloperInfo:(NSDictionary *)developerInfo forScopedModule:(id)scopedModule 71{ 72 [self setDeveloperInfo:developerInfo forScopeKey:((EXScopedBridgeModule *)scopedModule).scopeKey]; 73} 74 75- (void)setError:(NSError *)error forScopeKey:(NSString *)scopeKey 76{ 77 if (!scopeKey) { 78 NSString *kernelSuggestion = ([EXBuildConstants sharedInstance].isDevKernel) ? @"Make sure EXBuildConstants is configured to load a valid development Kernel JS bundle." : @""; 79 NSAssert(scopeKey, @"Cannot associate an error with a nil experience id. %@", kernelSuggestion); 80 } 81 EXErrorRecoveryRecord *record = [self _recordForScopeKey:scopeKey]; 82 if (error) { 83 if (!record) { 84 record = [[EXErrorRecoveryRecord alloc] init]; 85 @synchronized (_experienceInfo) { 86 _experienceInfo[scopeKey] = record; 87 } 88 } 89 // mark this experience id as having loading problems, so future attempts will bust the cache. 90 // this flag never gets unset until the app loads successfully, even if the error is nullified. 91 record.isRecovering = YES; 92 } 93 if (record) { 94 // if this record already shows an error, 95 // and the new error is about AppRegistry, 96 // don't override the previous error message. 97 if (record.error && 98 [error.localizedDescription rangeOfString:@"AppRegistry is not a registered callable module"].length != 0) { 99 DDLogWarn(@"Ignoring misleading error: %@", error); 100 } else { 101 record.error = error; 102 } 103 } 104} 105 106- (BOOL)errorBelongsToExperience:(NSError *)error 107{ 108 if (!error) { 109 return NO; 110 } 111 NSArray<NSString *> *scopeKeys; 112 @synchronized (_experienceInfo) { 113 scopeKeys = _experienceInfo.allKeys; 114 } 115 for (NSString *scopeKey in scopeKeys) { 116 EXErrorRecoveryRecord *record = [self _recordForScopeKey:scopeKey]; 117 if ([self isJSError:record.error equalToOtherJSError:error]) { 118 return YES; 119 } 120 } 121 return NO; 122} 123 124- (EXKernelAppRecord *)appRecordForError:(NSError *)error 125{ 126 if (!error) { 127 return nil; 128 } 129 NSArray<NSString *> *scopeKeys; 130 @synchronized (_experienceInfo) { 131 scopeKeys = _experienceInfo.allKeys; 132 } 133 for (NSString *scopeKey in scopeKeys) { 134 EXErrorRecoveryRecord *record = [self _recordForScopeKey:scopeKey]; 135 if ([self isJSError:record.error equalToOtherJSError:error]) { 136 return [[EXKernel sharedInstance].appRegistry newestRecordWithScopeKey:scopeKey]; 137 } 138 } 139 return nil; 140} 141 142- (void)experienceFinishedLoadingWithScopeKey:(NSString *)scopeKey 143{ 144 if (!scopeKey) { 145 NSAssert(scopeKey, @"Cannot mark an experience with nil id as loaded"); 146 } 147 EXErrorRecoveryRecord *record = [self _recordForScopeKey:scopeKey]; 148 if (!record) { 149 record = [[EXErrorRecoveryRecord alloc] init]; 150 @synchronized (_experienceInfo) { 151 _experienceInfo[scopeKey] = record; 152 } 153 } 154 record.dtmLastLoaded = [NSDate date]; 155 record.isRecovering = NO; 156 157 // maintain a global record of when anything last loaded, used to calculate autoreload backoff. 158 _dtmAnyExperienceLoaded = [NSDate date]; 159} 160 161- (BOOL)scopeKeyIsRecoveringFromError:(NSString *)scopeKey 162{ 163 EXErrorRecoveryRecord *record = [self _recordForScopeKey:scopeKey]; 164 if (record) { 165 return record.isRecovering; 166 } 167 return NO; 168} 169 170- (BOOL)experienceShouldReloadOnError:(NSString *)scopeKey 171{ 172 EXErrorRecoveryRecord *record = [self _recordForScopeKey:scopeKey]; 173 if (record) { 174 return ([record.dtmLastLoaded timeIntervalSinceNow] < -[self reloadBufferSeconds]); 175 } 176 // if we have no knowledge of this experience, this is probably a manifest loading error 177 // so we should assume we'd just hit the same issue again next time. don't try to autoreload. 178 return NO; 179} 180 181- (void)increaseAutoReloadBuffer 182{ 183 _reloadBufferDepth++; 184} 185 186#pragma mark - internal 187 188- (BOOL)isJSError:(NSError *)error1 equalToOtherJSError: (NSError *)error2 189{ 190 // use rangeOfString: to catch versioned RCTErrorDomain 191 if ([error1.domain rangeOfString:RCTErrorDomain].length > 0 && [error2.domain rangeOfString:RCTErrorDomain].length > 0) { 192 NSDictionary *userInfo1 = error1.userInfo; 193 NSDictionary *userInfo2 = error2.userInfo; 194 // could also possibly compare ([userInfo1[RCTJSStackTraceKey] isEqual:userInfo2[RCTJSStackTraceKey]]) if this isn't enough 195 return ([userInfo1[NSLocalizedDescriptionKey] isEqualToString:userInfo2[NSLocalizedDescriptionKey]]); 196 } 197 return [error1 isEqual:error2]; 198} 199 200- (EXErrorRecoveryRecord *)_recordForScopeKey:(NSString *)scopeKey; 201{ 202 EXErrorRecoveryRecord *result = nil; 203 if (scopeKey) { 204 @synchronized (_experienceInfo) { 205 result = _experienceInfo[scopeKey]; 206 } 207 } 208 return result; 209} 210 211- (NSTimeInterval)reloadBufferSeconds 212{ 213 NSTimeInterval interval = MIN(60.0 * 5.0, EX_AUTO_REFRESH_BUFFER_BASE_SECONDS * pow(1.5, _reloadBufferDepth)); 214 215 // if nothing has loaded for twice our current backoff interval, reset backoff 216 if ([_dtmAnyExperienceLoaded timeIntervalSinceNow] < -(interval * 2.0)) { 217 _reloadBufferDepth = 0; 218 interval = EX_AUTO_REFRESH_BUFFER_BASE_SECONDS; 219 } 220 return interval; 221} 222 223@end 224