@onekeyfe/react-native-background-thread 3.0.61 → 3.0.62

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.
@@ -4,7 +4,6 @@
4
4
  #include <atomic>
5
5
  #include <chrono>
6
6
  #include <condition_variable>
7
- #include <cstdio>
8
7
  #include <deque>
9
8
  #include <functional>
10
9
  #include <memory>
@@ -71,6 +70,166 @@ static std::mutex gWorkMutex;
71
70
  static std::unordered_map<int64_t, std::function<void(jsi::Runtime &)>> gPendingWork;
72
71
  static int64_t gNextWorkId = 0;
73
72
 
73
+ using JavaObjectRef = std::shared_ptr<_jobject>;
74
+
75
+ static constexpr size_t kRuntimeDrainBatchSize = 64;
76
+ static constexpr size_t kRuntimeQueueWarnThreshold = 128;
77
+ static constexpr size_t kRuntimeQueueWarnInterval = 128;
78
+
79
+ struct RuntimeWorkQueue {
80
+ std::deque<std::function<void(jsi::Runtime &)>> items;
81
+ bool drainScheduled = false;
82
+ };
83
+
84
+ static RuntimeWorkQueue gMainRuntimeWorkQueue;
85
+ static RuntimeWorkQueue gBgRuntimeWorkQueue;
86
+
87
+ static RuntimeWorkQueue &getRuntimeWorkQueue(bool isMain) {
88
+ return isMain ? gMainRuntimeWorkQueue : gBgRuntimeWorkQueue;
89
+ }
90
+
91
+ static bool callScheduleOnJSThread(const JavaObjectRef &ref, bool isMain, int64_t workId) {
92
+ JNIEnv *env = getJNIEnv();
93
+ if (!env || !ref) {
94
+ LOGE("executor: env=%p, ref=%p — aborting", env, ref.get());
95
+ return false;
96
+ }
97
+
98
+ jclass cls = env->GetObjectClass(ref.get());
99
+ if (!cls) {
100
+ LOGE("executor: GetObjectClass failed");
101
+ if (env->ExceptionCheck()) {
102
+ env->ExceptionDescribe();
103
+ env->ExceptionClear();
104
+ }
105
+ return false;
106
+ }
107
+
108
+ jmethodID mid = env->GetMethodID(cls, "scheduleOnJSThread", "(ZJ)Z");
109
+ if (!mid) {
110
+ LOGE("executor: scheduleOnJSThread method not found!");
111
+ if (env->ExceptionCheck()) {
112
+ env->ExceptionDescribe();
113
+ env->ExceptionClear();
114
+ }
115
+ env->DeleteLocalRef(cls);
116
+ return false;
117
+ }
118
+
119
+ LOGI("executor: calling scheduleOnJSThread(isMain=%d, workId=%ld)", isMain, (long)workId);
120
+ jboolean scheduled = env->CallBooleanMethod(
121
+ ref.get(),
122
+ mid,
123
+ static_cast<jboolean>(isMain),
124
+ static_cast<jlong>(workId));
125
+ if (env->ExceptionCheck()) {
126
+ LOGE("executor: JNI exception after scheduleOnJSThread");
127
+ env->ExceptionDescribe();
128
+ env->ExceptionClear();
129
+ env->DeleteLocalRef(cls);
130
+ return false;
131
+ }
132
+ env->DeleteLocalRef(cls);
133
+ return scheduled == JNI_TRUE;
134
+ }
135
+
136
+ static void scheduleRuntimeDrain(const JavaObjectRef &ref, bool isMain);
137
+
138
+ static void drainRuntimeWorkQueue(jsi::Runtime &rt, JavaObjectRef ref, bool isMain) {
139
+ size_t drained = 0;
140
+
141
+ while (drained < kRuntimeDrainBatchSize) {
142
+ std::function<void(jsi::Runtime &)> work;
143
+ {
144
+ std::lock_guard<std::mutex> lock(gWorkMutex);
145
+ auto &queue = getRuntimeWorkQueue(isMain);
146
+ if (queue.items.empty()) {
147
+ break;
148
+ }
149
+ work = std::move(queue.items.front());
150
+ queue.items.pop_front();
151
+ }
152
+
153
+ try {
154
+ work(rt);
155
+ } catch (const jsi::JSError &e) {
156
+ LOGE("JSError in runtime drain work: %s", e.getMessage().c_str());
157
+ } catch (const std::exception &e) {
158
+ LOGE("Error in runtime drain work: %s", e.what());
159
+ } catch (...) {
160
+ LOGE("Unknown error in runtime drain work");
161
+ }
162
+ drained += 1;
163
+ }
164
+
165
+ bool shouldReschedule = false;
166
+ size_t remaining = 0;
167
+ {
168
+ std::lock_guard<std::mutex> lock(gWorkMutex);
169
+ auto &queue = getRuntimeWorkQueue(isMain);
170
+ remaining = queue.items.size();
171
+ if (remaining == 0) {
172
+ queue.drainScheduled = false;
173
+ } else {
174
+ shouldReschedule = true;
175
+ }
176
+ }
177
+
178
+ if (drained > 1 || remaining > 0) {
179
+ LOGI("executor: drained runtime queue isMain=%d, drained=%zu, remaining=%zu",
180
+ isMain, drained, remaining);
181
+ }
182
+
183
+ if (shouldReschedule) {
184
+ scheduleRuntimeDrain(ref, isMain);
185
+ }
186
+ }
187
+
188
+ static void scheduleRuntimeDrain(const JavaObjectRef &ref, bool isMain) {
189
+ int64_t workId;
190
+ size_t queued = 0;
191
+ {
192
+ std::lock_guard<std::mutex> lock(gWorkMutex);
193
+ workId = gNextWorkId++;
194
+ queued = getRuntimeWorkQueue(isMain).items.size();
195
+ gPendingWork[workId] = [ref, isMain](jsi::Runtime &rt) {
196
+ drainRuntimeWorkQueue(rt, ref, isMain);
197
+ };
198
+ }
199
+
200
+ bool scheduled = callScheduleOnJSThread(ref, isMain, workId);
201
+ if (!scheduled) {
202
+ std::lock_guard<std::mutex> lock(gWorkMutex);
203
+ gPendingWork.erase(workId);
204
+ getRuntimeWorkQueue(isMain).drainScheduled = false;
205
+ LOGE("executor: failed to schedule runtime drain isMain=%d, workId=%ld, queued=%zu",
206
+ isMain, (long)workId, queued);
207
+ }
208
+ }
209
+
210
+ static void enqueueRuntimeWork(JavaObjectRef ref, bool isMain, std::function<void(jsi::Runtime &)> work) {
211
+ bool shouldSchedule = false;
212
+ size_t queued = 0;
213
+ {
214
+ std::lock_guard<std::mutex> lock(gWorkMutex);
215
+ auto &queue = getRuntimeWorkQueue(isMain);
216
+ queue.items.push_back(std::move(work));
217
+ queued = queue.items.size();
218
+ if (!queue.drainScheduled) {
219
+ queue.drainScheduled = true;
220
+ shouldSchedule = true;
221
+ }
222
+ }
223
+
224
+ if (queued >= kRuntimeQueueWarnThreshold && queued % kRuntimeQueueWarnInterval == 0) {
225
+ LOGE("executor: runtime queue backlog isMain=%d, queued=%zu", isMain, queued);
226
+ }
227
+
228
+ if (shouldSchedule) {
229
+ scheduleRuntimeDrain(ref, isMain);
230
+ }
231
+ }
232
+
74
233
  // Called from Kotlin after runOnJSQueueThread dispatches to the correct thread.
75
234
  extern "C" JNIEXPORT void JNICALL
