@onekeyfe/react-native-background-thread 3.0.31 → 3.0.32

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.
@@ -586,6 +586,28 @@ Java_com_backgroundthread_BackgroundThreadManager_nativeSetupErrorHandler(
586
586
  }
587
587
  }
588
588
 
589
+ // ── nativeInvalidateSharedRpc ───────────────────────────────────────────
590
+ // Synchronously quiesce the SharedRPC listener for a given runtimeId before
591
+ // the underlying JS runtime is torn down. Called from
592
+ // BackgroundThreadManager.restart() ahead of process restart / soft reload
593
+ // so any cross-runtime executor lambda still in flight short-circuits
594
+ // instead of dispatching onto a dying runtime (parity with iOS).
595
+ extern "C" JNIEXPORT jboolean JNICALL
596
+ Java_com_backgroundthread_BackgroundThreadManager_nativeInvalidateSharedRpc(
597
+ JNIEnv *env, jobject thiz, jstring runtimeId) {
598
+
599
+ const char *idChars = env->GetStringUTFChars(runtimeId, nullptr);
600
+ if (!idChars) {
601
+ return JNI_FALSE;
602
+ }
603
+ std::string id(idChars);
604
+ env->ReleaseStringUTFChars(runtimeId, idChars);
605
+
606
+ bool found = SharedRPC::invalidate(id);
607
+ LOGI("nativeInvalidateSharedRpc: id=%s found=%d", id.c_str(), found ? 1 : 0);
608
+ return found ? JNI_TRUE : JNI_FALSE;
609
+ }
610
+
589
611
  // ── nativeDestroy ───────────────────────────────────────────────────────
590
612
  // Clean up native resources.
591
613
  // Called from BackgroundThreadManager.destroy().
@@ -5,6 +5,8 @@ import android.content.Intent
5
5
  import android.net.Uri
6
6
  import android.os.Handler
7
7
  import android.os.Looper
8
+ import com.facebook.react.ReactApplication
9
+ import com.facebook.react.ReactHost
8
10
  import com.facebook.react.ReactPackage
9
11
  import com.facebook.proguard.annotations.DoNotStrip
10
12
  import com.facebook.react.ReactInstanceEventListener
@@ -21,6 +23,7 @@ import com.facebook.react.runtime.hermes.HermesInstance
21
23
  import com.facebook.react.shell.MainReactPackage
22
24
  import java.io.File
23
25
  import java.lang.ref.WeakReference
26
+ import java.util.concurrent.TimeUnit
24
27
 
25
28
  /**
26
29
  * Singleton manager for the background React Native runtime.
@@ -47,6 +50,14 @@ class BackgroundThreadManager private constructor() {
47
50
  @Volatile
48
51
  private var mainRuntimePtr: Long = 0
49
52
  private var mainReactContext: ReactApplicationContext? = null
53
+
54
+ // Captured lazily on the first installSharedBridgeInMainRuntime() call.
55
+ // Used by restart(mode='ui') to soft-reload the main runtime via
56
+ // ReactHost.reload(reason) so the bg runtime stays hot. Null when the
57
+ // host app is not on bridgeless / NewArch — restart() falls back to a
58
+ // full process restart in that case.
59
+ @Volatile
60
+ private var mainReactHost: ReactHost? = null
50
61
  private var isStarted = false
51
62
 
52
63
  companion object {
@@ -74,6 +85,14 @@ class BackgroundThreadManager private constructor() {
74
85
  private external fun nativeDestroy()
75
86
  private external fun nativeExecuteWork(runtimePtr: Long, workId: Long)
76
87
 
88
+ /**
89
+ * Synchronously mark the SharedRPC listener for `runtimeId` as dead
90
+ * before the underlying JS runtime is torn down. See
91
+ * SharedRPC::invalidate (cpp/SharedRPC.cpp) for the cross-runtime
92
+ * correctness rationale.
93
+ */
94
+ private external fun nativeInvalidateSharedRpc(runtimeId: String): Boolean
95
+
77
96
  // ── SharedBridge ────────────────────────────────────────────────────────
78
97
 
