From 800c4e144203a54cb684082d084ff0385acd8ec5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:05:16 +0200 Subject: [PATCH] fix: stop waiting for React Native once a sync gave up on it #34 A sync that had to start React Native left a listener on the React host until a context came up. When the start failed (a debug build without Metro) or never finished, every retried sync added another one, each holding its run, account and extras, for the life of the process, and they all fired at once when a context finally came up. The wait now ends once, and removes its listener, for whatever reason: a ready context, a start task that faulted, or 10 minutes (no sync or push job waits longer; JobScheduler stops them). A context that comes up later runs nothing of it. The adapter's own check of its run in the callback still covers a run it gave up sooner. --- .../com/anonymous/bulwarkmobile/HeadlessJs.kt | 86 ++++++++++++++----- 1 file changed, 63 insertions(+), 23 deletions(-) diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/HeadlessJs.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/HeadlessJs.kt index e8521c38..b9722584 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/HeadlessJs.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/HeadlessJs.kt @@ -1,6 +1,10 @@ package com.anonymous.bulwarkmobile import android.app.Application +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.util.Log import androidx.annotation.MainThread import com.facebook.react.ReactApplication import com.facebook.react.ReactInstanceEventListener @@ -19,6 +23,12 @@ import java.util.concurrent.atomic.AtomicBoolean * sync adapters. */ object HeadlessJs { + private const val TAG = "HeadlessJs" + + /** No caller waits longer for React Native: JobScheduler stops sync and push jobs after 10 minutes. */ + private const val MAX_WAIT_MS = 600_000L + private const val WATCH_MS = 1_000L + /** * Calls [onReady] once with a React context whose JS instance is up, * starting React Native if the process was woken for this work. @@ -30,6 +40,13 @@ object HeadlessJs { * first and the ready check after it; whichever sees the ready instance * first runs the task. * + * The wait ends once, and its listener goes with it: with a ready context, + * or without one when React fails to start (the start task faults) or + * after [MAX_WAIT_MS], when no caller can still be waiting. A context that + * comes up later runs nothing of it, so waits given up on don't pile up on + * the host with everything their [onReady] holds. (A caller that gives up + * sooner, like the sync adapter at its deadline, checks that in [onReady].) + * * Call it on the main thread; [onReady] runs there too. MainApplication * creates the ReactHost on first use without a lock (ExpoReactHostFactory), * so a background caller racing MainActivity could create a second one, @@ -41,45 +58,68 @@ object HeadlessJs { */ @MainThread fun withReadyReactContext(app: Application, onReady: (ReactContext) -> Unit): TaskInterface? { - val done = AtomicBoolean(false) - val runOnce = { context: ReactContext -> if (done.compareAndSet(false, true)) onReady(context) } val reactApp = app as ReactApplication if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) { val host = checkNotNull(reactApp.reactHost) { "ReactHost is not initialized in New Architecture" } - val listener = object : ReactInstanceEventListener { - override fun onReactContextInitialized(context: ReactContext) { - host.removeReactInstanceEventListener(this) - runOnce(context) - } - } - host.addReactInstanceEventListener(listener) + val wait = ContextWait(onReady) { host.removeReactInstanceEventListener(it) } + host.addReactInstanceEventListener(wait) val current = host.currentReactContext if (current != null && current.hasActiveReactInstance()) { - host.removeReactInstanceEventListener(listener) - runOnce(current) + wait.onReactContextInitialized(current) return null } - return host.start() + return host.start().also { wait.watch(it) } } @Suppress("DEPRECATION") val manager = reactApp.reactNativeHost.reactInstanceManager - val listener = object : ReactInstanceEventListener { - override fun onReactContextInitialized(context: ReactContext) { - manager.removeReactInstanceEventListener(this) - runOnce(context) - } - } - manager.addReactInstanceEventListener(listener) + val wait = ContextWait(onReady) { manager.removeReactInstanceEventListener(it) } + manager.addReactInstanceEventListener(wait) val current = manager.currentReactContext if (current != null && current.hasActiveReactInstance()) { - manager.removeReactInstanceEventListener(listener) - runOnce(current) - } else if (!manager.hasStartedCreatingInitialContext()) { - manager.createReactContextInBackground() + wait.onReactContextInitialized(current) + return null } + if (!manager.hasStartedCreatingInitialContext()) manager.createReactContextInBackground() + wait.watch(null) return null } + /** One caller's wait for a ready context: a listener on the host that ends once and then removes itself. */ + private class ContextWait( + private val onReady: (ReactContext) -> Unit, + private val removeListener: (ReactInstanceEventListener) -> Unit, + ) : ReactInstanceEventListener { + private val ended = AtomicBoolean(false) + + override fun onReactContextInitialized(context: ReactContext) { + if (end()) onReady(context) + } + + /** Ends the wait without a context once [boot] faulted or [MAX_WAIT_MS] passed; checked on the main thread. */ + fun watch(boot: TaskInterface?) { + val main = Handler(Looper.getMainLooper()) + val giveUpAt = SystemClock.elapsedRealtime() + MAX_WAIT_MS + main.postDelayed(object : Runnable { + override fun run() { + if (ended.get()) return + val faulted = boot?.isFaulted() == true + if (!faulted && SystemClock.elapsedRealtime() < giveUpAt) { + main.postDelayed(this, WATCH_MS) + } else if (end()) { + val why = if (faulted) "React Native did not start" else "no React context after $MAX_WAIT_MS ms" + Log.w(TAG, "$why; no longer waiting") + } + } + }, WATCH_MS) + } + + private fun end(): Boolean { + if (!ended.compareAndSet(false, true)) return false + removeListener(this) + return true + } + } + /** * Starts [config] on the UI thread, where HeadlessJsTaskContext wants it. * [onStarted] gets the task id in that same UI-thread turn, so before any