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