79
98
  /**
@@ -86,6 +105,23 @@ class BackgroundThreadManager private constructor() {
86
105
 
87
106
  fun installSharedBridgeInMainRuntime(context: ReactApplicationContext) {
88
107
  mainReactContext = context
108
+ // Capture the host the first time we see it; reload() needs it. We
109
+ // resolve via ReactApplication.reactHost — non-null only on bridgeless
110
+ // / NewArch builds. Bound here (not in restart()) because installShared
111
+ // Bridge is invoked from JS bootstrap, which is also when ReactContext
112
+ // is guaranteed live. Restart('ui') falls back to process restart if
113
+ // this stays null.
114
+ if (mainReactHost == null) {
115
+ try {
116
+ val app = context.applicationContext as? ReactApplication
117
+ mainReactHost = app?.reactHost
118
+ if (mainReactHost == null) {
119
+ BTLogger.warn("ReactHost unavailable (non-bridgeless host?); restart('ui') will fall back to process restart")
120
+ }
121
+ } catch (t: Throwable) {
122
+ BTLogger.error("Failed to resolve ReactHost: ${t.message}")
123
+ }
124
+ }
89
125
  context.runOnJSQueueThread {
90
126
  try {
91
127
  val ptr = context.javaScriptContextHolder?.get() ?: 0L
@@ -754,6 +790,180 @@ class BackgroundThreadManager private constructor() {
754
790
  }
755
791
  }
756
792
 
793
+ // ── Restart ─────────────────────────────────────────────────────────────
794
+ //
795
+ // Replaces direct use of `react-native-restart` from JS. The native module
796
+ // is the only piece that has visibility into BOTH the main and background
797
+ // ReactHost lifecycles, so it can sequence:
798
+ // 1. SharedRPC quiesce (mark listener alive=false, leak callback, drop
799
+ // executor) — synchronous, holds SharedRPC mutex internally
800
+ // 2. JS runtime teardown — see per-mode behaviour below
801
+ //
802
+ // mode='ui':
803
+ // Soft-reload the main runtime via ReactHost.reload(reason). bg runtime
804
+ // keeps running. After reload, JS bootstrap re-invokes installShared
805
+ // Bridge() (the TurboModule entry point), which re-installs the "main"
806
+ // SharedRPC listener and refreshes mainRuntimePtr. Requires bridgeless /
807
+ // NewArch — if ReactHost was never captured (old arch host), falls back
808
+ // to a process restart so callers get consistent reload semantics.
809
+ //
810
+ // mode='all':
811
+ // Process-level restart (Runtime.exit + makeRestartActivityTask). OTA
812
+ // install/switch and resetData want a clean slate on disk; a process
813
+ // relaunch trivially guarantees both runtimes bootstrap from fresh
814
+ // bundle content without us having to sequence "tear down bg → reload
815
+ // main → wait for new main → restart bg". iOS goes through soft reload
816
+ // for mode='all' because abrupt termination is App Store-unfriendly and
817
+ // iOS has no clean self-relaunch; Android does, so we keep using it.
818
+
819
+ fun restart(context: ReactApplicationContext, mode: String, reason: String, promise: com.facebook.react.bridge.Promise) {
820
+ val isUi = mode == "ui"
821
+ val isAll = mode == "all"
822
+ if (!isUi && !isAll) {
823
+ promise.reject(
824
+ "BG_RESTART_ERROR",
825
+ "BackgroundThread.restart: unsupported mode '$mode', expected 'ui' or 'all'"
826
+ )
827
+ return
828
+ }
829
+
830
+ BTLogger.info("restart: mode=$mode reason=$reason")
831
+
832
+ try {
833
+ nativeInvalidateSharedRpc("main")
834
+ if (isAll) {
835
+ nativeInvalidateSharedRpc("background")
836
+ }
837
+ } catch (t: Throwable) {
838
+ // Invalidate is best-effort; not fatal if the JNI call somehow
839
+ // throws (e.g. so library not loaded yet). Continue to restart.
840
+ BTLogger.error("restart: SharedRPC invalidate failed: ${t.message}")
841
+ }
842
+
843
+ val host = mainReactHost
844
+ if (isUi && host != null) {
845
+ BTLogger.info("restart(ui): soft reload via ReactHost.reload")
846
+ // Post to the UI thread to keep the call off the JS thread that
847
+ // is about to be torn down; ReactHost.reload is itself thread-
848
+ // safe but the work it kicks off (JNI teardown of the old
849
+ // ReactInstance) is cleaner when not initiated from inside the
850
+ // outgoing runtime's callback frame.
851
+ //
852
+ // Promise resolution is sequenced after reload() returns. The
853
+ // actual *delivery* of that resolve to JS is best-effort: the
854
+ // CallInvoker that would route it back is tied to the outgoing
855
+ // ReactInstance which reload() is about to invalidate, and the
856
+ // typical caller is `await restart(...)` whose continuation
857
+ // never runs because the reload supersedes it. The position
858
+ // matters only for the synchronous-throw case — if reload()
859
+ // throws inline, the catch block reaches the fallback / reject
860
+ // path instead of misreporting success.
861
+ //
862
+ // ReactHost.reload(reason) returns a TaskInterface<Void> that
863
+ // completes when the new ReactInstance is up; an async fault
864
+ // means reload failed AFTER we already invalidated the
865
+ // SharedRPC main listener — main runtime is torn down without
866
+ // rebuild, SharedRPC is permanently dead for "main". Watch the
867
+ // task on a daemon thread and fall back to a process restart
868
+ // on fault / cancellation / timeout so we converge on a known
869
+ // good state instead of leaving the app wedged. TaskInterface
870
+ // only exposes waitForCompletion + isFaulted/isCancelled/
871
+ // getError (no continueWith/onError callback in RN 0.83's
872
+ // public surface), so polling on a worker thread is what we
873
+ // have. Timeout is generous (15s) — observed worst case
874
+ // reload on a low-end Android is ~3s.
875
+ Handler(Looper.getMainLooper()).post {
876
+ val task = try {
877
+ host.reload("BackgroundThread.restart(ui): $reason").also {
878
+ promise.resolve(null)
879
+ }
880
+ } catch (t: Throwable) {
881
+ BTLogger.error("restart(ui): ReactHost.reload threw synchronously: ${t.message}")
882
+ if (!triggerProcessRestart(context)) {
883
+ promise.reject(
884
+ "BG_RESTART_ERROR",
885
+ "restart(ui): reload failed and process restart fallback also failed: ${t.message}",
886
+ t,
887
+ )
888
+ }
889
+ // if triggerProcessRestart returned true, Runtime.exit(0)
890
+ // ran and this reject is unreachable
891
+ null
892
+ }
893
+ if (task != null) {
894
+ Thread {
895
+ try {
896
+ val completed = task.waitForCompletion(15, TimeUnit.SECONDS)
897
+ if (!completed || task.isFaulted() || task.isCancelled()) {
898
+ val err = task.getError()
899
+ BTLogger.error(
900
+ "restart(ui): reload task did not complete cleanly " +
901
+ "(completed=$completed, faulted=${task.isFaulted()}, " +
902
+ "cancelled=${task.isCancelled()}): ${err?.message} — " +
903
+ "falling back to process restart"
904
+ )
905
+ Handler(Looper.getMainLooper()).post {
906
+ triggerProcessRestart(context)
907
+ }
908
+ } else {
909
+ BTLogger.info("restart(ui): reload task completed successfully")
910
+ }
911
+ } catch (t: Throwable) {
912
+ BTLogger.error(
913
+ "restart(ui): reload-task watch thread errored: ${t.message} — " +
914
+ "falling back to process restart"
915
+ )
916
+ Handler(Looper.getMainLooper()).post {
917
+ triggerProcessRestart(context)
918
+ }
919
+ }
920
+ }.apply {
921
+ isDaemon = true
922
+ name = "OneKey-BgThread-ReloadWatch"
923
+ start()
924
+ }
925
+ }
926
+ }
927
+ return
928
+ }
929
+
930
+ if (isUi) {
931
+ BTLogger.warn("restart(ui): ReactHost unavailable, falling back to process restart")
932
+ }
933
+ if (!triggerProcessRestart(context)) {
934
+ promise.reject(
935
+ "BG_RESTART_ERROR",
936
+ "Failed to trigger process restart (intent resolution or startActivity failed)"
937
+ )
938
+ }
939
+ // if triggerProcessRestart returned true, Runtime.exit(0) ran and
940
+ // any further code here is unreachable
941
+ }
942
+
943
+ /**
944
+ * Returns true if Runtime.exit(0) was reached (in which case the process
945
+ * is now terminating and any code after the call site is unreachable).
946
+ * Returns false if intent resolution failed or startActivity threw
947
+ * before exit() — caller is responsible for propagating that failure
948
+ * to the JS Promise so callers don't observe a false success.
949
+ */
950
+ private fun triggerProcessRestart(context: ReactApplicationContext): Boolean {
951
+ try {
952
+ val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
953
+ if (intent == null) {
954
+ BTLogger.error("triggerProcessRestart: launch intent not found for ${context.packageName}")
955
+ return false
956
+ }
957
+ val mainIntent = Intent.makeRestartActivityTask(intent.component)
958
+ context.startActivity(mainIntent)
959
+ Runtime.getRuntime().exit(0)
960
+ return true // unreachable; exit() does not return
961
+ } catch (t: Throwable) {
962
+ BTLogger.error("triggerProcessRestart: ${t.message}")
963
+ return false
964
+ }
965
+ }
966
+
757
967
  // ── Lifecycle ───────────────────────────────────────────────────────────
758
968
 
759
969
  val isBackgroundStarted: Boolean get() = isStarted
@@ -763,6 +973,7 @@ class BackgroundThreadManager private constructor() {
763
973
  bgRuntimePtr = 0
764
974
  mainRuntimePtr = 0
765
975
  mainReactContext = null
976
+ mainReactHost = null
766
977
  bgReactHost?.destroy("BackgroundThreadManager destroyed", null)
767
978
  bgReactHost = null
768
979
  isStarted = false
@@ -38,4 +38,8 @@ class BackgroundThreadModule(reactContext: ReactApplicationContext) :
38
38
  }
39
39
  }
40
40
  }
41
+
42
+ override fun restart(mode: String, reason: String, promise: Promise) {
43
+ BackgroundThreadManager.getInstance().restart(reactApplicationContext, mode, reason, promise)
44
+ }
41
45
  }
package/cpp/SharedRPC.cpp CHANGED
@@ -1,5 +1,7 @@
1
1
  #include "SharedRPC.h"
2
2
 
3
+ #include <algorithm>
4
+
3
5
  #ifdef __ANDROID__
4
6
  #include <android/log.h>
5
7
  #define RPC_LOG(...) __android_log_print(ANDROID_LOG_INFO, "SharedRPC", __VA_ARGS__)
