@onekeyfe/react-native-background-thread 3.0.59 → 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.
- package/android/src/main/cpp/cpp-adapter.cpp +363 -0
- package/android/src/main/java/com/backgroundthread/BackgroundThreadManager.kt +138 -26
- package/android/src/main/java/com/backgroundthread/BackgroundThreadModule.kt +11 -3
- package/ios/BackgroundThread.mm +48 -1
- package/ios/BackgroundThreadManager.h +43 -0
- package/ios/BackgroundThreadManager.mm +219 -10
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
*
|
|
437
|
-
*
|
|
501
|
+
* Evaluate a HBC segment into the background runtime with completion
|
|
502
|
+
* callback (fix A: eval-then-resolve).
|
|
438
503
|
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
*
|
|
446
|
-
*
|
|
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
|
|
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
|
|
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(
|
|
520
|
+
fun registerSegmentInBackground(
|
|
521
|
+
segmentId: Int,
|
|
522
|
+
path: String,
|
|
523
|
+
onComplete: (code: String?, message: String?) -> Unit
|
|
524
|
+
) {
|
|
454
525
|
if (!isStarted) {
|
|
455
|
-
|
|
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(
|
|
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(
|
|
540
|
+
onComplete("SPLIT_BUNDLE_NO_RUNTIME", "Background ReactContext not available")
|
|
468
541
|
return
|
|
469
542
|
}
|
|
470
543
|
|
|
471
|
-
//
|
|
472
|
-
//
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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) {
|
|
34
|
-
if (
|
|
35
|
-
|
|
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
|
}
|
package/ios/BackgroundThread.mm
CHANGED
|
@@ -38,7 +38,54 @@
|
|
|
38
38
|
path:path
|
|
39
39
|
completion:^(NSError * _Nullable error) {
|
|
40
40
|
if (error) {
|
|
41
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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:
|
|
227
|
-
userInfo:@{NSLocalizedDescriptionKey:
|
|
228
|
-
|
|
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
|