@onekeyfe/react-native-split-bundle-loader 3.0.60 → 3.0.61
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
|
@@ -5,7 +5,105 @@
|
|
|
5
5
|
#import <ReactCommon/RCTInstance.h>
|
|
6
6
|
#import <objc/runtime.h>
|
|
7
7
|
#import <CommonCrypto/CommonDigest.h>
|
|
8
|
+
#import <os/lock.h>
|
|
8
9
|
#include <jsi/jsi.h>
|
|
10
|
+
#include <cstdint>
|
|
11
|
+
|
|
12
|
+
namespace {
|
|
13
|
+
|
|
14
|
+
// Zero-copy jsi::Buffer over an NSData (M4/M5).
|
|
15
|
+
//
|
|
16
|
+
// WHY: the previous code did `std::string(data.bytes, data.length)` inside a
|
|
17
|
+
// jsi::StringBuffer — a FULL second copy of the (potentially multi-MB) segment
|
|
18
|
+
// bytes on top of whatever the NSData read already cost. For the hot segment
|
|
19
|
+
// path that doubles peak memory and adds a memcpy on the JS thread.
|
|
20
|
+
//
|
|
21
|
+
// This buffer instead RETAINS the NSData and hands jsi the NSData's own bytes
|
|
22
|
+
// directly. Combined with NSDataReadingMappedIfSafe at the read site, the
|
|
23
|
+
// segment is mmap'd and Hermes parses straight from the mapped pages — no heap
|
|
24
|
+
// copy at all on the happy path. The retained NSData also makes the buffer's
|
|
25
|
+
// lifetime explicit and self-owned: the executor block runs ASYNCHRONOUSLY
|
|
26
|
+
// (it's buffered until the entry bundle finishes), so the NSData MUST outlive
|
|
27
|
+
// the originating scope. Holding it inside the Buffer (which jsi keeps alive
|
|
28
|
+
// via the shared_ptr for the duration of evaluateJavaScript) guarantees that.
|
|
29
|
+
class NSDataJSIBuffer : public facebook::jsi::Buffer {
|
|
30
|
+
public:
|
|
31
|
+
explicit NSDataJSIBuffer(NSData *data) : data_(data) {}
|
|
32
|
+
size_t size() const override { return data_.length; }
|
|
33
|
+
const uint8_t *data() const override {
|
|
34
|
+
return static_cast<const uint8_t *>(data_.bytes);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private:
|
|
38
|
+
NSData *data_; // strong retain (ARC) — keeps mmap/heap bytes alive for jsi.
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
} // namespace
|
|
42
|
+
|
|
43
|
+
// Exactly-once settle guard for the C1 watchdog. Wraps a BOOL behind an
|
|
44
|
+
// os_unfair_lock. WHY AN OBJECT (not a __block BOOL + manual free): the executor
|
|
45
|
+
// block and the watchdog dispatch_after BOTH capture it and BOTH may run
|
|
46
|
+
// (dispatch_after is not cancellable, and on a wedge the executor can run AFTER
|
|
47
|
+
// the watchdog). Tying the lock's lifetime to ARC — both blocks retain this
|
|
48
|
+
// object, it deallocs only after both release — eliminates the use-after-free a
|
|
49
|
+
// manual free() in either block would cause. `tryClaim` returns YES to exactly
|
|
50
|
+
// one caller; the loser does nothing.
|
|
51
|
+
@interface SBLSettleGuard : NSObject
|
|
52
|
+
- (BOOL)tryClaim;
|
|
53
|
+
@end
|
|
54
|
+
|
|
55
|
+
@implementation SBLSettleGuard {
|
|
56
|
+
os_unfair_lock _lock;
|
|
57
|
+
BOOL _settled;
|
|
58
|
+
}
|
|
59
|
+
- (instancetype)init {
|
|
60
|
+
if (self = [super init]) {
|
|
61
|
+
_lock = OS_UNFAIR_LOCK_INIT;
|
|
62
|
+
_settled = NO;
|
|
63
|
+
}
|
|
64
|
+
return self;
|
|
65
|
+
}
|
|
66
|
+
- (BOOL)tryClaim {
|
|
67
|
+
os_unfair_lock_lock(&_lock);
|
|
68
|
+
BOOL won = !_settled;
|
|
69
|
+
if (won) {
|
|
70
|
+
_settled = YES;
|
|
71
|
+
}
|
|
72
|
+
os_unfair_lock_unlock(&_lock);
|
|
73
|
+
return won;
|
|
74
|
+
}
|
|
75
|
+
@end
|
|
76
|
+
|
|
77
|
+
// Watchdog window for the buffered runtime executor (C1). Post-entry eval is
|
|
78
|
+
// sub-millisecond, so this only ever elapses on a genuine wedge (entry bundle
|
|
79
|
+
// never finished evaluating) — never on a healthy slow device.
|
|
80
|
+
//
|
|
81
|
+
// Fix G: 30s (matching Android and the bg-runtime watchdog). The executor stays
|
|
82
|
+
// buffered until the main ENTRY bundle finishes evaluating; on a slow/throttled
|
|
83
|
+
// cold start that entry eval can itself exceed 10s, which would falsely trip the
|
|
84
|
+
// watchdog on a segment load that was about to succeed. 30s keeps the
|
|
85
|
+
// genuine-wedge safety net while leaving headroom for slow cold starts.
|
|
86
|
+
static const NSTimeInterval kSegmentEvalWatchdogSeconds = 30.0;
|
|
87
|
+
|
|
88
|
+
// NSError `code` values produced by +evaluateSegmentAtPath:... . These are
|
|
89
|
+
// DISTINCT (L8) so loadSegment: can map each to its own JS reject code and JS
|
|
90
|
+
// can classify retryable-vs-fatal:
|
|
91
|
+
// - HostMissing / NilInstance → SPLIT_BUNDLE_NO_RUNTIME (retryable):
|
|
92
|
+
// the runtime/host simply wasn't ready yet; a later attempt may succeed.
|
|
93
|
+
// - IvarMissing → SPLIT_BUNDLE_NATIVE_UNAVAILABLE (NOT retryable): a renamed
|
|
94
|
+
// ivar is a structural/build defect that no retry can fix.
|
|
95
|
+
// - Timeout → SPLIT_BUNDLE_TIMEOUT (retryable): buffered executor never ran.
|
|
96
|
+
// - IORead → SPLIT_BUNDLE_IO_ERROR: file read/mmap failed.
|
|
97
|
+
// - EvalThrow → SPLIT_BUNDLE_EVAL_ERROR (NOT retryable): a real bug in the
|
|
98
|
+
// segment's own JS/Hermes code; retrying just re-throws.
|
|
99
|
+
typedef NS_ENUM(NSInteger, ESegmentEvalError) {
|
|
100
|
+
ESegmentEvalErrorHostMissing = 1,
|
|
101
|
+
ESegmentEvalErrorIvarMissing = 2,
|
|
102
|
+
ESegmentEvalErrorNilInstance = 3,
|
|
103
|
+
ESegmentEvalErrorIORead = 4,
|
|
104
|
+
ESegmentEvalErrorEvalThrow = 5,
|
|
105
|
+
ESegmentEvalErrorTimeout = 6,
|
|
106
|
+
};
|
|
9
107
|
|
|
10
108
|
@implementation SplitBundleLoader
|
|
11
109
|
|
|
@@ -127,30 +225,200 @@
|
|
|
127
225
|
return result;
|
|
128
226
|
}
|
|
129
227
|
|
|
130
|
-
// MARK: -
|
|
228
|
+
// MARK: - RCTHost resolution helper
|
|
131
229
|
|
|
132
|
-
///
|
|
133
|
-
///
|
|
134
|
-
///
|
|
135
|
-
///
|
|
136
|
-
+ (
|
|
230
|
+
/// Resolves the bridgeless RCTHost via the AppDelegate's `reactHost` accessor
|
|
231
|
+
/// (New Architecture). Returns nil when the host is unavailable so callers can
|
|
232
|
+
/// reject gracefully. Extracted so both segment registration and the
|
|
233
|
+
/// evaluate-then-resolve path (#race) share one lookup.
|
|
234
|
+
+ (nullable RCTHost *)currentReactHost
|
|
137
235
|
{
|
|
138
|
-
// Bridgeless (New Architecture): get RCTHost via AppDelegate
|
|
139
236
|
id<UIApplicationDelegate> appDelegate = [UIApplication sharedApplication].delegate;
|
|
140
237
|
if ([appDelegate respondsToSelector:NSSelectorFromString(@"reactHost")]) {
|
|
141
238
|
RCTHost *host = [appDelegate performSelector:NSSelectorFromString(@"reactHost")];
|
|
142
|
-
if (host
|
|
143
|
-
|
|
144
|
-
return YES;
|
|
239
|
+
if (host) {
|
|
240
|
+
return host;
|
|
145
241
|
}
|
|
146
242
|
}
|
|
243
|
+
return nil;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// MARK: - Segment evaluation helper (resolve-after-eval, fixes lazy-segment race)
|
|
147
247
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
248
|
+
/// Evaluates a segment bundle into the CURRENT runtime and invokes `onEvaluated`
|
|
249
|
+
/// from INSIDE the same runtime-executor block, immediately after the segment's
|
|
250
|
+
/// `__d(...)` module definitions have run.
|
|
251
|
+
///
|
|
252
|
+
/// WHY THIS EXISTS — the "Requiring unknown module" race (#race):
|
|
253
|
+
/// The previous implementation called `RCTHost registerSegmentWithId:path:`,
|
|
254
|
+
/// which routes to `ReactInstance::registerSegment` →
|
|
255
|
+
/// `runtimeScheduler_->scheduleWork([]{ runtime.evaluateJavaScript(segment) })`.
|
|
256
|
+
/// That only ENQUEUES the eval onto the runtime scheduler and returns; the
|
|
257
|
+
/// loadSegment promise was resolved IMMEDIATELY afterwards. Metro's
|
|
258
|
+
/// `import().then(() => __r(moduleId))` microtask could therefore run `__r`
|
|
259
|
+
/// BEFORE the scheduled eval populated the module table → a FATAL, uncatchable
|
|
260
|
+
/// "Requiring unknown module" inside metroRequire.
|
|
261
|
+
///
|
|
262
|
+
/// We cannot fix this by merely scheduling `resolve` after `registerSegment` on
|
|
263
|
+
/// the same executor: `RuntimeScheduler_Modern::scheduleWork` pushes
|
|
264
|
+
/// ImmediatePriority tasks into a `std::priority_queue` keyed only on
|
|
265
|
+
/// `expirationTime` (RuntimeScheduler_Modern.cpp / Task.h `TaskPriorityComparer`).
|
|
266
|
+
/// `std::priority_queue` is NOT stable, so two same-tick tasks have UNDEFINED
|
|
267
|
+
/// relative order — FIFO is not guaranteed under the Modern scheduler.
|
|
268
|
+
///
|
|
269
|
+
/// Instead we evaluate the segment OURSELVES inside a single
|
|
270
|
+
/// `callFunctionOnBufferedRuntimeExecutor:` block (exactly like
|
|
271
|
+
/// `loadEntryBundle:`) and signal completion in that SAME block. Eval and the
|
|
272
|
+
/// resolve are now one atomic unit of work — there is no cross-task ordering to
|
|
273
|
+
/// lose, so any subsequent `__r(moduleId)` is guaranteed to find the module.
|
|
274
|
+
///
|
|
275
|
+
/// This is safe because OneKey segments are STANDALONE-EVALUATABLE Metro
|
|
276
|
+
/// bundles (the serializer emits `baseJSBundle`/`bundleToString` output — plain
|
|
277
|
+
/// top-level `__d(moduleId, factory, deps)` calls, NOT Hermes RAM/indexed
|
|
278
|
+
/// segments that would require the registerSegment manifest wiring). The paired
|
|
279
|
+
/// `.seg.hbc` is just the Hermes-compiled form of that same source, which
|
|
280
|
+
/// `evaluateJavaScript` runs identically to the entry bundle's `.hbc`.
|
|
281
|
+
///
|
|
282
|
+
/// `onEvaluated` is invoked EXACTLY ONCE with nil on success or a populated
|
|
283
|
+
/// NSError on failure. The NSError `code` is meaningful and mapped by the caller
|
|
284
|
+
/// to a distinct JS reject code (see ESegmentEvalError below + loadSegment:):
|
|
285
|
+
/// callers use it to classify retryable (no-runtime / timeout) vs fatal
|
|
286
|
+
/// (eval-throw / IO) failures.
|
|
287
|
+
+ (void)evaluateSegmentAtPath:(NSString *)bundlePath
|
|
288
|
+
segmentId:(int)segmentId
|
|
289
|
+
segmentKey:(NSString *)segmentKey
|
|
290
|
+
onEvaluated:(void (^)(NSError *_Nullable error))onEvaluated
|
|
291
|
+
{
|
|
292
|
+
RCTHost *host = [SplitBundleLoader currentReactHost];
|
|
293
|
+
if (!host) {
|
|
294
|
+
onEvaluated([NSError errorWithDomain:@"SplitBundleLoader"
|
|
295
|
+
code:ESegmentEvalErrorHostMissing
|
|
296
|
+
userInfo:@{NSLocalizedDescriptionKey: @"RCTHost not available for segment evaluation"}]);
|
|
297
|
+
return;
|
|
152
298
|
}
|
|
153
|
-
|
|
299
|
+
|
|
300
|
+
// Reach the RCTInstance the same way loadEntryBundle: does, so we can use
|
|
301
|
+
// the buffered runtime executor primitive (callFunctionOnBufferedRuntimeExecutor:).
|
|
302
|
+
Ivar ivar = class_getInstanceVariable([host class], "_instance");
|
|
303
|
+
if (!ivar) {
|
|
304
|
+
// L7: a missing `_instance` ivar means a future RN bump renamed/removed
|
|
305
|
+
// the field our reflection depends on. That silently disables ALL
|
|
306
|
+
// segment loading, so log loudly (error, with the class name) instead of
|
|
307
|
+
// failing quietly — the next RN upgrade then surfaces visibly in logs.
|
|
308
|
+
[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]]];
|
|
309
|
+
onEvaluated([NSError errorWithDomain:@"SplitBundleLoader"
|
|
310
|
+
code:ESegmentEvalErrorIvarMissing
|
|
311
|
+
userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"_instance ivar not found on %@", [host class]]}]);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
RCTInstance *instance = object_getIvar(host, ivar);
|
|
316
|
+
if (!instance) {
|
|
317
|
+
onEvaluated([NSError errorWithDomain:@"SplitBundleLoader"
|
|
318
|
+
code:ESegmentEvalErrorNilInstance
|
|
319
|
+
userInfo:@{NSLocalizedDescriptionKey: @"RCTInstance is nil"}]);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// M4/M5: mmap the segment (NSDataReadingMappedIfSafe) instead of a full
|
|
324
|
+
// heap read, then wrap it zero-copy in NSDataJSIBuffer (which retains the
|
|
325
|
+
// NSData). Net effect: no second copy, and the bytes stay alive for the
|
|
326
|
+
// async executor block because the buffer owns the NSData.
|
|
327
|
+
NSError *readError = nil;
|
|
328
|
+
NSData *data = [NSData dataWithContentsOfFile:bundlePath
|
|
329
|
+
options:NSDataReadingMappedIfSafe
|
|
330
|
+
error:&readError];
|
|
331
|
+
if (!data || data.length == 0) {
|
|
332
|
+
onEvaluated([NSError errorWithDomain:@"SplitBundleLoader"
|
|
333
|
+
code:ESegmentEvalErrorIORead
|
|
334
|
+
userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to read segment at %@%@", bundlePath, readError ? [NSString stringWithFormat:@": %@", readError.localizedDescription] : @""]}]);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// M6: preserve a meaningful eval source URL so in-segment crash frames are
|
|
339
|
+
// symbolicated. RN's registerSegment used
|
|
340
|
+
// `JSExecutor::getSyntheticBundlePath(segmentId, segmentPath)`, which for a
|
|
341
|
+
// non-main segment yields `seg-<id>.js` (see cxxreact/JSExecutor.cpp). We
|
|
342
|
+
// replicate that exact form so Hermes/Metro attribute frames to the segment
|
|
343
|
+
// the same way the native path did — using `lastPathComponent` here would
|
|
344
|
+
// degrade symbolication.
|
|
345
|
+
NSString *sourceURL = [NSString stringWithFormat:@"seg-%d.js", segmentId];
|
|
346
|
+
CFAbsoluteTime dispatchStart = CFAbsoluteTimeGetCurrent();
|
|
347
|
+
[SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] loadSegment: evaluating %@ (key=%@, %lu bytes)", sourceURL, segmentKey, (unsigned long)data.length]];
|
|
348
|
+
|
|
349
|
+
// C1: the executor block below is BUFFERED — callFunctionOnBufferedRuntimeExecutor
|
|
350
|
+
// does not run it until the main entry bundle finishes evaluating
|
|
351
|
+
// (RCTInstance/ReactInstance.cpp). If a segment load is requested before the
|
|
352
|
+
// entry completes (early startup, reload teardown, host swap) and the entry
|
|
353
|
+
// never completes, the block NEVER runs → onEvaluated would never fire →
|
|
354
|
+
// the JS promise hangs forever and inflightSegments wedges with no timeout.
|
|
355
|
+
//
|
|
356
|
+
// Guard: invoke `onEvaluated` EXACTLY ONCE. SBLSettleGuard wraps a BOOL
|
|
357
|
+
// behind an os_unfair_lock; whichever racer wins — the executor block (happy
|
|
358
|
+
// path) or the watchdog timer (genuine wedge) — gets YES from tryClaim and
|
|
359
|
+
// settles the promise; the loser does nothing. The guard is an ARC object
|
|
360
|
+
// captured (retained) by BOTH blocks, so its lock outlives both with no
|
|
361
|
+
// manual free() and therefore no use-after-free (both blocks can run, in
|
|
362
|
+
// either order, on a wedge).
|
|
363
|
+
SBLSettleGuard *settleGuard = [[SBLSettleGuard alloc] init];
|
|
364
|
+
|
|
365
|
+
[instance callFunctionOnBufferedRuntimeExecutor:^(facebook::jsi::Runtime &runtime) {
|
|
366
|
+
@autoreleasepool {
|
|
367
|
+
// If the watchdog already fired (entry took >30s then unwedged),
|
|
368
|
+
// the JS promise is already rejected — still evaluate the segment
|
|
369
|
+
// (the module table benefits) but don't double-settle.
|
|
370
|
+
BOOL won = [settleGuard tryClaim];
|
|
371
|
+
NSError *evalError = nil;
|
|
372
|
+
CFAbsoluteTime evalStart = CFAbsoluteTimeGetCurrent();
|
|
373
|
+
try {
|
|
374
|
+
auto buffer = std::make_shared<NSDataJSIBuffer>(data);
|
|
375
|
+
runtime.evaluateJavaScript(buffer, [sourceURL UTF8String]);
|
|
376
|
+
double evalMs = (CFAbsoluteTimeGetCurrent() - evalStart) * 1000.0;
|
|
377
|
+
[SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ evaluated in %.1fms", sourceURL, evalMs]];
|
|
378
|
+
} catch (const std::exception &e) {
|
|
379
|
+
// L8: a JS/Hermes eval throw is a REAL BUG in the segment's own
|
|
380
|
+
// code, not a transient runtime-readiness problem. Mapped to a
|
|
381
|
+
// NON-retryable code by the caller so JS caches it as failed.
|
|
382
|
+
evalError = [NSError errorWithDomain:@"SplitBundleLoader"
|
|
383
|
+
code:ESegmentEvalErrorEvalThrow
|
|
384
|
+
userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Segment evaluation failed for %@: %s", sourceURL, e.what()]}];
|
|
385
|
+
[SBLLogger warn:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ evaluation threw: %s", sourceURL, e.what()]];
|
|
386
|
+
} catch (...) {
|
|
387
|
+
evalError = [NSError errorWithDomain:@"SplitBundleLoader"
|
|
388
|
+
code:ESegmentEvalErrorEvalThrow
|
|
389
|
+
userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Segment evaluation failed for %@ (unknown C++ exception)", sourceURL]}];
|
|
390
|
+
[SBLLogger warn:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ evaluation threw an unknown exception", sourceURL]];
|
|
391
|
+
}
|
|
392
|
+
if (won) {
|
|
393
|
+
// Resolve/reject the JS promise from INSIDE this same block,
|
|
394
|
+
// strictly AFTER the segment eval above — the ordering guarantee
|
|
395
|
+
// that fixes the "Requiring unknown module" race (see method doc).
|
|
396
|
+
onEvaluated(evalError);
|
|
397
|
+
} else {
|
|
398
|
+
[SBLLogger warn:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ evaluated AFTER watchdog already settled (entry was wedged >%.0fs)", sourceURL, kSegmentEvalWatchdogSeconds]];
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}];
|
|
402
|
+
|
|
403
|
+
// C1 watchdog. Fires only on a genuine wedge: in steady state the entry
|
|
404
|
+
// bundle is long done and the buffered block runs sub-millisecond, so the
|
|
405
|
+
// guard is already claimed (tryClaim returns NO) long before this elapses.
|
|
406
|
+
// On a real wedge it settles the promise with a distinct, RETRYABLE timeout
|
|
407
|
+
// error so the JS loader can re-attempt instead of hanging inflightSegments
|
|
408
|
+
// forever. The block retains settleGuard, so the lock stays valid even if
|
|
409
|
+
// the executor block later runs (entry finally evaluates).
|
|
410
|
+
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kSegmentEvalWatchdogSeconds * NSEC_PER_SEC)),
|
|
411
|
+
dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
|
|
412
|
+
if ([settleGuard tryClaim]) {
|
|
413
|
+
[SBLLogger error:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ (key=%@) WATCHDOG fired after %.0fs — runtime executor never ran (entry bundle likely never finished evaluating). Rejecting as retryable timeout.", sourceURL, segmentKey, kSegmentEvalWatchdogSeconds]];
|
|
414
|
+
onEvaluated([NSError errorWithDomain:@"SplitBundleLoader"
|
|
415
|
+
code:ESegmentEvalErrorTimeout
|
|
416
|
+
userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Segment %@ eval timed out after %.0fs (buffered runtime executor never ran)", segmentKey, kSegmentEvalWatchdogSeconds]}]);
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
double dispatchMs = (CFAbsoluteTimeGetCurrent() - dispatchStart) * 1000.0;
|
|
421
|
+
[SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ dispatched in %.1fms (resolve fires after eval; watchdog %.0fs)", sourceURL, dispatchMs, kSegmentEvalWatchdogSeconds]];
|
|
154
422
|
}
|
|
155
423
|
|
|
156
424
|
// MARK: - getRuntimeBundleContext
|
|
@@ -280,7 +548,12 @@
|
|
|
280
548
|
return;
|
|
281
549
|
}
|
|
282
550
|
|
|
283
|
-
|
|
551
|
+
// M5: mmap + zero-copy (NSDataJSIBuffer retains the NSData for the async block),
|
|
552
|
+
// mirroring the segment path. Entry bundle is the largest single read, so this
|
|
553
|
+
// saves the biggest single copy.
|
|
554
|
+
NSData *data = [NSData dataWithContentsOfFile:bundlePath
|
|
555
|
+
options:NSDataReadingMappedIfSafe
|
|
556
|
+
error:nil];
|
|
284
557
|
if (!data || data.length == 0) {
|
|
285
558
|
[SBLLogger warn:[NSString stringWithFormat:@"loadEntryBundle: failed to read bundle at %@", bundlePath]];
|
|
286
559
|
return;
|
|
@@ -293,9 +566,8 @@
|
|
|
293
566
|
[instance callFunctionOnBufferedRuntimeExecutor:^(facebook::jsi::Runtime &runtime) {
|
|
294
567
|
@autoreleasepool {
|
|
295
568
|
CFAbsoluteTime evalStart = CFAbsoluteTimeGetCurrent();
|
|
296
|
-
auto buffer = std::make_shared<
|
|
297
|
-
|
|
298
|
-
runtime.evaluateJavaScript(std::move(buffer), [sourceURL UTF8String]);
|
|
569
|
+
auto buffer = std::make_shared<NSDataJSIBuffer>(data);
|
|
570
|
+
runtime.evaluateJavaScript(buffer, [sourceURL UTF8String]);
|
|
299
571
|
double evalMs = (CFAbsoluteTimeGetCurrent() - evalStart) * 1000.0;
|
|
300
572
|
[SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] loadEntryBundle: %@ evaluated in %.1fms", sourceURL, evalMs]];
|
|
301
573
|
}
|
|
@@ -342,17 +614,62 @@
|
|
|
342
614
|
return;
|
|
343
615
|
}
|
|
344
616
|
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
617
|
+
// Evaluate the segment into the current runtime and resolve ONLY after
|
|
618
|
+
// its module definitions have actually run (#race). We intentionally do
|
|
619
|
+
// NOT use registerSegmentWithId: + immediate resolve here: that resolves
|
|
620
|
+
// before the scheduler-enqueued eval completes, so Metro's
|
|
621
|
+
// `import().then(() => __r(moduleId))` can hit "Requiring unknown module"
|
|
622
|
+
// (a fatal, uncatchable crash). See +evaluateSegmentAtPath:... doc for
|
|
623
|
+
// the full ordering rationale (Modern scheduler priority_queue is not
|
|
624
|
+
// FIFO, so we collapse eval+resolve into one runtime-executor block).
|
|
625
|
+
// segId is threaded through for the synthetic eval source URL (M6) and
|
|
626
|
+
// for log parity with the previous register flow.
|
|
627
|
+
[SplitBundleLoader evaluateSegmentAtPath:absolutePath
|
|
628
|
+
segmentId:segId
|
|
629
|
+
segmentKey:segmentKey
|
|
630
|
+
onEvaluated:^(NSError *_Nullable evalError) {
|
|
631
|
+
if (evalError) {
|
|
632
|
+
// L8: map the helper's distinct NSError code to a distinct JS
|
|
633
|
+
// reject code so JS can classify retryable vs fatal (see
|
|
634
|
+
// ESegmentEvalError + installProdBundleLoader.ts H3).
|
|
635
|
+
NSString *rejectCode;
|
|
636
|
+
switch ((ESegmentEvalError)evalError.code) {
|
|
637
|
+
case ESegmentEvalErrorTimeout:
|
|
638
|
+
rejectCode = @"SPLIT_BUNDLE_TIMEOUT"; // retryable
|
|
639
|
+
break;
|
|
640
|
+
case ESegmentEvalErrorIORead:
|
|
641
|
+
rejectCode = @"SPLIT_BUNDLE_IO_ERROR"; // fatal
|
|
642
|
+
break;
|
|
643
|
+
case ESegmentEvalErrorEvalThrow:
|
|
644
|
+
rejectCode = @"SPLIT_BUNDLE_EVAL_ERROR"; // fatal (segment bug)
|
|
645
|
+
break;
|
|
646
|
+
case ESegmentEvalErrorIvarMissing:
|
|
647
|
+
// Fix 2: `_instance` ivar reflection failed — STRUCTURAL/
|
|
648
|
+
// PERMANENT. An RN version bump renamed/removed the
|
|
649
|
+
// private field our reflection depends on, so segment
|
|
650
|
+
// loading is disabled until the native code is updated.
|
|
651
|
+
// Retrying can NEVER recreate a renamed ivar, so this is
|
|
652
|
+
// fatal NATIVE_UNAVAILABLE — NOT retryable NO_RUNTIME. By
|
|
653
|
+
// contrast HostMissing / NilInstance below are genuinely
|
|
654
|
+
// transient (host/instance not up yet → a later attempt
|
|
655
|
+
// may succeed).
|
|
656
|
+
rejectCode = @"SPLIT_BUNDLE_NATIVE_UNAVAILABLE"; // fatal
|
|
657
|
+
break;
|
|
658
|
+
case ESegmentEvalErrorHostMissing:
|
|
659
|
+
case ESegmentEvalErrorNilInstance:
|
|
660
|
+
default:
|
|
661
|
+
rejectCode = @"SPLIT_BUNDLE_NO_RUNTIME"; // retryable
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
reject(rejectCode,
|
|
665
|
+
evalError.localizedDescription ?: @"Segment evaluation failed",
|
|
666
|
+
evalError);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
348
669
|
double segMs = (CFAbsoluteTimeGetCurrent() - segStart) * 1000.0;
|
|
349
|
-
[SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] Loaded segment %@ (id=%d) in %.1fms", segmentKey, segId, segMs]];
|
|
670
|
+
[SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] Loaded segment %@ (id=%d) in %.1fms (eval-complete)", segmentKey, segId, segMs]];
|
|
350
671
|
resolve(nil);
|
|
351
|
-
}
|
|
352
|
-
reject(@"SPLIT_BUNDLE_NO_RUNTIME",
|
|
353
|
-
regError.localizedDescription ?: @"Runtime not available",
|
|
354
|
-
regError);
|
|
355
|
-
}
|
|
672
|
+
}];
|
|
356
673
|
} @catch (NSException *exception) {
|
|
357
674
|
reject(@"SPLIT_BUNDLE_LOAD_ERROR",
|
|
358
675
|
[NSString stringWithFormat:@"Failed to load segment %@: %@", segmentKey, exception.reason],
|