76
235
  Java_com_backgroundthread_BackgroundThreadManager_nativeExecuteWork(
@@ -448,323 +607,6 @@ static void installTimersOnRuntime(jsi::Runtime &rt) {
448
607
  LOGI("Timer + rAF + rIC polyfills installed on bg runtime");
449
608
  }
450
609
 
451
- // ── Background segment eval (fix A: eval-then-resolve on the BG runtime) ──
452
- //
453
- // WHY THIS EXISTS — the "Requiring unknown module" race on the BACKGROUND
454
- // runtime:
455
- // The Kotlin BackgroundThreadManager.registerSegmentInBackground previously
456
- // called `ReactContext.registerSegment(...)`, which (bridgeless) routes through
457
- // ReactHostImpl.registerSegment → ReactInstance.registerSegment → C++
458
- // ReactInstance::registerSegment, and that only `scheduleWork`s the
459
- // evaluateJavaScript onto the RuntimeScheduler before returning. The Kotlin
460
- // completion callback then fired immediately, so the JS promise resolved BEFORE
461
- // the segment's `__d(...)` module definitions ran. Metro's
462
- // `import().then(() => __r(moduleId))` could then run `__r` before the module
463
- // table was populated → a fatal, uncatchable "Requiring unknown module". Locale
464
- // segments load through THIS bg path, so a language switch could still crash.
465
- //
466
- // THE FIX (mirrors the MAIN-runtime SplitBundleLoaderJSI fix and the iOS
467
- // callFunctionOnBufferedRuntimeExecutor: fix):
468
- // We evaluate the segment OURSELVES on the BACKGROUND JS thread and signal
469
- // completion in the SAME block, strictly AFTER eval. The accessor we use is the
470
- // background runtime's own RuntimeExecutor (`gBgTimerExecutor`), captured in
471
- // nativeInstallSharedBridge for the bg runtime (isMain=false). It dispatches via
472
- // scheduleOnJSThread(isMain=false, ...) → nativeExecuteWork, which runs the work
473
- // on the bg JS queue thread and then drainMicrotasks() — so this targets the
474
- // BACKGROUND runtime, NOT the main one. This is the correct bg analogue of the
475
- // main path's CallInvoker (the bg runtime is created by ReactHostImpl and the bg
476
- // ReactContext does not surface a usable jsCallInvokerHolder the way the main
477
- // one does, but its RuntimeExecutor already exists and routes to the bg JS
478
- // thread).
479
- //
480
- // Off-thread read (fix F): the segment file is read on the CALLING (native
481
- // module) thread before dispatch; only evaluateJavaScript + completion run on
482
- // the bg JS thread.
483
-
484
- // Java callback contract — implemented in Kotlin as
485
- // BackgroundThreadManager.SegmentEvalCallback.onComplete(error). Empty/null
486
- // error string → success. A message prefixed with "NO_RUNTIME:" → bg runtime
487
- // not ready (retryable); "IO_ERROR:" → file read failure (fatal); otherwise →
488
- // eval throw (fatal). Resolved exactly once on the Kotlin side via its watchdog
489
- // guard.
490
- static void invokeBgSegmentCallback(jobject globalCallback, const std::string &error) {
491
- JNIEnv *env = getJNIEnv();
492
- if (!env || !globalCallback) {
493
- return;
494
- }
495
- jclass cls = env->GetObjectClass(globalCallback);
496
- jmethodID mid =
497
- env->GetMethodID(cls, "onComplete", "(Ljava/lang/String;)V");
498
- if (mid) {
499
- jstring jerr = error.empty() ? nullptr : env->NewStringUTF(error.c_str());
500
- env->CallVoidMethod(globalCallback, mid, jerr);
501
- if (env->ExceptionCheck()) {
502
- LOGE("invokeBgSegmentCallback: JNI exception after onComplete");
503
- env->ExceptionDescribe();
504
- env->ExceptionClear();
505
- }
506
- if (jerr) {
507
- env->DeleteLocalRef(jerr);
508
- }
509
- } else {
510
- LOGE("invokeBgSegmentCallback: onComplete method not found!");
511
- if (env->ExceptionCheck()) {
512
- env->ExceptionDescribe();
513
- env->ExceptionClear();
514
- }
515
- }
516
- env->DeleteLocalRef(cls);
517
- }
518
-
519
- // ── Pending bg-eval callback registry (fix: bounded global-ref lifetime) ──
520
- //
521
- // The bg-eval work lambda captures a JNI global ref to the SegmentEvalCallback
522
- // and is enqueued onto gPendingWork for the bg JS thread. If that work is
523
- // enqueued but NEVER runs, the captured global ref would leak and the JS promise
524
- // would settle only via the Kotlin 30s watchdog. The drop paths that release it:
525
- // - Java schedule fails (JNI exception / missing scheduleOnJSThread): the C++
526
- // executor erases gPendingWork[workId] and drains this eval.
527
- // - bg context==null / ptr==0 in scheduleOnJSThread: Kotlin calls
528
- // nativeDropScheduledWork(workId) — which erases the work AND drains —
529
- // BEFORE it would call nativeExecuteWork. (nativeExecuteWork's own
530
- // rt==nullptr guard merely returns and is NOT a drain path; it is never
531
- // reached for a dead ptr because Kotlin intercepts that case first.)
532
- // - nativeDestroy: intentionally leaks the work lambdas (their ~jsi::Function
533
- // can't run on a torn-down runtime) but drains this registry to settle them.
534
- // To make this strictly bounded, every in-flight bg eval registers here. Whoever
535
- // settles it first (the work lambda after eval, OR a drain on a drop path) claims
536
- // it via `settled`, invokes the Java callback exactly once, and deletes the
537
- // global ref. This guarantees no leak and no double-invoke.
538
- struct PendingBgEval {
539
- jobject globalCallback; // owned: deleted by whoever settles
540
- std::shared_ptr<std::atomic<bool>> settled; // exactly-once claim
541
- };
542
- static std::mutex gBgEvalMutex;
543
- static std::unordered_map<int64_t, PendingBgEval> gPendingBgEvals;
544
- static int64_t gNextBgEvalId = 0;
545
-
546
- // Settle a pending bg eval exactly once: invoke the Java callback with `error`
547
- // (empty => success) and release the global ref. The shared `settled` flag is
548
- // the single source of truth for the one-shot — the registry entry may already
549
- // be gone (claimed/erased by the other party), so this is self-contained.
550
- static void settleBgEval(jobject globalCallback,
551
- const std::shared_ptr<std::atomic<bool>> &settled,
552
- const std::string &error) {
553
- if (!settled) {
554
- return;
555
- }
556
- bool expected = false;
557
- if (!settled->compare_exchange_strong(expected, true)) {
558
- // Already settled by the other party (lambda vs drain). Do nothing —
559
- // the winner already invoked + deleted the global ref.
560
- return;
561
- }
562
- invokeBgSegmentCallback(globalCallback, error);
563
- JNIEnv *env = getJNIEnv();
564
- if (env && globalCallback) {
565
- env->DeleteGlobalRef(globalCallback);
566
- }
567
- }
568
-
569
- // Settle ALL currently-registered bg evals with a retryable NO_RUNTIME-class
570
- // failure and clear the registry. Used when the bg runtime is going away (or is
571
- // unreachable) and any enqueued-but-unrun eval would otherwise leak its global
572
- // ref and hang the JS promise until the Kotlin watchdog. Each settle is
573
- // exactly-once (the work lambda may race us, but the shared flag arbitrates),
574
- // so this never double-invokes.
575
- static void drainPendingBgEvals(const std::string &reason) {
576
- // Move the entries OUT under the lock and clear the registry, then settle
577
- // OUTSIDE the lock. settleBgEval performs a Java upcall (onComplete via
578
- // CallVoidMethod), so holding gBgEvalMutex across it would hold a native
579
- // lock across arbitrary JS — a re-entrancy / deadlock hazard if a callback
580
- // ever synchronously re-enters a gBgEvalMutex-taking path. PendingBgEval is
581
- // copyable (jobject handle + shared_ptr); copies share the same global ref
582
- // and `settled` flag, and the shared flag still arbitrates exactly-once
583
- // against a racing work lambda, so the global ref is released exactly once.
584
- std::vector<PendingBgEval> drained;
585
- {
586
- std::lock_guard<std::mutex> lock(gBgEvalMutex);
587
- if (gPendingBgEvals.empty()) {
588
- return;
589
- }
590
- LOGE("[SplitBundle] draining %zu pending bg eval(s): %s",
591
- gPendingBgEvals.size(), reason.c_str());
592
- drained.reserve(gPendingBgEvals.size());
593
- for (auto &entry : gPendingBgEvals) {
594
- drained.push_back(entry.second);
595
- }
596
- gPendingBgEvals.clear();
597
- }
598
- for (auto &entry : drained) {
599
- settleBgEval(entry.globalCallback, entry.settled, "NO_RUNTIME:" + reason);
600
- }
601
- }
602
-
603
- // Reads the whole file at `path` into `out`. Returns false on failure.
604
- static bool readBgFileToString(const std::string &path, std::string &out) {
605
- FILE *f = std::fopen(path.c_str(), "rb");
606
- if (f == nullptr) {
607
- return false;
608
- }
609
- if (std::fseek(f, 0, SEEK_END) != 0) {
610
- std::fclose(f);
611
- return false;
612
- }
613
- long size = std::ftell(f);
614
- if (size < 0) {
615
- std::fclose(f);
616
- return false;
617
- }
618
- if (std::fseek(f, 0, SEEK_SET) != 0) {
619
- std::fclose(f);
620
- return false;
621
- }
622
- out.resize(static_cast<size_t>(size));
623
- size_t readBytes =
624
- (size == 0) ? 0 : std::fread(&out[0], 1, static_cast<size_t>(size), f);
625
- std::fclose(f);
626
- return readBytes == static_cast<size_t>(size);
627
- }
628
-
629
- // nativeEvaluateSegmentInBackground: schedule eval of the segment at `path`
630
- // onto the BACKGROUND JS thread via the bg RuntimeExecutor and invoke `callback`
631
- // from INSIDE that same block, strictly AFTER eval. Returns immediately; the
632
- // callback fires later on the bg JS thread (or synchronously here on a fail-fast
633
- // path such as bg runtime not ready / file read failure).
634
- extern "C" JNIEXPORT void JNICALL
635
- Java_com_backgroundthread_BackgroundThreadManager_nativeEvaluateSegmentInBackground(
636
- JNIEnv *env, jobject /* thiz */, jstring segmentPath, jstring sourceURL,
637
- jobject callback) {
638
-
639
- // Global-ref the callback: it is invoked later on a different thread.
640
- jobject globalCallback = env->NewGlobalRef(callback);
641
- // Shared one-shot claim flag for this eval — used by BOTH the work lambda
642
- // (after eval) and any drop-path drain, so exactly one of them invokes the
643
- // callback + deletes the global ref.
644
- auto settled = std::make_shared<std::atomic<bool>>(false);
645
-
646
- const char *pathChars = segmentPath ? env->GetStringUTFChars(segmentPath, nullptr) : nullptr;
647
- std::string path = pathChars ? std::string(pathChars) : std::string();
648
- if (pathChars) env->ReleaseStringUTFChars(segmentPath, pathChars);
649
-
650
- const char *urlChars = sourceURL ? env->GetStringUTFChars(sourceURL, nullptr) : nullptr;
651
- std::string url = urlChars ? std::string(urlChars) : std::string("segment");
652
- if (urlChars) env->ReleaseStringUTFChars(sourceURL, urlChars);
653
-
654
- // Fail-fast on THIS thread: settle exactly once, no registry entry created.
655
- auto finishOnThisThread = [&](const std::string &err) {
656
- settleBgEval(globalCallback, settled, err);
657
- };
658
-
659
- if (path.empty()) {
660
- finishOnThisThread("IO_ERROR:Empty segment path");
661
- return;
662
- }
663
-
664
- // Snapshot the bg RuntimeExecutor under the timer mutex (the same lock that
665
- // guards its assignment/teardown). If it's null the bg runtime hasn't
666
- // installed its SharedBridge yet → retryable NO_RUNTIME.
667
- RPCRuntimeExecutor executor;
668
- {
669
- std::lock_guard<std::mutex> lock(gTimerMutex);
670
- executor = gBgTimerExecutor;
671
- }
672
- if (!executor) {
673
- finishOnThisThread("NO_RUNTIME:Background runtime executor not available");
674
- return;
675
- }
676
-
677
- // F: read the segment file HERE, on the calling (native module) thread,
678
- // BEFORE dispatch — so only evaluateJavaScript + completion run on the bg JS
679
- // thread and the read does not block it or race the watchdog.
680
- std::string source;
681
- if (!readBgFileToString(path, source)) {
682
- finishOnThisThread("IO_ERROR:Failed to read bg segment file: " + path);
683
- return;
684
- }
685
- if (source.empty()) {
686
- finishOnThisThread("IO_ERROR:Empty bg segment file: " + path);
687
- return;
688
- }
689
-
690
- // Register this in-flight eval BEFORE dispatch so that if the enqueued work
691
- // never runs (schedule fails, context==null, ptr==0, or nativeDestroy drops
692
- // pending work) the drain can settle it as a retryable NO_RUNTIME failure
693
- // and release the global ref — instead of leaking it and leaning on the
694
- // Kotlin 30s watchdog. The work lambda removes its own entry when it runs.
695
- int64_t bgEvalId;
696
- {
697
- std::lock_guard<std::mutex> lock(gBgEvalMutex);
698
- bgEvalId = gNextBgEvalId++;
699
- gPendingBgEvals[bgEvalId] = PendingBgEval{globalCallback, settled};
700
- }
701
-
702
- // Move the already-read buffer + the global callback ref into the work
703
- // lambda. The lambda runs on the bg JS thread (via nativeExecuteWork), which
704
- // also drainMicrotasks() after — preserving eval+resolve as one atomic turn.
705
- executor([globalCallback, settled, bgEvalId, source = std::move(source),
706
- url = std::move(url)](jsi::Runtime &rt) {
707
- // We are running now → claim ownership and remove our registry entry so
708
- // a concurrent nativeDestroy drain can't also touch this eval.
709
- {
710
- std::lock_guard<std::mutex> lock(gBgEvalMutex);
711
- gPendingBgEvals.erase(bgEvalId);
712
- }
713
- std::string error;
714
- try {
715
- LOGI("[SplitBundle] bg evaluating segment %s (%zu bytes)", url.c_str(),
716
- source.size());
717
- auto buffer =
718
- std::make_shared<jsi::StringBuffer>(std::move(source));
719
- // Runs the segment's top-level __d(...) synchronously on the bg JS
720
- // thread before returning.
721
- rt.evaluateJavaScript(std::move(buffer), url);
722
- LOGI("[SplitBundle] bg segment %s evaluated", url.c_str());
723
- } catch (const jsi::JSError &e) {
724
- error = std::string("Bg segment eval JSError for ") + url + ": " +
725
- e.getMessage();
726
- LOGE("[SplitBundle] %s", error.c_str());
727
- } catch (const std::exception &e) {
728
- error = std::string("Bg segment eval failed for ") + url + ": " +
729
- e.what();
730
- LOGE("[SplitBundle] %s", error.c_str());
731
- } catch (...) {
732
- error = std::string("Bg segment eval failed for ") + url +
733
- " (unknown C++ exception)";
734
- LOGE("[SplitBundle] %s", error.c_str());
735
- }
736
- // Resolve/reject from INSIDE this same bg-JS-thread block, strictly
737
- // AFTER eval above — the ordering guarantee that fixes the race. The
738
- // shared `settled` flag makes this a no-op if a drain already claimed
739
- // it (it would not have, since we erased our entry above, but the flag
740
- // keeps the invariant airtight against any reorder).
741
- settleBgEval(globalCallback, settled, error);
742
- });
743
- }
744
-
745
- // nativeDropScheduledWork: clean up after a scheduleOnJSThread drop path where
746
- // CallVoidMethod itself SUCCEEDED but Kotlin then found the bg runtime
747
- // unreachable (context==null / ptr==0) and returned WITHOUT calling
748
- // nativeExecuteWork for this `workId`. Two things must be released:
749
- // 1. gPendingWork[workId] — the stored work lambda (holding the segment
750
- // SOURCE BUFFER). nativeExecuteWork is the only other eraser and it will
751
- // never run for this id, so without this it leaks until nativeDestroy.
752
- // 2. The in-flight bg eval(s) — settle as retryable NO_RUNTIME so the JNI
753
- // global ref is released and the JS promise resolves now instead of
754
- // hanging on the 30s watchdog. drain-all is sound: an unreachable bg JS
755
- // thread dooms every enqueued bg eval equally.
756
- // Exactly-once via the shared `settled` flag, so a recovered runtime that later
757
- // DOES run stale work (it can't — we erased it) would be a harmless no-op.
758
- extern "C" JNIEXPORT void JNICALL
759
- Java_com_backgroundthread_BackgroundThreadManager_nativeDropScheduledWork(
760
- JNIEnv * /* env */, jobject /* thiz */, jlong workId) {
761
- {
762
- std::lock_guard<std::mutex> lock(gWorkMutex);
763
- gPendingWork.erase(static_cast<int64_t>(workId));
764
- }
765
- drainPendingBgEvals("Background runtime unreachable when scheduling segment eval");
766
- }
767
-
768
610
  // ── nativeInstallSharedBridge ───────────────────────────────────────────
769
611
  // Install SharedStore and SharedRPC into a runtime.
770
612
  extern "C" JNIEXPORT void JNICALL
@@ -788,71 +630,7 @@ Java_com_backgroundthread_BackgroundThreadManager_nativeInstallSharedBridge(
788
630
  bool capturedIsMain = static_cast<bool>(isMain);
789
631
 
790
632
  RPCRuntimeExecutor executor = [ref, capturedIsMain](std::function<void(jsi::Runtime &)> work) {
791
- JNIEnv *env = getJNIEnv();
792
-
793
- // Settle any enqueued-but-now-unrunnable bg eval when this work will NOT
794
- // reach nativeExecuteWork. Bg eval lambdas are only ever dispatched via
795
- // the bg executor, so this is gated on !capturedIsMain — a main-thread
796
- // schedule hiccup must never falsely reject healthy bg evals. This
797
- // mirrors the existing context==null / ptr==0 (nativeDropScheduledWork)
798
- // and nativeDestroy drop paths: drain-all is sound because a failed bg
799
- // schedule means the bg JS thread is unreachable, so every enqueued bg
800
- // eval is equally doomed. NO_RUNTIME is retryable, so JS re-attempts.
801
- auto drainBgEvalsIfBg = [capturedIsMain](const char *reason) {
802
- if (!capturedIsMain) {
803
- drainPendingBgEvals(reason);
804
- }
805
- };
806
-
807
- if (!env || !ref) {
808
- LOGE("executor: env=%p, ref=%p — aborting", env, ref.get());
809
- drainBgEvalsIfBg("Background executor env/ref unavailable when scheduling segment eval");
810
- return;
811
- }
812
-
813
- int64_t workId;
814
- {
815
- std::lock_guard<std::mutex> lock(gWorkMutex);
816
- workId = gNextWorkId++;
817
- gPendingWork[workId] = std::move(work);
818
- }
819
-
820
- jclass cls = env->GetObjectClass(ref.get());
821
- jmethodID mid = env->GetMethodID(cls, "scheduleOnJSThread", "(ZJ)V");
822
- bool scheduled = false;
823
- if (mid) {
824
- LOGI("executor: calling scheduleOnJSThread(isMain=%d, workId=%ld)", capturedIsMain, (long)workId);
825
- env->CallVoidMethod(ref.get(), mid, static_cast<jboolean>(capturedIsMain), static_cast<jlong>(workId));
826
- if (env->ExceptionCheck()) {
827
- LOGE("executor: JNI exception after scheduleOnJSThread");
828
- env->ExceptionDescribe();
829
- env->ExceptionClear();
830
- } else {
831
- scheduled = true;
832
- }
833
- } else {
834
- LOGE("executor: scheduleOnJSThread method not found!");
835
- if (env->ExceptionCheck()) {
836
- env->ExceptionDescribe();
837
- env->ExceptionClear();
838
- }
839
- }
840
- env->DeleteLocalRef(cls);
841
-
842
- // Schedule failed (JNI exception or missing method): nativeExecuteWork
843
- // will never run this workId, so erase it now to free the stored work
844
- // (and its captured segment source buffer) instead of leaking it until
845
- // nativeDestroy. Then settle the corresponding bg eval(s) so the JNI
846
- // global ref is released and the JS promise resolves immediately rather
847
- // than hanging on the Kotlin 30s watchdog. Erasing also makes a late
848
- // Kotlin enqueue (if scheduleOnJSThread threw AFTER posting) a no-op.
849
- if (!scheduled) {
850
- {
851
- std::lock_guard<std::mutex> lock(gWorkMutex);
852
- gPendingWork.erase(workId);
853
- }
854
- drainBgEvalsIfBg("Background JS thread unreachable when scheduling segment eval");
855
- }
633
+ enqueueRuntimeWork(ref, capturedIsMain, std::move(work));
856
634
  };
857
635
 
858
636
  std::string runtimeId = isMain ? "main" : "background";
@@ -998,24 +776,20 @@ Java_com_backgroundthread_BackgroundThreadManager_nativeDestroy(
998
776
  // Drain pending cross-runtime work. Each std::function may capture a
999
777
  // shared_ptr<jsi::Function> tied to the destroyed runtime; leak them
1000
778
  // for the same reason as above.
1001
- //
1002
- // NOTE: the bg-eval work lambdas captured here also hold a JNI global ref
1003
- // to a SegmentEvalCallback. Leaking the std::function leaks that ref AND
1004
- // leaves the JS promise pending — so we settle those explicitly below via
1005
- // gPendingBgEvals (the entry survives independently of the leaked lambda).
1006
779
  {
1007
780
  std::lock_guard<std::mutex> lock(gWorkMutex);
1008
781
  for (auto &entry : gPendingWork) {
1009
782
  new std::function<void(jsi::Runtime &)>(std::move(entry.second));
1010
783
  }
1011
784
  gPendingWork.clear();
785
+ for (auto *queue : {&gMainRuntimeWorkQueue, &gBgRuntimeWorkQueue}) {
786
+ for (auto &work : queue->items) {
787
+ new std::function<void(jsi::Runtime &)>(std::move(work));
788
+ }
789
+ queue->items.clear();
790
+ queue->drainScheduled = false;
791
+ }
1012
792
  }
1013
793
 
1014
- // Drain pending bg-eval callbacks: the bg runtime is gone, so any eval that
1015
- // was enqueued but never ran must be settled NOW (retryable NO_RUNTIME) so
1016
- // the JS promise resolves immediately and the global ref is released —
1017
- // rather than leaking and relying on the Kotlin 30s watchdog.
1018
- drainPendingBgEvals("Background runtime destroyed before segment eval ran");
1019
-
1020
794
  LOGI("Native resources cleaned up");
1021
795
  }
@@ -24,7 +24,6 @@ import com.facebook.react.shell.MainReactPackage
24
24
  import java.io.File
25
25
  import java.lang.ref.WeakReference
26
26
  import java.util.concurrent.TimeUnit
27
- import java.util.concurrent.atomic.AtomicBoolean
28
27
 
29
28
  /**
30
29
  * Singleton manager for the background React Native runtime.
@@ -64,12 +63,6 @@ class BackgroundThreadManager private constructor() {
64
63
  companion object {
65
64
  private const val MODULE_NAME = "background"
66
65
 
67
- // Bounded watchdog: if the bg JS thread never drains to our scheduled
68
- // eval (e.g. the bg entry bundle never finished evaluating), reject as a
69
- // RETRYABLE timeout rather than leaving the JS promise pending forever.
70
- // Matches the main-runtime SplitBundleLoader watchdog.
71
- private const val BG_SEGMENT_EVAL_TIMEOUT_MS = 30_000L
72
-
73
66
  init {
74
67
  System.loadLibrary("background_thread")
75
68
  }
@@ -85,20 +78,6 @@ class BackgroundThreadManager private constructor() {
85
78
  }
86
79
  }
87
80
 
88
- /**
89
- * Completion contract invoked by the native (JNI) side AFTER the bg segment
90
- * has been evaluated into the BACKGROUND runtime. Called from the bg JS
91
- * thread (or synchronously on the caller thread for fail-fast paths).
92
- *
93
- * @param error null on success; a non-empty message on failure. A message
94
- * prefixed with "NO_RUNTIME:" means the bg runtime is not ready yet
95
- * (retryable); "IO_ERROR:" means the segment file read failed (fatal);
96
- * any other message is a segment JS/Hermes eval throw (fatal).
97
- */
98
- fun interface SegmentEvalCallback {
99
- fun onComplete(error: String?)
100
- }
101
-
102
81
  // ── JNI declarations ────────────────────────────────────────────────────
103
82
 
104
83
  private external fun nativeInstallSharedBridge(runtimePtr: Long, isMain: Boolean)
@@ -106,34 +85,6 @@ class BackgroundThreadManager private constructor() {
106
85
  private external fun nativeDestroy()
107
86
  private external fun nativeExecuteWork(runtimePtr: Long, workId: Long)
108
87
 
109
- /**
110
- * Evaluate the segment at [segmentPath] into the BACKGROUND runtime on its
111
- * JS thread and invoke [callback] from inside that same JS-thread block,
112
- * strictly AFTER the segment's `__d(...)` module definitions have run. This
113
- * is the ordering guarantee that fixes the bg "Requiring unknown module"
114
- * race (the bg analogue of the main-runtime SplitBundleLoaderJSI fix).
115
- *
116
- * Returns immediately; [callback] fires later on the bg JS thread (or
117
- * synchronously on the calling thread for fail-fast paths such as the bg
118
- * runtime not being ready or the segment file failing to read).
119
- */
120
- private external fun nativeEvaluateSegmentInBackground(
121
- segmentPath: String,
122
- sourceURL: String,
123
- callback: SegmentEvalCallback
124
- )
125
-
126
- /**
127
- * Settle every in-flight bg segment eval as a retryable NO_RUNTIME failure
128
- * without tearing the runtime down. Called from [scheduleOnJSThread] when
129
- * the bg runtime is momentarily unreachable (context == null / ptr == 0) so
130
- * any eval enqueued onto the native pending-work map — which will never be
131
- * drained on the JS thread in that state — releases its JNI global ref and
132
- * settles the JS promise immediately, instead of leaking until the next
133
- * teardown or relying on the bg watchdog. Exactly-once on the native side.
134
- */
135
- private external fun nativeDropScheduledWork(workId: Long)
136
-
137
88
  /**
138
89
  * Synchronously mark the SharedRPC listener for `runtimeId` as dead
139
90
  * before the underlying JS runtime is torn down. See
@@ -456,140 +407,87 @@ class BackgroundThreadManager private constructor() {
456
407
  * Routes to main or background runtime's JS queue thread, then calls nativeExecuteWork.
457
408
  */
458
409
  @DoNotStrip
459
- fun scheduleOnJSThread(isMain: Boolean, workId: Long) {
410
+ fun scheduleOnJSThread(isMain: Boolean, workId: Long): Boolean {
460
411
  val context = if (isMain) mainReactContext else bgReactHost?.currentReactContext
461
412
  BTLogger.info("scheduleOnJSThread: isMain=$isMain, workId=$workId, context=${context != null}")
462
413
  if (context == null) {
463
414
  BTLogger.error("scheduleOnJSThread: context is null! isMain=$isMain, mainCtx=${mainReactContext != null}, bgHost=${bgReactHost != null}, bgCtx=${bgReactHost?.currentReactContext != null}")
464
- // The just-enqueued native work will never reach the bg JS thread.
465
- // Drop it now: erase gPendingWork[workId] (frees the captured segment
466
- // source buffer) and, if it was a bg segment eval, settle it
467
- // (retryable NO_RUNTIME) so its JNI global ref is released and the JS
468
- // promise resolves instead of leaking until teardown / the bg watchdog.
469
- if (!isMain) {
470
- nativeDropScheduledWork(workId)
471
- }
472
- return
415
+ return false
473
416
  }
474
- context.runOnJSQueueThread {
475
- // Re-read ptr inside the block — if a reload happened between
476
- // scheduling and execution, the old ptr may be stale.
477
- val ptr = if (isMain) mainRuntimePtr else bgRuntimePtr
478
- BTLogger.info("scheduleOnJSThread runOnJSQueueThread: isMain=$isMain, workId=$workId, ptr=$ptr")
479
- if (ptr != 0L) {
480
- try {
481
- nativeExecuteWork(ptr, workId)
482
- } catch (e: Exception) {
483
- BTLogger.error("Error executing work on JS thread: ${e.message}")
484
- }
485
- } else {
486
- BTLogger.error("scheduleOnJSThread: ptr is 0! isMain=$isMain")
487
- // Same as the null-context case: the work won't run on this
488
- // (stale/torn-down) bg runtime. Drop gPendingWork[workId] (frees
489
- // the source buffer) and settle any pending bg eval so it doesn't
490
- // leak its global ref / hang the JS promise.
491
- if (!isMain) {
492
- nativeDropScheduledWork(workId)
417
+ return try {
418
+ val posted = context.runOnJSQueueThread {
419
+ // Re-read ptr inside the block if a reload happened between
420
+ // scheduling and execution, the old ptr may be stale.
421
+ val ptr = if (isMain) mainRuntimePtr else bgRuntimePtr
422
+ BTLogger.info("scheduleOnJSThread runOnJSQueueThread: isMain=$isMain, workId=$workId, ptr=$ptr")
423
+ if (ptr != 0L) {
424
+ try {
425
+ nativeExecuteWork(ptr, workId)
426
+ } catch (e: Exception) {
427
+ BTLogger.error("Error executing work on JS thread: ${e.message}")
428
+ }
429
+ } else {
430
+ BTLogger.error("scheduleOnJSThread: ptr is 0! isMain=$isMain")
493
431
  }
494
432
  }
433
+ if (!posted) {
434
+ BTLogger.error("scheduleOnJSThread: runOnJSQueueThread rejected workId=$workId isMain=$isMain")
435
+ }
436
+ posted
437
+ } catch (e: Exception) {
438
+ BTLogger.error("scheduleOnJSThread: failed to post workId=$workId isMain=$isMain error=${e.message}")
439
+ false
495
440
  }
496
441
  }
497
442
 
498
443
  // ── Segment Registration (Phase 2.5 spike) ─────────────────────────────
499
444
 
500
445
  /**
501
- * Evaluate a HBC segment into the background runtime with completion
502
- * callback (fix A: eval-then-resolve).
446
+ * Register a HBC segment in the background runtime.
447
+ * Uses CatalystInstance.registerSegment() on the background ReactContext.
503
448
  *
504
- * Previously this called `ReactContext.registerSegment(...)`, whose
505
- * completion fires BEFORE the segment bytecode is evaluated into the runtime
506
- * (registerSegment only ENQUEUES the eval). That races Metro's
507
- * `import().then(() => __r(moduleId))` and produces a fatal, uncatchable
508
- * "Requiring unknown module" — locale segments load through this bg path, so
509
- * a language switch could still crash. We now evaluate the segment OURSELVES
510
- * on the bg JS thread via [nativeEvaluateSegmentInBackground] and resolve
511
- * ONLY after eval completes, so eval + resolve are one atomic JS-thread turn.
449
+ * @param segmentId The segment ID to register
450
+ * @param path Absolute file path to the .seg.hbc file
451
+ * @throws IllegalStateException if background runtime is not started
452
+ * @throws IllegalArgumentException if segment file does not exist
453
+ */
454
+ /**
455
+ * Register a HBC segment in the background runtime with completion callback.
456
+ * Dispatches to the background JS queue thread and invokes the callback
457
+ * only after registerSegment has actually executed.
512
458
  *
513
- * @param segmentId The segment ID (used only for the synthetic source URL)
459
+ * @param segmentId The segment ID to register
514
460
  * @param path Absolute file path to the .seg.hbc file
515
- * @param onComplete Called with (code=null) on success, or
516
- * (code=<contract reject code>, message) on failure. The code is one of
517
- * the SHARED split-bundle contract codes so the JS loader's retryable set
518
- * { SPLIT_BUNDLE_NO_RUNTIME, SPLIT_BUNDLE_TIMEOUT } classifies correctly.
461
+ * @param onComplete Called with null on success, or an Exception on failure
519
462
  */
520
- fun registerSegmentInBackground(
521
- segmentId: Int,
522
- path: String,
523
- onComplete: (code: String?, message: String?) -> Unit
524
- ) {
463
+ fun registerSegmentInBackground(segmentId: Int, path: String, onComplete: (Exception?) -> Unit) {
525
464
  if (!isStarted) {
526
- // Bg runtime not started yet → retryable (the loader will re-attempt
527
- // once the bg host is up).
528
- onComplete("SPLIT_BUNDLE_NO_RUNTIME", "Background runtime not started")
465
+ onComplete(IllegalStateException("Background runtime not started"))
529
466
  return
530
467
  }
531
468
 
532
469
  val file = File(path)
533
470
  if (!file.exists()) {
534
- onComplete("SPLIT_BUNDLE_NOT_FOUND", "Segment file not found: $path")
471
+ onComplete(IllegalArgumentException("Segment file not found: $path"))
535
472
  return
536
473
  }
537
474
 
538
475
  val context = bgReactHost?.currentReactContext
539
476
  if (context == null) {
540
- onComplete("SPLIT_BUNDLE_NO_RUNTIME", "Background ReactContext not available")
477
+ onComplete(IllegalStateException("Background ReactContext not available"))
541
478
  return
542
479
  }
543
480
 
544
- // One-shot guard: native success/error AND the watchdog can each try to
545
- // settle; only the first wins.
546
- val settled = AtomicBoolean(false)
547
- val sourceURL = "seg-$segmentId.js"
548
- val segStart = System.nanoTime()
549
-
550
- // Bounded watchdog: if the bg JS thread never drains to our eval, reject
551
- // with the RETRYABLE SPLIT_BUNDLE_TIMEOUT instead of hanging forever.
552
- val watchdog = Handler(Looper.getMainLooper())
553
- val timeoutRunnable = Runnable {
554
- if (settled.compareAndSet(false, true)) {
555
- BTLogger.error("[SplitBundle] bg segment id=$segmentId eval timed out after ${BG_SEGMENT_EVAL_TIMEOUT_MS}ms (bg entry bundle likely never finished evaluating); rejecting as retryable timeout")
556
- onComplete("SPLIT_BUNDLE_TIMEOUT", "Bg segment eval timed out: id=$segmentId")
557
- }
558
- }
559
- watchdog.postDelayed(timeoutRunnable, BG_SEGMENT_EVAL_TIMEOUT_MS)
560
-
481
+ // Use ReactContext.registerSegment which works in both bridge
482
+ // and bridgeless modes.
561
483
  try {
562
- nativeEvaluateSegmentInBackground(path, sourceURL) { error ->
563
- if (settled.compareAndSet(false, true)) {
564
- watchdog.removeCallbacks(timeoutRunnable)
565
- if (error == null) {
566
- val segMs = (System.nanoTime() - segStart) / 1_000_000.0
567
- BTLogger.info("[SplitBundle] bg segment id=$segmentId evaluated in ${String.format("%.1f", segMs)}ms (eval-complete)")
568
- onComplete(null, null)
569
- } else {
570
- // Native prefixes its failures so we can map to the
571
- // shared contract codes: NO_RUNTIME (retryable),
572
- // IO_ERROR (fatal), else eval throw (fatal).
573
- when {
574
- error.startsWith("NO_RUNTIME:") ->
575
- onComplete("SPLIT_BUNDLE_NO_RUNTIME", error.removePrefix("NO_RUNTIME:"))
576
- error.startsWith("IO_ERROR:") ->
577
- onComplete("SPLIT_BUNDLE_IO_ERROR", error.removePrefix("IO_ERROR:"))
578
- else ->
579
- onComplete("SPLIT_BUNDLE_EVAL_ERROR", error)
580
- }
581
- }
582
- }
583
- }
584
- } catch (e: Throwable) {
585
- // nativeEvaluateSegmentInBackground itself failed to dispatch (e.g.
586
- // UnsatisfiedLinkError). Fail closed; do NOT fall back to the
587
- // race-prone registerSegment path.
588
- if (settled.compareAndSet(false, true)) {
589
- watchdog.removeCallbacks(timeoutRunnable)
590
- BTLogger.error("[SplitBundle] FATAL: nativeEvaluateSegmentInBackground threw for id=$segmentId: ${e.message}")
591
- onComplete("SPLIT_BUNDLE_NATIVE_UNAVAILABLE", "Bg native segment eval unavailable: ${e.message}")
484
+ context.registerSegment(segmentId, path) {
485
+ BTLogger.info("Segment registered in background runtime: id=$segmentId, path=$path")
486
+ onComplete(null)
592
487
  }
488
+ } catch (e: Exception) {
489
+ BTLogger.error("Failed to register segment in background runtime: ${e.message}")
490
+ onComplete(e)
593
491
  }
594
492
  }
595
493
 
@@ -30,17 +30,9 @@ class BackgroundThreadModule(reactContext: ReactApplicationContext) :
30
30
 
31
31
  override fun loadSegmentInBackground(segmentId: Double, path: String, promise: Promise) {
32
32
  BackgroundThreadManager.getInstance()
33
- .registerSegmentInBackground(segmentId.toInt(), path) { code, message ->
34
- if (code != null) {
35
- // Reject with the SHARED split-bundle contract code (e.g.
36
- // SPLIT_BUNDLE_NO_RUNTIME / SPLIT_BUNDLE_TIMEOUT are
37
- // retryable; SPLIT_BUNDLE_IO_ERROR / SPLIT_BUNDLE_EVAL_ERROR
38
- // / SPLIT_BUNDLE_NATIVE_UNAVAILABLE / SPLIT_BUNDLE_NOT_FOUND
39
- // are not) so the JS loader classifies retryability the same
40
- // way it does for the main path. registerSegmentInBackground
41
- // always supplies one of these contract codes here, so there
42
- // is no legacy/opaque default string to fall back to.
43
- promise.reject(code, message)
33
+ .registerSegmentInBackground(segmentId.toInt(), path) { error ->
34
+ if (error != null) {
35
+ promise.reject("BG_SEGMENT_LOAD_ERROR", error.message, error)
44
36
  } else {
45
37
  promise.resolve(null)
46
38
  }
@@ -38,54 +38,7 @@
38
38
  path:path
39
39
  completion:^(NSError * _Nullable error) {
40
40
  if (error) {
41
- // Fix 1/E: map the manager's DISTINCT NSError.code to its own JS
42
- // reject code (matching SplitBundleLoader's main-runtime mapping and
43
- // the shared error-code contract) so JS can classify
44
- // retryable-vs-fatal. EVERY EBgMgrSegmentEvalError case is mapped
45
- // EXPLICITLY here — there is no silent `default → NO_RUNTIME` that
46
- // could MISCLASSIFY a fatal failure (e.g. a missing segment file)
47
- // as a transient runtime-not-ready that gets retried/masked. The old
48
- // code both collapsed bg failures into one opaque
49
- // `BG_SEGMENT_LOAD_ERROR` and (before that) let raw codes 1/2 fall
50
- // through `default` to retryable NO_RUNTIME.
51
- NSString *rejectCode;
52
- switch ((EBgMgrSegmentEvalError)error.code) {
53
- case EBgMgrSegmentEvalErrorNotStarted:
54
- case EBgMgrSegmentEvalErrorNilInstance:
55
- // Bg runtime not started / RCTInstance nil — TRANSIENT: the
56
- // bg host simply wasn't ready yet; a later attempt may win.
57
- rejectCode = @"SPLIT_BUNDLE_NO_RUNTIME"; // retryable
58
- break;
59
- case EBgMgrSegmentEvalErrorFileNotFound:
60
- // Segment file missing — FATAL packaging/OTA corruption.
61
- rejectCode = @"SPLIT_BUNDLE_NOT_FOUND"; // fatal
62
- break;
63
- case EBgMgrSegmentEvalErrorIvarMissing:
64
- // `_rctInstance` ivar reflection failed — STRUCTURAL/PERMANENT
65
- // (an RN bump renamed the private field). Retrying is futile.
66
- rejectCode = @"SPLIT_BUNDLE_NATIVE_UNAVAILABLE"; // fatal
67
- break;
68
- case EBgMgrSegmentEvalErrorIORead:
69
- rejectCode = @"SPLIT_BUNDLE_IO_ERROR"; // fatal
70
- break;
71
- case EBgMgrSegmentEvalErrorEvalThrow:
72
- rejectCode = @"SPLIT_BUNDLE_EVAL_ERROR"; // fatal (segment bug)
73
- break;
74
- case EBgMgrSegmentEvalErrorTimeout:
75
- rejectCode = @"SPLIT_BUNDLE_TIMEOUT"; // retryable
76
- break;
77
- default:
78
- // Defensive LAST RESORT only: every real EBgMgrSegmentEvalError
79
- // case is handled above, so reaching here means the manager
80
- // emitted an unmapped code (a bug). Choose retryable
81
- // NO_RUNTIME so a stale/unknown native build degrades safely
82
- // rather than permanently poisoning a segment — but log it
83
- // loudly so the unmapped code is caught and named.
84
- [BTLogger warn:[NSString stringWithFormat:@"[SplitBundle] bg loadSegment: UNMAPPED EBgMgrSegmentEvalError code=%ld — defaulting to retryable NO_RUNTIME. This is a native bug: add an explicit case.", (long)error.code]];
85
- rejectCode = @"SPLIT_BUNDLE_NO_RUNTIME"; // retryable (defensive)
86
- break;
87
- }
88
- reject(rejectCode, error.localizedDescription, error);
41
+ reject(@"BG_SEGMENT_LOAD_ERROR", error.localizedDescription, error);
89
42
  } else {
90
43
  resolve(nil);
91
44
  }
@@ -6,49 +6,6 @@ NS_ASSUME_NONNULL_BEGIN
6
6
  @class RCTReactNativeFactory;
7
7
  @class RCTHost;
8
8
 
9
- /// NSError `code` values produced by `registerSegmentInBackground:` /
10
- /// `evaluateSegmentInBackground:` under the `BackgroundThread` error domain.
11
- ///
12
- /// These are DISTINCT (kept numerically aligned with SplitBundleLoader's
13
- /// `ESegmentEvalError`) so the TurboModule boundary
14
- /// (`BackgroundThread.loadSegmentInBackground`) can map each to its OWN JS
15
- /// reject code and JS can classify retryable-vs-fatal — rather than collapsing
16
- /// every bg failure into one opaque code (fix E). EVERY failure the manager
17
- /// produces MUST have a NAMED case here so the boundary's switch maps it
18
- /// explicitly: a raw integer literal would fall through to the boundary's
19
- /// defensive `default` (→ retryable NO_RUNTIME) and silently MISCLASSIFY a
20
- /// fatal failure (e.g. a missing segment file) as a transient one that gets
21
- /// retried/masked instead of surfaced. The mapping the boundary applies is:
22
- /// - NotStarted (bg runtime not started yet) → `SPLIT_BUNDLE_NO_RUNTIME` (retryable)
23
- /// - FileNotFound (segment file missing) → `SPLIT_BUNDLE_NOT_FOUND` (fatal)
24
- /// - NilInstance (bg RCTInstance is nil) → `SPLIT_BUNDLE_NO_RUNTIME` (retryable)
25
- /// - IORead (file read/mmap failed) → `SPLIT_BUNDLE_IO_ERROR` (fatal)
26
- /// - EvalThrow (segment JS/Hermes bug) → `SPLIT_BUNDLE_EVAL_ERROR` (fatal)
27
- /// - Timeout (buffered executor never ran)→ `SPLIT_BUNDLE_TIMEOUT` (retryable)
28
- /// - IvarMissing (`_rctInstance` reflection
29
- /// failed — STRUCTURAL/permanent)→ `SPLIT_BUNDLE_NATIVE_UNAVAILABLE` (fatal)
30
- ///
31
- /// WHY NilInstance and IvarMissing are split (and not both NO_RUNTIME): a nil
32
- /// RCTInstance is TRANSIENT — the bg host just isn't up yet, a later attempt
33
- /// can succeed. A missing `_rctInstance` ivar is STRUCTURAL/PERMANENT — an RN
34
- /// version bump renamed/removed the private field our reflection depends on, so
35
- /// bg segment loading is disabled until the native code is updated. Retrying
36
- /// the latter is futile, so it maps to fatal NATIVE_UNAVAILABLE.
37
- ///
38
- /// Declared here (not file-local in the .mm) so the boundary references these
39
- /// by NAME — a future renumbering stays exact instead of drifting against a
40
- /// hardcoded magic-number switch. Values 3-6 are kept STABLE; 1/2/7 fill in the
41
- /// previously-raw `registerSegmentInBackground:` codes and the structural split.
42
- typedef NS_ENUM(NSInteger, EBgMgrSegmentEvalError) {
43
- EBgMgrSegmentEvalErrorNotStarted = 1, // bg runtime not started yet (retryable)
44
- EBgMgrSegmentEvalErrorFileNotFound = 2, // segment file missing (fatal)
45
- EBgMgrSegmentEvalErrorNilInstance = 3, // bg RCTInstance is nil (retryable)
46
- EBgMgrSegmentEvalErrorIORead = 4, // file read/mmap failed (fatal)
47
- EBgMgrSegmentEvalErrorEvalThrow = 5, // segment JS/Hermes bug (fatal)
48
- EBgMgrSegmentEvalErrorTimeout = 6, // buffered executor never ran (retryable)
49
- EBgMgrSegmentEvalErrorIvarMissing = 7, // `_rctInstance` ivar reflection failed (fatal, structural)
50
- };
51
-
52
9
  @interface BackgroundThreadManager : NSObject
53
10
 
54
11
  /// Shared instance for singleton pattern
@@ -20,82 +20,6 @@
20
20
  #import <React/RCTReloadCommand.h>
21
21
  #import <ReactCommon/RCTHost.h>
22
22
  #import <objc/runtime.h>
23
- #import <os/lock.h>
24
- #include <jsi/jsi.h>
25
- #include <cstdint>
26
-
27
- namespace {
28
-
29
- // Zero-copy jsi::Buffer that retains its NSData for the async (buffered)
30
- // executor block's lifetime — same rationale as SplitBundleLoader's
31
- // NSDataJSIBuffer (M4/M5): no second full copy of the segment bytes, and the
32
- // mmap'd/heap bytes stay alive because the buffer owns the NSData.
33
- class BgMgrNSDataJSIBuffer : public facebook::jsi::Buffer {
34
- public:
35
- explicit BgMgrNSDataJSIBuffer(NSData *data) : data_(data) {}
36
- size_t size() const override { return data_.length; }
37
- const uint8_t *data() const override {
38
- return static_cast<const uint8_t *>(data_.bytes);
39
- }
40
-
41
- private:
42
- NSData *data_; // strong retain (ARC).
43
- };
44
-
45
- } // namespace
46
-
47
- // Exactly-once settle guard for the bg watchdog — same design as
48
- // SplitBundleLoader's SBLSettleGuard. An ARC object captured by both the
49
- // executor block and the watchdog dispatch_after; ARC keeps its lock alive
50
- // until both release, so neither block needs (or may do) a manual free —
51
- // avoiding the use-after-free that would occur if either freed the lock while
52
- // the other still runs.
53
- @interface BgMgrSettleGuard : NSObject
54
- - (BOOL)tryClaim;
55
- @end
56
-
57
- @implementation BgMgrSettleGuard {
58
- os_unfair_lock _lock;
59
- BOOL _settled;
60
- }
61
- - (instancetype)init {
62
- if (self = [super init]) {
63
- _lock = OS_UNFAIR_LOCK_INIT;
64
- _settled = NO;
65
- }
66
- return self;
67
- }
68
- - (BOOL)tryClaim {
69
- os_unfair_lock_lock(&_lock);
70
- BOOL won = !_settled;
71
- if (won) {
72
- _settled = YES;
73
- }
74
- os_unfair_lock_unlock(&_lock);
75
- return won;
76
- }
77
- @end
78
-
79
- // Watchdog window for the bg buffered runtime executor (H2 = bg-runtime port of
80
- // SplitBundleLoader's C1). The bg runtime's buffered executor stays buffered
81
- // until the bg entry bundle finishes evaluating in
82
- // BackgroundReactNativeDelegate.hostDidStart:; if that never completes (host
83
- // teardown, OTA-resolve abort), the block never runs — without this watchdog
84
- // the JS promise would hang inflightSegments forever.
85
- //
86
- // Fix G: 30s (matching Android and the main-runtime watchdog). The buffered
87
- // executor stays buffered until the bg ENTRY bundle finishes evaluating; on a
88
- // slow/throttled cold start that entry eval can itself exceed 10s, which would
89
- // falsely trip the watchdog on a load that was about to succeed. 30s keeps the
90
- // genuine-wedge safety net while leaving generous headroom for slow cold starts.
91
- static const NSTimeInterval kBgSegmentEvalWatchdogSeconds = 30.0;
92
-
93
- // EBgMgrSegmentEvalError NSError `code` values are declared in
94
- // BackgroundThreadManager.h so the TurboModule boundary
95
- // (BackgroundThread.loadSegmentInBackground) can map each distinct code to its
96
- // own JS reject code by NAME. They stay numerically aligned with
97
- // SplitBundleLoader's ESegmentEvalError; see installProdBundleLoader.ts for the
98
- // retryable-vs-fatal classification (H3 / fix E).
99
23
 
100
24
  @interface BackgroundThreadManager ()
101
25
  @property (nonatomic, strong) BackgroundReactNativeDelegate *reactNativeFactoryDelegate;
@@ -277,166 +201,33 @@ static NSString *const MODULE_DEBUG_URL = @"http://localhost:8082/apps/mobile/ba
277
201
  completion:(void (^)(NSError * _Nullable error))completion
278
202
  {
279
203
  if (!self.isStarted || !self.reactNativeFactoryDelegate) {
280
- // Transient: the bg runtime just isn't up yet. NotStarted → NO_RUNTIME
281
- // (retryable). Previously a raw `code:1` which fell through the
282
- // boundary's `default` — same destination, but now NAMED so the mapping
283
- // is explicit and can never drift (fix 1 / E).
284
204
  NSError *error = [NSError errorWithDomain:@"BackgroundThread"
285
- code:EBgMgrSegmentEvalErrorNotStarted
205
+ code:1
286
206
  userInfo:@{NSLocalizedDescriptionKey: @"Background runtime not started"}];
287
207
  if (completion) completion(error);
288
208
  return;
289
209
  }
290
210
 
291
- // Verify the file exists. FATAL: a missing segment file is real packaging /
292
- // OTA corruption — retrying just re-misses. Previously a raw `code:2` that
293
- // the boundary's `default` MISCLASSIFIED as retryable NO_RUNTIME, masking
294
- // the corruption; now FileNotFound → NOT_FOUND (fatal) (fix 1 / E — the
295
- // NO-SHIP blocker).
211
+ // Verify the file exists
296
212
  if (![[NSFileManager defaultManager] fileExistsAtPath:path]) {
297
213
  NSError *error = [NSError errorWithDomain:@"BackgroundThread"
298
- code:EBgMgrSegmentEvalErrorFileNotFound
214
+ code:2
299
215
  userInfo:@{NSLocalizedDescriptionKey:
300
216
  [NSString stringWithFormat:@"Segment file not found: %@", path]}];
301
217
  if (completion) completion(error);
302
218
  return;
303
219
  }
304
220
 
305
- [self evaluateSegmentInBackground:segmentId path:path completion:completion];
306
- }
307
-
308
- // Evaluate-then-resolve segment load for the BACKGROUND runtime (H2).
309
- //
310
- // WHY THIS REPLACES registerSegmentWithId: + immediate completion(nil):
311
- // The old path called `[delegate registerSegmentWithId:path:]` (which routes to
312
- // RCTInstance/ReactInstance::registerSegment — that only ENQUEUES
313
- // `runtime.evaluateJavaScript(segment)` on the runtime scheduler and returns)
314
- // then resolved the JS promise immediately. That is the exact
315
- // "Requiring unknown module" race the MAIN runtime already fixed in
316
- // SplitBundleLoader: Metro's `import().then(() => __r(moduleId))` microtask can
317
- // run `__r` BEFORE the scheduled eval populated the module table → a FATAL,
318
- // uncatchable crash. Locale segments load through THIS path in the bg runtime
319
- // (ServiceSetting.refreshLocaleMessages → import('./json/*.json') runs in
320
- // kit-bg), so a language switch could still crash.
321
- //
322
- // Fix: evaluate the segment OURSELVES inside one
323
- // `callFunctionOnBufferedRuntimeExecutor:` block on the bg RCTInstance and
324
- // signal completion in that SAME block, strictly AFTER eval — making eval +
325
- // resolve one atomic unit so any subsequent `__r(moduleId)` finds the module.
326
- // The bg RCTInstance is the delegate's private `_rctInstance` ivar; we read it
327
- // reflectively (the same pattern installSharedBridgeInMainRuntime: uses on the
328
- // main host) to keep this fix self-contained in this file rather than widening
329
- // the delegate's surface. Includes the same exactly-once + watchdog guard as
330
- // the main-runtime fix (C1) because the bg buffered executor is likewise
331
- // buffered until the bg entry bundle finishes evaluating in hostDidStart:.
332
- - (void)evaluateSegmentInBackground:(NSNumber *)segmentId
333
- path:(NSString *)path
334
- completion:(void (^)(NSError * _Nullable error))completion
335
- {
336
- // Reach the bg RCTInstance via the delegate's `_rctInstance` ivar. `id`
337
- // (not a typed RCTInstance*) mirrors installSharedBridgeInMainRuntime:'s
338
- // untyped handling and avoids needing the RCTInstance header here.
339
- BackgroundReactNativeDelegate *delegate = self.reactNativeFactoryDelegate;
340
- Ivar ivar = class_getInstanceVariable([delegate class], "_rctInstance");
341
- if (!ivar) {
342
- // Loud failure (L7 parity): a future RN/delegate refactor that renames
343
- // this ivar silently disables ALL bg segment loading — surface it.
344
- // STRUCTURAL/PERMANENT (not transient): retrying can never recreate a
345
- // renamed ivar, so this maps to fatal NATIVE_UNAVAILABLE — distinct from
346
- // the nil-instance case below, which IS transient. Misclassifying this
347
- // as NO_RUNTIME would make JS retry a permanently-broken reflection.
348
- [BTLogger error:[NSString stringWithFormat:@"[SplitBundle] FATAL: _rctInstance ivar not found on %@ — bg segment loading is DISABLED.", [delegate class]]];
349
- NSError *error = [NSError errorWithDomain:@"BackgroundThread"
350
- code:EBgMgrSegmentEvalErrorIvarMissing
351
- userInfo:@{NSLocalizedDescriptionKey: @"_rctInstance ivar not found on bg delegate"}];
352
- if (completion) completion(error);
353
- return;
354
- }
355
- id instance = object_getIvar(delegate, ivar);
356
- if (!instance) {
357
- [BTLogger error:@"[SplitBundle] bg loadSegment: background RCTInstance not available"];
358
- NSError *error = [NSError errorWithDomain:@"BackgroundThread"
359
- code:EBgMgrSegmentEvalErrorNilInstance
360
- userInfo:@{NSLocalizedDescriptionKey: @"Background RCTInstance not available"}];
361
- if (completion) completion(error);
362
- return;
363
- }
364
-
365
- // M4/M5: mmap + zero-copy buffer (retains the NSData for the async block).
366
- NSError *readError = nil;
367
- NSData *data = [NSData dataWithContentsOfFile:path
368
- options:NSDataReadingMappedIfSafe
369
- error:&readError];
370
- if (!data || data.length == 0) {
221
+ BOOL success = [self.reactNativeFactoryDelegate registerSegmentWithId:segmentId path:path];
222
+ if (success) {
223
+ if (completion) completion(nil);
224
+ } else {
371
225
  NSError *error = [NSError errorWithDomain:@"BackgroundThread"
372
- code:EBgMgrSegmentEvalErrorIORead
373
- userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to read bg segment at %@%@", path, readError ? [NSString stringWithFormat:@": %@", readError.localizedDescription] : @""]}];
226
+ code:3
227
+ userInfo:@{NSLocalizedDescriptionKey:
228
+ @"Failed to register segment in background runtime"}];
374
229
  if (completion) completion(error);
375
- return;
376
230
  }
377
-
378
- // M6: synthetic `seg-<id>.js` source URL for in-segment crash symbolication.
379
- int segIdInt = segmentId.intValue;
380
- NSString *sourceURL = [NSString stringWithFormat:@"seg-%d.js", segIdInt];
381
-
382
- // C1: exactly-once guard shared (and retained) by the executor block and the
383
- // watchdog. ARC-owned so the lock outlives both with no manual free.
384
- BgMgrSettleGuard *settleGuard = [[BgMgrSettleGuard alloc] init];
385
-
386
- CFAbsoluteTime dispatchStart = CFAbsoluteTimeGetCurrent();
387
- [BTLogger info:[NSString stringWithFormat:@"[SplitBundle] bg loadSegment: evaluating %@ (id=%d, %lu bytes)", sourceURL, segIdInt, (unsigned long)data.length]];
388
-
389
- // No __weak dance needed here (unlike the SharedRPC executor): this block
390
- // does not re-dispatch onto the instance — it runs synchronously inside the
391
- // instance's own runtime executor with a live `runtime &`, so by the time it
392
- // executes the instance is necessarily still alive.
393
- [instance callFunctionOnBufferedRuntimeExecutor:^(facebook::jsi::Runtime &runtime) {
394
- @autoreleasepool {
395
- BOOL won = [settleGuard tryClaim];
396
- NSError *evalError = nil;
397
- CFAbsoluteTime evalStart = CFAbsoluteTimeGetCurrent();
398
- try {
399
- auto buffer = std::make_shared<BgMgrNSDataJSIBuffer>(data);
400
- runtime.evaluateJavaScript(buffer, [sourceURL UTF8String]);
401
- double evalMs = (CFAbsoluteTimeGetCurrent() - evalStart) * 1000.0;
402
- [BTLogger info:[NSString stringWithFormat:@"[SplitBundle] bg loadSegment: %@ evaluated in %.1fms", sourceURL, evalMs]];
403
- } catch (const std::exception &e) {
404
- evalError = [NSError errorWithDomain:@"BackgroundThread"
405
- code:EBgMgrSegmentEvalErrorEvalThrow
406
- userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Bg segment evaluation failed for %@: %s", sourceURL, e.what()]}];
407
- [BTLogger error:[NSString stringWithFormat:@"[SplitBundle] bg loadSegment: %@ evaluation threw: %s", sourceURL, e.what()]];
408
- } catch (...) {
409
- evalError = [NSError errorWithDomain:@"BackgroundThread"
410
- code:EBgMgrSegmentEvalErrorEvalThrow
411
- userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Bg segment evaluation failed for %@ (unknown C++ exception)", sourceURL]}];
412
- [BTLogger error:[NSString stringWithFormat:@"[SplitBundle] bg loadSegment: %@ evaluation threw an unknown exception", sourceURL]];
413
- }
414
- if (won) {
415
- // Resolve AFTER eval — the ordering guarantee that fixes the race.
416
- if (completion) completion(evalError);
417
- } else {
418
- [BTLogger warn:[NSString stringWithFormat:@"[SplitBundle] bg loadSegment: %@ evaluated AFTER watchdog already settled (bg entry was wedged >%.0fs)", sourceURL, kBgSegmentEvalWatchdogSeconds]];
419
- }
420
- }
421
- }];
422
-
423
- // C1 watchdog — fires only on a genuine wedge (bg entry bundle never
424
- // finished evaluating). Rejects with a retryable timeout so the JS loader
425
- // re-attempts. settleGuard is retained by this block, so the lock stays
426
- // valid even if the executor block later runs.
427
- dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kBgSegmentEvalWatchdogSeconds * NSEC_PER_SEC)),
428
- dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
429
- if ([settleGuard tryClaim]) {
430
- [BTLogger error:[NSString stringWithFormat:@"[SplitBundle] bg loadSegment: %@ WATCHDOG fired after %.0fs — bg runtime executor never ran (bg entry bundle likely never finished evaluating). Rejecting as retryable timeout.", sourceURL, kBgSegmentEvalWatchdogSeconds]];
431
- NSError *timeoutError = [NSError errorWithDomain:@"BackgroundThread"
432
- code:EBgMgrSegmentEvalErrorTimeout
433
- userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Bg segment %@ eval timed out after %.0fs (buffered runtime executor never ran)", sourceURL, kBgSegmentEvalWatchdogSeconds]}];
434
- if (completion) completion(timeoutError);
435
- }
436
- });
437
-
438
- double dispatchMs = (CFAbsoluteTimeGetCurrent() - dispatchStart) * 1000.0;
439
- [BTLogger info:[NSString stringWithFormat:@"[SplitBundle] bg loadSegment: %@ dispatched in %.1fms (resolve fires after eval; watchdog %.0fs)", sourceURL, dispatchMs, kBgSegmentEvalWatchdogSeconds]];
440
231
  }
441
232
 
442
233
  #pragma mark - Restart
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-background-thread",
3
- "version": "3.0.61",
3
+ "version": "3.0.62",
4
4
  "description": "react-native-background-thread",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
package/src/SharedRPC.ts CHANGED
@@ -4,7 +4,7 @@ export interface ISharedRPC {
4
4
  // the payload value, so there is no read-back. `read`/`has`/`pendingCount`
5
5
  // (the old slot-map introspection) are gone.
6
6
  onWrite(
7
- callback: (callId: string, value: string | number | boolean) => void,
7
+ callback: (callId: string, value: string | number | boolean) => void
8
8
  ): void;
9
9
  // The calling runtime declares which SharedStore key holds its readiness
10
10
  // payload, so the native invalidate path clears exactly that key on teardown