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 forExperienceid:(NSString *)experienceId 46{ 47 if (!experienceId) { 48 NSAssert(experienceId, @"Cannot associate recovery info with a nil experience id"); 49 } 50 EXErrorRecoveryRecord *record = [self _recordForExperienceId:experienceId]; 51 if (!record) { 52 record = [[EXErrorRecoveryRecord alloc] init]; 53 @synchronized (_experienceInfo) { 54 _experienceInfo[experienceId] = record; 55 } 56 } 57 record.developerInfo = developerInfo; 58} 59 60- (NSDictionary *)developerInfoForExperienceId:(NSString *)experienceId 61{ 62 EXErrorRecoveryRecord *record = [self _recordForExperienceId:experienceId]; 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 forExperienceid:((EXScopedBridgeModule *)scopedModule).experienceId]; 72} 73 74- (void)setError:(NSError *)error forExperienceId:(NSString *)experienceId 75{ 76 if (!experienceId) { 77 NSString *kernelSuggestion = ([EXBuildConstants sharedInstance].isDevKernel) ? @"Make sure EXBuildConstants is configured to load a valid development Kernel JS bundle." : @""; 78 NSAssert(experienceId, @"Cannot associate an error with a nil experience id. %@", kernelSuggestion); 79 } 80 EXErrorRecoveryRecord *record = [self _recordForExperienceId:experienceId]; 81 if (error) { 82 if (!record) { 83 record = [[EXErrorRecoveryRecord alloc] init]; 84 @synchronized (_experienceInfo) { 85 _experienceInfo[experienceId] = 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 *> *experienceIds; 111 @synchronized (_experienceInfo) { 112 experienceIds = _experienceInfo.allKeys; 113 } 114 for (NSString *experienceId in experienceIds) { 115 EXErrorRecoveryRecord *record = [self _recordForExperienceId:experienceId]; 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 *> *experienceIds; 129 @synchronized (_experienceInfo) { 130 experienceIds = _experienceInfo.allKeys; 131 } 132 for (NSString *experienceId in experienceIds) { 133 EXErrorRecoveryRecord *record = [self _recordForExperienceId:experienceId]; 134 if ([self isJSError:record.error equalToOtherJSError:error]) { 135 return [[EXKernel sharedInstance].appRegistry newestRecordWithExperienceId:experienceId]; 136 } 137 } 138 return nil; 139} 140 141- (void)experienceFinishedLoadingWithId:(NSString *)experienceId 142{ 143 EXErrorRecoveryRecord *record = [self _recordForExperienceId:experienceId]; 144 if (!record) { 145 record = [[EXErrorRecoveryRecord alloc] init]; 146 @synchronized (_experienceInfo) { 147 _experienceInfo[experienceId] = record; 148 } 149 } 150 record.dtmLastLoaded = [NSDate date]; 151 record.isRecovering = NO; 152 153 // maintain a global record of when anything last loaded, used to calculate autoreload backoff. 154 _dtmAnyExperienceLoaded = [NSDate date]; 155} 156 157- (BOOL)experienceIdIsRecoveringFromError:(NSString *)experienceId 158{ 159 EXErrorRecoveryRecord *record = [self _recordForExperienceId:experienceId]; 160 if (record) { 161 return record.isRecovering; 162 } 163 return NO; 164} 165 166- (BOOL)experienceIdShouldReloadOnError:(NSString *)experienceId 167{ 168 EXErrorRecoveryRecord *record = [self _recordForExperienceId:experienceId]; 169 if (record) { 170 return ([record.dtmLastLoaded timeIntervalSinceNow] < -[self reloadBufferSeconds]); 171 } 172 // if we have no knowledge of this experience, this is probably a manifest loading error 173 // so we should assume we'd just hit the same issue again next time. don't try to autoreload. 174 return NO; 175} 176 177- (void)increaseAutoReloadBuffer 178{ 179 _reloadBufferDepth++; 180} 181 182#pragma mark - internal 183 184- (BOOL)isJSError:(NSError *)error1 equalToOtherJSError: (NSError *)error2 185{ 186 // use rangeOfString: to catch versioned RCTErrorDomain 187 if ([error1.domain rangeOfString:RCTErrorDomain].length > 0 && [error2.domain rangeOfString:RCTErrorDomain].length > 0) { 188 NSDictionary *userInfo1 = error1.userInfo; 189 NSDictionary *userInfo2 = error2.userInfo; 190 // could also possibly compare ([userInfo1[RCTJSStackTraceKey] isEqual:userInfo2[RCTJSStackTraceKey]]) if this isn't enough 191 return ([userInfo1[NSLocalizedDescriptionKey] isEqualToString:userInfo2[NSLocalizedDescriptionKey]]); 192 } 193 return [error1 isEqual:error2]; 194} 195 196- (EXErrorRecoveryRecord *)_recordForExperienceId: (NSString *)experienceId; 197{ 198 EXErrorRecoveryRecord *result = nil; 199 if (experienceId) { 200 @synchronized (_experienceInfo) { 201 result = _experienceInfo[experienceId]; 202 } 203 } 204 return result; 205} 206 207- (NSTimeInterval)reloadBufferSeconds 208{ 209 NSTimeInterval interval = MIN(60.0 * 5.0, EX_AUTO_REFRESH_BUFFER_BASE_SECONDS * pow(1.5, _reloadBufferDepth)); 210 211 // if nothing has loaded for twice our current backoff interval, reset backoff 212 if ([_dtmAnyExperienceLoaded timeIntervalSinceNow] < -(interval * 2.0)) { 213 _reloadBufferDepth = 0; 214 interval = EX_AUTO_REFRESH_BUFFER_BASE_SECONDS; 215 } 216 return interval; 217} 218 219@end 220