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