@onekeyfe/react-native-split-bundle-loader 3.0.63 → 3.0.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
cmake_minimum_required(VERSION 3.13)
|
|
2
|
+
|
|
3
|
+
# Native (JNI) side of the SplitBundleLoader module.
|
|
4
|
+
#
|
|
5
|
+
# WHY THIS EXISTS:
|
|
6
|
+
# The Kotlin `loadSegment` previously called `ReactContext.registerSegment`,
|
|
7
|
+
# whose completion callback fires BEFORE the segment bytecode is evaluated
|
|
8
|
+
# into the runtime (the C++ ReactInstance::registerSegment only ENQUEUES the
|
|
9
|
+
# eval onto the RuntimeScheduler and returns). That races Metro's
|
|
10
|
+
# `import().then(() => __r(moduleId))` and can produce a fatal, uncatchable
|
|
11
|
+
# "Requiring unknown module".
|
|
12
|
+
#
|
|
13
|
+
# This library evaluates the segment OURSELVES on the JS thread via the
|
|
14
|
+
# bridgeless CallInvoker (which receives `jsi::Runtime&`) and signals
|
|
15
|
+
# completion in that SAME callback, so eval + resolve are one atomic unit —
|
|
16
|
+
# mirroring the iOS `callFunctionOnBufferedRuntimeExecutor:` fix.
|
|
17
|
+
project(splitbundleloader)
|
|
18
|
+
|
|
19
|
+
set(CMAKE_CXX_STANDARD 20)
|
|
20
|
+
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
|
21
|
+
set(CMAKE_VERBOSE_MAKEFILE ON)
|
|
22
|
+
|
|
23
|
+
add_library(
|
|
24
|
+
splitbundleloader
|
|
25
|
+
SHARED
|
|
26
|
+
src/main/cpp/SplitBundleLoaderJSI.cpp
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
target_include_directories(
|
|
30
|
+
splitbundleloader
|
|
31
|
+
PRIVATE
|
|
32
|
+
src/main/cpp
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# jsi, reactnative (which carries CallInvoker / CallInvokerHolder headers and
|
|
36
|
+
# the merged libreactnative.so), and fbjni are all prefab targets exposed by
|
|
37
|
+
# the react-android AAR. See ReactAndroid build.gradle.kts prefab entries:
|
|
38
|
+
# - jsi (../ReactCommon/jsi/)
|
|
39
|
+
# - reactnative (turbomodule/ReactCommon/CallInvokerHolder.h,
|
|
40
|
+
# callinvoker/ReactCommon/CallInvoker.h, ...)
|
|
41
|
+
find_package(ReactAndroid REQUIRED CONFIG)
|
|
42
|
+
find_package(fbjni REQUIRED CONFIG)
|
|
43
|
+
|
|
44
|
+
target_link_libraries(
|
|
45
|
+
splitbundleloader
|
|
46
|
+
android
|
|
47
|
+
log
|
|
48
|
+
fbjni::fbjni
|
|
49
|
+
ReactAndroid::jsi
|
|
50
|
+
ReactAndroid::reactnative
|
|
51
|
+
)
|
package/android/build.gradle
CHANGED
|
@@ -33,10 +33,36 @@ android {
|
|
|
33
33
|
defaultConfig {
|
|
34
34
|
minSdkVersion getExtOrIntegerDefault("minSdkVersion")
|
|
35
35
|
targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
|
|
36
|
+
|
|
37
|
+
externalNativeBuild {
|
|
38
|
+
cmake {
|
|
39
|
+
// C++20 to match React Native's prefab targets.
|
|
40
|
+
cppFlags "-O2", "-frtti", "-fexceptions", "-std=c++20"
|
|
41
|
+
// The react-android / hermestooling prefab targets are built against
|
|
42
|
+
// the shared STL; without this the default static STL is selected and
|
|
43
|
+
// CMake configure fails ("static STL but library requires a shared
|
|
44
|
+
// STL [//ReactAndroid/hermestooling]"). Matches react-native-background-thread.
|
|
45
|
+
arguments "-DANDROID_STL=c++_shared"
|
|
46
|
+
// Only build the ABIs the app ships.
|
|
47
|
+
abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
buildFeatures {
|
|
39
53
|
buildConfig true
|
|
54
|
+
// Consume react-android / fbjni prefab packages (jsi, reactnative, fbjni)
|
|
55
|
+
// from the AAR for the native (JNI) side.
|
|
56
|
+
prefab true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Native (JNI) side: evaluates split-bundle segments on the JS thread and
|
|
60
|
+
// resolves the promise strictly AFTER eval (fixes the "Requiring unknown
|
|
61
|
+
// module" race). See src/main/cpp/SplitBundleLoaderJSI.cpp.
|
|
62
|
+
externalNativeBuild {
|
|
63
|
+
cmake {
|
|
64
|
+
path "CMakeLists.txt"
|
|
65
|
+
}
|
|
40
66
|
}
|
|
41
67
|
|
|
42
68
|
buildTypes {
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SplitBundleLoaderJSI.cpp
|
|
3
|
+
*
|
|
4
|
+
* JNI bridge that evaluates a Metro split-bundle segment into the CURRENT
|
|
5
|
+
* Hermes runtime and signals completion ONLY AFTER the segment's `__d(...)`
|
|
6
|
+
* module definitions have actually run.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS EXISTS — the "Requiring unknown module" race:
|
|
9
|
+
* The previous Kotlin implementation called `ReactContext.registerSegment`,
|
|
10
|
+
* which routes (bridgeless) through `ReactHostImpl.registerSegment` →
|
|
11
|
+
* `ReactInstance.registerSegment` → C++ `ReactInstance::registerSegment`, and
|
|
12
|
+
* that only does `runtimeScheduler_->scheduleWork([]{ evaluateJavaScript() })`
|
|
13
|
+
* — i.e. it ENQUEUES the eval and returns. `ReactHostImpl.registerSegment`
|
|
14
|
+
* then invokes the completion callback immediately on `Task.IMMEDIATE_EXECUTOR`,
|
|
15
|
+
* so the loadSegment promise resolves BEFORE the segment is evaluated. Metro's
|
|
16
|
+
* `import().then(() => __r(moduleId))` microtask can therefore run `__r` before
|
|
17
|
+
* the module table is populated → a fatal, uncatchable "Requiring unknown
|
|
18
|
+
* module" inside metroRequire.
|
|
19
|
+
*
|
|
20
|
+
* THE FIX (mirrors iOS callFunctionOnBufferedRuntimeExecutor:):
|
|
21
|
+
* We evaluate the segment OURSELVES on the JS thread via the bridgeless
|
|
22
|
+
* CallInvoker. `CallInvoker::invokeAsync(CallFunc&&)` schedules the callback
|
|
23
|
+
* onto the SAME RuntimeScheduler that registerSegment would have used, and the
|
|
24
|
+
* callback receives `jsi::Runtime&`. We read the segment file, call
|
|
25
|
+
* `runtime.evaluateJavaScript(...)`, and invoke the completion callback from
|
|
26
|
+
* INSIDE that same block, strictly AFTER eval. Eval + completion are one atomic
|
|
27
|
+
* unit of work on the JS thread, so any subsequent `__r(moduleId)` is
|
|
28
|
+
* guaranteed to find the module.
|
|
29
|
+
*
|
|
30
|
+
* SEGMENT FORMAT: OneKey segments are standalone-evaluatable Metro bundles
|
|
31
|
+
* (top-level `__d(moduleId, factory, deps)` calls; the `.seg.hbc` is just the
|
|
32
|
+
* Hermes-compiled form of the same `.seg.js`). C++ ReactInstance::registerSegment
|
|
33
|
+
* itself just calls `runtime.evaluateJavaScript(buffer, ...)` with no RAM/indexed
|
|
34
|
+
* segment manifest wiring, confirming these are evaluatable as plain scripts.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
#include <fbjni/fbjni.h>
|
|
38
|
+
#include <jsi/jsi.h>
|
|
39
|
+
|
|
40
|
+
#include <ReactCommon/CallInvoker.h>
|
|
41
|
+
#include <ReactCommon/CallInvokerHolder.h>
|
|
42
|
+
|
|
43
|
+
#include <android/log.h>
|
|
44
|
+
|
|
45
|
+
#include <cstdio>
|
|
46
|
+
#include <memory>
|
|
47
|
+
#include <string>
|
|
48
|
+
#include <vector>
|
|
49
|
+
|
|
50
|
+
#define SBL_LOG_TAG "SplitBundleLoader"
|
|
51
|
+
#define SBL_LOGI(...) \
|
|
52
|
+
__android_log_print(ANDROID_LOG_INFO, SBL_LOG_TAG, __VA_ARGS__)
|
|
53
|
+
#define SBL_LOGW(...) \
|
|
54
|
+
__android_log_print(ANDROID_LOG_WARN, SBL_LOG_TAG, __VA_ARGS__)
|
|
55
|
+
|
|
56
|
+
namespace facebook::react::splitbundleloader {
|
|
57
|
+
|
|
58
|
+
namespace {
|
|
59
|
+
|
|
60
|
+
// Reads the whole file at `path` into a std::string. Returns false on failure.
|
|
61
|
+
bool readFileToString(const std::string& path, std::string& out) {
|
|
62
|
+
FILE* f = std::fopen(path.c_str(), "rb");
|
|
63
|
+
if (f == nullptr) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
if (std::fseek(f, 0, SEEK_END) != 0) {
|
|
67
|
+
std::fclose(f);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
long size = std::ftell(f);
|
|
71
|
+
if (size < 0) {
|
|
72
|
+
std::fclose(f);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
if (std::fseek(f, 0, SEEK_SET) != 0) {
|
|
76
|
+
std::fclose(f);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
out.resize(static_cast<size_t>(size));
|
|
80
|
+
size_t read = (size == 0)
|
|
81
|
+
? 0
|
|
82
|
+
: std::fread(&out[0], 1, static_cast<size_t>(size), f);
|
|
83
|
+
std::fclose(f);
|
|
84
|
+
return read == static_cast<size_t>(size);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
} // namespace
|
|
88
|
+
|
|
89
|
+
// Java callback contract — implemented in Kotlin as
|
|
90
|
+
// SplitBundleLoaderModule.SegmentEvalCallback. `onComplete(null)` on success,
|
|
91
|
+
// `onComplete(errorMessage)` on failure. Invoked exactly once.
|
|
92
|
+
//
|
|
93
|
+
// Error-message prefix convention (read by the Kotlin side to pick the contract
|
|
94
|
+
// reject code): a message prefixed with "IO_ERROR:" is a segment file read/mmap
|
|
95
|
+
// failure → SPLIT_BUNDLE_IO_ERROR (non-retryable). Any other message is a
|
|
96
|
+
// segment JS/Hermes eval throw → SPLIT_BUNDLE_EVAL_ERROR (non-retryable).
|
|
97
|
+
struct JSegmentEvalCallback
|
|
98
|
+
: jni::JavaClass<JSegmentEvalCallback> {
|
|
99
|
+
static constexpr auto kJavaDescriptor =
|
|
100
|
+
"Lcom/splitbundleloader/SplitBundleLoaderModule$SegmentEvalCallback;";
|
|
101
|
+
|
|
102
|
+
// `error` empty → success (passes Java null); non-empty → failure (passes the
|
|
103
|
+
// message). We always pass null on success and a non-empty message on failure,
|
|
104
|
+
// so this mapping is unambiguous. `const` so it can be invoked through a
|
|
105
|
+
// (const) global_ref / alias_ref.
|
|
106
|
+
void onComplete(const std::string& error) const {
|
|
107
|
+
static const auto method =
|
|
108
|
+
javaClassStatic()->getMethod<void(jni::alias_ref<jni::JString>)>(
|
|
109
|
+
"onComplete");
|
|
110
|
+
jni::local_ref<jni::JString> arg =
|
|
111
|
+
error.empty() ? jni::local_ref<jni::JString>(nullptr)
|
|
112
|
+
: jni::make_jstring(error);
|
|
113
|
+
method(self(), arg);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
class SplitBundleLoaderJSI
|
|
118
|
+
: public jni::JavaClass<SplitBundleLoaderJSI> {
|
|
119
|
+
public:
|
|
120
|
+
static constexpr auto kJavaDescriptor =
|
|
121
|
+
"Lcom/splitbundleloader/SplitBundleLoaderModule;";
|
|
122
|
+
|
|
123
|
+
// Schedules evaluation of the segment at `segmentPath` onto the JS thread via
|
|
124
|
+
// the bridgeless CallInvoker, then invokes `callback` from INSIDE the same JS
|
|
125
|
+
// thread block, strictly AFTER the segment has been evaluated. This is the
|
|
126
|
+
// ordering guarantee that fixes the "Requiring unknown module" race.
|
|
127
|
+
//
|
|
128
|
+
// Does NOT block the calling (native modules) thread — returns immediately
|
|
129
|
+
// after scheduling. Resolution happens later on the JS thread.
|
|
130
|
+
static void nativeEvaluateSegment(
|
|
131
|
+
jni::alias_ref<jclass> /* unused */,
|
|
132
|
+
jni::alias_ref<CallInvokerHolder::javaobject> callInvokerHolder,
|
|
133
|
+
jni::alias_ref<jstring> segmentPath,
|
|
134
|
+
jni::alias_ref<jstring> sourceURL,
|
|
135
|
+
jni::alias_ref<JSegmentEvalCallback> callback) {
|
|
136
|
+
// Capture everything we need as values / global refs because the callback
|
|
137
|
+
// runs later on a different thread.
|
|
138
|
+
auto globalCallback = jni::make_global(callback);
|
|
139
|
+
|
|
140
|
+
if (!callInvokerHolder) {
|
|
141
|
+
globalCallback->onComplete("CallInvokerHolder is null");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
std::shared_ptr<CallInvoker> callInvoker =
|
|
146
|
+
callInvokerHolder->cthis()->getCallInvoker();
|
|
147
|
+
if (!callInvoker) {
|
|
148
|
+
globalCallback->onComplete("CallInvoker is null");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
std::string path = segmentPath ? segmentPath->toStdString() : std::string();
|
|
153
|
+
std::string url =
|
|
154
|
+
sourceURL ? sourceURL->toStdString() : std::string("segment");
|
|
155
|
+
|
|
156
|
+
if (path.empty()) {
|
|
157
|
+
globalCallback->onComplete("Empty segment path");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// F: Read the segment file HERE, on the calling (native module) thread,
|
|
162
|
+
// BEFORE invokeAsync. Doing the disk read inside the JS-thread callback
|
|
163
|
+
// would block the JS thread on I/O and race the Kotlin watchdog. We move
|
|
164
|
+
// the already-read buffer into the lambda so only evaluateJavaScript +
|
|
165
|
+
// completion run on the JS thread (mirrors iOS, which mmaps off-thread and
|
|
166
|
+
// only evaluates on the runtime thread). The read error is surfaced as a
|
|
167
|
+
// dedicated IO error so JS can classify it as NON-retryable.
|
|
168
|
+
std::string source;
|
|
169
|
+
bool ioOk = readFileToString(path, source);
|
|
170
|
+
if (!ioOk) {
|
|
171
|
+
globalCallback->onComplete("IO_ERROR:Failed to read segment file: " + path);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (source.empty()) {
|
|
175
|
+
globalCallback->onComplete("IO_ERROR:Empty segment file: " + path);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// CallFunc = std::function<void(jsi::Runtime&)>; this runs on the JS thread
|
|
180
|
+
// on the SAME RuntimeScheduler the segment registration would have used.
|
|
181
|
+
callInvoker->invokeAsync([globalCallback,
|
|
182
|
+
source = std::move(source),
|
|
183
|
+
url = std::move(url)](jsi::Runtime& runtime) {
|
|
184
|
+
std::string error;
|
|
185
|
+
try {
|
|
186
|
+
{
|
|
187
|
+
SBL_LOGI(
|
|
188
|
+
"[SplitBundle] evaluating segment %s (%zu bytes)",
|
|
189
|
+
url.c_str(),
|
|
190
|
+
source.size());
|
|
191
|
+
auto buffer = std::make_shared<jsi::StringBuffer>(std::move(source));
|
|
192
|
+
// Evaluate the segment into the CURRENT runtime. evaluateJavaScript
|
|
193
|
+
// runs the segment's top-level __d(...) module definitions
|
|
194
|
+
// synchronously on this JS thread before returning.
|
|
195
|
+
runtime.evaluateJavaScript(std::move(buffer), url);
|
|
196
|
+
SBL_LOGI("[SplitBundle] segment %s evaluated", url.c_str());
|
|
197
|
+
}
|
|
198
|
+
} catch (const jsi::JSError& e) {
|
|
199
|
+
error = std::string("Segment evaluation JSError for ") + url + ": " +
|
|
200
|
+
e.getMessage();
|
|
201
|
+
SBL_LOGW("[SplitBundle] %s", error.c_str());
|
|
202
|
+
} catch (const std::exception& e) {
|
|
203
|
+
error = std::string("Segment evaluation failed for ") + url + ": " +
|
|
204
|
+
e.what();
|
|
205
|
+
SBL_LOGW("[SplitBundle] %s", error.c_str());
|
|
206
|
+
} catch (...) {
|
|
207
|
+
error = std::string("Segment evaluation failed for ") + url +
|
|
208
|
+
" (unknown C++ exception)";
|
|
209
|
+
SBL_LOGW("[SplitBundle] %s", error.c_str());
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Attach to the JVM for this (JS) thread before calling back into Java.
|
|
213
|
+
// The JS thread is a native (pthread) thread not implicitly attached to
|
|
214
|
+
// the JVM; ThreadScope ensures a valid JNIEnv for the callback.
|
|
215
|
+
jni::ThreadScope ts;
|
|
216
|
+
// Resolve/reject from INSIDE this same JS-thread block, strictly AFTER
|
|
217
|
+
// eval above — the ordering guarantee that fixes the race.
|
|
218
|
+
globalCallback->onComplete(error);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
static void registerNatives() {
|
|
223
|
+
// Static JNI methods on a plain JavaClass (NOT a HybridClass): bind via
|
|
224
|
+
// the class's registerNatives, not registerHybrid.
|
|
225
|
+
javaClassStatic()->registerNatives({
|
|
226
|
+
makeNativeMethod(
|
|
227
|
+
"nativeEvaluateSegment",
|
|
228
|
+
SplitBundleLoaderJSI::nativeEvaluateSegment),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
} // namespace facebook::react::splitbundleloader
|
|
234
|
+
|
|
235
|
+
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* /* reserved */) {
|
|
236
|
+
return facebook::jni::initialize(vm, [] {
|
|
237
|
+
facebook::react::splitbundleloader::SplitBundleLoaderJSI::registerNatives();
|
|
238
|
+
});
|
|
239
|
+
}
|
|
@@ -5,13 +5,18 @@ import android.content.res.AssetManager
|
|
|
5
5
|
import com.facebook.react.bridge.Arguments
|
|
6
6
|
import com.facebook.react.bridge.Promise
|
|
7
7
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
8
|
+
import com.facebook.react.common.annotations.FrameworkAPI
|
|
8
9
|
import com.facebook.react.module.annotations.ReactModule
|
|
10
|
+
import com.facebook.react.turbomodule.core.CallInvokerHolderImpl
|
|
9
11
|
import java.io.File
|
|
10
12
|
import java.io.FileInputStream
|
|
11
13
|
import java.io.FileOutputStream
|
|
12
14
|
import java.io.IOException
|
|
13
15
|
import java.security.MessageDigest
|
|
14
16
|
import java.util.concurrent.Semaphore
|
|
17
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
18
|
+
import android.os.Handler
|
|
19
|
+
import android.os.Looper
|
|
15
20
|
|
|
16
21
|
/**
|
|
17
22
|
* TurboModule entry point for SplitBundleLoader.
|
|
@@ -26,9 +31,65 @@ import java.util.concurrent.Semaphore
|
|
|
26
31
|
class SplitBundleLoaderModule(reactContext: ReactApplicationContext) :
|
|
27
32
|
NativeSplitBundleLoaderSpec(reactContext) {
|
|
28
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Completion contract invoked by the native (JNI) side AFTER the segment
|
|
36
|
+
* has been evaluated into the runtime. Called from the JS thread.
|
|
37
|
+
*
|
|
38
|
+
* @param error null on success; a non-empty message on failure. A failure
|
|
39
|
+
* message prefixed with "IO_ERROR:" denotes a segment file read/mmap
|
|
40
|
+
* failure (mapped to SPLIT_BUNDLE_IO_ERROR); any other message denotes a
|
|
41
|
+
* segment JS/Hermes eval throw (mapped to SPLIT_BUNDLE_EVAL_ERROR).
|
|
42
|
+
*/
|
|
43
|
+
fun interface SegmentEvalCallback {
|
|
44
|
+
fun onComplete(error: String?)
|
|
45
|
+
}
|
|
46
|
+
|
|
29
47
|
companion object {
|
|
30
48
|
const val NAME = "SplitBundleLoader"
|
|
31
49
|
private const val BUILTIN_EXTRACT_DIR = "onekey-builtin-segments"
|
|
50
|
+
|
|
51
|
+
// Bounded watchdog: if the JS thread is wedged and the segment eval
|
|
52
|
+
// never runs, reject rather than leaving the JS promise pending forever.
|
|
53
|
+
// Generous because a cold JS thread under load can legitimately take a
|
|
54
|
+
// while to drain to our scheduled eval.
|
|
55
|
+
private const val SEGMENT_EVAL_TIMEOUT_MS = 30_000L
|
|
56
|
+
|
|
57
|
+
// Loads the JNI library that provides nativeEvaluateSegment. Wrapped so
|
|
58
|
+
// a missing/failed load is detectable: loadSegment then fail-closes with
|
|
59
|
+
// SPLIT_BUNDLE_NATIVE_UNAVAILABLE instead of crashing (we deliberately do
|
|
60
|
+
// NOT fall back to the legacy registerSegment path).
|
|
61
|
+
@JvmStatic
|
|
62
|
+
@Volatile
|
|
63
|
+
var nativeLibLoaded: Boolean = false
|
|
64
|
+
private set
|
|
65
|
+
|
|
66
|
+
init {
|
|
67
|
+
nativeLibLoaded = try {
|
|
68
|
+
System.loadLibrary("splitbundleloader")
|
|
69
|
+
true
|
|
70
|
+
} catch (e: Throwable) {
|
|
71
|
+
SBLLogger.warn("[SplitBundle] failed to load native lib 'splitbundleloader': ${e.message}")
|
|
72
|
+
false
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* JNI entry point. Schedules evaluation of the segment at [segmentPath]
|
|
78
|
+
* onto the JS thread via the bridgeless CallInvoker and invokes
|
|
79
|
+
* [callback] from inside that same JS-thread block, strictly AFTER the
|
|
80
|
+
* segment's `__d(...)` module definitions have run. This is the ordering
|
|
81
|
+
* guarantee that fixes the "Requiring unknown module" race.
|
|
82
|
+
*
|
|
83
|
+
* Returns immediately; [callback] fires later on the JS thread.
|
|
84
|
+
*/
|
|
85
|
+
@JvmStatic
|
|
86
|
+
@OptIn(FrameworkAPI::class)
|
|
87
|
+
external fun nativeEvaluateSegment(
|
|
88
|
+
callInvokerHolder: CallInvokerHolderImpl,
|
|
89
|
+
segmentPath: String,
|
|
90
|
+
sourceURL: String,
|
|
91
|
+
callback: SegmentEvalCallback
|
|
92
|
+
)
|
|
32
93
|
// #18: Limit concurrent asset extractions to avoid I/O contention
|
|
33
94
|
private const val MAX_CONCURRENT_EXTRACTS = 2
|
|
34
95
|
private val extractSemaphore = Semaphore(MAX_CONCURRENT_EXTRACTS)
|
|
@@ -197,6 +258,7 @@ class SplitBundleLoaderModule(reactContext: ReactApplicationContext) :
|
|
|
197
258
|
// loadSegment
|
|
198
259
|
// -----------------------------------------------------------------------
|
|
199
260
|
|
|
261
|
+
@OptIn(FrameworkAPI::class)
|
|
200
262
|
override fun loadSegment(
|
|
201
263
|
segmentId: Double,
|
|
202
264
|
segmentKey: String,
|
|
@@ -229,15 +291,119 @@ class SplitBundleLoaderModule(reactContext: ReactApplicationContext) :
|
|
|
229
291
|
return
|
|
230
292
|
}
|
|
231
293
|
|
|
232
|
-
// Use ReactContext.registerSegment which works in both bridge
|
|
233
|
-
// and bridgeless modes. In bridge mode it delegates to
|
|
234
|
-
// CatalystInstance; in bridgeless mode it delegates to ReactHost.
|
|
235
294
|
val reactContext = reactApplicationContext
|
|
236
295
|
val segStart = System.nanoTime()
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
296
|
+
|
|
297
|
+
// PRIMARY PATH (fixes the "Requiring unknown module" race):
|
|
298
|
+
// Evaluate the segment OURSELVES on the JS thread via the bridgeless
|
|
299
|
+
// CallInvoker and resolve ONLY after eval completes. We intentionally
|
|
300
|
+
// do NOT use ReactContext.registerSegment here: that resolves its
|
|
301
|
+
// callback immediately (on Task.IMMEDIATE_EXECUTOR) while the actual
|
|
302
|
+
// segment eval is merely ENQUEUED onto the RuntimeScheduler, so the
|
|
303
|
+
// promise resolves BEFORE the segment's __d(...) module definitions
|
|
304
|
+
// run — Metro's import().then(() => __r(moduleId)) can then hit a
|
|
305
|
+
// fatal "Requiring unknown module". nativeEvaluateSegment collapses
|
|
306
|
+
// eval + resolve into one JS-thread block (mirrors the iOS
|
|
307
|
+
// callFunctionOnBufferedRuntimeExecutor: fix).
|
|
308
|
+
val callInvokerHolder =
|
|
309
|
+
reactContext.jsCallInvokerHolder as? CallInvokerHolderImpl
|
|
310
|
+
|
|
311
|
+
if (nativeLibLoaded && callInvokerHolder != null) {
|
|
312
|
+
val sourceURL = File(absolutePath).name
|
|
313
|
+
// One-shot guard: native success/error AND the watchdog can each
|
|
314
|
+
// try to settle the promise; only the first wins.
|
|
315
|
+
val settled = AtomicBoolean(false)
|
|
316
|
+
|
|
317
|
+
// Bounded watchdog: if the JS thread never drains to our eval,
|
|
318
|
+
// reject instead of hanging the JS promise forever.
|
|
319
|
+
val watchdog = Handler(Looper.getMainLooper())
|
|
320
|
+
val timeoutRunnable = Runnable {
|
|
321
|
+
if (settled.compareAndSet(false, true)) {
|
|
322
|
+
SBLLogger.warn("[SplitBundle] segment $segmentKey (id=$segId) eval timed out after ${SEGMENT_EVAL_TIMEOUT_MS}ms")
|
|
323
|
+
// D: Reject with the RETRYABLE timeout code from the shared
|
|
324
|
+
// contract (NOT SPLIT_BUNDLE_EVAL_TIMEOUT). A wedged JS
|
|
325
|
+
// thread is a transient condition; using the retryable
|
|
326
|
+
// SPLIT_BUNDLE_TIMEOUT lets the JS loader re-attempt rather
|
|
327
|
+
// than caching this segment as a permanent failure.
|
|
328
|
+
promise.reject(
|
|
329
|
+
"SPLIT_BUNDLE_TIMEOUT",
|
|
330
|
+
"Segment eval timed out: $segmentKey (id=$segId)"
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
watchdog.postDelayed(timeoutRunnable, SEGMENT_EVAL_TIMEOUT_MS)
|
|
335
|
+
|
|
336
|
+
// Catch Throwable, NOT just Exception, around the native call.
|
|
337
|
+
// nativeEvaluateSegment is `external`: if the symbol is
|
|
338
|
+
// registered-but-broken (or the lib half-loaded) the JVM raises
|
|
339
|
+
// UnsatisfiedLinkError, which is a java.lang.Error — it would
|
|
340
|
+
// sail past the outer `catch (e: Exception)` and leave the JS
|
|
341
|
+
// promise unsettled forever (the watchdog would eventually fire,
|
|
342
|
+
// but only after a 30s hang). Settle exactly once here via the
|
|
343
|
+
// same AtomicBoolean one-shot guard and cancel the watchdog.
|
|
344
|
+
try {
|
|
345
|
+
nativeEvaluateSegment(
|
|
346
|
+
callInvokerHolder,
|
|
347
|
+
absolutePath,
|
|
348
|
+
sourceURL
|
|
349
|
+
) { error ->
|
|
350
|
+
if (settled.compareAndSet(false, true)) {
|
|
351
|
+
watchdog.removeCallbacks(timeoutRunnable)
|
|
352
|
+
if (error == null) {
|
|
353
|
+
val segMs = (System.nanoTime() - segStart) / 1_000_000.0
|
|
354
|
+
SBLLogger.info("[SplitBundle] segment $segmentKey (id=$segId) evaluated in ${String.format("%.1f", segMs)}ms (eval-complete)")
|
|
355
|
+
promise.resolve(null)
|
|
356
|
+
} else {
|
|
357
|
+
// The native side prefixes I/O failures (file
|
|
358
|
+
// read/mmap) with "IO_ERROR:" so we can map them to
|
|
359
|
+
// SPLIT_BUNDLE_IO_ERROR (non-retryable). Everything
|
|
360
|
+
// else is a segment JS/Hermes eval throw →
|
|
361
|
+
// SPLIT_BUNDLE_EVAL_ERROR (also non-retryable, a real
|
|
362
|
+
// bug in the segment's own code).
|
|
363
|
+
if (error.startsWith("IO_ERROR:")) {
|
|
364
|
+
val msg = error.removePrefix("IO_ERROR:")
|
|
365
|
+
SBLLogger.warn("[SplitBundle] segment $segmentKey (id=$segId) IO failed: $msg")
|
|
366
|
+
promise.reject("SPLIT_BUNDLE_IO_ERROR", msg)
|
|
367
|
+
} else {
|
|
368
|
+
SBLLogger.warn("[SplitBundle] segment $segmentKey (id=$segId) eval failed: $error")
|
|
369
|
+
promise.reject("SPLIT_BUNDLE_EVAL_ERROR", error)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
} catch (t: Throwable) {
|
|
375
|
+
// UnsatisfiedLinkError / any native dispatch failure. The
|
|
376
|
+
// callback never fired (the native side never got far enough
|
|
377
|
+
// to invoke it), so settle the promise ourselves — exactly
|
|
378
|
+
// once — as a fatal, non-retryable native fault.
|
|
379
|
+
if (settled.compareAndSet(false, true)) {
|
|
380
|
+
watchdog.removeCallbacks(timeoutRunnable)
|
|
381
|
+
SBLLogger.error("[SplitBundle] FATAL: nativeEvaluateSegment threw for $segmentKey (id=$segId): ${t.message}")
|
|
382
|
+
promise.reject(
|
|
383
|
+
"SPLIT_BUNDLE_NATIVE_UNAVAILABLE",
|
|
384
|
+
"Native segment eval threw: $segmentKey (id=$segId): ${t.message}"
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
} else {
|
|
389
|
+
// B: FAIL CLOSED. On this app (RN 0.81, NewArch / bridgeless),
|
|
390
|
+
// jsCallInvokerHolder is always a CallInvokerHolderImpl and the
|
|
391
|
+
// JNI lib ships in the AAR, so reaching here means a genuine
|
|
392
|
+
// native fault (loadLibrary failed or the holder cast failed) —
|
|
393
|
+
// NOT a benign legacy-bridge mode (this app never runs the old
|
|
394
|
+
// bridge). We deliberately do NOT fall back to the legacy
|
|
395
|
+
// ReactContext.registerSegment path: that resolves before eval
|
|
396
|
+
// and reintroduces the intermittent, uncatchable "Requiring
|
|
397
|
+
// unknown module" native crash. Failing closed (segment load
|
|
398
|
+
// unavailable → JS error boundary) is strictly better than an
|
|
399
|
+
// intermittent crash, so we reject with the dedicated
|
|
400
|
+
// NATIVE_UNAVAILABLE code (non-retryable; the JS loader will not
|
|
401
|
+
// hammer-retry a structurally broken native primitive).
|
|
402
|
+
SBLLogger.error("[SplitBundle] FATAL: native eval primitive unavailable (libLoaded=$nativeLibLoaded, holderCast=${callInvokerHolder != null}) for $segmentKey (id=$segId); failing closed instead of using the race-prone legacy registerSegment fallback")
|
|
403
|
+
promise.reject(
|
|
404
|
+
"SPLIT_BUNDLE_NATIVE_UNAVAILABLE",
|
|
405
|
+
"Native segment eval unavailable (libLoaded=$nativeLibLoaded, holderCast=${callInvokerHolder != null}): $segmentKey (id=$segId)"
|
|
406
|
+
)
|
|
241
407
|
}
|
|
242
408
|
} catch (e: Exception) {
|
|
243
409
|
promise.reject("SPLIT_BUNDLE_LOAD_ERROR", e.message, e)
|
package/ios/SplitBundleLoader.mm
CHANGED
|
@@ -3,9 +3,415 @@
|
|
|
3
3
|
#import <ReactCommon/RCTHost.h>
|
|
4
4
|
#import <ReactCommon/RCTHost+Internal.h>
|
|
5
5
|
#import <ReactCommon/RCTInstance.h>
|
|
6
|
+
#import <UIKit/UIKit.h>
|
|
6
7
|
#import <objc/runtime.h>
|
|
7
8
|
#import <CommonCrypto/CommonDigest.h>
|
|
9
|
+
#import <os/lock.h>
|
|
8
10
|
#include <jsi/jsi.h>
|
|
11
|
+
#include <cstdint>
|
|
12
|
+
|
|
13
|
+
namespace {
|
|
14
|
+
|
|
15
|
+
// Zero-copy jsi::Buffer over an NSData (M4/M5).
|
|
16
|
+
//
|
|
17
|
+
// WHY: the previous code did `std::string(data.bytes, data.length)` inside a
|
|
18
|
+
// jsi::StringBuffer — a FULL second copy of the (potentially multi-MB) segment
|
|
19
|
+
// bytes on top of whatever the NSData read already cost. For the hot segment
|
|
20
|
+
// path that doubles peak memory and adds a memcpy on the JS thread.
|
|
21
|
+
//
|
|
22
|
+
// This buffer instead RETAINS the NSData and hands jsi the NSData's own bytes
|
|
23
|
+
// directly. Combined with NSDataReadingMappedIfSafe at the read site, the
|
|
24
|
+
// segment is mmap'd and Hermes parses straight from the mapped pages — no heap
|
|
25
|
+
// copy at all on the happy path. The retained NSData also makes the buffer's
|
|
26
|
+
// lifetime explicit and self-owned: the executor block runs ASYNCHRONOUSLY
|
|
27
|
+
// (it's buffered until the entry bundle finishes), so the NSData MUST outlive
|
|
28
|
+
// the originating scope. Holding it inside the Buffer (which jsi keeps alive
|
|
29
|
+
// via the shared_ptr for the duration of evaluateJavaScript) guarantees that.
|
|
30
|
+
class NSDataJSIBuffer : public facebook::jsi::Buffer {
|
|
31
|
+
public:
|
|
32
|
+
explicit NSDataJSIBuffer(NSData *data) : data_(data) {}
|
|
33
|
+
size_t size() const override { return data_.length; }
|
|
34
|
+
const uint8_t *data() const override {
|
|
35
|
+
return static_cast<const uint8_t *>(data_.bytes);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private:
|
|
39
|
+
NSData *data_; // strong retain (ARC) — keeps mmap/heap bytes alive for jsi.
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
} // namespace
|
|
43
|
+
|
|
44
|
+
// Exactly-once settle guard for the C1 watchdog. Wraps a BOOL behind an
|
|
45
|
+
// os_unfair_lock. WHY AN OBJECT (not a __block BOOL + manual free): the executor
|
|
46
|
+
// block and the watchdog dispatch_after BOTH capture it and BOTH may run
|
|
47
|
+
// (dispatch_after is not cancellable, and on a wedge the executor can run AFTER
|
|
48
|
+
// the watchdog). Tying the lock's lifetime to ARC — both blocks retain this
|
|
49
|
+
// object, it deallocs only after both release — eliminates the use-after-free a
|
|
50
|
+
// manual free() in either block would cause. `tryClaim` returns YES to exactly
|
|
51
|
+
// one caller; the loser does nothing.
|
|
52
|
+
@interface SBLSettleGuard : NSObject
|
|
53
|
+
- (BOOL)tryClaim;
|
|
54
|
+
@end
|
|
55
|
+
|
|
56
|
+
@implementation SBLSettleGuard {
|
|
57
|
+
os_unfair_lock _lock;
|
|
58
|
+
BOOL _settled;
|
|
59
|
+
}
|
|
60
|
+
- (instancetype)init {
|
|
61
|
+
if (self = [super init]) {
|
|
62
|
+
_lock = OS_UNFAIR_LOCK_INIT;
|
|
63
|
+
_settled = NO;
|
|
64
|
+
}
|
|
65
|
+
return self;
|
|
66
|
+
}
|
|
67
|
+
- (BOOL)tryClaim {
|
|
68
|
+
os_unfair_lock_lock(&_lock);
|
|
69
|
+
BOOL won = !_settled;
|
|
70
|
+
if (won) {
|
|
71
|
+
_settled = YES;
|
|
72
|
+
}
|
|
73
|
+
os_unfair_lock_unlock(&_lock);
|
|
74
|
+
return won;
|
|
75
|
+
}
|
|
76
|
+
@end
|
|
77
|
+
|
|
78
|
+
// ACTIVE-TIME watchdog for the buffered runtime executor (replaces the bare
|
|
79
|
+
// dispatch_after C1 watchdog).
|
|
80
|
+
//
|
|
81
|
+
// WHY: the old watchdog was a single `dispatch_after(NOW + 30s)`. dispatch_after
|
|
82
|
+
// arms against an ABSOLUTE wall/uptime deadline that keeps elapsing while the
|
|
83
|
+
// app is backgrounded/suspended. If the app is suspended during cold start while
|
|
84
|
+
// a segment's eval is still BUFFERED (waiting for the entry bundle to finish),
|
|
85
|
+
// the 30s deadline can pass entirely off-screen; on FOREGROUND RESUME the stale
|
|
86
|
+
// block fires essentially instantly and wins the SBLSettleGuard race against the
|
|
87
|
+
// buffered executor that was ~1ms from succeeding — false-rejecting the segment
|
|
88
|
+
// as SPLIT_BUNDLE_TIMEOUT and white-screening the app.
|
|
89
|
+
//
|
|
90
|
+
// This watchdog instead accumulates ONLY foreground/active wall-time: suspended
|
|
91
|
+
// time never accrues toward the 30s, and a fresh foreground-grace window after
|
|
92
|
+
// every resume guarantees the buffered executor gets a chance to flush first, so
|
|
93
|
+
// a stale deadline can never fire instantly on resume.
|
|
94
|
+
//
|
|
95
|
+
// Time base: CLOCK_UPTIME_RAW already excludes device sleep; on top of that we
|
|
96
|
+
// only count intervals during which the app is in the active state. All mutable
|
|
97
|
+
// timing state is confined to a single serial queue (_queue) so the timer tick,
|
|
98
|
+
// the start/cancel calls, and the foreground/background notification callbacks
|
|
99
|
+
// never race. The fired/cancelled one-shot flag makes both onTimeout and teardown
|
|
100
|
+
// happen at most once regardless of which thread triggers them.
|
|
101
|
+
@interface SBLActiveWatchdog : NSObject
|
|
102
|
+
- (instancetype)initWithTimeoutMs:(uint64_t)timeoutMs
|
|
103
|
+
foregroundGraceMs:(uint64_t)foregroundGraceMs
|
|
104
|
+
onTimeout:(dispatch_block_t)onTimeout;
|
|
105
|
+
- (void)start;
|
|
106
|
+
- (void)cancel;
|
|
107
|
+
@end
|
|
108
|
+
|
|
109
|
+
@implementation SBLActiveWatchdog {
|
|
110
|
+
// Serial queue that owns ALL mutable state below — every read/write of these
|
|
111
|
+
// ivars happens on _queue, so no additional lock is needed.
|
|
112
|
+
dispatch_queue_t _queue;
|
|
113
|
+
dispatch_source_t _timer;
|
|
114
|
+
|
|
115
|
+
uint64_t _timeoutMs; // active-time budget before firing (e.g. 30000).
|
|
116
|
+
uint64_t _foregroundGraceMs; // post-resume window during which we won't fire.
|
|
117
|
+
|
|
118
|
+
uint64_t _accumulatedActiveMs; // folded active time from completed intervals.
|
|
119
|
+
uint64_t _currentIntervalStartUptimeNs; // CLOCK_UPTIME_RAW at active start.
|
|
120
|
+
uint64_t _graceUntilUptimeNs; // no fire before this uptime (grace window).
|
|
121
|
+
BOOL _isActive; // app currently in active/foreground state.
|
|
122
|
+
BOOL _finished; // one-shot: fired OR cancelled.
|
|
123
|
+
|
|
124
|
+
dispatch_block_t _onTimeout;
|
|
125
|
+
BOOL _observersRegistered;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
- (instancetype)initWithTimeoutMs:(uint64_t)timeoutMs
|
|
129
|
+
foregroundGraceMs:(uint64_t)foregroundGraceMs
|
|
130
|
+
onTimeout:(dispatch_block_t)onTimeout {
|
|
131
|
+
if (self = [super init]) {
|
|
132
|
+
_queue = dispatch_queue_create("com.onekey.splitbundle.watchdog", DISPATCH_QUEUE_SERIAL);
|
|
133
|
+
_timeoutMs = timeoutMs;
|
|
134
|
+
_foregroundGraceMs = foregroundGraceMs;
|
|
135
|
+
_onTimeout = [onTimeout copy];
|
|
136
|
+
_accumulatedActiveMs = 0;
|
|
137
|
+
_currentIntervalStartUptimeNs = 0;
|
|
138
|
+
_graceUntilUptimeNs = 0;
|
|
139
|
+
_isActive = NO;
|
|
140
|
+
_finished = NO;
|
|
141
|
+
_observersRegistered = NO;
|
|
142
|
+
}
|
|
143
|
+
return self;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
static uint64_t SBLNowUptimeNs(void) {
|
|
147
|
+
return clock_gettime_nsec_np(CLOCK_UPTIME_RAW);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Current active-interval elapsed (ms) while _isActive; 0 otherwise. _queue only.
|
|
151
|
+
- (uint64_t)currentIntervalMsLocked {
|
|
152
|
+
if (!_isActive || _currentIntervalStartUptimeNs == 0) {
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
uint64_t now = SBLNowUptimeNs();
|
|
156
|
+
if (now <= _currentIntervalStartUptimeNs) {
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
return (now - _currentIntervalStartUptimeNs) / 1000000ULL;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
- (void)start {
|
|
163
|
+
// Seed initial app-state + register observers on the MAIN thread:
|
|
164
|
+
// -applicationState and the notification center are both main-thread concerns.
|
|
165
|
+
// We then hop to _queue with the captured `activeNow` to prime timing + timer,
|
|
166
|
+
// so the timing-state ivars are only ever touched on _queue.
|
|
167
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
168
|
+
[self registerObservers];
|
|
169
|
+
// Treat anything other than Background as "active" for timing purposes
|
|
170
|
+
// (Inactive still makes progress toward the wedge).
|
|
171
|
+
UIApplicationState appState = UIApplication.sharedApplication.applicationState;
|
|
172
|
+
BOOL activeNow = (appState != UIApplicationStateBackground);
|
|
173
|
+
dispatch_async(self->_queue, ^{
|
|
174
|
+
[self primeTimerLockedWithActive:activeNow];
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Initialize timing state and create+resume the polling timer. _queue only.
|
|
180
|
+
- (void)primeTimerLockedWithActive:(BOOL)activeNow {
|
|
181
|
+
if (_finished) {
|
|
182
|
+
return; // cancelled before we got to prime — nothing to do.
|
|
183
|
+
}
|
|
184
|
+
_isActive = activeNow;
|
|
185
|
+
// Apply an initial grace window so a slow first foreground frame doesn't
|
|
186
|
+
// immediately trip the watchdog the very first tick. Read the clock ONCE so
|
|
187
|
+
// the interval start and the grace deadline share the same base instant.
|
|
188
|
+
if (activeNow) {
|
|
189
|
+
uint64_t now = SBLNowUptimeNs();
|
|
190
|
+
_currentIntervalStartUptimeNs = now;
|
|
191
|
+
_graceUntilUptimeNs = now + _foregroundGraceMs * 1000000ULL;
|
|
192
|
+
} else {
|
|
193
|
+
_currentIntervalStartUptimeNs = 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Polling timer: survives pause/resume trivially (unlike a one-shot
|
|
197
|
+
// dispatch_after). Tick every 500ms. Leeway gives the scheduler slack.
|
|
198
|
+
_timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, _queue);
|
|
199
|
+
if (!_timer) {
|
|
200
|
+
// Fail safe: if the timer source can't be created we cannot guard against a
|
|
201
|
+
// wedge. Settle now (as a RETRYABLE timeout) via fireLocked rather than
|
|
202
|
+
// leaving the watchdog half-started (observers registered, _finished=NO,
|
|
203
|
+
// tick never firing) — that stuck state would hang loadSegment: forever.
|
|
204
|
+
// The JS loader re-attempts on SPLIT_BUNDLE_TIMEOUT, and the buffered
|
|
205
|
+
// executor, if it does run, still benefits the module table.
|
|
206
|
+
[self fireLocked];
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
dispatch_source_set_timer(_timer,
|
|
210
|
+
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(500 * NSEC_PER_MSEC)),
|
|
211
|
+
(uint64_t)(500 * NSEC_PER_MSEC),
|
|
212
|
+
(uint64_t)(100 * NSEC_PER_MSEC));
|
|
213
|
+
__weak SBLActiveWatchdog *weakSelf = self;
|
|
214
|
+
dispatch_source_set_event_handler(_timer, ^{
|
|
215
|
+
SBLActiveWatchdog *strongSelf = weakSelf;
|
|
216
|
+
if (strongSelf) {
|
|
217
|
+
[strongSelf tickLocked];
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
dispatch_resume(_timer);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Timer tick — runs on _queue (the timer's own queue), so state is consistent.
|
|
224
|
+
- (void)tickLocked {
|
|
225
|
+
if (_finished) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (!_isActive) {
|
|
229
|
+
return; // only foreground/active time counts toward the timeout.
|
|
230
|
+
}
|
|
231
|
+
uint64_t now = SBLNowUptimeNs();
|
|
232
|
+
if (now < _graceUntilUptimeNs) {
|
|
233
|
+
return; // inside the post-resume grace window — give the executor a chance.
|
|
234
|
+
}
|
|
235
|
+
uint64_t totalActiveMs = _accumulatedActiveMs + [self currentIntervalMsLocked];
|
|
236
|
+
if (totalActiveMs >= _timeoutMs) {
|
|
237
|
+
[self fireLocked];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Fire onTimeout at most once, then tear everything down. _queue only.
|
|
242
|
+
- (void)fireLocked {
|
|
243
|
+
if (_finished) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
_finished = YES;
|
|
247
|
+
dispatch_block_t cb = _onTimeout;
|
|
248
|
+
_onTimeout = nil;
|
|
249
|
+
[self teardownTimerLocked];
|
|
250
|
+
// Remove observers on the main thread (where they were registered).
|
|
251
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
252
|
+
[self removeObservers];
|
|
253
|
+
});
|
|
254
|
+
if (cb) {
|
|
255
|
+
cb();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Cancel the timer source exactly once. _queue only.
|
|
260
|
+
- (void)teardownTimerLocked {
|
|
261
|
+
if (_timer) {
|
|
262
|
+
dispatch_source_cancel(_timer);
|
|
263
|
+
_timer = nil; // drop our ref; the source is retained until cancel completes.
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Public cancel — thread-safe, may be called from the executor block's success
|
|
268
|
+
// path on a DIFFERENT thread. Hops onto _queue so it serializes with the tick.
|
|
269
|
+
- (void)cancel {
|
|
270
|
+
dispatch_async(_queue, ^{
|
|
271
|
+
if (self->_finished) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
self->_finished = YES;
|
|
275
|
+
self->_onTimeout = nil;
|
|
276
|
+
[self teardownTimerLocked];
|
|
277
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
278
|
+
[self removeObservers];
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// MARK: - App-state observers (main thread)
|
|
284
|
+
|
|
285
|
+
- (void)registerObservers {
|
|
286
|
+
if (_observersRegistered) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
_observersRegistered = YES;
|
|
290
|
+
NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
|
|
291
|
+
[nc addObserver:self
|
|
292
|
+
selector:@selector(handleDidBecomeActive)
|
|
293
|
+
name:UIApplicationDidBecomeActiveNotification
|
|
294
|
+
object:nil];
|
|
295
|
+
[nc addObserver:self
|
|
296
|
+
selector:@selector(handleWillResignActive)
|
|
297
|
+
name:UIApplicationWillResignActiveNotification
|
|
298
|
+
object:nil];
|
|
299
|
+
// DidEnterBackground is folded in too: on some transitions WillResignActive
|
|
300
|
+
// and DidEnterBackground both arrive; the resign handler is idempotent (it
|
|
301
|
+
// no-ops when already inactive), so treating background like resign is safe.
|
|
302
|
+
[nc addObserver:self
|
|
303
|
+
selector:@selector(handleWillResignActive)
|
|
304
|
+
name:UIApplicationDidEnterBackgroundNotification
|
|
305
|
+
object:nil];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
- (void)removeObservers {
|
|
309
|
+
if (!_observersRegistered) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
_observersRegistered = NO;
|
|
313
|
+
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
- (void)handleWillResignActive {
|
|
317
|
+
// Sample the resign instant on the main thread BEFORE the app can suspend.
|
|
318
|
+
uint64_t resignAtUptimeNs = SBLNowUptimeNs();
|
|
319
|
+
// Fold + pause SYNCHRONOUSLY (dispatch_sync, not async). The pause must take
|
|
320
|
+
// effect before this lifecycle callback returns — i.e. before the app is
|
|
321
|
+
// suspended. Why sync is required, not just a synchronous timestamp:
|
|
322
|
+
// - the polling timer fires on _queue; a tick may already be PENDING on
|
|
323
|
+
// _queue when we resign. tickLocked recomputes `now` at EXECUTION time, so
|
|
324
|
+
// if that pending tick doesn't run until the app RESUMES (minutes later),
|
|
325
|
+
// it would see _isActive==YES + the old interval start and fold all the
|
|
326
|
+
// suspended-but-awake time (CLOCK_UPTIME_RAW keeps advancing) → fire a
|
|
327
|
+
// false SPLIT_BUNDLE_TIMEOUT before the resume grace is even armed.
|
|
328
|
+
// - dispatch_sync drains the serial queue first: any pending tick runs NOW
|
|
329
|
+
// (at resign time, pre-suspension, with a valid small interval — no false
|
|
330
|
+
// fire), then this fold runs and sets _isActive=NO. So by the time the app
|
|
331
|
+
// suspends, the clock is stopped and no stale tick can fire on resume.
|
|
332
|
+
// Safe from deadlock: _queue blocks never dispatch_sync back to the main
|
|
333
|
+
// thread (observer removal uses dispatch_async), and the queue's blocks are
|
|
334
|
+
// all O(1).
|
|
335
|
+
dispatch_sync(_queue, ^{
|
|
336
|
+
if (self->_finished || !self->_isActive) {
|
|
337
|
+
return; // idempotent: already inactive or already settled.
|
|
338
|
+
}
|
|
339
|
+
// Fold ONLY the active interval up to resignAt into the accumulator, then
|
|
340
|
+
// stop the clock. Suspended time after this point does NOT accrue.
|
|
341
|
+
if (self->_currentIntervalStartUptimeNs != 0 &&
|
|
342
|
+
resignAtUptimeNs > self->_currentIntervalStartUptimeNs) {
|
|
343
|
+
self->_accumulatedActiveMs +=
|
|
344
|
+
(resignAtUptimeNs - self->_currentIntervalStartUptimeNs) / 1000000ULL;
|
|
345
|
+
}
|
|
346
|
+
self->_isActive = NO;
|
|
347
|
+
self->_currentIntervalStartUptimeNs = 0;
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
- (void)handleDidBecomeActive {
|
|
352
|
+
dispatch_async(_queue, ^{
|
|
353
|
+
if (self->_finished || self->_isActive) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
// Resume the clock and arm a fresh grace window so the buffered executor
|
|
357
|
+
// gets a chance to flush before the watchdog can fire again. One clock read
|
|
358
|
+
// so the interval start and the grace deadline share the same base instant.
|
|
359
|
+
uint64_t now = SBLNowUptimeNs();
|
|
360
|
+
self->_isActive = YES;
|
|
361
|
+
self->_currentIntervalStartUptimeNs = now;
|
|
362
|
+
self->_graceUntilUptimeNs = now + self->_foregroundGraceMs * 1000000ULL;
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
- (void)dealloc {
|
|
367
|
+
// Defensive: observers are normally removed via cancel/fire, but if this
|
|
368
|
+
// object is released without either (shouldn't happen — the executor block
|
|
369
|
+
// retains it until cancel), make sure we don't leave a dangling observer.
|
|
370
|
+
if (_observersRegistered) {
|
|
371
|
+
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
|
372
|
+
}
|
|
373
|
+
// Make the "timer is nil by dealloc" invariant explicit: cancel/fireLocked
|
|
374
|
+
// both null _timer before the last retain drops, but guard here so a future
|
|
375
|
+
// early-return on those paths can't orphan a running dispatch_source_t. Safe
|
|
376
|
+
// to touch _timer without hopping to _queue — no other thread references a
|
|
377
|
+
// deallocating object.
|
|
378
|
+
if (_timer) {
|
|
379
|
+
dispatch_source_cancel(_timer);
|
|
380
|
+
_timer = nil;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
@end
|
|
384
|
+
|
|
385
|
+
// Watchdog window for the buffered runtime executor (C1). Post-entry eval is
|
|
386
|
+
// sub-millisecond, so this only ever elapses on a genuine wedge (entry bundle
|
|
387
|
+
// never finished evaluating) — never on a healthy slow device.
|
|
388
|
+
//
|
|
389
|
+
// Fix G: 30s (matching Android and the bg-runtime watchdog). The executor stays
|
|
390
|
+
// buffered until the main ENTRY bundle finishes evaluating; on a slow/throttled
|
|
391
|
+
// cold start that entry eval can itself exceed 10s, which would falsely trip the
|
|
392
|
+
// watchdog on a segment load that was about to succeed. 30s keeps the
|
|
393
|
+
// genuine-wedge safety net while leaving headroom for slow cold starts.
|
|
394
|
+
static const NSTimeInterval kSegmentEvalWatchdogSeconds = 30.0;
|
|
395
|
+
|
|
396
|
+
// NSError `code` values produced by +evaluateSegmentAtPath:... . These are
|
|
397
|
+
// DISTINCT (L8) so loadSegment: can map each to its own JS reject code and JS
|
|
398
|
+
// can classify retryable-vs-fatal:
|
|
399
|
+
// - HostMissing / NilInstance → SPLIT_BUNDLE_NO_RUNTIME (retryable):
|
|
400
|
+
// the runtime/host simply wasn't ready yet; a later attempt may succeed.
|
|
401
|
+
// - IvarMissing → SPLIT_BUNDLE_NATIVE_UNAVAILABLE (NOT retryable): a renamed
|
|
402
|
+
// ivar is a structural/build defect that no retry can fix.
|
|
403
|
+
// - Timeout → SPLIT_BUNDLE_TIMEOUT (retryable): buffered executor never ran.
|
|
404
|
+
// - IORead → SPLIT_BUNDLE_IO_ERROR: file read/mmap failed.
|
|
405
|
+
// - EvalThrow → SPLIT_BUNDLE_EVAL_ERROR (NOT retryable): a real bug in the
|
|
406
|
+
// segment's own JS/Hermes code; retrying just re-throws.
|
|
407
|
+
typedef NS_ENUM(NSInteger, ESegmentEvalError) {
|
|
408
|
+
ESegmentEvalErrorHostMissing = 1,
|
|
409
|
+
ESegmentEvalErrorIvarMissing = 2,
|
|
410
|
+
ESegmentEvalErrorNilInstance = 3,
|
|
411
|
+
ESegmentEvalErrorIORead = 4,
|
|
412
|
+
ESegmentEvalErrorEvalThrow = 5,
|
|
413
|
+
ESegmentEvalErrorTimeout = 6,
|
|
414
|
+
};
|
|
9
415
|
|
|
10
416
|
@implementation SplitBundleLoader
|
|
11
417
|
|
|
@@ -127,30 +533,217 @@
|
|
|
127
533
|
return result;
|
|
128
534
|
}
|
|
129
535
|
|
|
130
|
-
// MARK: -
|
|
536
|
+
// MARK: - RCTHost resolution helper
|
|
131
537
|
|
|
132
|
-
///
|
|
133
|
-
///
|
|
134
|
-
///
|
|
135
|
-
///
|
|
136
|
-
+ (
|
|
538
|
+
/// Resolves the bridgeless RCTHost via the AppDelegate's `reactHost` accessor
|
|
539
|
+
/// (New Architecture). Returns nil when the host is unavailable so callers can
|
|
540
|
+
/// reject gracefully. Extracted so both segment registration and the
|
|
541
|
+
/// evaluate-then-resolve path (#race) share one lookup.
|
|
542
|
+
+ (nullable RCTHost *)currentReactHost
|
|
137
543
|
{
|
|
138
|
-
// Bridgeless (New Architecture): get RCTHost via AppDelegate
|
|
139
544
|
id<UIApplicationDelegate> appDelegate = [UIApplication sharedApplication].delegate;
|
|
140
545
|
if ([appDelegate respondsToSelector:NSSelectorFromString(@"reactHost")]) {
|
|
141
546
|
RCTHost *host = [appDelegate performSelector:NSSelectorFromString(@"reactHost")];
|
|
142
|
-
if (host
|
|
143
|
-
|
|
144
|
-
return YES;
|
|
547
|
+
if (host) {
|
|
548
|
+
return host;
|
|
145
549
|
}
|
|
146
550
|
}
|
|
551
|
+
return nil;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// MARK: - Segment evaluation helper (resolve-after-eval, fixes lazy-segment race)
|
|
555
|
+
|
|
556
|
+
/// Evaluates a segment bundle into the CURRENT runtime and invokes `onEvaluated`
|
|
557
|
+
/// from INSIDE the same runtime-executor block, immediately after the segment's
|
|
558
|
+
/// `__d(...)` module definitions have run.
|
|
559
|
+
///
|
|
560
|
+
/// WHY THIS EXISTS — the "Requiring unknown module" race (#race):
|
|
561
|
+
/// The previous implementation called `RCTHost registerSegmentWithId:path:`,
|
|
562
|
+
/// which routes to `ReactInstance::registerSegment` →
|
|
563
|
+
/// `runtimeScheduler_->scheduleWork([]{ runtime.evaluateJavaScript(segment) })`.
|
|
564
|
+
/// That only ENQUEUES the eval onto the runtime scheduler and returns; the
|
|
565
|
+
/// loadSegment promise was resolved IMMEDIATELY afterwards. Metro's
|
|
566
|
+
/// `import().then(() => __r(moduleId))` microtask could therefore run `__r`
|
|
567
|
+
/// BEFORE the scheduled eval populated the module table → a FATAL, uncatchable
|
|
568
|
+
/// "Requiring unknown module" inside metroRequire.
|
|
569
|
+
///
|
|
570
|
+
/// We cannot fix this by merely scheduling `resolve` after `registerSegment` on
|
|
571
|
+
/// the same executor: `RuntimeScheduler_Modern::scheduleWork` pushes
|
|
572
|
+
/// ImmediatePriority tasks into a `std::priority_queue` keyed only on
|
|
573
|
+
/// `expirationTime` (RuntimeScheduler_Modern.cpp / Task.h `TaskPriorityComparer`).
|
|
574
|
+
/// `std::priority_queue` is NOT stable, so two same-tick tasks have UNDEFINED
|
|
575
|
+
/// relative order — FIFO is not guaranteed under the Modern scheduler.
|
|
576
|
+
///
|
|
577
|
+
/// Instead we evaluate the segment OURSELVES inside a single
|
|
578
|
+
/// `callFunctionOnBufferedRuntimeExecutor:` block (exactly like
|
|
579
|
+
/// `loadEntryBundle:`) and signal completion in that SAME block. Eval and the
|
|
580
|
+
/// resolve are now one atomic unit of work — there is no cross-task ordering to
|
|
581
|
+
/// lose, so any subsequent `__r(moduleId)` is guaranteed to find the module.
|
|
582
|
+
///
|
|
583
|
+
/// This is safe because OneKey segments are STANDALONE-EVALUATABLE Metro
|
|
584
|
+
/// bundles (the serializer emits `baseJSBundle`/`bundleToString` output — plain
|
|
585
|
+
/// top-level `__d(moduleId, factory, deps)` calls, NOT Hermes RAM/indexed
|
|
586
|
+
/// segments that would require the registerSegment manifest wiring). The paired
|
|
587
|
+
/// `.seg.hbc` is just the Hermes-compiled form of that same source, which
|
|
588
|
+
/// `evaluateJavaScript` runs identically to the entry bundle's `.hbc`.
|
|
589
|
+
///
|
|
590
|
+
/// `onEvaluated` is invoked EXACTLY ONCE with nil on success or a populated
|
|
591
|
+
/// NSError on failure. The NSError `code` is meaningful and mapped by the caller
|
|
592
|
+
/// to a distinct JS reject code (see ESegmentEvalError below + loadSegment:):
|
|
593
|
+
/// callers use it to classify retryable (no-runtime / timeout) vs fatal
|
|
594
|
+
/// (eval-throw / IO) failures.
|
|
595
|
+
+ (void)evaluateSegmentAtPath:(NSString *)bundlePath
|
|
596
|
+
segmentId:(int)segmentId
|
|
597
|
+
segmentKey:(NSString *)segmentKey
|
|
598
|
+
onEvaluated:(void (^)(NSError *_Nullable error))onEvaluated
|
|
599
|
+
{
|
|
600
|
+
RCTHost *host = [SplitBundleLoader currentReactHost];
|
|
601
|
+
if (!host) {
|
|
602
|
+
onEvaluated([NSError errorWithDomain:@"SplitBundleLoader"
|
|
603
|
+
code:ESegmentEvalErrorHostMissing
|
|
604
|
+
userInfo:@{NSLocalizedDescriptionKey: @"RCTHost not available for segment evaluation"}]);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
147
607
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
608
|
+
// Reach the RCTInstance the same way loadEntryBundle: does, so we can use
|
|
609
|
+
// the buffered runtime executor primitive (callFunctionOnBufferedRuntimeExecutor:).
|
|
610
|
+
Ivar ivar = class_getInstanceVariable([host class], "_instance");
|
|
611
|
+
if (!ivar) {
|
|
612
|
+
// L7: a missing `_instance` ivar means a future RN bump renamed/removed
|
|
613
|
+
// the field our reflection depends on. That silently disables ALL
|
|
614
|
+
// segment loading, so log loudly (error, with the class name) instead of
|
|
615
|
+
// failing quietly — the next RN upgrade then surfaces visibly in logs.
|
|
616
|
+
[SBLLogger error:[NSString stringWithFormat:@"[SplitBundle] FATAL: _instance ivar not found on %@ — segment loading is DISABLED. An RN upgrade likely renamed this private field; SplitBundleLoader reflection must be updated.", [host class]]];
|
|
617
|
+
onEvaluated([NSError errorWithDomain:@"SplitBundleLoader"
|
|
618
|
+
code:ESegmentEvalErrorIvarMissing
|
|
619
|
+
userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"_instance ivar not found on %@", [host class]]}]);
|
|
620
|
+
return;
|
|
152
621
|
}
|
|
153
|
-
|
|
622
|
+
|
|
623
|
+
RCTInstance *instance = object_getIvar(host, ivar);
|
|
624
|
+
if (!instance) {
|
|
625
|
+
onEvaluated([NSError errorWithDomain:@"SplitBundleLoader"
|
|
626
|
+
code:ESegmentEvalErrorNilInstance
|
|
627
|
+
userInfo:@{NSLocalizedDescriptionKey: @"RCTInstance is nil"}]);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// M4/M5: mmap the segment (NSDataReadingMappedIfSafe) instead of a full
|
|
632
|
+
// heap read, then wrap it zero-copy in NSDataJSIBuffer (which retains the
|
|
633
|
+
// NSData). Net effect: no second copy, and the bytes stay alive for the
|
|
634
|
+
// async executor block because the buffer owns the NSData.
|
|
635
|
+
NSError *readError = nil;
|
|
636
|
+
NSData *data = [NSData dataWithContentsOfFile:bundlePath
|
|
637
|
+
options:NSDataReadingMappedIfSafe
|
|
638
|
+
error:&readError];
|
|
639
|
+
if (!data || data.length == 0) {
|
|
640
|
+
onEvaluated([NSError errorWithDomain:@"SplitBundleLoader"
|
|
641
|
+
code:ESegmentEvalErrorIORead
|
|
642
|
+
userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to read segment at %@%@", bundlePath, readError ? [NSString stringWithFormat:@": %@", readError.localizedDescription] : @""]}]);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// M6: preserve a meaningful eval source URL so in-segment crash frames are
|
|
647
|
+
// symbolicated. RN's registerSegment used
|
|
648
|
+
// `JSExecutor::getSyntheticBundlePath(segmentId, segmentPath)`, which for a
|
|
649
|
+
// non-main segment yields `seg-<id>.js` (see cxxreact/JSExecutor.cpp). We
|
|
650
|
+
// replicate that exact form so Hermes/Metro attribute frames to the segment
|
|
651
|
+
// the same way the native path did — using `lastPathComponent` here would
|
|
652
|
+
// degrade symbolication.
|
|
653
|
+
NSString *sourceURL = [NSString stringWithFormat:@"seg-%d.js", segmentId];
|
|
654
|
+
CFAbsoluteTime dispatchStart = CFAbsoluteTimeGetCurrent();
|
|
655
|
+
[SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] loadSegment: evaluating %@ (key=%@, %lu bytes)", sourceURL, segmentKey, (unsigned long)data.length]];
|
|
656
|
+
|
|
657
|
+
// C1: the executor block below is BUFFERED — callFunctionOnBufferedRuntimeExecutor
|
|
658
|
+
// does not run it until the main entry bundle finishes evaluating
|
|
659
|
+
// (RCTInstance/ReactInstance.cpp). If a segment load is requested before the
|
|
660
|
+
// entry completes (early startup, reload teardown, host swap) and the entry
|
|
661
|
+
// never completes, the block NEVER runs → onEvaluated would never fire →
|
|
662
|
+
// the JS promise hangs forever and inflightSegments wedges with no timeout.
|
|
663
|
+
//
|
|
664
|
+
// Guard: invoke `onEvaluated` EXACTLY ONCE. SBLSettleGuard wraps a BOOL
|
|
665
|
+
// behind an os_unfair_lock; whichever racer wins — the executor block (happy
|
|
666
|
+
// path) or the watchdog timer (genuine wedge) — gets YES from tryClaim and
|
|
667
|
+
// settles the promise; the loser does nothing. The guard is an ARC object
|
|
668
|
+
// captured (retained) by BOTH blocks, so its lock outlives both with no
|
|
669
|
+
// manual free() and therefore no use-after-free (both blocks can run, in
|
|
670
|
+
// either order, on a wedge).
|
|
671
|
+
SBLSettleGuard *settleGuard = [[SBLSettleGuard alloc] init];
|
|
672
|
+
|
|
673
|
+
// C1 active-time watchdog. Constructed BEFORE the executor block so the block
|
|
674
|
+
// can capture (retain) it and tear it down on the happy path. It fires only
|
|
675
|
+
// on a genuine wedge AND only after kSegmentEvalWatchdogSeconds of
|
|
676
|
+
// FOREGROUND/active time has accrued — backgrounded/suspended time never
|
|
677
|
+
// counts, so a stale deadline can never fire instantly on a foreground
|
|
678
|
+
// resume (the white-screen bug this replaces). On fire it settles the SAME
|
|
679
|
+
// SBLSettleGuard via tryClaim, so there is still exactly one settle.
|
|
680
|
+
SBLActiveWatchdog *watchdog = [[SBLActiveWatchdog alloc]
|
|
681
|
+
initWithTimeoutMs:(uint64_t)(kSegmentEvalWatchdogSeconds * 1000.0)
|
|
682
|
+
foregroundGraceMs:500
|
|
683
|
+
onTimeout:^{
|
|
684
|
+
if ([settleGuard tryClaim]) {
|
|
685
|
+
[SBLLogger error:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ (key=%@) WATCHDOG fired after %.0fs active time — runtime executor never ran (entry bundle likely never finished evaluating). Rejecting as retryable timeout.", sourceURL, segmentKey, kSegmentEvalWatchdogSeconds]];
|
|
686
|
+
onEvaluated([NSError errorWithDomain:@"SplitBundleLoader"
|
|
687
|
+
code:ESegmentEvalErrorTimeout
|
|
688
|
+
userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Segment %@ eval timed out after %.0fs active time (buffered runtime executor never ran)", segmentKey, kSegmentEvalWatchdogSeconds]}]);
|
|
689
|
+
}
|
|
690
|
+
}];
|
|
691
|
+
|
|
692
|
+
[instance callFunctionOnBufferedRuntimeExecutor:^(facebook::jsi::Runtime &runtime) {
|
|
693
|
+
@autoreleasepool {
|
|
694
|
+
// If the watchdog already fired (entry took >30s active then
|
|
695
|
+
// unwedged), the JS promise is already rejected — still evaluate the
|
|
696
|
+
// segment (the module table benefits) but don't double-settle.
|
|
697
|
+
BOOL won = [settleGuard tryClaim];
|
|
698
|
+
NSError *evalError = nil;
|
|
699
|
+
CFAbsoluteTime evalStart = CFAbsoluteTimeGetCurrent();
|
|
700
|
+
try {
|
|
701
|
+
auto buffer = std::make_shared<NSDataJSIBuffer>(data);
|
|
702
|
+
runtime.evaluateJavaScript(buffer, [sourceURL UTF8String]);
|
|
703
|
+
double evalMs = (CFAbsoluteTimeGetCurrent() - evalStart) * 1000.0;
|
|
704
|
+
[SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ evaluated in %.1fms", sourceURL, evalMs]];
|
|
705
|
+
} catch (const std::exception &e) {
|
|
706
|
+
// L8: a JS/Hermes eval throw is a REAL BUG in the segment's own
|
|
707
|
+
// code, not a transient runtime-readiness problem. Mapped to a
|
|
708
|
+
// NON-retryable code by the caller so JS caches it as failed.
|
|
709
|
+
evalError = [NSError errorWithDomain:@"SplitBundleLoader"
|
|
710
|
+
code:ESegmentEvalErrorEvalThrow
|
|
711
|
+
userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Segment evaluation failed for %@: %s", sourceURL, e.what()]}];
|
|
712
|
+
[SBLLogger warn:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ evaluation threw: %s", sourceURL, e.what()]];
|
|
713
|
+
} catch (...) {
|
|
714
|
+
evalError = [NSError errorWithDomain:@"SplitBundleLoader"
|
|
715
|
+
code:ESegmentEvalErrorEvalThrow
|
|
716
|
+
userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Segment evaluation failed for %@ (unknown C++ exception)", sourceURL]}];
|
|
717
|
+
[SBLLogger warn:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ evaluation threw an unknown exception", sourceURL]];
|
|
718
|
+
}
|
|
719
|
+
// Tear down the active-time watchdog (timer + observers) regardless
|
|
720
|
+
// of who won the settle: the buffered executor has now run, so the
|
|
721
|
+
// watchdog has no further job. Capturing `watchdog` here is also what
|
|
722
|
+
// RETAINS it for the lifetime of this async block (until cancel). It
|
|
723
|
+
// is safe to cancel from this (different) thread — cancel hops onto
|
|
724
|
+
// the watchdog's own serial queue and is a one-shot.
|
|
725
|
+
[watchdog cancel];
|
|
726
|
+
if (won) {
|
|
727
|
+
// Resolve/reject the JS promise from INSIDE this same block,
|
|
728
|
+
// strictly AFTER the segment eval above — the ordering guarantee
|
|
729
|
+
// that fixes the "Requiring unknown module" race (see method doc).
|
|
730
|
+
onEvaluated(evalError);
|
|
731
|
+
} else {
|
|
732
|
+
[SBLLogger warn:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ evaluated AFTER watchdog already settled (entry was wedged >%.0fs active time)", sourceURL, kSegmentEvalWatchdogSeconds]];
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}];
|
|
736
|
+
|
|
737
|
+
// Arm the watchdog AFTER scheduling the executor. Fires only on a genuine
|
|
738
|
+
// wedge: in steady state the entry bundle is long done and the buffered block
|
|
739
|
+
// runs sub-millisecond, so it cancels the watchdog (and the guard is already
|
|
740
|
+
// claimed) long before kSegmentEvalWatchdogSeconds of ACTIVE time elapses.
|
|
741
|
+
// Because only foreground/active time is counted, a suspend-during-cold-start
|
|
742
|
+
// can no longer let a stale deadline false-fire on resume.
|
|
743
|
+
[watchdog start];
|
|
744
|
+
|
|
745
|
+
double dispatchMs = (CFAbsoluteTimeGetCurrent() - dispatchStart) * 1000.0;
|
|
746
|
+
[SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ dispatched in %.1fms (resolve fires after eval; watchdog %.0fs)", sourceURL, dispatchMs, kSegmentEvalWatchdogSeconds]];
|
|
154
747
|
}
|
|
155
748
|
|
|
156
749
|
// MARK: - getRuntimeBundleContext
|
|
@@ -280,7 +873,12 @@
|
|
|
280
873
|
return;
|
|
281
874
|
}
|
|
282
875
|
|
|
283
|
-
|
|
876
|
+
// M5: mmap + zero-copy (NSDataJSIBuffer retains the NSData for the async block),
|
|
877
|
+
// mirroring the segment path. Entry bundle is the largest single read, so this
|
|
878
|
+
// saves the biggest single copy.
|
|
879
|
+
NSData *data = [NSData dataWithContentsOfFile:bundlePath
|
|
880
|
+
options:NSDataReadingMappedIfSafe
|
|
881
|
+
error:nil];
|
|
284
882
|
if (!data || data.length == 0) {
|
|
285
883
|
[SBLLogger warn:[NSString stringWithFormat:@"loadEntryBundle: failed to read bundle at %@", bundlePath]];
|
|
286
884
|
return;
|
|
@@ -293,9 +891,8 @@
|
|
|
293
891
|
[instance callFunctionOnBufferedRuntimeExecutor:^(facebook::jsi::Runtime &runtime) {
|
|
294
892
|
@autoreleasepool {
|
|
295
893
|
CFAbsoluteTime evalStart = CFAbsoluteTimeGetCurrent();
|
|
296
|
-
auto buffer = std::make_shared<
|
|
297
|
-
|
|
298
|
-
runtime.evaluateJavaScript(std::move(buffer), [sourceURL UTF8String]);
|
|
894
|
+
auto buffer = std::make_shared<NSDataJSIBuffer>(data);
|
|
895
|
+
runtime.evaluateJavaScript(buffer, [sourceURL UTF8String]);
|
|
299
896
|
double evalMs = (CFAbsoluteTimeGetCurrent() - evalStart) * 1000.0;
|
|
300
897
|
[SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] loadEntryBundle: %@ evaluated in %.1fms", sourceURL, evalMs]];
|
|
301
898
|
}
|
|
@@ -342,17 +939,62 @@
|
|
|
342
939
|
return;
|
|
343
940
|
}
|
|
344
941
|
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
942
|
+
// Evaluate the segment into the current runtime and resolve ONLY after
|
|
943
|
+
// its module definitions have actually run (#race). We intentionally do
|
|
944
|
+
// NOT use registerSegmentWithId: + immediate resolve here: that resolves
|
|
945
|
+
// before the scheduler-enqueued eval completes, so Metro's
|
|
946
|
+
// `import().then(() => __r(moduleId))` can hit "Requiring unknown module"
|
|
947
|
+
// (a fatal, uncatchable crash). See +evaluateSegmentAtPath:... doc for
|
|
948
|
+
// the full ordering rationale (Modern scheduler priority_queue is not
|
|
949
|
+
// FIFO, so we collapse eval+resolve into one runtime-executor block).
|
|
950
|
+
// segId is threaded through for the synthetic eval source URL (M6) and
|
|
951
|
+
// for log parity with the previous register flow.
|
|
952
|
+
[SplitBundleLoader evaluateSegmentAtPath:absolutePath
|
|
953
|
+
segmentId:segId
|
|
954
|
+
segmentKey:segmentKey
|
|
955
|
+
onEvaluated:^(NSError *_Nullable evalError) {
|
|
956
|
+
if (evalError) {
|
|
957
|
+
// L8: map the helper's distinct NSError code to a distinct JS
|
|
958
|
+
// reject code so JS can classify retryable vs fatal (see
|
|
959
|
+
// ESegmentEvalError + installProdBundleLoader.ts H3).
|
|
960
|
+
NSString *rejectCode;
|
|
961
|
+
switch ((ESegmentEvalError)evalError.code) {
|
|
962
|
+
case ESegmentEvalErrorTimeout:
|
|
963
|
+
rejectCode = @"SPLIT_BUNDLE_TIMEOUT"; // retryable
|
|
964
|
+
break;
|
|
965
|
+
case ESegmentEvalErrorIORead:
|
|
966
|
+
rejectCode = @"SPLIT_BUNDLE_IO_ERROR"; // fatal
|
|
967
|
+
break;
|
|
968
|
+
case ESegmentEvalErrorEvalThrow:
|
|
969
|
+
rejectCode = @"SPLIT_BUNDLE_EVAL_ERROR"; // fatal (segment bug)
|
|
970
|
+
break;
|
|
971
|
+
case ESegmentEvalErrorIvarMissing:
|
|
972
|
+
// Fix 2: `_instance` ivar reflection failed — STRUCTURAL/
|
|
973
|
+
// PERMANENT. An RN version bump renamed/removed the
|
|
974
|
+
// private field our reflection depends on, so segment
|
|
975
|
+
// loading is disabled until the native code is updated.
|
|
976
|
+
// Retrying can NEVER recreate a renamed ivar, so this is
|
|
977
|
+
// fatal NATIVE_UNAVAILABLE — NOT retryable NO_RUNTIME. By
|
|
978
|
+
// contrast HostMissing / NilInstance below are genuinely
|
|
979
|
+
// transient (host/instance not up yet → a later attempt
|
|
980
|
+
// may succeed).
|
|
981
|
+
rejectCode = @"SPLIT_BUNDLE_NATIVE_UNAVAILABLE"; // fatal
|
|
982
|
+
break;
|
|
983
|
+
case ESegmentEvalErrorHostMissing:
|
|
984
|
+
case ESegmentEvalErrorNilInstance:
|
|
985
|
+
default:
|
|
986
|
+
rejectCode = @"SPLIT_BUNDLE_NO_RUNTIME"; // retryable
|
|
987
|
+
break;
|
|
988
|
+
}
|
|
989
|
+
reject(rejectCode,
|
|
990
|
+
evalError.localizedDescription ?: @"Segment evaluation failed",
|
|
991
|
+
evalError);
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
348
994
|
double segMs = (CFAbsoluteTimeGetCurrent() - segStart) * 1000.0;
|
|
349
|
-
[SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] Loaded segment %@ (id=%d) in %.1fms", segmentKey, segId, segMs]];
|
|
995
|
+
[SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] Loaded segment %@ (id=%d) in %.1fms (eval-complete)", segmentKey, segId, segMs]];
|
|
350
996
|
resolve(nil);
|
|
351
|
-
}
|
|
352
|
-
reject(@"SPLIT_BUNDLE_NO_RUNTIME",
|
|
353
|
-
regError.localizedDescription ?: @"Runtime not available",
|
|
354
|
-
regError);
|
|
355
|
-
}
|
|
997
|
+
}];
|
|
356
998
|
} @catch (NSException *exception) {
|
|
357
999
|
reject(@"SPLIT_BUNDLE_LOAD_ERROR",
|
|
358
1000
|
[NSString stringWithFormat:@"Failed to load segment %@: %@", segmentKey, exception.reason],
|