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