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