@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
+ )
@@ -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
- reactContext.registerSegment(segId, absolutePath) {
238
- val segMs = (System.nanoTime() - segStart) / 1_000_000.0
239
- SBLLogger.info("[SplitBundle] segment $segmentKey (id=$segId) registered in ${String.format("%.1f", segMs)}ms")
240
- promise.resolve(null)
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)
@@ -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: - Segment registration helper
536
+ // MARK: - RCTHost resolution helper
131
537
 
132
- /// Registers a segment with the current runtime via bridgeless (RCTHost) architecture (#13).
133
- ///
134
- /// Thread safety (#57): This method is called from the TurboModule (JS thread).
135
- /// No queue dispatch is needed.
136
- + (BOOL)registerSegment:(int)segmentId path:(NSString *)path error:(NSError **)outError
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 && [host respondsToSelector:@selector(registerSegmentWithId:path:)]) {
143
- [host registerSegmentWithId:@(segmentId) path:path];
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
- if (outError) {
149
- *outError = [NSError errorWithDomain:@"SplitBundleLoader"
150
- code:1
151
- userInfo:@{NSLocalizedDescriptionKey: @"RCTHost not available for segment registration"}];
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
- return NO;
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
- NSData *data = [NSData dataWithContentsOfFile:bundlePath];
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<facebook::jsi::StringBuffer>(
297
- std::string(static_cast<const char *>(data.bytes), data.length));
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
- // Register segment (#13: supports both bridge and bridgeless)
346
- NSError *regError = nil;
347
- if ([SplitBundleLoader registerSegment:segId path:absolutePath error:&regError]) {
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
- } else {
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],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-split-bundle-loader",
3
- "version": "3.0.63",
3
+ "version": "3.0.65",
4
4
  "description": "react-native-split-bundle-loader",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",