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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@
4
4
  #include <atomic>
5
5
  #include <chrono>
6
6
  #include <condition_variable>
7
+ #include <cstdio>
7
8
  #include <deque>
8
9
  #include <functional>
9
10
  #include <memory>
@@ -447,6 +448,323 @@ static void installTimersOnRuntime(jsi::Runtime &rt) {
447
448
  LOGI("Timer + rAF + rIC polyfills installed on bg runtime");
448
449
  }
449
450
 
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
+
450
768
  // ── nativeInstallSharedBridge ───────────────────────────────────────────
451
769
  // Install SharedStore and SharedRPC into a runtime.
452
770
  extern "C" JNIEXPORT void JNICALL
@@ -471,8 +789,24 @@ Java_com_backgroundthread_BackgroundThreadManager_nativeInstallSharedBridge(
471
789
 
472
790
  RPCRuntimeExecutor executor = [ref, capturedIsMain](std::function<void(jsi::Runtime &)> work) {
473
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
+
474
807
  if (!env || !ref) {
475
808
  LOGE("executor: env=%p, ref=%p — aborting", env, ref.get());
809
+ drainBgEvalsIfBg("Background executor env/ref unavailable when scheduling segment eval");
476
810
  return;
477
811
  }
478
812
 
@@ -485,6 +819,7 @@ Java_com_backgroundthread_BackgroundThreadManager_nativeInstallSharedBridge(
485
819
 
486
820
  jclass cls = env->GetObjectClass(ref.get());
487
821
  jmethodID mid = env->GetMethodID(cls, "scheduleOnJSThread", "(ZJ)V");
822
+ bool scheduled = false;
488
823
  if (mid) {
489
824
  LOGI("executor: calling scheduleOnJSThread(isMain=%d, workId=%ld)", capturedIsMain, (long)workId);
490
825
  env->CallVoidMethod(ref.get(), mid, static_cast<jboolean>(capturedIsMain), static_cast<jlong>(workId));
@@ -492,6 +827,8 @@ Java_com_backgroundthread_BackgroundThreadManager_nativeInstallSharedBridge(
492
827
  LOGE("executor: JNI exception after scheduleOnJSThread");
493
828
  env->ExceptionDescribe();
494
829
  env->ExceptionClear();
830
+ } else {
831
+ scheduled = true;
495
832
  }
496
833
  } else {
497
834
  LOGE("executor: scheduleOnJSThread method not found!");
@@ -501,6 +838,21 @@ Java_com_backgroundthread_BackgroundThreadManager_nativeInstallSharedBridge(
501
838
  }
502
839
  }
503
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
+ }
504
856
  };
505
857
 
506
858
  std::string runtimeId = isMain ? "main" : "background";
@@ -646,6 +998,11 @@ Java_com_backgroundthread_BackgroundThreadManager_nativeDestroy(
646
998
  // Drain pending cross-runtime work. Each std::function may capture a
647
999
  // shared_ptr<jsi::Function> tied to the destroyed runtime; leak them
648
1000
  // 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).
649
1006
  {
650
1007
  std::lock_guard<std::mutex> lock(gWorkMutex);
651
1008
  for (auto &entry : gPendingWork) {
@@ -654,5 +1011,11 @@ Java_com_backgroundthread_BackgroundThreadManager_nativeDestroy(
654
1011
  gPendingWork.clear();
655
1012
  }
656
1013
 
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
+
657
1020
  LOGI("Native resources cleaned up");
658
1021
  }
@@ -24,6 +24,7 @@ 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
27
28
 
28
29
  /**
29
30
  * Singleton manager for the background React Native runtime.
@@ -63,6 +64,12 @@ class BackgroundThreadManager private constructor() {
63
64
  companion object {
64
65
  private const val MODULE_NAME = "background"
65
66
 
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
+
66
73
  init {
67
74
  System.loadLibrary("background_thread")
68
75
  }
@@ -78,6 +85,20 @@ class BackgroundThreadManager private constructor() {
78
85
  }
79
86
  }
80
87
 
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
+
81
102
  // ── JNI declarations ────────────────────────────────────────────────────
82
103
 
83
104
  private external fun nativeInstallSharedBridge(runtimePtr: Long, isMain: Boolean)
@@ -85,6 +106,34 @@ class BackgroundThreadManager private constructor() {
85
106
  private external fun nativeDestroy()
86
107
  private external fun nativeExecuteWork(runtimePtr: Long, workId: Long)
87
108
 
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
+
88
137
  /**
89
138
  * Synchronously mark the SharedRPC listener for `runtimeId` as dead
90
139
  * before the underlying JS runtime is torn down. See
@@ -412,8 +461,17 @@ class BackgroundThreadManager private constructor() {
412
461
  BTLogger.info("scheduleOnJSThread: isMain=$isMain, workId=$workId, context=${context != null}")
413
462
  if (context == null) {
414
463
  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
473
  }
416
- context?.runOnJSQueueThread {
474
+ context.runOnJSQueueThread {
417
475
  // Re-read ptr inside the block — if a reload happened between
418
476
  // scheduling and execution, the old ptr may be stale.
419
477
  val ptr = if (isMain) mainRuntimePtr else bgRuntimePtr
@@ -426,6 +484,13 @@ class BackgroundThreadManager private constructor() {
426
484
  }
427
485
  } else {
428
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)
493
+ }
429
494
  }
430
495
  }
431
496
  }
@@ -433,51 +498,98 @@ class BackgroundThreadManager private constructor() {
433
498
  // ── Segment Registration (Phase 2.5 spike) ─────────────────────────────
434
499
 
435
500
  /**
436
- * Register a HBC segment in the background runtime.
437
- * Uses CatalystInstance.registerSegment() on the background ReactContext.
501
+ * Evaluate a HBC segment into the background runtime with completion
502
+ * callback (fix A: eval-then-resolve).
438
503
  *
439
- * @param segmentId The segment ID to register
440
- * @param path Absolute file path to the .seg.hbc file
441
- * @throws IllegalStateException if background runtime is not started
442
- * @throws IllegalArgumentException if segment file does not exist
443
- */
444
- /**
445
- * Register a HBC segment in the background runtime with completion callback.
446
- * Dispatches to the background JS queue thread and invokes the callback
447
- * only after registerSegment has actually executed.
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.
448
512
  *
449
- * @param segmentId The segment ID to register
513
+ * @param segmentId The segment ID (used only for the synthetic source URL)
450
514
  * @param path Absolute file path to the .seg.hbc file
451
- * @param onComplete Called with null on success, or an Exception on failure
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.
452
519
  */
453
- fun registerSegmentInBackground(segmentId: Int, path: String, onComplete: (Exception?) -> Unit) {
520
+ fun registerSegmentInBackground(
521
+ segmentId: Int,
522
+ path: String,
523
+ onComplete: (code: String?, message: String?) -> Unit
524
+ ) {
454
525
  if (!isStarted) {
455
- onComplete(IllegalStateException("Background runtime not started"))
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")
456
529
  return
457
530
  }
458
531
 
459
532
  val file = File(path)
460
533
  if (!file.exists()) {
461
- onComplete(IllegalArgumentException("Segment file not found: $path"))
534
+ onComplete("SPLIT_BUNDLE_NOT_FOUND", "Segment file not found: $path")
462
535
  return
463
536
  }
464
537
 
465
538
  val context = bgReactHost?.currentReactContext
466
539
  if (context == null) {
467
- onComplete(IllegalStateException("Background ReactContext not available"))
540
+ onComplete("SPLIT_BUNDLE_NO_RUNTIME", "Background ReactContext not available")
468
541
  return
469
542
  }
470
543
 
471
- // Use ReactContext.registerSegment which works in both bridge
472
- // and bridgeless modes.
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
+
473
561
  try {
474
- context.registerSegment(segmentId, path) {
475
- BTLogger.info("Segment registered in background runtime: id=$segmentId, path=$path")
476
- onComplete(null)
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}")
477
592
  }
478
- } catch (e: Exception) {
479
- BTLogger.error("Failed to register segment in background runtime: ${e.message}")
480
- onComplete(e)
481
593
  }
482
594
  }
483
595
 
@@ -30,9 +30,17 @@ 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) { error ->
34
- if (error != null) {
35
- promise.reject("BG_SEGMENT_LOAD_ERROR", error.message, error)
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)
36
44
  } else {
37
45
  promise.resolve(null)
38
46
  }
@@ -38,7 +38,54 @@
38
38
  path:path
39
39
  completion:^(NSError * _Nullable error) {
40
40
  if (error) {
41
- reject(@"BG_SEGMENT_LOAD_ERROR", error.localizedDescription, 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);
42
89
  } else {
43
90
  resolve(nil);
44
91
  }
@@ -6,6 +6,49 @@ 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
+
9
52
  @interface BackgroundThreadManager : NSObject
10
53
 
11
54
  /// Shared instance for singleton pattern
@@ -20,6 +20,82 @@
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).
23
99
 
24
100
  @interface BackgroundThreadManager ()
25
101
  @property (nonatomic, strong) BackgroundReactNativeDelegate *reactNativeFactoryDelegate;
@@ -201,33 +277,166 @@ static NSString *const MODULE_DEBUG_URL = @"http://localhost:8082/apps/mobile/ba
201
277
  completion:(void (^)(NSError * _Nullable error))completion
202
278
  {
203
279
  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).
204
284
  NSError *error = [NSError errorWithDomain:@"BackgroundThread"
205
- code:1
285
+ code:EBgMgrSegmentEvalErrorNotStarted
206
286
  userInfo:@{NSLocalizedDescriptionKey: @"Background runtime not started"}];
207
287
  if (completion) completion(error);
208
288
  return;
209
289
  }
210
290
 
211
- // Verify the file exists
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).
212
296
  if (![[NSFileManager defaultManager] fileExistsAtPath:path]) {
213
297
  NSError *error = [NSError errorWithDomain:@"BackgroundThread"
214
- code:2
298
+ code:EBgMgrSegmentEvalErrorFileNotFound
215
299
  userInfo:@{NSLocalizedDescriptionKey:
216
300
  [NSString stringWithFormat:@"Segment file not found: %@", path]}];
217
301
  if (completion) completion(error);
218
302
  return;
219
303
  }
220
304
 
221
- BOOL success = [self.reactNativeFactoryDelegate registerSegmentWithId:segmentId path:path];
222
- if (success) {
223
- if (completion) completion(nil);
224
- } else {
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]]];
225
349
  NSError *error = [NSError errorWithDomain:@"BackgroundThread"
226
- code:3
227
- userInfo:@{NSLocalizedDescriptionKey:
228
- @"Failed to register segment in background runtime"}];
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"}];
229
361
  if (completion) completion(error);
362
+ return;
230
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) {
371
+ 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] : @""]}];
374
+ if (completion) completion(error);
375
+ return;
376
+ }
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]];
231
440
  }
232
441
 
233
442
  #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.60",
3
+ "version": "3.0.61",
4
4
  "description": "react-native-background-thread",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",