@@ -23,24 +25,68 @@ void SharedRPC::install(jsi::Runtime &rt, RPCRuntimeExecutor executor,
23
25
  auto obj = jsi::Object::createFromHostObject(rt, rpc);
24
26
  rt.global().setProperty(rt, "sharedRPC", std::move(obj));
25
27
 
28
+ auto alive = std::make_shared<std::atomic<bool>>(true);
29
+
30
+ std::lock_guard<std::mutex> lock(mutex_);
31
+ // Defensive dedup: under the normal restart flow, invalidate() has already
32
+ // run for this runtimeId and erased the entry, so this loop matches
33
+ // nothing. The branch survives as a fallback for any path that re-installs
34
+ // without first calling invalidate (e.g. legacy host integrations, partial
35
+ // teardown). Same correctness invariants as invalidate(): flip alive=false
36
+ // so any executor lambda already in flight short-circuits, and leak the
37
+ // jsi::Function callback because destroying it on a wrong/dying thread
38
+ // crashes (null deref in Pointer::~Pointer).
39
+ for (auto &listener : listeners_) {
40
+ if (listener.runtimeId == runtimeId) {
41
+ if (listener.alive) {
42
+ listener.alive->store(false);
43
+ }
44
+ if (listener.callback) {
45
+ new std::shared_ptr<jsi::Function>(std::move(listener.callback));
46
+ }
47
+ }
48
+ }
49
+ listeners_.erase(
50
+ std::remove_if(listeners_.begin(), listeners_.end(),
51
+ [&runtimeId](const RuntimeListener &l) {
52
+ return l.runtimeId == runtimeId;
53
+ }),
54
+ listeners_.end());
55
+ listeners_.push_back(
56
+ {runtimeId, &rt, std::move(executor), nullptr, std::move(alive)});
57
+ }
58
+
59
+ bool SharedRPC::invalidate(const std::string &runtimeId) {
26
60
  std::lock_guard<std::mutex> lock(mutex_);
27
- // Remove any existing listener with the same runtimeId (reload scenario).
28
- // IMPORTANT: The old listener's callback is a jsi::Function tied to the old
29
- // runtime. On reload, that runtime is already destroyed, so calling
30
- // ~jsi::Function() would crash (null deref in Pointer::~Pointer).
31
- // We intentionally leak the callback to avoid this.
61
+ bool found = false;
32
62
  for (auto &listener : listeners_) {
33
- if (listener.runtimeId == runtimeId && listener.callback) {
63
+ if (listener.runtimeId != runtimeId) continue;
64
+ if (listener.alive) {
65
+ listener.alive->store(false);
66
+ }
67
+ if (listener.callback) {
68
+ // Same rationale as install(): destroying a jsi::Function tied to a
69
+ // torn-down runtime crashes. Leak it; the runtime is going away anyway.
34
70
  new std::shared_ptr<jsi::Function>(std::move(listener.callback));
35
71
  }
72
+ // Drop the executor closure so nothing tries to dispatch via the dying
73
+ // RCTInstance/CallInvoker after this point.
74
+ listener.executor = nullptr;
75
+ found = true;
36
76
  }
77
+ // Erase the dead entries. Already-dispatched executor lambdas hold their
78
+ // own shared_ptr<alive> snapshot, so erasing here does not affect them —
79
+ // it only prevents NEW notifyOtherRuntime() snapshots from picking up the
80
+ // dead listener. Without the erase, a mode='all' restart whose post-reload
81
+ // re-install never fires would leave a permanently-dead entry in the
82
+ // vector. The next install() for the same runtimeId pushes a fresh entry.
37
83
  listeners_.erase(
38
84
  std::remove_if(listeners_.begin(), listeners_.end(),
39
85
  [&runtimeId](const RuntimeListener &l) {
40
86
  return l.runtimeId == runtimeId;
41
87
  }),
42
88
  listeners_.end());
43
- listeners_.push_back({runtimeId, &rt, std::move(executor), nullptr});
89
+ return found;
44
90
  }
45
91
 
46
92
  void SharedRPC::reset() {
@@ -48,7 +94,12 @@ void SharedRPC::reset() {
48
94
  slots_.clear();
49
95
  // Intentionally leak jsi::Function callbacks to avoid destroying them on the
50
96
  // wrong thread (same rationale as the leak in install() for reload scenarios).
97
+ // Also flip alive=false so any executor lambda still in flight short-circuits
98
+ // before touching a torn-down runtime.
51
99
  for (auto &listener : listeners_) {
100
+ if (listener.alive) {
101
+ listener.alive->store(false);
102
+ }
52
103
  if (listener.callback) {
53
104
  new std::shared_ptr<jsi::Function>(std::move(listener.callback));
54
105
  }
@@ -59,25 +110,49 @@ void SharedRPC::reset() {
59
110
  void SharedRPC::notifyOtherRuntime(jsi::Runtime &callerRt, const std::string &callId) {
60
111
  // Collect executors and callbacks under lock, then invoke outside lock
61
112
  // to avoid deadlock (executor may schedule work that also acquires mutex_).
62
- std::vector<std::pair<RPCRuntimeExecutor, std::shared_ptr<jsi::Function>>> toNotify;
113
+ //
114
+ // Each snapshot carries the listener's shared `alive` flag. The flag is
115
+ // checked twice — once here (so an already-invalidated listener is never
116
+ // even scheduled) and once again inside the dispatched lambda (so a
117
+ // listener invalidated AFTER snapshot but BEFORE the lambda runs is also
118
+ // short-circuited before touching the dying runtime).
119
+ struct Snapshot {
120
+ RPCRuntimeExecutor executor;
121
+ std::shared_ptr<jsi::Function> callback;
122
+ std::shared_ptr<std::atomic<bool>> alive;
123
+ };
124
+ std::vector<Snapshot> toNotify;
63
125
  {
64
126
  std::lock_guard<std::mutex> lock(mutex_);
65
127
  RPC_LOG("notifyOtherRuntime: callId=%s, listeners=%zu, callerRt=%p",
66
128
  callId.c_str(), listeners_.size(), &callerRt);
67
129
  for (auto &listener : listeners_) {
68
- RPC_LOG(" listener: id=%s, rt=%p, hasCallback=%d",
69
- listener.runtimeId.c_str(), listener.runtime, listener.callback != nullptr);
130
+ RPC_LOG(" listener: id=%s, rt=%p, hasCallback=%d, alive=%d",
131
+ listener.runtimeId.c_str(), listener.runtime,
132
+ listener.callback != nullptr,
133
+ listener.alive ? listener.alive->load() : 0);
70
134
  if (listener.runtime == &callerRt) continue;
71
135
  if (!listener.callback) continue;
72
- toNotify.emplace_back(listener.executor, listener.callback);
136
+ if (!listener.executor) continue;
137
+ if (!listener.alive || !listener.alive->load()) continue;
138
+ toNotify.push_back({listener.executor, listener.callback, listener.alive});
73
139
  }
74
140
  RPC_LOG(" toNotify count: %zu", toNotify.size());
75
141
  }
76
142
 
77
- for (auto &[executor, cb] : toNotify) {
143
+ for (auto &snap : toNotify) {
78
144
  auto id = callId;
79
145
  RPC_LOG(" invoking executor for callId=%s", id.c_str());
80
- executor([cb, id](jsi::Runtime &rt) {
146
+ auto cb = snap.callback;
147
+ auto alive = snap.alive;
148
+ snap.executor([cb, alive, id](jsi::Runtime &rt) {
149
+ // Listener was invalidated between snapshot and dispatch — bail
150
+ // before calling into a runtime that may already be torn down.
151
+ if (!alive || !alive->load()) {
152
+ RPC_LOG(" executor work skipped (listener invalidated) for callId=%s",
153
+ id.c_str());
154
+ return;
155
+ }
81
156
  RPC_LOG(" executor work running for callId=%s", id.c_str());
82
157
  try {
83
158
  cb->call(rt, jsi::String::createFromUtf8(rt, id));
package/cpp/SharedRPC.h CHANGED
@@ -1,6 +1,7 @@
1
1
  #pragma once
2
2
 
3
3
  #include <jsi/jsi.h>
4
+ #include <atomic>
4
5
  #include <functional>
5
6
  #include <memory>
6
7
  #include <mutex>
@@ -22,6 +23,11 @@ struct RuntimeListener {
22
23
  jsi::Runtime *runtime;
23
24
  RPCRuntimeExecutor executor;
24
25
  std::shared_ptr<jsi::Function> callback; // JS onWrite callback
26
+ // Liveness flag, shared with any executor lambda already in flight.
27
+ // Flipped to false by invalidate() (or by a follow-up install() that
28
+ // replaces this listener) so notifyOtherRuntime and any already-enqueued
29
+ // lambda can short-circuit before touching a torn-down runtime.
30
+ std::shared_ptr<std::atomic<bool>> alive;
25
31
  };
26
32
 
27
33
  class SharedRPC : public jsi::HostObject {
@@ -37,6 +43,14 @@ public:
37
43
  static void install(jsi::Runtime &rt, RPCRuntimeExecutor executor,
38
44
  const std::string &runtimeId);
39
45
 
46
+ /// Synchronously quiesce the listener for runtimeId before the underlying
47
+ /// JS runtime is torn down. Marks alive=false (so any executor lambda
48
+ /// already in flight short-circuits), leaks the jsi::Function callback
49
+ /// (destroying it on a wrong/dying thread crashes), and clears the
50
+ /// executor closure (drops the lambda's captured RCTInstance/CallInvoker).
51
+ /// Safe to call from any thread. Returns true if a listener was found.
52
+ static bool invalidate(const std::string &runtimeId);
53
+
40
54
  static void reset();
41
55
 
42
56
  private:
@@ -368,10 +368,18 @@ static NSURL *resolveBundleSourceURL(NSString *jsBundleSourceNS)
368
368
  // Install SharedStore into background runtime
369
369
  SharedStore::install(runtime);
370
370
 
371
- // Install SharedRPC with executor for cross-runtime notifications
372
- RCTInstance *bgInstance = _rctInstance;
373
- RPCRuntimeExecutor bgExecutor = [bgInstance](std::function<void(jsi::Runtime &)> work) {
374
- [bgInstance callFunctionOnBufferedRuntimeExecutor:[work](jsi::Runtime &rt) {
371
+ // Install SharedRPC with executor for cross-runtime notifications.
372
+ // Capture the bg RCTInstance __weak so executor lambdas still in flight
373
+ // when the bg host is torn down (BackgroundThread.restart mode=all,
374
+ // OTA bundle install) no-op cleanly instead of dispatching onto a
375
+ // freed instance and crashing.
376
+ __weak RCTInstance *weakBgInstance = _rctInstance;
377
+ RPCRuntimeExecutor bgExecutor = [weakBgInstance](std::function<void(jsi::Runtime &)> work) {
378
+ RCTInstance *strongBgInstance = weakBgInstance;
379
+ if (!strongBgInstance) {
380
+ return;
381
+ }
382
+ [strongBgInstance callFunctionOnBufferedRuntimeExecutor:[work](jsi::Runtime &rt) {
375
383
  work(rt);
376
384
  }];
377
385
  };
@@ -9,5 +9,9 @@
9
9
  path:(NSString *)path
10
10
  resolve:(RCTPromiseResolveBlock)resolve
11
11
  reject:(RCTPromiseRejectBlock)reject;
12
+ - (void)restart:(NSString *)mode
13
+ reason:(NSString *)reason
14
+ resolve:(RCTPromiseResolveBlock)resolve
15
+ reject:(RCTPromiseRejectBlock)reject;
12
16
 
13
17
  @end
@@ -45,6 +45,22 @@
45
45
  }];
46
46
  }
47
47
 
48
+ - (void)restart:(NSString *)mode
49
+ reason:(NSString *)reason
50
+ resolve:(RCTPromiseResolveBlock)resolve
51
+ reject:(RCTPromiseRejectBlock)reject {
52
+ BackgroundThreadManager *manager = [BackgroundThreadManager sharedInstance];
53
+ [manager restartWithMode:mode
54
+ reason:reason
55
+ completion:^(NSError * _Nullable error) {
56
+ if (error) {
57
+ reject(@"BG_RESTART_ERROR", error.localizedDescription, error);
58
+ } else {
59
+ resolve(nil);
60
+ }
61
+ }];
62
+ }
63
+
48
64
  + (NSString *)moduleName
49
65
  {
50
66
  return @"BackgroundThread";
@@ -12,7 +12,14 @@ NS_ASSUME_NONNULL_BEGIN
12
12
  + (instancetype)sharedInstance;
13
13
 
14
14
  /// Install SharedBridge HostObject into the main (UI) runtime.
15
- /// Call this from your AppDelegate's ReactNativeDelegate hostDidStart: callback.
15
+ ///
16
+ /// MUST be invoked from the host app's RCTReactNativeFactoryDelegate
17
+ /// hostDidStart: callback on EVERY main RCTHost lifecycle start — including
18
+ /// after a `BackgroundThread.restart` (both modes) reloads the main bridge.
19
+ /// The module cannot self-invoke this because it does not own the RCTHost
20
+ /// reference; restartWithMode: emits a loud error log if it detects this
21
+ /// contract was violated post-reload.
22
+ ///
16
23
  /// @param host The RCTHost for the main runtime
17
24
  + (void)installSharedBridgeInMainRuntime:(RCTHost *)host;
18
25
 
@@ -23,8 +30,14 @@ NS_ASSUME_NONNULL_BEGIN
23
30
  /// @param entryURL The custom entry URL for the background runner
24
31
  - (void)startBackgroundRunnerWithEntryURL:(NSString *)entryURL;
25
32
 
26
- /// Check if background runner is started
27
- @property (nonatomic, readonly) BOOL isStarted;
33
+ /// Check if background runner is started.
34
+ ///
35
+ /// Atomic to match the implementation's redeclaration (the post-reload
36
+ /// health-check reads this on the main thread while the start path writes
37
+ /// it from whichever thread the caller is on). Keeping the header
38
+ /// `nonatomic` while the impl was `atomic` tripped Clang's
39
+ /// -Wproperty-attribute-mismatch and breaks CI builds under -Werror.
40
+ @property (atomic, readonly) BOOL isStarted;
28
41
 
29
42
  /// Register a HBC segment in the background runtime (Phase 2.5 spike)
30
43
  /// @param segmentId The segment ID to register
@@ -34,6 +47,67 @@ NS_ASSUME_NONNULL_BEGIN
34
47
  path:(NSString *)path
35
48
  completion:(void (^)(NSError * _Nullable error))completion;
36
49
 
50
+ /// Restart the JS runtime(s). Replaces direct use of `react-native-restart`.
51
+ /// Coordinates SharedRPC quiesce + RCTReloadCommand so cross-runtime traffic
52
+ /// in flight during reload is silently dropped instead of crashing on a
53
+ /// torn-down RCTInstance / dangling jsi::Function callback.
54
+ ///
55
+ /// ## Post-reload contract (host responsibility)
56
+ ///
57
+ /// After `RCTTriggerReloadCommandListeners` rebuilds the main RCTHost, the
58
+ /// host app's `RCTReactNativeFactoryDelegate.hostDidStart:` is responsible
59
+ /// for re-arming BOTH halves of the integration on the new host:
60
+ /// 1. `+[BackgroundThreadManager installSharedBridgeInMainRuntime:newHost]`
61
+ /// — re-arms the "main" SharedRPC listener; without this main→bg RPC
62
+ /// silently breaks even though both runtimes are alive.
63
+ /// 2. (mode='all' only) `[BackgroundThreadManager.sharedInstance
64
+ /// startBackgroundRunner]` — recreates the bg RCTHost since the
65
+ /// previous one was released and `isStarted` was reset to NO.
66
+ ///
67
+ /// The module defends against host integration omissions with a two-stage
68
+ /// post-reload health-check (`dispatch_after` on the main queue scheduled
69
+ /// before the reload is triggered — does NOT depend on
70
+ /// `RCTJavaScriptDidLoadNotification`, which is unreliable in bridgeless /
71
+ /// NewArch). Stage 1 fires ~3s after `restartWithMode:` returns; stage 2
72
+ /// (if needed) ~3s later. The check decides:
73
+ /// - Both halves healthy at stage 1 → log success, done.
74
+ /// - Anything missing at stage 1 → reschedule stage 2 unconditionally.
75
+ /// Earlier designs short-circuited the "main ready, bg not ready"
76
+ /// case at stage 1 as a stable signal, but hosts that gate
77
+ /// startBackgroundRunner on async work (feature flag, login, network)
78
+ /// can be mainReady=YES while the real start call is still inflight;
79
+ /// short-circuiting would race them and the host's late start would
80
+ /// be silently dropped (now warn-logged on the early-return path).
81
+ /// - Stage 2 → final verdict; whatever's missing is logged and self-
82
+ /// healed where possible.
83
+ ///
84
+ /// For mode='all' self-respawn, the module replays the host's last entry
85
+ /// URL — cached in `startBackgroundRunnerWithEntryURL:` — instead of the
86
+ /// default `background.bundle`. This is critical on OTA-equipped hosts:
87
+ /// without the cache, self-respawn would load the bundled bg bundle from
88
+ /// the IPA while main runs the OTA-updated main bundle, and the next
89
+ /// cross-runtime RPC would moduleId-mismatch and crash. The cache makes
90
+ /// self-respawn a true safety net for the broken-AppDelegate-wiring case.
91
+ /// The bundled default name `"background.bundle"` is intentionally treated
92
+ /// as "no real cache" by the self-respawn fallback so the OTA-mismatch
93
+ /// warning still fires for hosts that initially bootstrapped with the
94
+ /// default URL and never swapped to an OTA-resolved path.
95
+ ///
96
+ /// Concurrent restart() calls are made safe via a monotonic generation
97
+ /// counter: each invocation captures its own generation and each health-
98
+ /// check stage bails if a newer restart() has superseded it.
99
+ ///
100
+ /// @param mode `@"ui"` to reload only the main runtime (bg stays hot);
101
+ /// `@"all"` to reload both. Any other value invokes completion
102
+ /// with NSError domain `BackgroundThread` code 10.
103
+ /// @param reason Free-form attribution string forwarded to
104
+ /// RCTTriggerReloadCommandListeners and host logs.
105
+ /// @param completion Invoked once the reload has been triggered. nil error
106
+ /// on success.
107
+ - (void)restartWithMode:(NSString *)mode
108
+ reason:(NSString *)reason
109
+ completion:(void (^)(NSError * _Nullable error))completion;
110
+
37
111
  @end
38
112
 
39
113
  NS_ASSUME_NONNULL_END
@@ -17,6 +17,7 @@
17
17
 
18
18
  #include "SharedStore.h"
19
19
  #include "SharedRPC.h"
20
+ #import <React/RCTReloadCommand.h>
20
21
  #import <ReactCommon/RCTHost.h>
21
22
  #import <objc/runtime.h>
22
23
 
@@ -24,9 +25,50 @@
24
25
  @property (nonatomic, strong) BackgroundReactNativeDelegate *reactNativeFactoryDelegate;
25
26
  @property (nonatomic, strong) RCTReactNativeFactory *reactNativeFactory;
26
27
  @property (nonatomic, assign) BOOL hasListeners;
27
- @property (nonatomic, assign, readwrite) BOOL isStarted;
28
+ // Atomic because the post-reload health-check reads it on the main thread
29
+ // while startBackgroundRunnerWithEntryURL: writes it from whichever thread
30
+ // the caller is on (the module's public surface does not constrain caller
31
+ // thread). Atomic gives us the memory-barrier needed for cross-thread
32
+ // reads to see the latest store. The set+dispatch pattern in start... is
33
+ // still TOCTOU-racy for concurrent first-time starts, but that hazard
34
+ // pre-exists this PR and is out of scope here.
35
+ @property (atomic, assign, readwrite) BOOL isStarted;
36
+ // Flipped to YES inside the installSharedBridgeInMainRuntime: lambda and to
37
+ // NO at the start of restartWithMode:. Read by the post-reload health-check
38
+ // to detect when the host app's hostDidStart: failed to re-invoke the
39
+ // install on the new RCTHost — which would silently break main→bg RPC.
40
+ @property (atomic, assign) BOOL mainSharedBridgeInstalled;
41
+ // Monotonic counter bumped on every restartWithMode: call. The post-reload
42
+ // health-check captures its own generation and bails if a newer restart()
43
+ // supersedes it, preventing the second restart's flag reset from making
44
+ // the first restart's check misreport. Touched only on the main thread
45
+ // (the dispatch_block_t `work` runs there), so non-atomic is correct.
46
+ @property (nonatomic, assign) NSUInteger restartGeneration;
47
+ // Cached from the most recent startBackgroundRunnerWithEntryURL: call. Used
48
+ // by the mode='all' self-respawn fallback to preserve the host's last
49
+ // chosen entry URL (typically an OTA-resolved bundle path) across restart;
50
+ // `reactNativeFactoryDelegate` is released for mode='all', so without this
51
+ // cache we'd fall back to the bundled "background.bundle" — which on OTA
52
+ // devices is a moduleId-mismatch crash waiting to happen. Atomic for the
53
+ // same reason as isStarted: written from the caller's thread (public API
54
+ // doesn't pin start... to main) and read on main by the health-check.
55
+ @property (atomic, copy) NSString *lastEntryURL;
56
+
57
+ // Forward declaration so restartWithMode: can call it; definition lives
58
+ // after restartWithMode: for readability (the call site is the natural
59
+ // place to start reading the restart flow).
60
+ - (void)scheduleHealthCheckForRestart:(NSString *)mode
61
+ isAll:(BOOL)isAll
62
+ generation:(NSUInteger)myGen
63
+ retried:(BOOL)retried;
28
64
  @end
29
65
 
66
+ // First-stage delay for the post-reload health-check. Picked so the host's
67
+ // hostDidStart: chain has time to run on a typical device. On slower
68
+ // devices the chain can exceed this; that's why the check is structured as
69
+ // two stages — see scheduleHealthCheckForRestart:isAll:generation:retried:.
70
+ static const NSTimeInterval kRestartHealthCheckDelaySeconds = 3.0;
71
+
30
72
  @implementation BackgroundThreadManager
31
73
 
32
74
  static BackgroundThreadManager *_sharedInstance = nil;
@@ -71,14 +113,28 @@ static NSString *const MODULE_DEBUG_URL = @"http://localhost:8082/apps/mobile/ba
71
113
  [instance callFunctionOnBufferedRuntimeExecutor:^(facebook::jsi::Runtime &runtime) {
72
114
  SharedStore::install(runtime);
73
115
 
74
- // Install SharedRPC with executor for cross-runtime notifications
75
- id capturedInstance = instance;
76
- RPCRuntimeExecutor mainExecutor = [capturedInstance](std::function<void(jsi::Runtime &)> work) {
77
- [capturedInstance callFunctionOnBufferedRuntimeExecutor:[work](jsi::Runtime &rt) {
116
+ // Install SharedRPC with executor for cross-runtime notifications.
117
+ // Capture the RCTInstance __weak: when the main host is torn down on
118
+ // reload (RNRestart / RCTReloadCommand / BackgroundThread.restart) the
119
+ // instance is dealloc'd, and any executor lambda still in flight will
120
+ // see a nil strong reference and bail out — instead of dispatching
121
+ // callFunctionOnBufferedRuntimeExecutor on a freed instance and
122
+ // crashing (EXC_BAD_ACCESS / use-after-free).
123
+ __weak id weakInstance = instance;
124
+ RPCRuntimeExecutor mainExecutor = [weakInstance](std::function<void(jsi::Runtime &)> work) {
125
+ id strongInstance = weakInstance;
126
+ if (!strongInstance) {
127
+ return;
128
+ }
129
+ [strongInstance callFunctionOnBufferedRuntimeExecutor:[work](jsi::Runtime &rt) {
78
130
  work(rt);
79
131
  }];
80
132
  };
81
133
  SharedRPC::install(runtime, std::move(mainExecutor), "main");
134
+ // Set the integration-health flag from inside the JS-thread lambda so
135
+ // it only flips true AFTER the listener is actually live; the post-
136
+ // reload observer reads this to detect host hostDidStart: omissions.
137
+ [BackgroundThreadManager sharedInstance].mainSharedBridgeInstalled = YES;
82
138
  [BTLogger info:@"SharedStore and SharedRPC installed in main runtime"];
83
139
  }];
84
140
  }
@@ -96,9 +152,27 @@ static NSString *const MODULE_DEBUG_URL = @"http://localhost:8082/apps/mobile/ba
96
152
 
97
153
  - (void)startBackgroundRunnerWithEntryURL:(NSString *)entryURL {
98
154
  if (self.isStarted) {
155
+ // Surface the URL mismatch when a second start... call would
156
+ // otherwise silently drop the host's intent — most common in the
157
+ // self-respawn-vs-host-start race after restart('all'): self-
158
+ // respawn boots with cached URL, then the host's async
159
+ // hostDidStart chain (gated on feature flag / login / network)
160
+ // finally calls start with a different URL and gets short-
161
+ // circuited. The log lets that race be diagnosed from production
162
+ // traces instead of "why is bg on the old URL".
163
+ if (entryURL.length > 0 && ![entryURL isEqualToString:self.lastEntryURL]) {
164
+ [BTLogger warn:[NSString stringWithFormat:
165
+ @"startBackgroundRunnerWithEntryURL: ignored (already started); requested=%@ active=%@",
166
+ entryURL, self.lastEntryURL ?: @"<nil>"]];
167
+ }
99
168
  return;
100
169
  }
101
170
  self.isStarted = YES;
171
+ // Cache the URL before we even start so the mode='all' self-respawn
172
+ // path can preserve it across a restart that releases the delegate.
173
+ // Updated unconditionally — first-time and re-start with a new URL
174
+ // both reflect into lastEntryURL.
175
+ self.lastEntryURL = entryURL;
102
176
  [BTLogger info:[NSString stringWithFormat:@"Starting background runner with entryURL: %@", entryURL]];
103
177
 
104
178
  dispatch_async(dispatch_get_main_queue(), ^{
@@ -156,4 +230,210 @@ static NSString *const MODULE_DEBUG_URL = @"http://localhost:8082/apps/mobile/ba
156
230
  }
157
231
  }
158
232
 
233
+ #pragma mark - Restart
234
+
235
+ - (void)restartWithMode:(NSString *)mode
236
+ reason:(NSString *)reason
237
+ completion:(void (^)(NSError * _Nullable error))completion
238
+ {
239
+ BOOL isUI = [mode isEqualToString:@"ui"];
240
+ BOOL isAll = [mode isEqualToString:@"all"];
241
+
242
+ if (!isUI && !isAll) {
243
+ NSError *error = [NSError errorWithDomain:@"BackgroundThread"
244
+ code:10
245
+ userInfo:@{NSLocalizedDescriptionKey:
246
+ [NSString stringWithFormat:@"BackgroundThread.restart: unsupported mode '%@', expected 'ui' or 'all'", mode]}];
247
+ if (completion) completion(error);
248
+ return;
249
+ }
250
+
251
+ NSString *attributedReason = reason.length > 0
252
+ ? [NSString stringWithFormat:@"BackgroundThread.restart(%@): %@", mode, reason]
253
+ : [NSString stringWithFormat:@"BackgroundThread.restart(%@)", mode];
254
+
255
+ [BTLogger info:[NSString stringWithFormat:@"restart: mode=%@ reason=%@", mode, reason ?: @"<none>"]];
256
+
257
+ // Bridge teardown must happen on the main thread; everything before
258
+ // RCTTriggerReloadCommandListeners runs synchronously on that thread so
259
+ // the quiesce is provably done before any reload-driven instance dealloc.
260
+ dispatch_block_t work = ^{
261
+ // (1) Quiesce SharedRPC listener(s). Synchronous, holds the SharedRPC
262
+ // mutex internally — any concurrent notifyOtherRuntime that gets past
263
+ // the lock will see alive=false.
264
+ SharedRPC::invalidate("main");
265
+ if (isAll) {
266
+ SharedRPC::invalidate("background");
267
+ }
268
+
269
+ // (2) For mode=all, drop our strong refs to the bg host so ARC can
270
+ // dealloc it. We also reset isStarted so the post-reload hostDidStart
271
+ // callback (which is invoked from AppDelegate after the main reload
272
+ // completes) re-enters startBackgroundRunner instead of short-circuiting.
273
+ if (isAll) {
274
+ [BTLogger info:@"restart(all): releasing bg factory + resetting isStarted"];
275
+ self.reactNativeFactory = nil;
276
+ self.reactNativeFactoryDelegate = nil;
277
+ self.isStarted = NO;
278
+ }
279
+
280
+ // (3) Schedule a defensive post-reload health-check BEFORE triggering
281
+ // the reload. The check uses pure dispatch_after on our own state
282
+ // (mainSharedBridgeInstalled, isStarted) instead of an
283
+ // RCTJavaScriptDidLoadNotification observer — that notification's
284
+ // post timing is unreliable in bridgeless / NewArch, and an
285
+ // observer-based design introduces observer-leak + cross-generation
286
+ // misreporting hazards that dispatch_after + restartGeneration
287
+ // sidesteps entirely. See scheduleHealthCheckForRestart:... for the
288
+ // two-stage retry that tolerates slow devices.
289
+ self.mainSharedBridgeInstalled = NO;
290
+ NSUInteger myGen = ++self.restartGeneration;
291
+ [self scheduleHealthCheckForRestart:mode isAll:isAll generation:myGen retried:NO];
292
+
293
+ // (4) Trigger the main bridge reload. Equivalent to what
294
+ // react-native-restart did, but now sequenced AFTER quiesce so the
295
+ // SharedRPC race window is closed.
296
+ RCTTriggerReloadCommandListeners(attributedReason);
297
+
298
+ // (5) For mode=all, the new main host's hostDidStart will call
299
+ // BackgroundThreadBridge.startBackgroundRunner again (isStarted==NO
300
+ // now), recreating the bg host and re-installing the "background"
301
+ // listener via BackgroundRunnerReactNativeDelegate.hostDidStart.
302
+ // If the host fails to do so, the health-check above self-heals
303
+ // (with the default-entry-URL caveat noted there).
304
+ // For mode=ui, the bg host stays as-is; only the "main" listener
305
+ // gets re-installed in installSharedBridgeInMainRuntime.
306
+
307
+ if (completion) completion(nil);
308
+ };
309
+
310
+ if ([NSThread isMainThread]) {
311
+ work();
312
+ } else {
313
+ dispatch_async(dispatch_get_main_queue(), work);
314
+ }
315
+ }
316
+
317
+ #pragma mark - Restart Health Check
318
+
319
+ // Two-stage post-reload health-check.
320
+ //
321
+ // Stage 1 fires at +kRestartHealthCheckDelaySeconds (~3s) from restart()
322
+ // dispatch. Reads `mainSharedBridgeInstalled` and (for mode='all')
323
+ // `isStarted` — both signals the module owns. Decides:
324
+ // - Both healthy → log success, done.
325
+ // - Anything missing → reschedule stage 2 (+~3s more, total ~6s) and
326
+ // defer the final verdict. This is intentional: even the "main ready,
327
+ // bg not ready" case is rescheduled rather than self-respawned at
328
+ // stage 1, because hosts that gate startBackgroundRunner on async
329
+ // work (feature-flag fetch, login, network callback) can be
330
+ // mainReady=YES while their async start is still in flight — see the
331
+ // in-line comment in the stage-1 branch below.
332
+ //
333
+ // Stage 2 fires at total ~6s. Whatever state we see is the final verdict;
334
+ // any remaining gap is logged as an integration failure (with self-heal
335
+ // where possible). 6s comfortably covers a low-end iPhone OTA reload chain
336
+ // (observed worst case ~2.5s); past that, integration is more likely
337
+ // broken than slow.
338
+ //
339
+ // Generation check at the top of each stage block bails out if a newer
340
+ // restart() has bumped restartGeneration in the meantime — its own check
341
+ // will run, and reading flags now would mix cycles.
342
+ - (void)scheduleHealthCheckForRestart:(NSString *)mode
343
+ isAll:(BOOL)isAll
344
+ generation:(NSUInteger)myGen
345
+ retried:(BOOL)retried
346
+ {
347
+ __weak BackgroundThreadManager *weakSelf = self;
348
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
349
+ (int64_t)(kRestartHealthCheckDelaySeconds * NSEC_PER_SEC)),
350
+ dispatch_get_main_queue(), ^{
351
+ BackgroundThreadManager *strongSelf = weakSelf;
352
+ if (!strongSelf) return;
353
+ if (strongSelf.restartGeneration != myGen) {
354
+ [BTLogger info:[NSString stringWithFormat:
355
+ @"restart(%@) health-check stage%@ superseded by newer restart (gen %lu → %lu)",
356
+ mode, retried ? @"2" : @"1",
357
+ (unsigned long)myGen, (unsigned long)strongSelf.restartGeneration]];
358
+ return;
359
+ }
360
+
361
+ BOOL mainReady = strongSelf.mainSharedBridgeInstalled;
362
+ BOOL bgReady = !isAll || strongSelf.isStarted;
363
+ NSString *stage = retried ? @"2" : @"1";
364
+
365
+ if (mainReady && bgReady) {
366
+ [BTLogger info:[NSString stringWithFormat:
367
+ @"restart(%@): post-reload health-check stage%@ OK (mainReady=YES, bgReady=YES)",
368
+ mode, stage]];
369
+ return;
370
+ }
371
+
372
+ // Stage 1: anything missing → reschedule stage 2.
373
+ //
374
+ // Earlier rounds short-circuited the "mainReady=YES, bgReady=NO"
375
+ // case as a stable signal that the host wasn't going to call
376
+ // startBackgroundRunner. That assumption holds only for hosts
377
+ // whose hostDidStart: synchronously calls both install AND start.
378
+ // Hosts that gate startBackgroundRunner on async work (feature
379
+ // flag fetch, login state, network callback) can have
380
+ // mainReady=YES while their async start call is still in flight;
381
+ // self-respawning at stage 1 would race them and the host's
382
+ // later (real) start would be silently dropped by the early
383
+ // return in startBackgroundRunnerWithEntryURL: (now logged as a
384
+ // warn, see #2). Stage 2 (+~3s) is sized to give that async path
385
+ // time to land; if it still hasn't by then, the host is broken
386
+ // and self-respawn is correct.
387
+ if (!retried) {
388
+ [BTLogger info:[NSString stringWithFormat:
389
+ @"restart(%@): stage1 incomplete (mainReady=%@, bgReady=%@) — rescheduling stage2 to cover slow hostDidStart chains and async host start calls",
390
+ mode, mainReady ? @"YES" : @"NO", bgReady ? @"YES" : @"NO"]];
391
+ [strongSelf scheduleHealthCheckForRestart:mode isAll:isAll generation:myGen retried:YES];
392
+ return;
393
+ }
394
+
395
+ // Stage 2 final verdict.
396
+
397
+ if (isAll && !strongSelf.isStarted) {
398
+ NSString *cachedURL = strongSelf.lastEntryURL;
399
+ // Treat the bundled default name as "no real cache" so the
400
+ // OTA warn still fires for hosts that initially bootstrapped
401
+ // with the default URL and haven't yet swapped to an OTA-
402
+ // resolved path. Such hosts have OTA risk but no signal in
403
+ // lastEntryURL that distinguishes them.
404
+ BOOL hasCustomCachedURL = cachedURL.length > 0
405
+ && ![cachedURL isEqualToString:@"background.bundle"];
406
+ if (hasCustomCachedURL) {
407
+ // Preferred path: replay the host's last entry URL. On OTA
408
+ // devices this is the OTA-resolved bundle, which keeps the
409
+ // bg moduleId table aligned with the new main bundle and
410
+ // avoids the silent → crash regression of falling back to
411
+ // the bundled `background.bundle`.
412
+ [BTLogger info:[NSString stringWithFormat:
413
+ @"restart(%@): bg not respawned by host AppDelegate; self-respawning with cached entryURL=%@",
414
+ mode, cachedURL]];
415
+ [strongSelf startBackgroundRunnerWithEntryURL:cachedURL];
416
+ } else {
417
+ // Cache is empty or holds the bundled default. Fall back
418
+ // and warn — on an OTA-equipped host this is the path
419
+ // that historically produces a moduleId-mismatch crash on
420
+ // the next cross-runtime RPC.
421
+ [BTLogger warn:[NSString stringWithFormat:
422
+ @"restart(%@): bg not respawned and no custom cached entryURL (cache=%@); falling back to default. If host uses OTA bundles, wire AppDelegate.hostDidStart: to call startBackgroundRunner explicitly to avoid moduleId-mismatch.",
423
+ mode, cachedURL ?: @"<nil>"]];
424
+ [strongSelf startBackgroundRunner];
425
+ }
426
+ }
427
+
428
+ if (!strongSelf.mainSharedBridgeInstalled) {
429
+ [BTLogger error:[NSString stringWithFormat:
430
+ @"restart(%@): SharedBridge not re-installed in main runtime within ~%.1fs after reload (stage%@). "
431
+ @"Host AppDelegate's hostDidStart: must invoke "
432
+ @"+[BackgroundThreadManager installSharedBridgeInMainRuntime:newHost] "
433
+ @"on the new RCTHost or main→bg RPC will silently fail.",
434
+ mode, kRestartHealthCheckDelaySeconds * 2, stage]];
435
+ }
436
+ });
437
+ }
438
+
159
439
  @end
@@ -1,5 +1,31 @@
1
1
  "use strict";
2
2
 
3
3
  import { TurboModuleRegistry } from 'react-native';
4
+
5
+ /**
6
+ * Allowed values for `restart()`'s `mode` argument.
7
+ *
8
+ * Defined as a string literal union (not a TypeScript `enum`) on purpose:
9
+ *
10
+ * 1. **Zero runtime cost.** A regular `enum` compiles to a JS object that
11
+ * ships with the bundle. A literal union erases to nothing.
12
+ * 2. **Codegen-friendly.** React Native's TurboModule codegen reads this
13
+ * spec to generate native types; its supported set is primitives,
14
+ * Object/Array, Promise, and string-literal unions. Plain TS `enum`
15
+ * is not in that set — behaviour varies by RN version and best
16
+ * avoided in spec files.
17
+ * 3. **Callsite type-safety without a runtime import.** Consumers can
18
+ * `import type { RestartMode } from '@onekeyfe/react-native-background-thread'`
19
+ * and pass a plain string; TypeScript narrows it without a runtime
20
+ * dependency on this module's value space.
21
+ *
22
+ * Migration note: the JSDoc on `restart()` historically said callers
23
+ * should use the `EAppRestartMode` enum from `@onekeyhq/shared`. If that
24
+ * is a regular TS string enum, its values aren't assignable to this union
25
+ * directly — either cast at the call site (`mode as RestartMode`) or
26
+ * migrate `EAppRestartMode` to a string-literal union / `as const` object
27
+ * so the two stay aligned.
28
+ */
29
+
4
30
  export default TurboModuleRegistry.getEnforcing('BackgroundThread');
5
31
  //# sourceMappingURL=NativeBackgroundThread.js.map
@@ -1,8 +1,84 @@
1
1
  import type { TurboModule } from 'react-native';
2
+ /**
3
+ * Allowed values for `restart()`'s `mode` argument.
4
+ *
5
+ * Defined as a string literal union (not a TypeScript `enum`) on purpose:
6
+ *
7
+ * 1. **Zero runtime cost.** A regular `enum` compiles to a JS object that
8
+ * ships with the bundle. A literal union erases to nothing.
9
+ * 2. **Codegen-friendly.** React Native's TurboModule codegen reads this
10
+ * spec to generate native types; its supported set is primitives,
11
+ * Object/Array, Promise, and string-literal unions. Plain TS `enum`
12
+ * is not in that set — behaviour varies by RN version and best
13
+ * avoided in spec files.
14
+ * 3. **Callsite type-safety without a runtime import.** Consumers can
15
+ * `import type { RestartMode } from '@onekeyfe/react-native-background-thread'`
16
+ * and pass a plain string; TypeScript narrows it without a runtime
17
+ * dependency on this module's value space.
18
+ *
19
+ * Migration note: the JSDoc on `restart()` historically said callers
20
+ * should use the `EAppRestartMode` enum from `@onekeyhq/shared`. If that
21
+ * is a regular TS string enum, its values aren't assignable to this union
22
+ * directly — either cast at the call site (`mode as RestartMode`) or
23
+ * migrate `EAppRestartMode` to a string-literal union / `as const` object
24
+ * so the two stay aligned.
25
+ */
26
+ export type RestartMode = 'ui' | 'all';
2
27
  export interface Spec extends TurboModule {
3
28
  startBackgroundRunnerWithEntryURL(entryURL: string): void;
4
29
  installSharedBridge(): void;
5
30
  loadSegmentInBackground(segmentId: number, path: string): Promise<void>;
31
+ /**
32
+ * Reload one or both JS runtimes. Replaces `react-native-restart`.
33
+ *
34
+ * `mode` (see {@link RestartMode}):
35
+ * - `'ui'`: restart only the main JS runtime (the one driving the UI).
36
+ * The background runtime stays hot, preserving its state and avoiding
37
+ * the SharedRPC reload race that crashed iOS on language switch.
38
+ * - `'all'`: restart both main and background JS runtimes. Required when
39
+ * the JS bundle on disk has changed (OTA install/switch) so the two
40
+ * runtimes cannot keep running mismatched moduleId tables.
41
+ *
42
+ * Any other value rejects the promise — callers should pass a
43
+ * `RestartMode` literal. The native validation still runs at runtime
44
+ * (it's the source of truth for what the platforms accept), so a string
45
+ * cast that smuggles in an unknown value will be rejected there.
46
+ *
47
+ * `reason` is forwarded to RCTTriggerReloadCommandListeners and to host
48
+ * logs / Sentry breadcrumbs so production restarts are attributable.
49
+ *
50
+ * ## Platform behaviour for `mode='all'`
51
+ *
52
+ * iOS and Android tear down the JS runtimes the same way, but the OS
53
+ * process around them differs — code that runs natively (timers,
54
+ * singletons, in-memory caches, scheduled jobs, foreground services)
55
+ * cannot assume cross-platform survival semantics:
56
+ *
57
+ * - **iOS**: process survives. The main RCTHost is rebuilt in-place via
58
+ * `RCTTriggerReloadCommandListeners`; the bg RCTHost is released and
59
+ * re-spawned by the host AppDelegate's `hostDidStart:`, with the
60
+ * module's two-stage `dispatch_after` health-check as a fallback if
61
+ * the host integration fails to re-arm (intentionally not driven by
62
+ * `RCTJavaScriptDidLoadNotification`, whose timing is unreliable in
63
+ * bridgeless / NewArch). Any process-level state (NSUserDefaults cache,
64
+ * live URLSession tasks, GCD timers attached to the app process)
65
+ * survives. iOS goes through soft reload here because abrupt
66
+ * termination is App Store-unfriendly and iOS lacks a clean self-
67
+ * relaunch path.
68
+ * - **Android**: process is killed. `BackgroundThreadManager` invokes
69
+ * `Runtime.exit(0)` after queuing `makeRestartActivityTask`, so the
70
+ * JVM is replaced wholesale. Any process-level state is lost; Activity
71
+ * stack is rebuilt from the launch intent. The choice is intentional
72
+ * — `mode='all'` is meant for OTA install/switch where a fresh re-
73
+ * read of both bundles from disk is desirable, and Android has a
74
+ * clean self-relaunch path that iOS doesn't.
75
+ *
76
+ * Callers that depend on process-level state (anything held in native
77
+ * singletons, background services, JNI handles) must not rely on it
78
+ * surviving `mode='all'` cross-platform. For UI-only resets that should
79
+ * preserve process state on both platforms, use `mode='ui'`.
80
+ */
81
+ restart(mode: RestartMode, reason: string): Promise<void>;
6
82
  }
7
83
  declare const _default: Spec;
8
84
  export default _default;
@@ -1,4 +1,5 @@
1
1
  export declare const BackgroundThread: import("./NativeBackgroundThread").Spec;
2
+ export type { RestartMode } from './NativeBackgroundThread';
2
3
  export { getSharedStore } from './SharedStore';
3
4
  export type { ISharedStore } from './SharedStore';
4
5
  export { getSharedRPC } from './SharedRPC';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-background-thread",
3
- "version": "3.0.31",
3
+ "version": "3.0.32",
4
4
  "description": "react-native-background-thread",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -84,7 +84,7 @@
84
84
  "typescript": "^5.9.2"
85
85
  },
86
86
  "peerDependencies": {
87
- "@onekeyfe/react-native-bundle-update": ">=3.0.31",
87
+ "@onekeyfe/react-native-bundle-update": "*",
88
88
  "react": "*",
89
89
  "react-native": "*"
90
90
  },
@@ -1,13 +1,88 @@
1
1
  import { TurboModuleRegistry } from 'react-native';
2
2
 
3
3
  import type { TurboModule } from 'react-native';
4
+
5
+ /**
6
+ * Allowed values for `restart()`'s `mode` argument.
7
+ *
8
+ * Defined as a string literal union (not a TypeScript `enum`) on purpose:
9
+ *
10
+ * 1. **Zero runtime cost.** A regular `enum` compiles to a JS object that
11
+ * ships with the bundle. A literal union erases to nothing.
12
+ * 2. **Codegen-friendly.** React Native's TurboModule codegen reads this
13
+ * spec to generate native types; its supported set is primitives,
14
+ * Object/Array, Promise, and string-literal unions. Plain TS `enum`
15
+ * is not in that set — behaviour varies by RN version and best
16
+ * avoided in spec files.
17
+ * 3. **Callsite type-safety without a runtime import.** Consumers can
18
+ * `import type { RestartMode } from '@onekeyfe/react-native-background-thread'`
19
+ * and pass a plain string; TypeScript narrows it without a runtime
20
+ * dependency on this module's value space.
21
+ *
22
+ * Migration note: the JSDoc on `restart()` historically said callers
23
+ * should use the `EAppRestartMode` enum from `@onekeyhq/shared`. If that
24
+ * is a regular TS string enum, its values aren't assignable to this union
25
+ * directly — either cast at the call site (`mode as RestartMode`) or
26
+ * migrate `EAppRestartMode` to a string-literal union / `as const` object
27
+ * so the two stay aligned.
28
+ */
29
+ export type RestartMode = 'ui' | 'all';
30
+
4
31
  export interface Spec extends TurboModule {
5
32
  startBackgroundRunnerWithEntryURL(entryURL: string): void;
6
33
  installSharedBridge(): void;
7
- loadSegmentInBackground(
8
- segmentId: number,
9
- path: string,
10
- ): Promise<void>;
34
+ loadSegmentInBackground(segmentId: number, path: string): Promise<void>;
35
+ /**
36
+ * Reload one or both JS runtimes. Replaces `react-native-restart`.
37
+ *
38
+ * `mode` (see {@link RestartMode}):
39
+ * - `'ui'`: restart only the main JS runtime (the one driving the UI).
40
+ * The background runtime stays hot, preserving its state and avoiding
41
+ * the SharedRPC reload race that crashed iOS on language switch.
42
+ * - `'all'`: restart both main and background JS runtimes. Required when
43
+ * the JS bundle on disk has changed (OTA install/switch) so the two
44
+ * runtimes cannot keep running mismatched moduleId tables.
45
+ *
46
+ * Any other value rejects the promise — callers should pass a
47
+ * `RestartMode` literal. The native validation still runs at runtime
48
+ * (it's the source of truth for what the platforms accept), so a string
49
+ * cast that smuggles in an unknown value will be rejected there.
50
+ *
51
+ * `reason` is forwarded to RCTTriggerReloadCommandListeners and to host
52
+ * logs / Sentry breadcrumbs so production restarts are attributable.
53
+ *
54
+ * ## Platform behaviour for `mode='all'`
55
+ *
56
+ * iOS and Android tear down the JS runtimes the same way, but the OS
57
+ * process around them differs — code that runs natively (timers,
58
+ * singletons, in-memory caches, scheduled jobs, foreground services)
59
+ * cannot assume cross-platform survival semantics:
60
+ *
61
+ * - **iOS**: process survives. The main RCTHost is rebuilt in-place via
62
+ * `RCTTriggerReloadCommandListeners`; the bg RCTHost is released and
63
+ * re-spawned by the host AppDelegate's `hostDidStart:`, with the
64
+ * module's two-stage `dispatch_after` health-check as a fallback if
65
+ * the host integration fails to re-arm (intentionally not driven by
66
+ * `RCTJavaScriptDidLoadNotification`, whose timing is unreliable in
67
+ * bridgeless / NewArch). Any process-level state (NSUserDefaults cache,
68
+ * live URLSession tasks, GCD timers attached to the app process)
69
+ * survives. iOS goes through soft reload here because abrupt
70
+ * termination is App Store-unfriendly and iOS lacks a clean self-
71
+ * relaunch path.
72
+ * - **Android**: process is killed. `BackgroundThreadManager` invokes
73
+ * `Runtime.exit(0)` after queuing `makeRestartActivityTask`, so the
74
+ * JVM is replaced wholesale. Any process-level state is lost; Activity
75
+ * stack is rebuilt from the launch intent. The choice is intentional
76
+ * — `mode='all'` is meant for OTA install/switch where a fresh re-
77
+ * read of both bundles from disk is desirable, and Android has a
78
+ * clean self-relaunch path that iOS doesn't.
79
+ *
80
+ * Callers that depend on process-level state (anything held in native
81
+ * singletons, background services, JNI handles) must not rely on it
82
+ * surviving `mode='all'` cross-platform. For UI-only resets that should
83
+ * preserve process state on both platforms, use `mode='ui'`.
84
+ */
85
+ restart(mode: RestartMode, reason: string): Promise<void>;
11
86
  }
12
87
 
13
88
  export default TurboModuleRegistry.getEnforcing<Spec>('BackgroundThread');
package/src/SharedRPC.ts CHANGED
@@ -7,7 +7,6 @@ export interface ISharedRPC {
7
7
  }
8
8
 
9
9
  declare global {
10
- // eslint-disable-next-line no-var
11
10
  var sharedRPC: ISharedRPC | undefined;
12
11
  }
13
12
 
@@ -9,7 +9,6 @@ export interface ISharedStore {
9
9
  }
10
10
 
11
11
  declare global {
12
- // eslint-disable-next-line no-var
13
12
  var sharedStore: ISharedStore | undefined;
14
13
  }
15
14
 
package/src/index.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import NativeBackgroundThread from './NativeBackgroundThread';
2
2
 
3
3
  export const BackgroundThread = NativeBackgroundThread;
4
+ export type { RestartMode } from './NativeBackgroundThread';
4
5
  export { getSharedStore } from './SharedStore';
5
6
  export type { ISharedStore } from './SharedStore';
6
7
  export { getSharedRPC } from './SharedRPC';