From 409c8d2d7a11dbde456e860539bf1df6090025d9 Mon Sep 17 00:00:00 2001
From: Mike <84413695+snipemanmike@users.noreply.github.com>
Date: Sat, 1 Aug 2026 01:49:07 -0700
Subject: [PATCH 1/7] feat(mobile): add Android background connection service
---
.../android/build.gradle | 21 ++
.../android/src/main/AndroidManifest.xml | 27 ++
.../T3BackgroundConnectionModule.kt | 107 ++++++
.../T3BackgroundConnectionReceiver.kt | 26 ++
.../T3BackgroundConnectionRecoveryPolicy.kt | 13 +
.../T3BackgroundConnectionRestartBackoff.kt | 12 +
.../T3BackgroundConnectionService.kt | 169 +++++++++
.../T3BackgroundConnectionState.kt | 330 ++++++++++++++++++
...3BackgroundConnectionRecoveryPolicyTest.kt | 57 +++
...3BackgroundConnectionRestartBackoffTest.kt | 16 +
.../expo-module.config.json | 6 +
apps/mobile/src/App.tsx | 13 +-
...ckgroundConnectionSettingsSection.test.tsx | 142 ++++++++
.../BackgroundConnectionSettingsSection.tsx | 117 +++++++
.../settings-model.test.ts | 52 +++
.../background-connection/settings-model.ts | 25 ++
.../features/settings/SettingsRouteScreen.tsx | 5 +
.../src/native/backgroundConnection.test.ts | 108 ++++++
.../mobile/src/native/backgroundConnection.ts | 141 ++++++++
19 files changed, 1386 insertions(+), 1 deletion(-)
create mode 100644 apps/mobile/modules/t3-background-connection/android/build.gradle
create mode 100644 apps/mobile/modules/t3-background-connection/android/src/main/AndroidManifest.xml
create mode 100644 apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionModule.kt
create mode 100644 apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionReceiver.kt
create mode 100644 apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionRecoveryPolicy.kt
create mode 100644 apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionRestartBackoff.kt
create mode 100644 apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionService.kt
create mode 100644 apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionState.kt
create mode 100644 apps/mobile/modules/t3-background-connection/android/src/test/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionRecoveryPolicyTest.kt
create mode 100644 apps/mobile/modules/t3-background-connection/android/src/test/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionRestartBackoffTest.kt
create mode 100644 apps/mobile/modules/t3-background-connection/expo-module.config.json
create mode 100644 apps/mobile/src/features/background-connection/BackgroundConnectionSettingsSection.test.tsx
create mode 100644 apps/mobile/src/features/background-connection/BackgroundConnectionSettingsSection.tsx
create mode 100644 apps/mobile/src/features/background-connection/settings-model.test.ts
create mode 100644 apps/mobile/src/features/background-connection/settings-model.ts
create mode 100644 apps/mobile/src/native/backgroundConnection.test.ts
create mode 100644 apps/mobile/src/native/backgroundConnection.ts
diff --git a/apps/mobile/modules/t3-background-connection/android/build.gradle b/apps/mobile/modules/t3-background-connection/android/build.gradle
new file mode 100644
index 00000000000..29f46fdedad
--- /dev/null
+++ b/apps/mobile/modules/t3-background-connection/android/build.gradle
@@ -0,0 +1,21 @@
+apply plugin: 'com.android.library'
+apply plugin: 'org.jetbrains.kotlin.android'
+
+group = 'com.t3tools.backgroundconnection'
+version = '0.0.0'
+
+android {
+ namespace 'expo.modules.t3backgroundconnection'
+ compileSdk rootProject.ext.compileSdkVersion
+
+ defaultConfig {
+ minSdkVersion rootProject.ext.minSdkVersion
+ targetSdkVersion rootProject.ext.targetSdkVersion
+ }
+}
+
+dependencies {
+ implementation project(':expo-modules-core')
+ implementation 'com.facebook.react:react-android'
+ testImplementation 'junit:junit:4.13.2'
+}
diff --git a/apps/mobile/modules/t3-background-connection/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-background-connection/android/src/main/AndroidManifest.xml
new file mode 100644
index 00000000000..00929f012f0
--- /dev/null
+++ b/apps/mobile/modules/t3-background-connection/android/src/main/AndroidManifest.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionModule.kt b/apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionModule.kt
new file mode 100644
index 00000000000..c7f8a128f73
--- /dev/null
+++ b/apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionModule.kt
@@ -0,0 +1,107 @@
+package expo.modules.t3backgroundconnection
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.provider.Settings
+import expo.modules.kotlin.functions.Queues
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class T3BackgroundConnectionModule : Module() {
+ private val statusListener: (Map) -> Unit = { status ->
+ sendEvent(T3BackgroundConnectionState.STATUS_EVENT, status)
+ }
+ private val stopRequestListener: () -> Unit = {
+ sendEvent(T3BackgroundConnectionState.STOP_REQUEST_EVENT)
+ }
+
+ override fun definition() = ModuleDefinition {
+ Name("T3BackgroundConnection")
+
+ Events(
+ T3BackgroundConnectionState.STATUS_EVENT,
+ T3BackgroundConnectionState.STOP_REQUEST_EVENT,
+ )
+
+ OnCreate {
+ val context = applicationContext()
+ T3BackgroundConnectionState.initialize(context)
+ T3BackgroundConnectionState.addStatusListener(statusListener)
+ T3BackgroundConnectionState.addStopRequestListener(stopRequestListener)
+ }
+
+ OnDestroy {
+ T3BackgroundConnectionState.removeStatusListener(statusListener)
+ T3BackgroundConnectionState.removeStopRequestListener(stopRequestListener)
+ }
+
+ OnActivityEntersForeground {
+ // The battery-optimization request has no result callback. Refreshing on
+ // resume makes the settings status reflect the user's choice immediately.
+ val context = applicationContext()
+ if (T3BackgroundConnectionState.shouldEnsureStartedOnActivityForeground(context)) {
+ // A visible Activity is an allowed foreground-service start context.
+ // A successful start also cancels any pending recovery alarm.
+ T3BackgroundConnectionController.ensureStarted(context)
+ }
+ T3BackgroundConnectionState.refreshStatus()
+ }
+
+ Function("getStatus") {
+ T3BackgroundConnectionState.status(applicationContext())
+ }
+
+ AsyncFunction("setEnabled") { enabled: Boolean ->
+ val context = applicationContext()
+ T3BackgroundConnectionState.setEnabled(context, enabled)
+ if (enabled) {
+ T3BackgroundConnectionController.ensureStarted(context)
+ } else {
+ T3BackgroundConnectionState.requestOrderlyStop(context)
+ }
+ T3BackgroundConnectionState.status(context)
+ }.runOnQueue(Queues.MAIN)
+
+ Function("ensureStarted") {
+ val context = applicationContext()
+ T3BackgroundConnectionController.ensureStarted(context)
+ T3BackgroundConnectionState.status(context)
+ }
+
+ AsyncFunction("requestBatteryOptimizationExemption") {
+ val context = applicationContext()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ val intent = Intent(
+ Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
+ Uri.parse("package:${context.packageName}"),
+ )
+ val activity = appContext.currentActivity
+ if (activity != null) {
+ activity.startActivity(intent)
+ } else {
+ context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ }
+ }
+ T3BackgroundConnectionState.status(context)
+ }.runOnQueue(Queues.MAIN)
+
+ Function("setRuntimeReady") { ready: Boolean ->
+ val context = applicationContext()
+ T3BackgroundConnectionState.markRuntimeReady(ready)
+ T3BackgroundConnectionState.status(context)
+ }
+
+ Function("acknowledgeStop") {
+ val context = applicationContext()
+ T3BackgroundConnectionState.acknowledgeStop(context)
+ T3BackgroundConnectionState.status(context)
+ }
+ }
+
+ private fun applicationContext(): Context =
+ requireNotNull(appContext.reactContext?.applicationContext) {
+ "T3BackgroundConnection requires an Android React context"
+ }
+}
diff --git a/apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionReceiver.kt b/apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionReceiver.kt
new file mode 100644
index 00000000000..0ba61cc509c
--- /dev/null
+++ b/apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionReceiver.kt
@@ -0,0 +1,26 @@
+package expo.modules.t3backgroundconnection
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.facebook.react.HeadlessJsTaskService
+
+class T3BackgroundConnectionReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent?) {
+ if (
+ intent?.action != Intent.ACTION_BOOT_COMPLETED &&
+ intent?.action != Intent.ACTION_MY_PACKAGE_REPLACED &&
+ intent?.action != T3BackgroundConnectionState.RESTART_ACTION
+ ) {
+ return
+ }
+ if (!T3BackgroundConnectionState.isEnabled(context)) return
+
+ if (T3BackgroundConnectionController.ensureStarted(context)) {
+ // Acquire before returning from the receiver, but only after Android
+ // accepted the service launch so a rejected FGS cannot leak the static
+ // React Native wake lock.
+ HeadlessJsTaskService.acquireWakeLockNow(context)
+ }
+ }
+}
diff --git a/apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionRecoveryPolicy.kt b/apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionRecoveryPolicy.kt
new file mode 100644
index 00000000000..caa228a0322
--- /dev/null
+++ b/apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionRecoveryPolicy.kt
@@ -0,0 +1,13 @@
+package expo.modules.t3backgroundconnection
+
+internal object T3BackgroundConnectionRecoveryPolicy {
+ fun shouldScheduleRestartAfterStartFailure(
+ batteryOptimizationIgnored: Boolean,
+ ): Boolean = batteryOptimizationIgnored
+
+ fun shouldEnsureStartedOnActivityForeground(
+ enabled: Boolean,
+ serviceRunning: Boolean,
+ runtimeReady: Boolean,
+ ): Boolean = enabled && (!serviceRunning || !runtimeReady)
+}
diff --git a/apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionRestartBackoff.kt b/apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionRestartBackoff.kt
new file mode 100644
index 00000000000..db9a74a277a
--- /dev/null
+++ b/apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionRestartBackoff.kt
@@ -0,0 +1,12 @@
+package expo.modules.t3backgroundconnection
+
+internal object T3BackgroundConnectionRestartBackoff {
+ private const val INITIAL_DELAY_MS = 1_000L
+ private const val MAX_DELAY_MS = 5 * 60_000L
+
+ fun delayMs(consecutiveFailures: Int): Long {
+ val exponent = consecutiveFailures.coerceIn(0, 20)
+ val exponentialDelay = INITIAL_DELAY_MS * (1L shl exponent)
+ return exponentialDelay.coerceAtMost(MAX_DELAY_MS)
+ }
+}
diff --git a/apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionService.kt b/apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionService.kt
new file mode 100644
index 00000000000..fb7b921811a
--- /dev/null
+++ b/apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionService.kt
@@ -0,0 +1,169 @@
+package expo.modules.t3backgroundconnection
+
+import android.annotation.SuppressLint
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.net.wifi.WifiManager
+import android.os.Build
+import com.facebook.react.HeadlessJsTaskService
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.jstasks.HeadlessJsTaskConfig
+
+class T3BackgroundConnectionService : HeadlessJsTaskService() {
+ private var wifiLock: WifiManager.WifiLock? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ T3BackgroundConnectionState.initialize(this)
+ startInForeground()
+ T3BackgroundConnectionState.markServiceRunning(true)
+ acquireWifiLock()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (!T3BackgroundConnectionState.isEnabled(this)) {
+ stopSelf(startId)
+ return Service.START_NOT_STICKY
+ }
+
+ if (T3BackgroundConnectionState.claimTask()) {
+ super.onStartCommand(intent, flags, startId)
+ }
+ return Service.START_STICKY
+ }
+
+ override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig =
+ HeadlessJsTaskConfig(
+ T3BackgroundConnectionState.TASK_NAME,
+ Arguments.createMap(),
+ 0,
+ true,
+ )
+
+ override fun onHeadlessJsTaskFinish(taskId: Int) {
+ val shouldRestart = T3BackgroundConnectionState.isEnabled(this)
+ T3BackgroundConnectionState.releaseTask()
+ super.onHeadlessJsTaskFinish(taskId)
+ if (shouldRestart) {
+ T3BackgroundConnectionState.scheduleRestartAfterUnexpectedTaskFinish(this)
+ }
+ }
+
+ override fun onDestroy() {
+ releaseWifiLock()
+ T3BackgroundConnectionState.markServiceRunning(false)
+ super.onDestroy()
+ }
+
+ private fun startInForeground() {
+ createNotificationChannel()
+ val notification = createNotification()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ startForeground(
+ NOTIFICATION_ID,
+ notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING,
+ )
+ } else {
+ startForeground(NOTIFICATION_ID, notification)
+ }
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ val channel = NotificationChannel(
+ NOTIFICATION_CHANNEL_ID,
+ "Background connection",
+ NotificationManager.IMPORTANCE_LOW,
+ ).apply {
+ description = "Keeps T3 Code connected while the app is in the background"
+ setSound(null, null)
+ enableLights(false)
+ enableVibration(false)
+ setShowBadge(false)
+ lockscreenVisibility = Notification.VISIBILITY_PRIVATE
+ }
+ getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
+ }
+
+ private fun createNotification(): Notification {
+ val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
+ val contentIntent = launchIntent?.let {
+ PendingIntent.getActivity(
+ this,
+ 0,
+ it,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+ val smallIcon = resources.getIdentifier("notification_icon", "drawable", packageName)
+ .takeIf { it != 0 }
+ ?: android.R.drawable.stat_notify_sync_noanim
+
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
+ } else {
+ @Suppress("DEPRECATION")
+ Notification.Builder(this)
+ }.apply {
+ setContentTitle("T3 Code")
+ setContentText("Connected in background")
+ setSmallIcon(smallIcon)
+ setOngoing(true)
+ setOnlyAlertOnce(true)
+ setShowWhen(false)
+ setCategory(Notification.CATEGORY_SERVICE)
+ setVisibility(Notification.VISIBILITY_PRIVATE)
+ contentIntent?.let(::setContentIntent)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE)
+ }
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ @Suppress("DEPRECATION")
+ setPriority(Notification.PRIORITY_LOW)
+ @Suppress("DEPRECATION")
+ setSound(null)
+ }
+ }.build()
+ }
+
+ @SuppressLint("WakelockTimeout")
+ @Suppress("DEPRECATION")
+ private fun acquireWifiLock() {
+ try {
+ val wifiManager = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
+ wifiLock = wifiManager.createWifiLock(
+ WifiManager.WIFI_MODE_FULL_HIGH_PERF,
+ "$packageName:t3-background-connection",
+ ).apply {
+ setReferenceCounted(false)
+ acquire()
+ }
+ } catch (_: RuntimeException) {
+ // The lock is best-effort. The foreground task and connection supervisor
+ // remain authoritative on devices that reject or do not expose it.
+ wifiLock = null
+ }
+ }
+
+ private fun releaseWifiLock() {
+ try {
+ wifiLock?.takeIf { it.isHeld }?.release()
+ } catch (_: RuntimeException) {
+ // The system may already have released the lock during process teardown.
+ } finally {
+ wifiLock = null
+ }
+ }
+
+ private companion object {
+ const val NOTIFICATION_CHANNEL_ID = "t3code_background_connection"
+ const val NOTIFICATION_ID = 0x7433
+ }
+}
diff --git a/apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionState.kt b/apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionState.kt
new file mode 100644
index 00000000000..1208ce14bc2
--- /dev/null
+++ b/apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionState.kt
@@ -0,0 +1,330 @@
+package expo.modules.t3backgroundconnection
+
+import android.annotation.SuppressLint
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.os.PowerManager
+import android.os.SystemClock
+import android.util.Log
+import java.util.concurrent.CopyOnWriteArraySet
+import java.util.concurrent.atomic.AtomicBoolean
+
+internal object T3BackgroundConnectionState {
+ const val TASK_NAME = "T3BackgroundConnection"
+ const val STATUS_EVENT = "onStatusChange"
+ const val STOP_REQUEST_EVENT = "onStopRequested"
+ const val RESTART_ACTION =
+ "expo.modules.t3backgroundconnection.action.RESTART_BACKGROUND_CONNECTION"
+
+ private const val PREFERENCES_NAME = "t3_background_connection"
+ private const val ENABLED_KEY = "enabled"
+ private const val STOP_FALLBACK_DELAY_MS = 5_000L
+ private const val STABLE_RUNTIME_RESET_DELAY_MS = 60_000L
+
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val serviceRunning = AtomicBoolean(false)
+ private val runtimeReady = AtomicBoolean(false)
+ private val taskStarted = AtomicBoolean(false)
+ private val statusListeners = CopyOnWriteArraySet<(Map) -> Unit>()
+ private val stopRequestListeners = CopyOnWriteArraySet<() -> Unit>()
+
+ @Volatile
+ private var applicationContext: Context? = null
+
+ @Volatile
+ private var stopFallback: Runnable? = null
+
+ @Volatile
+ private var restartFallback: Runnable? = null
+
+ @Volatile
+ private var stableRuntimeReset: Runnable? = null
+
+ private var consecutiveUnexpectedFailures = 0
+
+ fun initialize(context: Context) {
+ applicationContext = context.applicationContext
+ }
+
+ fun isEnabled(context: Context): Boolean =
+ preferences(context).getBoolean(ENABLED_KEY, false)
+
+ @SuppressLint("ApplySharedPref")
+ fun setEnabled(context: Context, enabled: Boolean) {
+ initialize(context)
+ val wasEnabled = isEnabled(context)
+ // Commit before starting the service so a process death cannot start a
+ // sticky service whose boot-time preference still says it is disabled.
+ preferences(context).edit().putBoolean(ENABLED_KEY, enabled).commit()
+ if (enabled) {
+ cancelStopFallback()
+ if (!wasEnabled) {
+ resetUnexpectedRestartBackoff()
+ }
+ } else {
+ resetUnexpectedRestartBackoff()
+ }
+ emitStatus()
+ }
+
+ fun status(context: Context): Map {
+ initialize(context)
+
+ return mapOf(
+ "supported" to true,
+ "enabled" to isEnabled(context),
+ "serviceRunning" to serviceRunning.get(),
+ "runtimeReady" to runtimeReady.get(),
+ "batteryOptimizationIgnored" to isBatteryOptimizationIgnored(context),
+ )
+ }
+
+ fun shouldEnsureStartedOnActivityForeground(context: Context): Boolean =
+ T3BackgroundConnectionRecoveryPolicy.shouldEnsureStartedOnActivityForeground(
+ enabled = isEnabled(context),
+ serviceRunning = serviceRunning.get(),
+ runtimeReady = runtimeReady.get(),
+ )
+
+ fun isBatteryOptimizationIgnored(context: Context): Boolean {
+ val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
+ return powerManager.isIgnoringBatteryOptimizations(context.packageName)
+ }
+
+ fun addStatusListener(listener: (Map) -> Unit) {
+ statusListeners.add(listener)
+ }
+
+ fun removeStatusListener(listener: (Map) -> Unit) {
+ statusListeners.remove(listener)
+ }
+
+ fun addStopRequestListener(listener: () -> Unit) {
+ stopRequestListeners.add(listener)
+ }
+
+ fun removeStopRequestListener(listener: () -> Unit) {
+ stopRequestListeners.remove(listener)
+ }
+
+ fun refreshStatus() {
+ emitStatus()
+ }
+
+ fun markServiceRunning(running: Boolean) {
+ serviceRunning.set(running)
+ if (!running) {
+ runtimeReady.set(false)
+ taskStarted.set(false)
+ cancelStopFallback()
+ cancelStableRuntimeReset()
+ }
+ emitStatus()
+ }
+
+ fun markRuntimeReady(ready: Boolean) {
+ val appliedReady = ready && serviceRunning.get()
+ runtimeReady.set(appliedReady)
+ if (appliedReady) {
+ scheduleStableRuntimeReset()
+ } else {
+ cancelStableRuntimeReset()
+ }
+ emitStatus()
+ }
+
+ fun claimTask(): Boolean = taskStarted.compareAndSet(false, true)
+
+ fun releaseTask() {
+ taskStarted.set(false)
+ runtimeReady.set(false)
+ cancelStableRuntimeReset()
+ emitStatus()
+ }
+
+ fun requestOrderlyStop(context: Context) {
+ initialize(context)
+ cancelStopFallback()
+ if (!serviceRunning.get()) {
+ stopService(context)
+ return
+ }
+
+ // A claimed task can be creating the React context, or can already own
+ // subscriptions while it bootstraps Clerk and persistence. Keep the
+ // service alive long enough for that task to observe the disabled
+ // preference and unwind; only an idle service is safe to stop immediately.
+ // The bounded fallback below still handles a runtime that never responds.
+ if (!taskStarted.get()) {
+ stopService(context)
+ return
+ }
+
+ mainHandler.post {
+ if (!isEnabled(context)) {
+ stopRequestListeners.forEach { listener -> listener() }
+ }
+ }
+
+ val fallback = Runnable {
+ stopFallback = null
+ if (isEnabled(context)) return@Runnable
+ runtimeReady.set(false)
+ emitStatus()
+ stopService(context)
+ }
+ stopFallback = fallback
+ mainHandler.postDelayed(fallback, STOP_FALLBACK_DELAY_MS)
+ }
+
+ fun acknowledgeStop(context: Context) {
+ initialize(context)
+ cancelStopFallback()
+ runtimeReady.set(false)
+ emitStatus()
+ if (!isEnabled(context)) {
+ stopService(context)
+ }
+ }
+
+ fun scheduleRestartAfterUnexpectedTaskFinish(context: Context) {
+ initialize(context)
+ cancelScheduledRestart(context)
+ val delayMs = T3BackgroundConnectionRestartBackoff.delayMs(consecutiveUnexpectedFailures)
+ consecutiveUnexpectedFailures = (consecutiveUnexpectedFailures + 1).coerceAtMost(20)
+ try {
+ val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ alarmManager.setAndAllowWhileIdle(
+ AlarmManager.ELAPSED_REALTIME_WAKEUP,
+ SystemClock.elapsedRealtime() + delayMs,
+ checkNotNull(restartPendingIntent(context, PendingIntent.FLAG_UPDATE_CURRENT)),
+ )
+ } catch (error: RuntimeException) {
+ // AlarmManager is the durable path. Keep an in-process fallback for an
+ // unusual vendor failure so recovery still has a best-effort attempt.
+ Log.w("T3BackgroundConnection", "Could not schedule durable restart alarm", error)
+ val restart = Runnable {
+ restartFallback = null
+ if (isEnabled(context)) {
+ T3BackgroundConnectionController.ensureStarted(context)
+ }
+ }
+ restartFallback = restart
+ mainHandler.postDelayed(restart, delayMs)
+ }
+ }
+
+ fun scheduleRestartAfterStartFailure(context: Context) {
+ if (
+ T3BackgroundConnectionRecoveryPolicy.shouldScheduleRestartAfterStartFailure(
+ batteryOptimizationIgnored = isBatteryOptimizationIgnored(context),
+ )
+ ) {
+ scheduleRestartAfterUnexpectedTaskFinish(context)
+ return
+ }
+
+ // An alarm is not a valid exemption from Android's background foreground-
+ // service launch restrictions. Re-arming it here would create an endless
+ // failure ladder on a non-exempt app. Leave the feature enabled and recover
+ // on the next Activity foreground, boot/package update, or sticky restart.
+ cancelScheduledRestart(context)
+ }
+
+ private fun preferences(context: Context) =
+ context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
+
+ private fun emitStatus() {
+ val context = applicationContext ?: return
+ val snapshot = status(context)
+ mainHandler.post {
+ statusListeners.forEach { listener -> listener(snapshot) }
+ }
+ }
+
+ private fun cancelStopFallback() {
+ stopFallback?.let(mainHandler::removeCallbacks)
+ stopFallback = null
+ }
+
+ fun cancelScheduledRestart(context: Context) {
+ restartFallback?.let(mainHandler::removeCallbacks)
+ restartFallback = null
+ val pendingIntent = restartPendingIntent(context, PendingIntent.FLAG_NO_CREATE) ?: return
+ val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ alarmManager.cancel(pendingIntent)
+ pendingIntent.cancel()
+ }
+
+ private fun scheduleStableRuntimeReset() {
+ cancelStableRuntimeReset()
+ val reset = Runnable {
+ stableRuntimeReset = null
+ if (runtimeReady.get() && serviceRunning.get()) {
+ consecutiveUnexpectedFailures = 0
+ }
+ }
+ stableRuntimeReset = reset
+ mainHandler.postDelayed(reset, STABLE_RUNTIME_RESET_DELAY_MS)
+ }
+
+ private fun cancelStableRuntimeReset() {
+ stableRuntimeReset?.let(mainHandler::removeCallbacks)
+ stableRuntimeReset = null
+ }
+
+ private fun resetUnexpectedRestartBackoff() {
+ consecutiveUnexpectedFailures = 0
+ applicationContext?.let(::cancelScheduledRestart)
+ cancelStableRuntimeReset()
+ }
+
+ private fun restartPendingIntent(context: Context, creationFlag: Int): PendingIntent? =
+ PendingIntent.getBroadcast(
+ context.applicationContext,
+ RESTART_REQUEST_CODE,
+ Intent(context.applicationContext, T3BackgroundConnectionReceiver::class.java).apply {
+ action = RESTART_ACTION
+ },
+ creationFlag or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ private fun stopService(context: Context) {
+ context.applicationContext.stopService(
+ Intent(context.applicationContext, T3BackgroundConnectionService::class.java),
+ )
+ }
+
+ private const val RESTART_REQUEST_CODE = 0x7434
+}
+
+internal object T3BackgroundConnectionController {
+ fun ensureStarted(context: Context): Boolean {
+ val applicationContext = context.applicationContext
+ T3BackgroundConnectionState.initialize(applicationContext)
+ if (!T3BackgroundConnectionState.isEnabled(applicationContext)) return false
+
+ val intent = Intent(applicationContext, T3BackgroundConnectionService::class.java)
+ return try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ applicationContext.startForegroundService(intent)
+ } else {
+ applicationContext.startService(intent)
+ }
+ T3BackgroundConnectionState.cancelScheduledRestart(applicationContext)
+ true
+ } catch (error: RuntimeException) {
+ // Android can reject a background FGS launch. Keep the preference
+ // enabled and let the recovery policy either retry an exempt app or
+ // park until a foreground/OS trigger can legally start the service.
+ Log.w("T3BackgroundConnection", "Could not start foreground service", error)
+ T3BackgroundConnectionState.scheduleRestartAfterStartFailure(applicationContext)
+ false
+ }
+ }
+}
diff --git a/apps/mobile/modules/t3-background-connection/android/src/test/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionRecoveryPolicyTest.kt b/apps/mobile/modules/t3-background-connection/android/src/test/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionRecoveryPolicyTest.kt
new file mode 100644
index 00000000000..97d8a8db52e
--- /dev/null
+++ b/apps/mobile/modules/t3-background-connection/android/src/test/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionRecoveryPolicyTest.kt
@@ -0,0 +1,57 @@
+package expo.modules.t3backgroundconnection
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class T3BackgroundConnectionRecoveryPolicyTest {
+ @Test
+ fun `start failure retries only while battery optimization is ignored`() {
+ assertTrue(
+ T3BackgroundConnectionRecoveryPolicy.shouldScheduleRestartAfterStartFailure(
+ batteryOptimizationIgnored = true,
+ ),
+ )
+ assertFalse(
+ T3BackgroundConnectionRecoveryPolicy.shouldScheduleRestartAfterStartFailure(
+ batteryOptimizationIgnored = false,
+ ),
+ )
+ }
+
+ @Test
+ fun `foreground recovers an enabled service that is stopped or not ready`() {
+ assertTrue(
+ T3BackgroundConnectionRecoveryPolicy.shouldEnsureStartedOnActivityForeground(
+ enabled = true,
+ serviceRunning = false,
+ runtimeReady = false,
+ ),
+ )
+ assertTrue(
+ T3BackgroundConnectionRecoveryPolicy.shouldEnsureStartedOnActivityForeground(
+ enabled = true,
+ serviceRunning = true,
+ runtimeReady = false,
+ ),
+ )
+ }
+
+ @Test
+ fun `foreground leaves a healthy or disabled service alone`() {
+ assertFalse(
+ T3BackgroundConnectionRecoveryPolicy.shouldEnsureStartedOnActivityForeground(
+ enabled = true,
+ serviceRunning = true,
+ runtimeReady = true,
+ ),
+ )
+ assertFalse(
+ T3BackgroundConnectionRecoveryPolicy.shouldEnsureStartedOnActivityForeground(
+ enabled = false,
+ serviceRunning = false,
+ runtimeReady = false,
+ ),
+ )
+ }
+}
diff --git a/apps/mobile/modules/t3-background-connection/android/src/test/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionRestartBackoffTest.kt b/apps/mobile/modules/t3-background-connection/android/src/test/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionRestartBackoffTest.kt
new file mode 100644
index 00000000000..3ee6af0b907
--- /dev/null
+++ b/apps/mobile/modules/t3-background-connection/android/src/test/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionRestartBackoffTest.kt
@@ -0,0 +1,16 @@
+package expo.modules.t3backgroundconnection
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class T3BackgroundConnectionRestartBackoffTest {
+ @Test
+ fun `repeated failures back off and remain bounded`() {
+ assertEquals(1_000L, T3BackgroundConnectionRestartBackoff.delayMs(0))
+ assertEquals(2_000L, T3BackgroundConnectionRestartBackoff.delayMs(1))
+ assertEquals(4_000L, T3BackgroundConnectionRestartBackoff.delayMs(2))
+ assertEquals(256_000L, T3BackgroundConnectionRestartBackoff.delayMs(8))
+ assertEquals(300_000L, T3BackgroundConnectionRestartBackoff.delayMs(9))
+ assertEquals(300_000L, T3BackgroundConnectionRestartBackoff.delayMs(Int.MAX_VALUE))
+ }
+}
diff --git a/apps/mobile/modules/t3-background-connection/expo-module.config.json b/apps/mobile/modules/t3-background-connection/expo-module.config.json
new file mode 100644
index 00000000000..e726231c8b4
--- /dev/null
+++ b/apps/mobile/modules/t3-background-connection/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+ "platforms": ["android"],
+ "android": {
+ "modules": ["expo.modules.t3backgroundconnection.T3BackgroundConnectionModule"]
+ }
+}
diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx
index 06bd4bc5773..31d43ce35ca 100644
--- a/apps/mobile/src/App.tsx
+++ b/apps/mobile/src/App.tsx
@@ -2,7 +2,7 @@ import { BlurTargetView } from "expo-blur";
import * as Linking from "expo-linking";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
-import { StatusBar, useColorScheme } from "react-native";
+import { Platform, StatusBar, useColorScheme } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
@@ -22,6 +22,7 @@ import { appAtomRegistry } from "./state/atom-registry";
import { OverlayPortalHost } from "./components/OverlayPortal";
import { appBlurTargetRef } from "./lib/appBlurTarget";
import { useThemeColor } from "./lib/useThemeColor";
+import { ensureBackgroundConnectionStarted } from "./native/backgroundConnection";
import "../global.css";
@@ -57,12 +58,22 @@ function SplashScreenCoordinator() {
return null;
}
+function BackgroundConnectionServiceCoordinator() {
+ useEffect(() => {
+ if (Platform.OS === "android") {
+ ensureBackgroundConnectionStarted();
+ }
+ }, []);
+ return null;
+}
+
export default function App() {
const colorScheme = useColorScheme();
const statusBarBg = useThemeColor("--color-status-bar");
return (
+
diff --git a/apps/mobile/src/features/background-connection/BackgroundConnectionSettingsSection.test.tsx b/apps/mobile/src/features/background-connection/BackgroundConnectionSettingsSection.test.tsx
new file mode 100644
index 00000000000..00818103e7b
--- /dev/null
+++ b/apps/mobile/src/features/background-connection/BackgroundConnectionSettingsSection.test.tsx
@@ -0,0 +1,142 @@
+import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+import type { BackgroundConnectionStatus } from "../../native/backgroundConnection";
+
+const native = vi.hoisted(() => ({
+ status: null as BackgroundConnectionStatus | null,
+ setEnabled: vi.fn(),
+ requestExemption: vi.fn(),
+ removeStatusListener: vi.fn(),
+}));
+
+const reactNative = vi.hoisted(() => ({
+ alert: vi.fn(),
+ removeAppStateListener: vi.fn(),
+}));
+
+vi.mock("react", () => ({
+ useCallback: (callback: unknown) => callback,
+ useEffect: (setup: () => void | (() => void)) => {
+ setup();
+ },
+ useState: (initial: unknown) => [
+ typeof initial === "function" ? (initial as () => unknown)() : initial,
+ vi.fn(),
+ ],
+}));
+
+vi.mock("react-native", () => ({
+ Alert: { alert: reactNative.alert },
+ AppState: {
+ addEventListener: vi.fn(() => ({ remove: reactNative.removeAppStateListener })),
+ },
+ Platform: { OS: "android" },
+ Pressable: "Pressable",
+ View: "View",
+}));
+
+vi.mock("../../components/AppText", () => ({ AppText: "Text" }));
+vi.mock("../settings/components/SettingsSection", () => ({
+ SettingsSection: "SettingsSection",
+}));
+vi.mock("../settings/components/SettingsSwitchRow", () => ({
+ SettingsSwitchRow: "SettingsSwitchRow",
+}));
+vi.mock("../../native/backgroundConnection", () => ({
+ addBackgroundConnectionStatusListener: vi.fn(() => ({
+ remove: native.removeStatusListener,
+ })),
+ getBackgroundConnectionStatus: () => native.status,
+ requestBackgroundConnectionBatteryOptimizationExemption: native.requestExemption,
+ setBackgroundConnectionEnabled: native.setEnabled,
+}));
+
+import { BackgroundConnectionSettingsSection } from "./BackgroundConnectionSettingsSection";
+
+interface ElementNode {
+ readonly props?: {
+ readonly children?: unknown;
+ readonly onValueChange?: (enabled: boolean) => void;
+ };
+}
+
+function children(node: unknown): ReadonlyArray {
+ const value = (node as ElementNode | null)?.props?.children;
+ if (value === undefined) return [];
+ return Array.isArray(value) ? value : [value];
+}
+
+function renderSwitch(): ElementNode {
+ const root = BackgroundConnectionSettingsSection();
+ const section = children(root)[0];
+ return children(section)[0] as ElementNode;
+}
+
+function status(overrides: Partial = {}): BackgroundConnectionStatus {
+ return {
+ supported: true,
+ enabled: false,
+ serviceRunning: false,
+ runtimeReady: false,
+ batteryOptimizationIgnored: true,
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ native.status = status();
+ native.setEnabled.mockImplementation(async (enabled: boolean) =>
+ status({
+ enabled,
+ serviceRunning: enabled,
+ runtimeReady: enabled,
+ }),
+ );
+ native.requestExemption.mockResolvedValue(status({ enabled: true }));
+});
+
+describe("BackgroundConnectionSettingsSection", () => {
+ it("enables the native runtime and keeps it enabled when battery exemption is declined", async () => {
+ native.setEnabled.mockResolvedValue(
+ status({
+ enabled: true,
+ serviceRunning: true,
+ runtimeReady: true,
+ batteryOptimizationIgnored: false,
+ }),
+ );
+ native.requestExemption.mockResolvedValue(
+ status({ enabled: true, batteryOptimizationIgnored: false }),
+ );
+
+ renderSwitch().props?.onValueChange?.(true);
+
+ await vi.waitFor(() => {
+ expect(native.setEnabled).toHaveBeenCalledWith(true);
+ expect(reactNative.alert).toHaveBeenCalledOnce();
+ });
+ const buttons = reactNative.alert.mock.calls[0]?.[2] as
+ | ReadonlyArray<{ readonly text: string; readonly onPress?: () => void }>
+ | undefined;
+ buttons?.find((button) => button.text === "Allow")?.onPress?.();
+ await vi.waitFor(() => expect(native.requestExemption).toHaveBeenCalledOnce());
+
+ expect(native.setEnabled).not.toHaveBeenCalledWith(false);
+ });
+
+ it("disables the native runtime without requesting a battery exemption", async () => {
+ native.status = status({
+ enabled: true,
+ serviceRunning: true,
+ runtimeReady: true,
+ });
+ native.setEnabled.mockResolvedValue(status());
+
+ renderSwitch().props?.onValueChange?.(false);
+
+ await vi.waitFor(() => expect(native.setEnabled).toHaveBeenCalledWith(false));
+ expect(reactNative.alert).not.toHaveBeenCalled();
+ expect(native.requestExemption).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/mobile/src/features/background-connection/BackgroundConnectionSettingsSection.tsx b/apps/mobile/src/features/background-connection/BackgroundConnectionSettingsSection.tsx
new file mode 100644
index 00000000000..52f8794d810
--- /dev/null
+++ b/apps/mobile/src/features/background-connection/BackgroundConnectionSettingsSection.tsx
@@ -0,0 +1,117 @@
+import { useCallback, useEffect, useState } from "react";
+import { Alert, AppState, Platform, Pressable, View } from "react-native";
+
+import { AppText as Text } from "../../components/AppText";
+import {
+ addBackgroundConnectionStatusListener,
+ getBackgroundConnectionStatus,
+ requestBackgroundConnectionBatteryOptimizationExemption,
+ setBackgroundConnectionEnabled,
+ type BackgroundConnectionStatus,
+} from "../../native/backgroundConnection";
+import { SettingsSection } from "../settings/components/SettingsSection";
+import { SettingsSwitchRow } from "../settings/components/SettingsSwitchRow";
+import {
+ backgroundConnectionStatusLabel,
+ shouldRequestBackgroundConnectionBatteryExemption,
+} from "./settings-model";
+
+function promptForBatteryExemption(
+ requestExemption: () => Promise,
+): void {
+ Alert.alert(
+ "Allow unrestricted battery use?",
+ "Android can otherwise pause the background connection while T3 Code is locked or another app is open.",
+ [
+ { text: "Not Now", style: "cancel" },
+ { text: "Allow", onPress: () => void requestExemption() },
+ ],
+ );
+}
+
+export function BackgroundConnectionSettingsSection() {
+ const [status, setStatus] = useState(getBackgroundConnectionStatus);
+ const [changing, setChanging] = useState(false);
+
+ useEffect(() => {
+ if (Platform.OS !== "android") {
+ return;
+ }
+ setStatus(getBackgroundConnectionStatus());
+ const nativeSubscription = addBackgroundConnectionStatusListener(setStatus);
+ const appStateSubscription = AppState.addEventListener("change", (nextState) => {
+ if (nextState === "active") {
+ setStatus(getBackgroundConnectionStatus());
+ }
+ });
+ return () => {
+ nativeSubscription.remove();
+ appStateSubscription.remove();
+ };
+ }, []);
+
+ const requestExemption = useCallback(async () => {
+ const next = await requestBackgroundConnectionBatteryOptimizationExemption();
+ setStatus(next);
+ return next;
+ }, []);
+
+ const handleEnabledChange = useCallback(
+ async (enabled: boolean) => {
+ if (changing) {
+ return;
+ }
+ setChanging(true);
+ setStatus((current) => ({ ...current, enabled }));
+ const next = await setBackgroundConnectionEnabled(enabled);
+ setStatus(next);
+ setChanging(false);
+ if (shouldRequestBackgroundConnectionBatteryExemption(next)) {
+ promptForBatteryExemption(requestExemption);
+ }
+ },
+ [changing, requestExemption],
+ );
+
+ if (Platform.OS !== "android") {
+ return null;
+ }
+
+ const statusLabel = backgroundConnectionStatusLabel(status);
+ const canRetryBatteryExemption = shouldRequestBackgroundConnectionBatteryExemption(status);
+
+ return (
+
+
+ void handleEnabledChange(enabled)}
+ />
+
+ {canRetryBatteryExemption ? (
+ void requestExemption()}
+ className="py-1"
+ >
+ {statusLabel}
+
+ Tap to allow unrestricted battery use.
+
+
+ ) : (
+ {statusLabel}
+ )}
+
+
+
+ Keeps environments and active work synchronized while your phone is locked or another app is
+ open. This can noticeably increase battery use, and Android will show a silent ongoing
+ service status.
+
+
+ );
+}
diff --git a/apps/mobile/src/features/background-connection/settings-model.test.ts b/apps/mobile/src/features/background-connection/settings-model.test.ts
new file mode 100644
index 00000000000..b7670034e65
--- /dev/null
+++ b/apps/mobile/src/features/background-connection/settings-model.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import type { BackgroundConnectionStatus } from "../../native/backgroundConnection";
+import {
+ backgroundConnectionStatusLabel,
+ shouldRequestBackgroundConnectionBatteryExemption,
+} from "./settings-model";
+
+const status = (overrides: Partial): BackgroundConnectionStatus => ({
+ supported: true,
+ enabled: true,
+ serviceRunning: true,
+ runtimeReady: true,
+ batteryOptimizationIgnored: true,
+ ...overrides,
+});
+
+describe("backgroundConnectionStatusLabel", () => {
+ it("reports the fully ready runtime", () => {
+ expect(backgroundConnectionStatusLabel(status({}))).toBe("Running");
+ });
+
+ it("reports startup until both native service and JavaScript are ready", () => {
+ expect(backgroundConnectionStatusLabel(status({ runtimeReady: false }))).toBe("Starting");
+ expect(backgroundConnectionStatusLabel(status({ serviceRunning: false }))).toBe("Starting");
+ });
+
+ it("makes degraded battery protection explicit", () => {
+ expect(backgroundConnectionStatusLabel(status({ batteryOptimizationIgnored: false }))).toBe(
+ "Battery optimization enabled",
+ );
+ });
+
+ it("reports disabled and unsupported installations as stopped", () => {
+ expect(backgroundConnectionStatusLabel(status({ enabled: false }))).toBe("Stopped");
+ expect(backgroundConnectionStatusLabel(status({ supported: false }))).toBe("Stopped");
+ });
+
+ it("keeps the feature enabled when the battery exemption is still declined", () => {
+ const declined = status({ batteryOptimizationIgnored: false });
+ expect(shouldRequestBackgroundConnectionBatteryExemption(declined)).toBe(true);
+ expect(declined.enabled).toBe(true);
+ });
+
+ it("does not request an exemption while disabling", () => {
+ expect(
+ shouldRequestBackgroundConnectionBatteryExemption(
+ status({ enabled: false, batteryOptimizationIgnored: false }),
+ ),
+ ).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/features/background-connection/settings-model.ts b/apps/mobile/src/features/background-connection/settings-model.ts
new file mode 100644
index 00000000000..ed31d1863df
--- /dev/null
+++ b/apps/mobile/src/features/background-connection/settings-model.ts
@@ -0,0 +1,25 @@
+import type { BackgroundConnectionStatus } from "../../native/backgroundConnection";
+
+export type BackgroundConnectionStatusLabel =
+ | "Running"
+ | "Starting"
+ | "Stopped"
+ | "Battery optimization enabled";
+
+export function backgroundConnectionStatusLabel(
+ status: BackgroundConnectionStatus,
+): BackgroundConnectionStatusLabel {
+ if (!status.supported || !status.enabled) {
+ return "Stopped";
+ }
+ if (!status.batteryOptimizationIgnored) {
+ return "Battery optimization enabled";
+ }
+ return status.serviceRunning && status.runtimeReady ? "Running" : "Starting";
+}
+
+export function shouldRequestBackgroundConnectionBatteryExemption(
+ status: BackgroundConnectionStatus,
+): boolean {
+ return status.supported && status.enabled && !status.batteryOptimizationIgnored;
+}
diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
index 49adfe75cb2..63ecfd41238 100644
--- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
@@ -47,6 +47,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re
import { SettingsRow } from "./components/SettingsRow";
import { SettingsSection } from "./components/SettingsSection";
import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
+import { BackgroundConnectionSettingsSection } from "../background-connection/BackgroundConnectionSettingsSection";
type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";
@@ -127,6 +128,8 @@ function LocalSettingsRouteScreen() {
+
+
@@ -515,6 +518,8 @@ function ConfiguredSettingsRouteScreen() {
+
+
diff --git a/apps/mobile/src/native/backgroundConnection.test.ts b/apps/mobile/src/native/backgroundConnection.test.ts
new file mode 100644
index 00000000000..755d6206a34
--- /dev/null
+++ b/apps/mobile/src/native/backgroundConnection.test.ts
@@ -0,0 +1,108 @@
+import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+const expoMocks = vi.hoisted(() => ({
+ requireOptionalNativeModule: vi.fn((_name: string): unknown => null),
+}));
+
+vi.mock("expo", () => ({
+ NativeModule: class {
+ readonly __nativeModule = true;
+ },
+ requireOptionalNativeModule: expoMocks.requireOptionalNativeModule,
+}));
+
+const runningStatus = {
+ supported: true,
+ enabled: true,
+ serviceRunning: true,
+ runtimeReady: true,
+ batteryOptimizationIgnored: true,
+} as const;
+
+describe("backgroundConnection native bridge", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.resetModules();
+ expoMocks.requireOptionalNativeModule.mockReturnValue(null);
+ });
+
+ it("is safe when the Android native module is unavailable", async () => {
+ const bridge = await import("./backgroundConnection");
+
+ expect(bridge.getBackgroundConnectionStatus()).toEqual({
+ supported: false,
+ enabled: false,
+ serviceRunning: false,
+ runtimeReady: false,
+ batteryOptimizationIgnored: false,
+ });
+ await expect(bridge.setBackgroundConnectionEnabled(true)).resolves.toMatchObject({
+ supported: false,
+ });
+ await expect(
+ bridge.requestBackgroundConnectionBatteryOptimizationExemption(),
+ ).resolves.toMatchObject({ supported: false });
+ expect(bridge.ensureBackgroundConnectionStarted()).toMatchObject({ supported: false });
+ expect(bridge.addBackgroundConnectionStatusListener(vi.fn()).remove).not.toThrow();
+ expect(bridge.addBackgroundConnectionStopRequestListener(vi.fn()).remove).not.toThrow();
+ });
+
+ it("forwards status and lifecycle calls to the installed native module", async () => {
+ const setEnabled = vi.fn(async () => runningStatus);
+ const ensureStarted = vi.fn(() => runningStatus);
+ const requestBatteryOptimizationExemption = vi.fn(async () => runningStatus);
+ const setRuntimeReady = vi.fn(() => runningStatus);
+ const acknowledgeStop = vi.fn(() => runningStatus);
+ expoMocks.requireOptionalNativeModule.mockReturnValue({
+ getStatus: () => runningStatus,
+ setEnabled,
+ ensureStarted,
+ requestBatteryOptimizationExemption,
+ setRuntimeReady,
+ acknowledgeStop,
+ });
+ const bridge = await import("./backgroundConnection");
+
+ expect(bridge.getBackgroundConnectionStatus()).toEqual(runningStatus);
+ await expect(bridge.setBackgroundConnectionEnabled(true)).resolves.toEqual(runningStatus);
+ await expect(bridge.requestBackgroundConnectionBatteryOptimizationExemption()).resolves.toEqual(
+ runningStatus,
+ );
+ expect(bridge.ensureBackgroundConnectionStarted()).toEqual(runningStatus);
+ expect(bridge.setBackgroundConnectionRuntimeReady(true)).toEqual(runningStatus);
+ expect(bridge.acknowledgeBackgroundConnectionStop()).toEqual(runningStatus);
+ expect(setEnabled).toHaveBeenCalledWith(true);
+ expect(requestBatteryOptimizationExemption).toHaveBeenCalledOnce();
+ expect(ensureStarted).toHaveBeenCalledOnce();
+ expect(setRuntimeReady).toHaveBeenCalledWith(true);
+ expect(acknowledgeStop).toHaveBeenCalledOnce();
+ });
+
+ it("normalizes native status events and exposes the stop request", async () => {
+ const listeners = new Map void>();
+ const remove = vi.fn();
+ expoMocks.requireOptionalNativeModule.mockReturnValue({
+ getStatus: () => runningStatus,
+ addListener: (eventName: string, listener: (event?: unknown) => void) => {
+ listeners.set(eventName, listener);
+ return { remove };
+ },
+ });
+ const bridge = await import("./backgroundConnection");
+ const onStatus = vi.fn();
+ const onStop = vi.fn();
+
+ const statusSubscription = bridge.addBackgroundConnectionStatusListener(onStatus);
+ bridge.addBackgroundConnectionStopRequestListener(onStop);
+ listeners.get("onStatusChange")?.({
+ ...runningStatus,
+ runtimeReady: 1,
+ });
+ listeners.get("onStopRequested")?.();
+ statusSubscription.remove();
+
+ expect(onStatus).toHaveBeenCalledWith({ ...runningStatus, runtimeReady: false });
+ expect(onStop).toHaveBeenCalledOnce();
+ expect(remove).toHaveBeenCalledOnce();
+ });
+});
diff --git a/apps/mobile/src/native/backgroundConnection.ts b/apps/mobile/src/native/backgroundConnection.ts
new file mode 100644
index 00000000000..06cf1b01111
--- /dev/null
+++ b/apps/mobile/src/native/backgroundConnection.ts
@@ -0,0 +1,141 @@
+import { NativeModule, requireOptionalNativeModule } from "expo";
+
+export interface BackgroundConnectionStatus {
+ readonly supported: boolean;
+ readonly enabled: boolean;
+ readonly serviceRunning: boolean;
+ readonly runtimeReady: boolean;
+ readonly batteryOptimizationIgnored: boolean;
+}
+
+type BackgroundConnectionNativeEvents = {
+ readonly onStatusChange: (status: BackgroundConnectionStatus) => void;
+ readonly onStopRequested: () => void;
+};
+
+declare class BackgroundConnectionNativeModule extends NativeModule {
+ readonly getStatus?: () => BackgroundConnectionStatus;
+ readonly setEnabled?: (enabled: boolean) => Promise;
+ readonly ensureStarted?: () => BackgroundConnectionStatus;
+ readonly requestBatteryOptimizationExemption?: () => Promise;
+ readonly setRuntimeReady?: (ready: boolean) => BackgroundConnectionStatus;
+ readonly acknowledgeStop?: () => BackgroundConnectionStatus;
+}
+
+export interface BackgroundConnectionSubscription {
+ readonly remove: () => void;
+}
+
+const UNSUPPORTED_STATUS: BackgroundConnectionStatus = {
+ supported: false,
+ enabled: false,
+ serviceRunning: false,
+ runtimeReady: false,
+ batteryOptimizationIgnored: false,
+};
+
+let cachedNativeModule: BackgroundConnectionNativeModule | null | undefined;
+
+function getNativeModule(): BackgroundConnectionNativeModule | null {
+ if (cachedNativeModule !== undefined) return cachedNativeModule;
+ try {
+ cachedNativeModule =
+ requireOptionalNativeModule("T3BackgroundConnection");
+ } catch {
+ cachedNativeModule = null;
+ }
+ return cachedNativeModule;
+}
+
+function normalizeStatus(status: BackgroundConnectionStatus | null | undefined) {
+ if (status?.supported !== true) return UNSUPPORTED_STATUS;
+ return {
+ supported: true,
+ enabled: status.enabled === true,
+ serviceRunning: status.serviceRunning === true,
+ runtimeReady: status.runtimeReady === true,
+ batteryOptimizationIgnored: status.batteryOptimizationIgnored === true,
+ } satisfies BackgroundConnectionStatus;
+}
+
+export function getBackgroundConnectionStatus(): BackgroundConnectionStatus {
+ try {
+ return normalizeStatus(getNativeModule()?.getStatus?.());
+ } catch {
+ return UNSUPPORTED_STATUS;
+ }
+}
+
+export async function setBackgroundConnectionEnabled(
+ enabled: boolean,
+): Promise {
+ try {
+ const nativeModule = getNativeModule();
+ if (!nativeModule?.setEnabled) return UNSUPPORTED_STATUS;
+ return normalizeStatus(await nativeModule.setEnabled(enabled));
+ } catch {
+ return getBackgroundConnectionStatus();
+ }
+}
+
+export function ensureBackgroundConnectionStarted(): BackgroundConnectionStatus {
+ try {
+ return normalizeStatus(getNativeModule()?.ensureStarted?.());
+ } catch {
+ return getBackgroundConnectionStatus();
+ }
+}
+
+export async function requestBackgroundConnectionBatteryOptimizationExemption(): Promise {
+ try {
+ const nativeModule = getNativeModule();
+ if (!nativeModule?.requestBatteryOptimizationExemption) return UNSUPPORTED_STATUS;
+ return normalizeStatus(await nativeModule.requestBatteryOptimizationExemption());
+ } catch {
+ return getBackgroundConnectionStatus();
+ }
+}
+
+export function setBackgroundConnectionRuntimeReady(ready: boolean): BackgroundConnectionStatus {
+ try {
+ return normalizeStatus(getNativeModule()?.setRuntimeReady?.(ready));
+ } catch {
+ return getBackgroundConnectionStatus();
+ }
+}
+
+export function acknowledgeBackgroundConnectionStop(): BackgroundConnectionStatus {
+ try {
+ return normalizeStatus(getNativeModule()?.acknowledgeStop?.());
+ } catch {
+ return getBackgroundConnectionStatus();
+ }
+}
+
+export function addBackgroundConnectionStatusListener(
+ listener: (status: BackgroundConnectionStatus) => void,
+): BackgroundConnectionSubscription {
+ try {
+ const nativeModule = getNativeModule();
+ if (!nativeModule) return NOOP_SUBSCRIPTION;
+ return nativeModule.addListener("onStatusChange", (status) => {
+ listener(normalizeStatus(status));
+ });
+ } catch {
+ return NOOP_SUBSCRIPTION;
+ }
+}
+
+export function addBackgroundConnectionStopRequestListener(
+ listener: () => void,
+): BackgroundConnectionSubscription {
+ try {
+ return getNativeModule()?.addListener("onStopRequested", listener) ?? NOOP_SUBSCRIPTION;
+ } catch {
+ return NOOP_SUBSCRIPTION;
+ }
+}
+
+const NOOP_SUBSCRIPTION: BackgroundConnectionSubscription = {
+ remove() {},
+};
From f95b46a23bce06ae76d8833d09fba4a3def86a80 Mon Sep 17 00:00:00 2001
From: Mike <84413695+snipemanmike@users.noreply.github.com>
Date: Sat, 1 Aug 2026 01:49:08 -0700
Subject: [PATCH 2/7] feat(mobile): keep connection state mounted headlessly
---
apps/mobile/index.ts | 2 +
apps/mobile/src/Stack.tsx | 10 +
.../background-root.test.ts | 270 ++++++++++++++++++
.../background-connection/background-root.ts | 250 ++++++++++++++++
.../background-task.test.ts | 115 ++++++++
.../background-connection/background-task.ts | 85 ++++++
.../headless-task-registration.test.ts | 41 +++
.../headless-task-registration.ts | 42 +++
.../retained-thread.test.ts | 98 +++++++
.../background-connection/retained-thread.ts | 124 ++++++++
.../target-selection.test.ts | 58 ++++
.../background-connection/target-selection.ts | 36 +++
.../connection/useConnectionController.ts | 17 +-
.../src/features/home/useThreadListActions.ts | 12 +-
.../SettingsClientStorageRouteScreen.tsx | 25 +-
apps/mobile/src/persistence/imperative.ts | 10 +
.../src/persistence/mobile-storage.test.ts | 59 ++++
apps/mobile/src/persistence/mobile-storage.ts | 72 ++++-
18 files changed, 1319 insertions(+), 7 deletions(-)
create mode 100644 apps/mobile/src/features/background-connection/background-root.test.ts
create mode 100644 apps/mobile/src/features/background-connection/background-root.ts
create mode 100644 apps/mobile/src/features/background-connection/background-task.test.ts
create mode 100644 apps/mobile/src/features/background-connection/background-task.ts
create mode 100644 apps/mobile/src/features/background-connection/headless-task-registration.test.ts
create mode 100644 apps/mobile/src/features/background-connection/headless-task-registration.ts
create mode 100644 apps/mobile/src/features/background-connection/retained-thread.test.ts
create mode 100644 apps/mobile/src/features/background-connection/retained-thread.ts
create mode 100644 apps/mobile/src/features/background-connection/target-selection.test.ts
create mode 100644 apps/mobile/src/features/background-connection/target-selection.ts
create mode 100644 apps/mobile/src/persistence/mobile-storage.test.ts
diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts
index 9b887f58a28..d4e8490e1c1 100644
--- a/apps/mobile/index.ts
+++ b/apps/mobile/index.ts
@@ -4,6 +4,7 @@ import { LogBox } from "react-native";
import { featureFlags } from "react-native-screens";
import App from "./src/App";
+import { registerBackgroundConnectionHeadlessTask } from "./src/features/background-connection/headless-task-registration";
// Required for react-native-screens' iOS FormSheet sizing fix when a nested
// native stack is rendered inside a non-fitToContents formSheet.
@@ -13,4 +14,5 @@ if (process.env.EXPO_PUBLIC_SHOWCASE === "1") {
LogBox.ignoreAllLogs();
}
+registerBackgroundConnectionHeadlessTask();
registerRootComponent(App);
diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx
index 719a6a4ad59..a9c59aaa873 100644
--- a/apps/mobile/src/Stack.tsx
+++ b/apps/mobile/src/Stack.tsx
@@ -24,6 +24,8 @@ import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardi
import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";
import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout";
import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider";
+import { parseActiveThreadPath } from "./features/keyboard/hardwareKeyboardCommands";
+import { saveBackgroundConnectionRetainedThread } from "./features/background-connection/retained-thread";
import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet";
import { ReviewSheet } from "./features/review/ReviewSheet";
import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen";
@@ -326,6 +328,14 @@ function RootStackLayout(props: {
const path = getPathFromState(props.state, navigationPathConfig);
const pathname = path.startsWith("/") ? path : `/${path}`;
const workspacePathname = workspacePathFromState(props.state);
+ const activeThread = parseActiveThreadPath(workspacePathname);
+
+ useEffect(() => {
+ if (Platform.OS !== "android" || activeThread === null) {
+ return;
+ }
+ void saveBackgroundConnectionRetainedThread(activeThread);
+ }, [activeThread?.environmentId, activeThread?.threadId]);
return (
diff --git a/apps/mobile/src/features/background-connection/background-root.test.ts b/apps/mobile/src/features/background-connection/background-root.test.ts
new file mode 100644
index 00000000000..0cc0e754957
--- /dev/null
+++ b/apps/mobile/src/features/background-connection/background-root.test.ts
@@ -0,0 +1,270 @@
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";
+import { AsyncResult, type AtomRegistry } from "effect/unstable/reactivity";
+import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+const atoms = vi.hoisted(() => ({
+ catalog: { name: "catalog" },
+ serverConfigs: { name: "server-configs" },
+ threadShells: { name: "thread-shells" },
+ shellStates: new Map(),
+ detailStates: new Map(),
+}));
+
+const retained = vi.hoisted(() => {
+ const state: {
+ snapshot: { loaded: boolean; thread: ScopedThreadRef | null };
+ subscribeCount: number;
+ releaseCount: number;
+ } = {
+ snapshot: { loaded: true, thread: null },
+ subscribeCount: 0,
+ releaseCount: 0,
+ };
+ const listeners = new Set<() => void>();
+ return {
+ state,
+ listeners,
+ clear: vi.fn(async () => {
+ state.snapshot = { loaded: true, thread: null };
+ for (const listener of listeners) {
+ listener();
+ }
+ }),
+ ensureLoaded: vi.fn(async () => state.snapshot.thread),
+ };
+});
+
+vi.mock("../../connection/catalog", () => ({
+ environmentCatalog: { catalogValueAtom: atoms.catalog },
+}));
+
+vi.mock("../../state/server", () => ({
+ environmentServerConfigsAtom: atoms.serverConfigs,
+}));
+
+vi.mock("../../state/shell", () => ({
+ environmentShell: {
+ stateAtom: (environmentId: string) => {
+ let atom = atoms.shellStates.get(environmentId);
+ if (atom === undefined) {
+ atom = { name: `shell:${environmentId}` };
+ atoms.shellStates.set(environmentId, atom);
+ }
+ return atom;
+ },
+ },
+}));
+
+vi.mock("../../state/threads", () => ({
+ environmentThreadShells: { threadShellsAtom: atoms.threadShells },
+ environmentThreads: {
+ stateAtom: (environmentId: string, threadId: string) => {
+ const key = `${environmentId}:${threadId}`;
+ let atom = atoms.detailStates.get(key);
+ if (atom === undefined) {
+ atom = { name: `detail:${key}`, detailKey: key };
+ atoms.detailStates.set(key, atom);
+ }
+ return atom;
+ },
+ },
+}));
+
+vi.mock("./retained-thread", () => ({
+ clearBackgroundConnectionRetainedThread: retained.clear,
+ ensureBackgroundConnectionRetainedThreadLoaded: retained.ensureLoaded,
+ getBackgroundConnectionRetainedThreadSnapshot: () => retained.state.snapshot,
+ subscribeBackgroundConnectionRetainedThread: (listener: () => void) => {
+ retained.state.subscribeCount += 1;
+ retained.listeners.add(listener);
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ retained.state.releaseCount += 1;
+ retained.listeners.delete(listener);
+ };
+ },
+}));
+
+import { acquireBackgroundConnectionRoot, createBackgroundConnectionRoot } from "./background-root";
+
+interface CatalogState {
+ readonly isReady: boolean;
+ readonly entries: ReadonlyMap;
+}
+
+function atomName(atom: unknown): string {
+ return (atom as { readonly name: string }).name;
+}
+
+function createRegistry(options: {
+ readonly catalog: CatalogState;
+ readonly threadShells?: ReadonlyArray;
+ readonly deletedThreadKeys?: ReadonlySet;
+}) {
+ let catalog = options.catalog;
+ const threadShells = options.threadShells ?? [];
+ const deletedThreadKeys = options.deletedThreadKeys ?? new Set();
+ const callbacks = new Map void>>();
+ const mountCounts = new Map();
+ const mountReleaseCounts = new Map();
+ const subscribeCounts = new Map();
+ const subscribeReleaseCounts = new Map();
+
+ const increment = (counts: Map, name: string) =>
+ counts.set(name, (counts.get(name) ?? 0) + 1);
+ const read = (atom: unknown): unknown => {
+ if (atom === atoms.catalog) return catalog;
+ if (atom === atoms.threadShells) return threadShells;
+ const detailKey = (atom as { readonly detailKey?: string }).detailKey;
+ if (detailKey !== undefined) {
+ return AsyncResult.success({
+ status: deletedThreadKeys.has(detailKey) ? "deleted" : "live",
+ });
+ }
+ return null;
+ };
+
+ const registry = {
+ get: read,
+ mount(atom: unknown) {
+ const name = atomName(atom);
+ increment(mountCounts, name);
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ increment(mountReleaseCounts, name);
+ };
+ },
+ subscribe(
+ atom: unknown,
+ callback: (value: unknown) => void,
+ subscribeOptions?: { readonly immediate?: boolean },
+ ) {
+ const name = atomName(atom);
+ increment(subscribeCounts, name);
+ const atomCallbacks = callbacks.get(atom) ?? new Set();
+ atomCallbacks.add(callback);
+ callbacks.set(atom, atomCallbacks);
+ if (subscribeOptions?.immediate === true) {
+ callback(read(atom));
+ }
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ increment(subscribeReleaseCounts, name);
+ atomCallbacks.delete(callback);
+ };
+ },
+ } as unknown as AtomRegistry.AtomRegistry;
+
+ return {
+ registry,
+ mountCount: (name: string) => mountCounts.get(name) ?? 0,
+ mountReleaseCount: (name: string) => mountReleaseCounts.get(name) ?? 0,
+ subscribeCount: (name: string) => subscribeCounts.get(name) ?? 0,
+ subscribeReleaseCount: (name: string) => subscribeReleaseCounts.get(name) ?? 0,
+ setCatalog(next: CatalogState) {
+ catalog = next;
+ for (const callback of callbacks.get(atoms.catalog) ?? []) {
+ callback(catalog);
+ }
+ },
+ };
+}
+
+const environmentId = EnvironmentId.make("environment-1");
+const threadId = ThreadId.make("thread-1");
+const retainedThread = { environmentId, threadId };
+
+beforeEach(() => {
+ retained.state.snapshot = { loaded: true, thread: retainedThread };
+ retained.state.subscribeCount = 0;
+ retained.state.releaseCount = 0;
+ retained.listeners.clear();
+ retained.clear.mockClear();
+ retained.ensureLoaded.mockClear();
+ atoms.shellStates.clear();
+ atoms.detailStates.clear();
+});
+
+describe("background connection root", () => {
+ it("starts and stops each lease exactly once", () => {
+ const harness = createRegistry({
+ catalog: { isReady: true, entries: new Map([[environmentId, {}]]) },
+ });
+ const root = createBackgroundConnectionRoot(harness.registry);
+
+ root.start();
+ root.start();
+
+ expect(harness.mountCount("server-configs")).toBe(1);
+ expect(harness.mountCount(`shell:${environmentId}`)).toBe(1);
+ expect(harness.subscribeCount("catalog")).toBe(1);
+ expect(harness.subscribeCount("thread-shells")).toBe(1);
+ expect(harness.subscribeCount(`detail:${environmentId}:${threadId}`)).toBe(1);
+ expect(retained.state.subscribeCount).toBe(1);
+
+ root.stop();
+ root.stop();
+
+ expect(harness.mountReleaseCount("server-configs")).toBe(1);
+ expect(harness.mountReleaseCount(`shell:${environmentId}`)).toBe(1);
+ expect(harness.subscribeReleaseCount("catalog")).toBe(1);
+ expect(harness.subscribeReleaseCount("thread-shells")).toBe(1);
+ expect(harness.subscribeReleaseCount(`detail:${environmentId}:${threadId}`)).toBe(1);
+ expect(retained.state.releaseCount).toBe(1);
+ });
+
+ it("shares one root until the final owner releases it", () => {
+ const harness = createRegistry({
+ catalog: { isReady: true, entries: new Map([[environmentId, {}]]) },
+ });
+
+ const releaseFirst = acquireBackgroundConnectionRoot(harness.registry);
+ const releaseSecond = acquireBackgroundConnectionRoot(harness.registry);
+ expect(harness.mountCount("server-configs")).toBe(1);
+
+ releaseFirst();
+ expect(harness.mountReleaseCount("server-configs")).toBe(0);
+ releaseSecond();
+ releaseSecond();
+ expect(harness.mountReleaseCount("server-configs")).toBe(1);
+ });
+
+ it("clears a retained target when its environment is removed", () => {
+ const harness = createRegistry({
+ catalog: { isReady: true, entries: new Map([[environmentId, {}]]) },
+ });
+ const root = createBackgroundConnectionRoot(harness.registry);
+ root.start();
+
+ harness.setCatalog({ isReady: true, entries: new Map() });
+
+ expect(retained.clear).toHaveBeenCalledOnce();
+ expect(retained.state.snapshot.thread).toBeNull();
+ expect(harness.subscribeReleaseCount(`detail:${environmentId}:${threadId}`)).toBe(1);
+ root.stop();
+ });
+
+ it("clears and releases an immediately deleted retained thread", () => {
+ const detailKey = `${environmentId}:${threadId}`;
+ const harness = createRegistry({
+ catalog: { isReady: true, entries: new Map([[environmentId, {}]]) },
+ deletedThreadKeys: new Set([detailKey]),
+ });
+ const root = createBackgroundConnectionRoot(harness.registry);
+
+ root.start();
+
+ expect(retained.clear).toHaveBeenCalledOnce();
+ expect(retained.state.snapshot.thread).toBeNull();
+ expect(harness.subscribeCount(`detail:${detailKey}`)).toBe(1);
+ expect(harness.subscribeReleaseCount(`detail:${detailKey}`)).toBe(1);
+ root.stop();
+ });
+});
diff --git a/apps/mobile/src/features/background-connection/background-root.ts b/apps/mobile/src/features/background-connection/background-root.ts
new file mode 100644
index 00000000000..3e165d153dd
--- /dev/null
+++ b/apps/mobile/src/features/background-connection/background-root.ts
@@ -0,0 +1,250 @@
+import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
+import * as Option from "effect/Option";
+import { AsyncResult, type AtomRegistry } from "effect/unstable/reactivity";
+
+import { environmentCatalog } from "../../connection/catalog";
+import { environmentServerConfigsAtom } from "../../state/server";
+import { environmentShell } from "../../state/shell";
+import { environmentThreadShells, environmentThreads } from "../../state/threads";
+import {
+ clearBackgroundConnectionRetainedThread,
+ ensureBackgroundConnectionRetainedThreadLoaded,
+ getBackgroundConnectionRetainedThreadSnapshot,
+ subscribeBackgroundConnectionRetainedThread,
+} from "./retained-thread";
+import { selectBackgroundConnectionThreadTargets } from "./target-selection";
+
+interface DetailLease {
+ readonly ref: ScopedThreadRef;
+ readonly release: () => void;
+}
+
+interface BackgroundConnectionRoot {
+ readonly start: () => void;
+ readonly stop: () => void;
+}
+
+function refKey(ref: ScopedThreadRef): string {
+ return JSON.stringify([ref.environmentId, ref.threadId]);
+}
+
+function refsEqual(left: ScopedThreadRef | null, right: ScopedThreadRef): boolean {
+ return (
+ left !== null && left.environmentId === right.environmentId && left.threadId === right.threadId
+ );
+}
+
+function releaseBestEffort(label: string, release: (() => void) | null): void {
+ if (release === null) {
+ return;
+ }
+ try {
+ release();
+ } catch (error) {
+ console.error(`[background-connection] failed to release ${label}`, error);
+ }
+}
+
+export function createBackgroundConnectionRoot(
+ registry: AtomRegistry.AtomRegistry,
+): BackgroundConnectionRoot {
+ let started = false;
+ let retainedThread = getBackgroundConnectionRetainedThreadSnapshot().thread;
+ let catalogReady = false;
+ let catalogEnvironmentIds = new Set();
+ let threadShells = registry.get(environmentThreadShells.threadShellsAtom);
+ let catalogRelease: (() => void) | null = null;
+ let threadShellsRelease: (() => void) | null = null;
+ let serverConfigsRelease: (() => void) | null = null;
+ let retainedThreadRelease: (() => void) | null = null;
+ let clearingDeletedKey: string | null = null;
+ const shellLeases = new Map void>();
+ const detailLeases = new Map();
+
+ const clearRetainedIfCurrent = (ref: ScopedThreadRef) => {
+ if (!refsEqual(retainedThread, ref)) {
+ return;
+ }
+ const key = refKey(ref);
+ if (clearingDeletedKey === key) {
+ return;
+ }
+ clearingDeletedKey = key;
+ void clearBackgroundConnectionRetainedThread().finally(() => {
+ if (clearingDeletedKey === key) {
+ clearingDeletedKey = null;
+ }
+ });
+ };
+
+ const syncDetailLeases = () => {
+ if (!started) {
+ return;
+ }
+ const targets = selectBackgroundConnectionThreadTargets(retainedThread, threadShells);
+ const targetKeys = new Set(targets.map(refKey));
+
+ for (const [key, lease] of detailLeases) {
+ if (targetKeys.has(key)) {
+ continue;
+ }
+ detailLeases.delete(key);
+ releaseBestEffort(`thread detail ${key}`, lease.release);
+ }
+
+ for (const ref of targets) {
+ const key = refKey(ref);
+ if (detailLeases.has(key)) {
+ continue;
+ }
+ const atom = environmentThreads.stateAtom(ref.environmentId, ref.threadId);
+ let subscribedRelease: (() => void) | null = null;
+ const lease: DetailLease = {
+ ref,
+ release: () => subscribedRelease?.(),
+ };
+ // Register the lease before subscribing because an immediate deleted
+ // state can synchronously clear the retained target and re-enter this
+ // reconciliation.
+ detailLeases.set(key, lease);
+ subscribedRelease = registry.subscribe(
+ atom,
+ (result) => {
+ const state = Option.getOrNull(AsyncResult.value(result));
+ if (state?.status === "deleted") {
+ clearRetainedIfCurrent(ref);
+ }
+ },
+ { immediate: true },
+ );
+ if (!detailLeases.has(key)) {
+ subscribedRelease();
+ }
+ }
+ };
+
+ const validateRetainedEnvironment = () => {
+ if (
+ catalogReady &&
+ retainedThread !== null &&
+ !catalogEnvironmentIds.has(retainedThread.environmentId)
+ ) {
+ clearRetainedIfCurrent(retainedThread);
+ }
+ };
+
+ const syncRetainedThread = () => {
+ retainedThread = getBackgroundConnectionRetainedThreadSnapshot().thread;
+ validateRetainedEnvironment();
+ syncDetailLeases();
+ };
+
+ return {
+ start() {
+ if (started) {
+ return;
+ }
+ started = true;
+ retainedThreadRelease = subscribeBackgroundConnectionRetainedThread(syncRetainedThread);
+ retainedThread = getBackgroundConnectionRetainedThreadSnapshot().thread;
+ serverConfigsRelease = registry.mount(environmentServerConfigsAtom);
+ catalogRelease = registry.subscribe(
+ environmentCatalog.catalogValueAtom,
+ (catalog) => {
+ catalogReady = catalog.isReady;
+ catalogEnvironmentIds = new Set(catalog.entries.keys());
+
+ for (const [environmentId, release] of shellLeases) {
+ if (catalogEnvironmentIds.has(environmentId)) {
+ continue;
+ }
+ shellLeases.delete(environmentId);
+ releaseBestEffort(`environment shell ${environmentId}`, release);
+ }
+ for (const environmentId of catalogEnvironmentIds) {
+ if (!shellLeases.has(environmentId)) {
+ shellLeases.set(
+ environmentId,
+ registry.mount(environmentShell.stateAtom(environmentId)),
+ );
+ }
+ }
+ validateRetainedEnvironment();
+ },
+ { immediate: true },
+ );
+ threadShellsRelease = registry.subscribe(
+ environmentThreadShells.threadShellsAtom,
+ (nextThreadShells) => {
+ threadShells = nextThreadShells;
+ syncDetailLeases();
+ },
+ { immediate: true },
+ );
+ void ensureBackgroundConnectionRetainedThreadLoaded();
+ syncDetailLeases();
+ },
+ stop() {
+ if (!started) {
+ return;
+ }
+ started = false;
+ releaseBestEffort("retained-thread listener", retainedThreadRelease);
+ retainedThreadRelease = null;
+ releaseBestEffort("thread-shell listener", threadShellsRelease);
+ threadShellsRelease = null;
+ releaseBestEffort("environment catalog", catalogRelease);
+ catalogRelease = null;
+ releaseBestEffort("server configs", serverConfigsRelease);
+ serverConfigsRelease = null;
+ for (const [environmentId, release] of shellLeases) {
+ releaseBestEffort(`environment shell ${environmentId}`, release);
+ }
+ shellLeases.clear();
+ for (const [key, lease] of detailLeases) {
+ releaseBestEffort(`thread detail ${key}`, lease.release);
+ }
+ detailLeases.clear();
+ },
+ };
+}
+
+const roots = new WeakMap<
+ AtomRegistry.AtomRegistry,
+ { readonly root: BackgroundConnectionRoot; owners: number }
+>();
+
+export function acquireBackgroundConnectionRoot(registry: AtomRegistry.AtomRegistry): () => void {
+ let shared = roots.get(registry);
+ if (shared === undefined) {
+ const root = createBackgroundConnectionRoot(registry);
+ shared = { root, owners: 1 };
+ roots.set(registry, shared);
+ try {
+ root.start();
+ } catch (error) {
+ roots.delete(registry);
+ root.stop();
+ throw error;
+ }
+ } else {
+ shared.owners += 1;
+ }
+ let released = false;
+ return () => {
+ if (released) {
+ return;
+ }
+ released = true;
+ const current = roots.get(registry);
+ if (current === undefined) {
+ return;
+ }
+ current.owners -= 1;
+ if (current.owners > 0) {
+ return;
+ }
+ current.root.stop();
+ roots.delete(registry);
+ };
+}
diff --git a/apps/mobile/src/features/background-connection/background-task.test.ts b/apps/mobile/src/features/background-connection/background-task.test.ts
new file mode 100644
index 00000000000..3c32bfbcde3
--- /dev/null
+++ b/apps/mobile/src/features/background-connection/background-task.test.ts
@@ -0,0 +1,115 @@
+import { beforeEach, expect, it, vi } from "vite-plus/test";
+
+const mocks = vi.hoisted(() => ({
+ acquireRoot: vi.fn(() => vi.fn()),
+ acquireOutbox: vi.fn(() => vi.fn()),
+ relayStop: vi.fn(),
+ setRuntimeReady: vi.fn(),
+ acknowledgeStop: vi.fn(),
+ enabled: true,
+ firstAttempt: Promise.resolve(),
+ stopListener: null as (() => void) | null,
+ removeStopListener: vi.fn(),
+}));
+
+vi.mock("../cloud/backgroundManagedRelayAuth", () => ({
+ startBackgroundManagedRelayAuth: () => ({
+ firstAttempt: mocks.firstAttempt,
+ retryNow: vi.fn(),
+ stop: mocks.relayStop,
+ }),
+}));
+vi.mock("../../native/backgroundConnection", () => ({
+ addBackgroundConnectionStopRequestListener: (listener: () => void) => {
+ mocks.stopListener = listener;
+ return { remove: mocks.removeStopListener };
+ },
+ getBackgroundConnectionStatus: () => ({ enabled: mocks.enabled }),
+ setBackgroundConnectionRuntimeReady: mocks.setRuntimeReady,
+ acknowledgeBackgroundConnectionStop: mocks.acknowledgeStop,
+}));
+vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: {} }));
+vi.mock("../../state/use-thread-outbox-drain", () => ({
+ acquireThreadOutboxDrain: mocks.acquireOutbox,
+}));
+vi.mock("./background-root", () => ({
+ acquireBackgroundConnectionRoot: mocks.acquireRoot,
+}));
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.resetModules();
+ mocks.enabled = true;
+ mocks.firstAttempt = Promise.resolve();
+ mocks.stopListener = null;
+});
+
+function deferred() {
+ let resolve!: () => void;
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+}
+
+it("shares one runtime across duplicate native task starts and cleans it up once", async () => {
+ const { runBackgroundConnectionHeadlessTask } = await import("./background-task");
+
+ const first = runBackgroundConnectionHeadlessTask();
+ const second = runBackgroundConnectionHeadlessTask();
+ expect(first).toBe(second);
+ await vi.waitFor(() => {
+ expect(mocks.acquireRoot).toHaveBeenCalledOnce();
+ expect(mocks.acquireOutbox).toHaveBeenCalledOnce();
+ expect(mocks.setRuntimeReady).toHaveBeenCalledWith(true);
+ });
+
+ mocks.stopListener?.();
+ await Promise.all([first, second]);
+
+ expect(mocks.acquireRoot.mock.results[0]?.value).toHaveBeenCalledOnce();
+ expect(mocks.acquireOutbox.mock.results[0]?.value).toHaveBeenCalledOnce();
+ expect(mocks.relayStop).toHaveBeenCalledOnce();
+ expect(mocks.removeStopListener).toHaveBeenCalledOnce();
+ expect(mocks.setRuntimeReady).toHaveBeenLastCalledWith(false);
+ expect(mocks.acknowledgeStop).toHaveBeenCalledOnce();
+});
+
+it("cleans up a task that finishes loading after the feature was disabled", async () => {
+ mocks.enabled = false;
+ const { runBackgroundConnectionHeadlessTask } = await import("./background-task");
+
+ await runBackgroundConnectionHeadlessTask();
+
+ expect(mocks.acquireRoot).not.toHaveBeenCalled();
+ expect(mocks.acquireOutbox).not.toHaveBeenCalled();
+ expect(mocks.setRuntimeReady).not.toHaveBeenCalledWith(true);
+ expect(mocks.relayStop).not.toHaveBeenCalled();
+ expect(mocks.removeStopListener).toHaveBeenCalledOnce();
+ expect(mocks.setRuntimeReady).toHaveBeenLastCalledWith(false);
+ expect(mocks.acknowledgeStop).toHaveBeenCalledOnce();
+});
+
+it("marks direct connections ready and stops promptly while relay auth is loading", async () => {
+ const authBootstrap = deferred();
+ mocks.firstAttempt = authBootstrap.promise;
+ const { runBackgroundConnectionHeadlessTask } = await import("./background-task");
+
+ const task = runBackgroundConnectionHeadlessTask();
+ await vi.waitFor(() => {
+ expect(mocks.acquireRoot).toHaveBeenCalledOnce();
+ expect(mocks.acquireOutbox).toHaveBeenCalledOnce();
+ expect(mocks.setRuntimeReady).toHaveBeenCalledWith(true);
+ });
+
+ mocks.stopListener?.();
+ await task;
+
+ expect(mocks.setRuntimeReady).toHaveBeenCalledWith(true);
+ expect(mocks.acquireRoot.mock.results[0]?.value).toHaveBeenCalledOnce();
+ expect(mocks.acquireOutbox.mock.results[0]?.value).toHaveBeenCalledOnce();
+ expect(mocks.relayStop).toHaveBeenCalledOnce();
+ expect(mocks.acknowledgeStop).toHaveBeenCalledOnce();
+
+ authBootstrap.resolve();
+});
diff --git a/apps/mobile/src/features/background-connection/background-task.ts b/apps/mobile/src/features/background-connection/background-task.ts
new file mode 100644
index 00000000000..9695b5bf221
--- /dev/null
+++ b/apps/mobile/src/features/background-connection/background-task.ts
@@ -0,0 +1,85 @@
+import { startBackgroundManagedRelayAuth } from "../cloud/backgroundManagedRelayAuth";
+import {
+ acknowledgeBackgroundConnectionStop,
+ addBackgroundConnectionStopRequestListener,
+ getBackgroundConnectionStatus,
+ setBackgroundConnectionRuntimeReady,
+} from "../../native/backgroundConnection";
+import { appAtomRegistry } from "../../state/atom-registry";
+import { acquireThreadOutboxDrain } from "../../state/use-thread-outbox-drain";
+import { acquireBackgroundConnectionRoot } from "./background-root";
+
+let activeTask: Promise | null = null;
+
+function bestEffortCleanup(label: string, cleanup: (() => void) | null): void {
+ if (cleanup === null) {
+ return;
+ }
+ try {
+ cleanup();
+ } catch (error) {
+ console.error(`[background-connection] ${label} cleanup failed`, error);
+ }
+}
+
+async function runTask(): Promise {
+ let hasStopBeenRequested = false;
+ let requestStop!: () => void;
+ const stopRequested = new Promise((resolve) => {
+ requestStop = () => {
+ hasStopBeenRequested = true;
+ resolve();
+ };
+ });
+ // Subscribe before acquiring anything so a disable request cannot race the
+ // cold React runtime bootstrap and leave the service waiting indefinitely.
+ const stopSubscription = addBackgroundConnectionStopRequestListener(requestStop);
+ // Native may have stopped the service before this cold JS runtime finished
+ // loading and installed its listener. Treat the persisted disabled state as
+ // the same stop request so that a late task cannot leak its leases.
+ if (!getBackgroundConnectionStatus().enabled) {
+ requestStop();
+ }
+ let relayAuth: ReturnType | null = null;
+ let releaseRoot: (() => void) | null = null;
+ let releaseOutbox: (() => void) | null = null;
+
+ try {
+ if (hasStopBeenRequested) {
+ return;
+ }
+ relayAuth = startBackgroundManagedRelayAuth();
+ releaseRoot = acquireBackgroundConnectionRoot(appAtomRegistry);
+ releaseOutbox = acquireThreadOutboxDrain(appAtomRegistry);
+ // Relay authentication owns its own retry loop. Direct/Tailscale
+ // connections and the shared outbox are fully operational once these
+ // leases are mounted, even if Clerk is slow or temporarily unavailable.
+ setBackgroundConnectionRuntimeReady(true);
+ await stopRequested;
+ } catch (error) {
+ console.error("[background-connection] headless runtime failed", error);
+ // Normal completion lets native release React Native's wake lock and use
+ // its bounded exponential restart ladder for bootstrap defects.
+ } finally {
+ bestEffortCleanup("outbox", releaseOutbox);
+ bestEffortCleanup("root", releaseRoot);
+ bestEffortCleanup("relay authentication", () => relayAuth?.stop());
+ bestEffortCleanup("stop listener", () => stopSubscription.remove());
+ setBackgroundConnectionRuntimeReady(false);
+ acknowledgeBackgroundConnectionStop();
+ }
+}
+
+/** Native guards task creation too; this JS guard protects hot reloads and races. */
+export function runBackgroundConnectionHeadlessTask(): Promise {
+ if (activeTask !== null) {
+ return activeTask;
+ }
+ const task = runTask().finally(() => {
+ if (activeTask === task) {
+ activeTask = null;
+ }
+ });
+ activeTask = task;
+ return task;
+}
diff --git a/apps/mobile/src/features/background-connection/headless-task-registration.test.ts b/apps/mobile/src/features/background-connection/headless-task-registration.test.ts
new file mode 100644
index 00000000000..6bbf4ebaaef
--- /dev/null
+++ b/apps/mobile/src/features/background-connection/headless-task-registration.test.ts
@@ -0,0 +1,41 @@
+import { beforeEach, expect, it, vi } from "vite-plus/test";
+
+const registerHeadlessTask = vi.hoisted(() => vi.fn());
+
+vi.mock("react-native", () => ({
+ AppRegistry: { registerHeadlessTask },
+}));
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.resetModules();
+});
+
+it("registers exactly one Android Headless JS task", async () => {
+ const registration = await import("./headless-task-registration");
+
+ registration.registerBackgroundConnectionHeadlessTask();
+ registration.registerBackgroundConnectionHeadlessTask();
+
+ expect(registerHeadlessTask).toHaveBeenCalledOnce();
+ expect(registerHeadlessTask).toHaveBeenCalledWith(
+ registration.BACKGROUND_CONNECTION_HEADLESS_TASK,
+ expect.any(Function),
+ );
+});
+
+it("finishes the native task when its dynamic import fails", async () => {
+ const registration = await import("./headless-task-registration");
+ const error = new Error("bundle chunk unavailable");
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
+
+ await expect(
+ registration.runRegisteredBackgroundConnectionTask(() => Promise.reject(error)),
+ ).resolves.toBeUndefined();
+
+ expect(consoleError).toHaveBeenCalledWith(
+ "[background-connection] registered headless task failed",
+ error,
+ );
+ consoleError.mockRestore();
+});
diff --git a/apps/mobile/src/features/background-connection/headless-task-registration.ts b/apps/mobile/src/features/background-connection/headless-task-registration.ts
new file mode 100644
index 00000000000..0ad53ae67ee
--- /dev/null
+++ b/apps/mobile/src/features/background-connection/headless-task-registration.ts
@@ -0,0 +1,42 @@
+import { AppRegistry } from "react-native";
+
+export const BACKGROUND_CONNECTION_HEADLESS_TASK = "T3BackgroundConnection";
+
+let registered = false;
+
+interface BackgroundConnectionTaskModule {
+ readonly runBackgroundConnectionHeadlessTask: () => Promise;
+}
+
+type BackgroundConnectionTaskLoader = () => Promise;
+
+const loadBackgroundConnectionTask: BackgroundConnectionTaskLoader = () =>
+ import("./background-task");
+
+/**
+ * React Native does not finish an unlimited Headless JS task when its provider
+ * rejects with a generic error. Convert import/bootstrap defects into normal
+ * completion so native can release its wake lock and apply bounded recovery.
+ */
+export async function runRegisteredBackgroundConnectionTask(
+ loadTask: BackgroundConnectionTaskLoader = loadBackgroundConnectionTask,
+): Promise {
+ try {
+ const { runBackgroundConnectionHeadlessTask } = await loadTask();
+ await runBackgroundConnectionHeadlessTask();
+ } catch (error) {
+ console.error("[background-connection] registered headless task failed", error);
+ }
+}
+
+/** Must run before Expo registers the Activity's root component. */
+export function registerBackgroundConnectionHeadlessTask(): void {
+ if (registered) {
+ return;
+ }
+ registered = true;
+ AppRegistry.registerHeadlessTask(
+ BACKGROUND_CONNECTION_HEADLESS_TASK,
+ () => () => runRegisteredBackgroundConnectionTask(),
+ );
+}
diff --git a/apps/mobile/src/features/background-connection/retained-thread.test.ts b/apps/mobile/src/features/background-connection/retained-thread.test.ts
new file mode 100644
index 00000000000..80a38baba5b
--- /dev/null
+++ b/apps/mobile/src/features/background-connection/retained-thread.test.ts
@@ -0,0 +1,98 @@
+import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";
+import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+const persistence = vi.hoisted(() => ({
+ load: vi.fn<() => Promise>(),
+ save: vi.fn<(_thread: ScopedThreadRef) => Promise>(),
+ clear: vi.fn<() => Promise>(),
+}));
+
+vi.mock("../../persistence/imperative", () => ({
+ loadBackgroundConnectionRetainedThread: persistence.load,
+ saveBackgroundConnectionRetainedThread: persistence.save,
+ clearBackgroundConnectionRetainedThread: persistence.clear,
+}));
+
+function deferred() {
+ let resolve!: (value: A) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, reject, resolve };
+}
+
+const firstThread = {
+ environmentId: EnvironmentId.make("environment-1"),
+ threadId: ThreadId.make("thread-1"),
+};
+const secondThread = {
+ environmentId: EnvironmentId.make("environment-1"),
+ threadId: ThreadId.make("thread-2"),
+};
+
+beforeEach(() => {
+ vi.resetModules();
+ persistence.load.mockReset();
+ persistence.save.mockReset();
+ persistence.clear.mockReset();
+ persistence.load.mockResolvedValue(null);
+ persistence.save.mockResolvedValue(undefined);
+ persistence.clear.mockResolvedValue(undefined);
+});
+
+describe("background retained thread", () => {
+ it("does not let a cold load replace a thread saved during startup", async () => {
+ const coldLoad = deferred();
+ persistence.load.mockReturnValueOnce(coldLoad.promise);
+ const retained = await import("./retained-thread");
+
+ const loading = retained.ensureBackgroundConnectionRetainedThreadLoaded();
+ const saving = retained.saveBackgroundConnectionRetainedThread(secondThread);
+ await Promise.resolve();
+ expect(persistence.save).not.toHaveBeenCalled();
+ coldLoad.resolve(firstThread);
+ await Promise.all([loading, saving]);
+
+ expect(retained.getBackgroundConnectionRetainedThreadSnapshot()).toEqual({
+ loaded: true,
+ thread: secondThread,
+ });
+ });
+
+ it("persists rapid thread changes in invocation order", async () => {
+ const firstWrite = deferred();
+ persistence.save.mockReturnValueOnce(firstWrite.promise).mockResolvedValueOnce(undefined);
+ const retained = await import("./retained-thread");
+
+ const savingFirst = retained.saveBackgroundConnectionRetainedThread(firstThread);
+ const savingSecond = retained.saveBackgroundConnectionRetainedThread(secondThread);
+ await Promise.resolve();
+
+ expect(persistence.save).toHaveBeenCalledTimes(1);
+ firstWrite.resolve();
+ await Promise.all([savingFirst, savingSecond]);
+ expect(persistence.save.mock.calls.map(([thread]) => thread)).toEqual([
+ firstThread,
+ secondThread,
+ ]);
+ });
+
+ it("does not let a slow save overwrite a later clear", async () => {
+ const slowSave = deferred();
+ persistence.save.mockReturnValueOnce(slowSave.promise);
+ const retained = await import("./retained-thread");
+
+ const saving = retained.saveBackgroundConnectionRetainedThread(firstThread);
+ const clearing = retained.clearBackgroundConnectionRetainedThread();
+ await Promise.resolve();
+
+ expect(persistence.save).toHaveBeenCalledOnce();
+ expect(persistence.clear).not.toHaveBeenCalled();
+ slowSave.resolve();
+ await Promise.all([saving, clearing]);
+ expect(persistence.clear).toHaveBeenCalledOnce();
+ expect(retained.getBackgroundConnectionRetainedThreadSnapshot().thread).toBeNull();
+ });
+});
diff --git a/apps/mobile/src/features/background-connection/retained-thread.ts b/apps/mobile/src/features/background-connection/retained-thread.ts
new file mode 100644
index 00000000000..641ac1ebd01
--- /dev/null
+++ b/apps/mobile/src/features/background-connection/retained-thread.ts
@@ -0,0 +1,124 @@
+import type { ScopedThreadRef } from "@t3tools/contracts";
+
+import {
+ clearBackgroundConnectionRetainedThread as clearPersistedRetainedThread,
+ loadBackgroundConnectionRetainedThread as loadPersistedRetainedThread,
+ saveBackgroundConnectionRetainedThread as savePersistedRetainedThread,
+} from "../../persistence/imperative";
+
+interface RetainedThreadSnapshot {
+ readonly loaded: boolean;
+ readonly thread: ScopedThreadRef | null;
+}
+
+const EMPTY_SNAPSHOT: RetainedThreadSnapshot = Object.freeze({
+ loaded: false,
+ thread: null,
+});
+
+let snapshot = EMPTY_SNAPSHOT;
+let revision = 0;
+let loading: Promise | null = null;
+let persistenceQueue: Promise = Promise.resolve();
+const listeners = new Set<() => void>();
+
+function threadRefsEqual(left: ScopedThreadRef | null, right: ScopedThreadRef | null): boolean {
+ return (
+ left === right ||
+ (left !== null &&
+ right !== null &&
+ left.environmentId === right.environmentId &&
+ left.threadId === right.threadId)
+ );
+}
+
+function publish(next: RetainedThreadSnapshot): void {
+ if (snapshot.loaded === next.loaded && threadRefsEqual(snapshot.thread, next.thread)) {
+ return;
+ }
+ snapshot = next;
+ for (const listener of listeners) {
+ listener();
+ }
+}
+
+export function getBackgroundConnectionRetainedThreadSnapshot(): RetainedThreadSnapshot {
+ return snapshot;
+}
+
+export function subscribeBackgroundConnectionRetainedThread(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+export function ensureBackgroundConnectionRetainedThreadLoaded(): Promise {
+ if (snapshot.loaded) {
+ return Promise.resolve(snapshot.thread);
+ }
+ if (loading !== null) {
+ return loading;
+ }
+
+ const loadRevision = revision;
+ const persistedLoad = persistenceQueue.then(
+ loadPersistedRetainedThread,
+ loadPersistedRetainedThread,
+ );
+ // Loading can also remove malformed persisted data. Treat it as part of the
+ // same storage sequence so that cleanup cannot race and delete a newer save.
+ persistenceQueue = persistedLoad.then(
+ () => undefined,
+ () => undefined,
+ );
+ loading = persistedLoad
+ .then((thread) => {
+ if (revision === loadRevision) {
+ publish({ loaded: true, thread });
+ }
+ return snapshot.thread;
+ })
+ .catch((error) => {
+ console.warn("[background-connection] failed to load the retained thread", error);
+ if (revision === loadRevision) {
+ publish({ loaded: true, thread: null });
+ }
+ return snapshot.thread;
+ })
+ .finally(() => {
+ loading = null;
+ });
+ return loading;
+}
+
+function enqueuePersistence(operation: () => Promise): Promise {
+ const queued = persistenceQueue.then(operation, operation);
+ persistenceQueue = queued.catch(() => undefined);
+ return queued;
+}
+
+export async function saveBackgroundConnectionRetainedThread(
+ thread: ScopedThreadRef,
+): Promise {
+ revision += 1;
+ publish({ loaded: true, thread });
+ try {
+ await enqueuePersistence(() => savePersistedRetainedThread(thread));
+ } catch (error) {
+ console.warn("[background-connection] failed to persist the retained thread", error);
+ }
+}
+
+export async function clearBackgroundConnectionRetainedThread(): Promise {
+ revision += 1;
+ publish({ loaded: true, thread: null });
+ try {
+ await enqueuePersistence(clearPersistedRetainedThread);
+ } catch (error) {
+ console.warn("[background-connection] failed to clear the retained thread", error);
+ }
+}
+
+export function clearBackgroundConnectionRetainedThreadInMemory(): void {
+ revision += 1;
+ publish({ loaded: true, thread: null });
+}
diff --git a/apps/mobile/src/features/background-connection/target-selection.test.ts b/apps/mobile/src/features/background-connection/target-selection.test.ts
new file mode 100644
index 00000000000..dfdb059b544
--- /dev/null
+++ b/apps/mobile/src/features/background-connection/target-selection.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vite-plus/test";
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import { EnvironmentId, ProjectId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";
+
+import { selectBackgroundConnectionThreadTargets } from "./target-selection";
+
+const environmentId = EnvironmentId.make("environment-1");
+const projectId = ProjectId.make("project-1");
+
+function shell(id: string, status: "starting" | "running" | "ready"): EnvironmentThreadShell {
+ return {
+ environmentId,
+ id: ThreadId.make(id),
+ projectId,
+ title: id,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ session: { status },
+ } as EnvironmentThreadShell;
+}
+
+describe("background connection target selection", () => {
+ it("keeps the retained thread followed by starting and running threads", () => {
+ const retained: ScopedThreadRef = {
+ environmentId,
+ threadId: ThreadId.make("retained"),
+ };
+
+ expect(
+ selectBackgroundConnectionThreadTargets(retained, [
+ shell("settled", "ready"),
+ shell("starting", "starting"),
+ shell("running", "running"),
+ ]),
+ ).toEqual([
+ retained,
+ { environmentId, threadId: ThreadId.make("starting") },
+ { environmentId, threadId: ThreadId.make("running") },
+ ]);
+ });
+
+ it("deduplicates a retained running thread without changing order", () => {
+ const retained = { environmentId, threadId: ThreadId.make("running") };
+ expect(
+ selectBackgroundConnectionThreadTargets(retained, [
+ shell("running", "running"),
+ shell("other", "starting"),
+ ]),
+ ).toEqual([retained, { environmentId, threadId: ThreadId.make("other") }]);
+ });
+
+ it("drops active targets after their shell settles", () => {
+ expect(selectBackgroundConnectionThreadTargets(null, [shell("task", "running")])).toHaveLength(
+ 1,
+ );
+ expect(selectBackgroundConnectionThreadTargets(null, [shell("task", "ready")])).toEqual([]);
+ });
+});
diff --git a/apps/mobile/src/features/background-connection/target-selection.ts b/apps/mobile/src/features/background-connection/target-selection.ts
new file mode 100644
index 00000000000..4687ec6e33a
--- /dev/null
+++ b/apps/mobile/src/features/background-connection/target-selection.ts
@@ -0,0 +1,36 @@
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import type { ScopedThreadRef } from "@t3tools/contracts";
+
+function refKey(ref: ScopedThreadRef): string {
+ return JSON.stringify([ref.environmentId, ref.threadId]);
+}
+
+/**
+ * Keep one user-relevant settled thread plus every thread that is currently doing work.
+ * The retained thread is deliberately first and the shell order is preserved.
+ */
+export function selectBackgroundConnectionThreadTargets(
+ retainedThread: ScopedThreadRef | null,
+ threadShells: ReadonlyArray,
+): ReadonlyArray {
+ const targets: ScopedThreadRef[] = [];
+ const seen = new Set();
+ const append = (ref: ScopedThreadRef) => {
+ const key = refKey(ref);
+ if (seen.has(key)) {
+ return;
+ }
+ seen.add(key);
+ targets.push(ref);
+ };
+
+ if (retainedThread !== null) {
+ append(retainedThread);
+ }
+ for (const thread of threadShells) {
+ if (thread.session?.status === "starting" || thread.session?.status === "running") {
+ append({ environmentId: thread.environmentId, threadId: thread.id });
+ }
+ }
+ return targets;
+}
diff --git a/apps/mobile/src/features/connection/useConnectionController.ts b/apps/mobile/src/features/connection/useConnectionController.ts
index bad6b6f1720..22e62bfe45f 100644
--- a/apps/mobile/src/features/connection/useConnectionController.ts
+++ b/apps/mobile/src/features/connection/useConnectionController.ts
@@ -10,6 +10,7 @@ import type {
} from "@t3tools/contracts/relay";
import * as Option from "effect/Option";
import { useCallback, useMemo } from "react";
+import { Platform } from "react-native";
import { environmentCatalog } from "../../connection/catalog";
import {
@@ -20,6 +21,10 @@ import { useEnvironments } from "../../state/environments";
import { relayEnvironmentDiscovery } from "../../state/relay";
import { useAtomCommand } from "../../state/use-atom-command";
import { projectWorkspaceEnvironment, type WorkspaceEnvironment } from "../../state/workspaceModel";
+import {
+ clearBackgroundConnectionRetainedThread,
+ getBackgroundConnectionRetainedThreadSnapshot,
+} from "../background-connection/retained-thread";
export interface RelayEnvironmentView {
readonly environment: RelayClientEnvironmentRecord;
@@ -85,7 +90,17 @@ export function useConnectionController() {
[registerEnvironment],
);
const removeEnvironment = useCallback(
- (environmentId: EnvironmentId) => removeEnvironmentMutation(environmentId),
+ async (environmentId: EnvironmentId) => {
+ const result = await removeEnvironmentMutation(environmentId);
+ if (
+ Platform.OS === "android" &&
+ result._tag === "Success" &&
+ getBackgroundConnectionRetainedThreadSnapshot().thread?.environmentId === environmentId
+ ) {
+ void clearBackgroundConnectionRetainedThread();
+ }
+ return result;
+ },
[removeEnvironmentMutation],
);
const retryEnvironment = useCallback(
diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts
index 0c621a04e38..96f15927492 100644
--- a/apps/mobile/src/features/home/useThreadListActions.ts
+++ b/apps/mobile/src/features/home/useThreadListActions.ts
@@ -3,11 +3,15 @@ import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settl
import * as Cause from "effect/Cause";
import * as Haptics from "expo-haptics";
import { useCallback, useRef } from "react";
-import { Alert } from "react-native";
+import { Alert, Platform } from "react-native";
import { showConfirmDialog } from "../../components/ConfirmDialogHost";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";
+import {
+ clearBackgroundConnectionRetainedThread,
+ getBackgroundConnectionRetainedThreadSnapshot,
+} from "../background-connection/retained-thread";
import { appAtomRegistry } from "../../state/atom-registry";
import { environmentServerConfigsAtom } from "../../state/server";
import { threadEnvironment } from "../../state/threads";
@@ -142,6 +146,12 @@ function useThreadActionExecutor(
if (action === "archive" || action === "unarchive" || action === "delete") {
refreshArchivedThreadsForEnvironment(thread.environmentId);
}
+ if (Platform.OS === "android" && action === "delete") {
+ const retained = getBackgroundConnectionRetainedThreadSnapshot().thread;
+ if (retained?.environmentId === thread.environmentId && retained.threadId === thread.id) {
+ void clearBackgroundConnectionRetainedThread();
+ }
+ }
onCompleted?.(action, thread);
return true;
} finally {
diff --git a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx
index 9e18d4675fb..13b9d4ee704 100644
--- a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx
@@ -2,10 +2,14 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import { SymbolView } from "expo-symbols";
import { useMemo } from "react";
-import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
+import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppText as Text } from "../../components/AppText";
+import {
+ clearBackgroundConnectionRetainedThread,
+ getBackgroundConnectionRetainedThreadSnapshot,
+} from "../background-connection/retained-thread";
import { useThemeColor } from "../../lib/useThemeColor";
import {
clearClientCacheAtom,
@@ -47,8 +51,16 @@ export function SettingsClientStorageRouteScreen() {
{
text: "Clear Cache",
style: "destructive",
- onPress: () =>
- clearCache({ type: "environment", environmentId: environment.environmentId }),
+ onPress: () => {
+ const retained = getBackgroundConnectionRetainedThreadSnapshot().thread;
+ if (
+ Platform.OS === "android" &&
+ retained?.environmentId === environment.environmentId
+ ) {
+ void clearBackgroundConnectionRetainedThread();
+ }
+ clearCache({ type: "environment", environmentId: environment.environmentId });
+ },
},
],
);
@@ -63,7 +75,12 @@ export function SettingsClientStorageRouteScreen() {
{
text: "Clear All Caches",
style: "destructive",
- onPress: () => clearCache({ type: "all" }),
+ onPress: () => {
+ if (Platform.OS === "android") {
+ void clearBackgroundConnectionRetainedThread();
+ }
+ clearCache({ type: "all" });
+ },
},
],
);
diff --git a/apps/mobile/src/persistence/imperative.ts b/apps/mobile/src/persistence/imperative.ts
index b66f6597c15..eaa59bc47a3 100644
--- a/apps/mobile/src/persistence/imperative.ts
+++ b/apps/mobile/src/persistence/imperative.ts
@@ -51,3 +51,13 @@ export const loadRecentThreadShortcuts = () =>
export const saveRecentThreadShortcuts = (
threads: ReadonlyArray,
) => runStorage((storage) => storage.saveRecentThreadShortcuts(threads));
+
+export const loadBackgroundConnectionRetainedThread = () =>
+ runStorage((storage) => storage.loadBackgroundConnectionRetainedThread);
+export const saveBackgroundConnectionRetainedThread = (
+ thread: Parameters<
+ MobileStorage.MobileStorage["Service"]["saveBackgroundConnectionRetainedThread"]
+ >[0],
+) => runStorage((storage) => storage.saveBackgroundConnectionRetainedThread(thread));
+export const clearBackgroundConnectionRetainedThread = () =>
+ runStorage((storage) => storage.clearBackgroundConnectionRetainedThread);
diff --git a/apps/mobile/src/persistence/mobile-storage.test.ts b/apps/mobile/src/persistence/mobile-storage.test.ts
new file mode 100644
index 00000000000..42560fc1e67
--- /dev/null
+++ b/apps/mobile/src/persistence/mobile-storage.test.ts
@@ -0,0 +1,59 @@
+import { expect, it } from "@effect/vitest";
+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import { vi } from "vite-plus/test";
+
+const secureStore = vi.hoisted(() => new Map());
+
+vi.mock("expo-secure-store", () => ({
+ getItemAsync: vi.fn((key: string) => Promise.resolve(secureStore.get(key) ?? null)),
+ setItemAsync: vi.fn((key: string, value: string) => {
+ secureStore.set(key, value);
+ return Promise.resolve();
+ }),
+ deleteItemAsync: vi.fn((key: string) => {
+ secureStore.delete(key);
+ return Promise.resolve();
+ }),
+}));
+
+vi.mock("react-native", () => ({
+ Platform: { OS: "android" },
+}));
+
+import * as MobileSecureStorage from "./mobile-secure-storage";
+import * as MobileStorage from "./mobile-storage";
+
+const RETAINED_THREAD_KEY = "t3code.background-connection.retained-thread";
+
+const makeStorage = MobileStorage.make().pipe(
+ Effect.provideService(MobileSecureStorage.MobileSecureStorage, MobileSecureStorage.make),
+);
+
+it.effect("round-trips and clears the retained background thread", () =>
+ Effect.gen(function* () {
+ secureStore.clear();
+ const storage = yield* makeStorage;
+ const retained = {
+ environmentId: EnvironmentId.make("environment-1"),
+ threadId: ThreadId.make("thread-1"),
+ };
+
+ yield* storage.saveBackgroundConnectionRetainedThread(retained);
+ expect(yield* storage.loadBackgroundConnectionRetainedThread).toEqual(retained);
+
+ yield* storage.clearBackgroundConnectionRetainedThread;
+ expect(yield* storage.loadBackgroundConnectionRetainedThread).toBeNull();
+ }),
+);
+
+it.effect("removes malformed retained-thread storage", () =>
+ Effect.gen(function* () {
+ secureStore.clear();
+ secureStore.set(RETAINED_THREAD_KEY, JSON.stringify({ environmentId: "environment-1" }));
+ const storage = yield* makeStorage;
+
+ expect(yield* storage.loadBackgroundConnectionRetainedThread).toBeNull();
+ expect(secureStore.has(RETAINED_THREAD_KEY)).toBe(false);
+ }),
+);
diff --git a/apps/mobile/src/persistence/mobile-storage.ts b/apps/mobile/src/persistence/mobile-storage.ts
index 266e8c1a9a8..b0ea8d56744 100644
--- a/apps/mobile/src/persistence/mobile-storage.ts
+++ b/apps/mobile/src/persistence/mobile-storage.ts
@@ -1,4 +1,8 @@
-import { EnvironmentId } from "@t3tools/contracts";
+import {
+ EnvironmentId,
+ ScopedThreadRef,
+ type ScopedThreadRef as ScopedThreadRefType,
+} from "@t3tools/contracts";
import * as Arr from "effect/Array";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
@@ -17,6 +21,15 @@ const CONNECTIONS_KEY = "t3code.connections";
const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id";
const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration";
const RECENT_THREAD_SHORTCUTS_KEY = "t3code.recent-thread-shortcuts";
+const BACKGROUND_CONNECTION_RETAINED_THREAD_KEY = "t3code.background-connection.retained-thread";
+
+const BackgroundConnectionRetainedThread = Schema.fromJsonString(ScopedThreadRef);
+const decodeBackgroundConnectionRetainedThread = Schema.decodeUnknownEffect(
+ BackgroundConnectionRetainedThread,
+);
+const encodeBackgroundConnectionRetainedThread = Schema.encodeEffect(
+ BackgroundConnectionRetainedThread,
+);
export class MobileStorageDecodeError extends Schema.TaggedErrorClass()(
"MobileStorageDecodeError",
@@ -114,6 +127,20 @@ export class MobileStorage extends Context.Service<
void,
MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError
>;
+ readonly loadBackgroundConnectionRetainedThread: Effect.Effect<
+ ScopedThreadRefType | null,
+ MobileSecureStorage.MobileSecureStorageError
+ >;
+ readonly saveBackgroundConnectionRetainedThread: (
+ thread: ScopedThreadRefType,
+ ) => Effect.Effect<
+ void,
+ MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError
+ >;
+ readonly clearBackgroundConnectionRetainedThread: Effect.Effect<
+ void,
+ MobileSecureStorage.MobileSecureStorageError
+ >;
}
>()("@t3tools/mobile/persistence/MobileStorage") {}
@@ -245,6 +272,44 @@ export const make = Effect.fn("MobileStorage.make")(function* () {
),
);
+ const loadBackgroundConnectionRetainedThread = secureStorage
+ .getItem(BACKGROUND_CONNECTION_RETAINED_THREAD_KEY)
+ .pipe(
+ Effect.flatMap((encoded) => {
+ if (encoded === null || encoded.trim().length === 0) {
+ return Effect.succeed(null);
+ }
+ return decodeBackgroundConnectionRetainedThread(encoded).pipe(
+ Effect.map((thread): ScopedThreadRefType | null => thread),
+ Effect.catch((cause) =>
+ Effect.logWarning("Ignored an invalid retained background thread.").pipe(
+ Effect.annotateLogs({
+ storageKey: BACKGROUND_CONNECTION_RETAINED_THREAD_KEY,
+ cause: String(cause),
+ }),
+ Effect.andThen(secureStorage.removeItem(BACKGROUND_CONNECTION_RETAINED_THREAD_KEY)),
+ Effect.as(null),
+ ),
+ ),
+ );
+ }),
+ );
+
+ const saveBackgroundConnectionRetainedThread = Effect.fn(
+ "MobileStorage.saveBackgroundConnectionRetainedThread",
+ )(function* (thread: ScopedThreadRefType) {
+ const encoded = yield* encodeBackgroundConnectionRetainedThread(thread).pipe(
+ Effect.mapError(
+ (cause) =>
+ new MobileStorageEncodeError({
+ key: BACKGROUND_CONNECTION_RETAINED_THREAD_KEY,
+ cause,
+ }),
+ ),
+ );
+ yield* secureStorage.setItem(BACKGROUND_CONNECTION_RETAINED_THREAD_KEY, encoded);
+ });
+
return MobileStorage.of({
loadSavedConnections,
saveConnection,
@@ -260,6 +325,11 @@ export const make = Effect.fn("MobileStorage.make")(function* () {
),
loadRecentThreadShortcuts,
saveRecentThreadShortcuts: (threads) => writeJson(RECENT_THREAD_SHORTCUTS_KEY, { threads }),
+ loadBackgroundConnectionRetainedThread,
+ saveBackgroundConnectionRetainedThread,
+ clearBackgroundConnectionRetainedThread: secureStorage.removeItem(
+ BACKGROUND_CONNECTION_RETAINED_THREAD_KEY,
+ ),
});
});
From 95d48e234829eef2a0372a2e78ecdbc74a45dac9 Mon Sep 17 00:00:00 2001
From: Mike <84413695+snipemanmike@users.noreply.github.com>
Date: Sat, 1 Aug 2026 01:49:08 -0700
Subject: [PATCH 3/7] refactor(mobile): share the thread outbox drain worker
---
.../src/state/use-thread-outbox-drain.test.ts | 87 ++
.../src/state/use-thread-outbox-drain.ts | 764 ++++++++++--------
apps/mobile/src/state/use-thread-outbox.ts | 2 +-
3 files changed, 530 insertions(+), 323 deletions(-)
create mode 100644 apps/mobile/src/state/use-thread-outbox-drain.test.ts
diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts
new file mode 100644
index 00000000000..a94d9547829
--- /dev/null
+++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts
@@ -0,0 +1,87 @@
+import { AtomRegistry } from "effect/unstable/reactivity";
+import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+vi.mock("../lib/uuid", () => ({
+ randomHex: vi.fn(() => "00"),
+ uuidv4: vi.fn(() => "00000000-0000-4000-8000-000000000000"),
+}));
+
+vi.mock("./presentation", async () => {
+ const { Atom } = await import("effect/unstable/reactivity");
+ return { environmentPresentations: { presentationsAtom: Atom.make(new Map()) } };
+});
+
+vi.mock("./projects", async () => {
+ const { Atom } = await import("effect/unstable/reactivity");
+ return { environmentProjects: { projectsAtom: Atom.make([]) } };
+});
+
+vi.mock("./threads", async () => {
+ const { Atom } = await import("effect/unstable/reactivity");
+ return {
+ environmentThreadShells: { threadShellsAtom: Atom.make([]) },
+ threadEnvironment: {
+ setInteractionMode: {},
+ setRuntimeMode: {},
+ startTurn: {},
+ updateMetadata: {},
+ },
+ };
+});
+
+vi.mock("./use-thread-outbox", async () => {
+ const { Atom } = await import("effect/unstable/reactivity");
+ return {
+ editingQueuedMessageIdsAtom: Atom.make({}),
+ threadOutboxShellStatusesAtom: Atom.make(new Map()),
+ };
+});
+
+vi.mock("./thread-outbox", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ ensureThreadOutboxLoaded: vi.fn(),
+ };
+});
+
+import { ensureThreadOutboxLoaded } from "./thread-outbox";
+import { acquireThreadOutboxDrain } from "./use-thread-outbox-drain";
+
+describe("thread outbox drain ownership", () => {
+ beforeEach(() => {
+ vi.mocked(ensureThreadOutboxLoaded).mockClear();
+ });
+
+ it("shares one dispatcher until the final owner releases", async () => {
+ const registry = AtomRegistry.make();
+ const subscribe = vi.spyOn(registry, "subscribe");
+
+ const releaseUi = acquireThreadOutboxDrain(registry);
+ const subscriptionsPerDispatcher = subscribe.mock.calls.length;
+ expect(subscriptionsPerDispatcher).toBeGreaterThan(0);
+ expect(ensureThreadOutboxLoaded).toHaveBeenCalledTimes(1);
+
+ const releaseBackground = acquireThreadOutboxDrain(registry);
+ expect(subscribe).toHaveBeenCalledTimes(subscriptionsPerDispatcher);
+ expect(ensureThreadOutboxLoaded).toHaveBeenCalledTimes(1);
+
+ releaseUi();
+ releaseUi();
+ const releaseReplacementUi = acquireThreadOutboxDrain(registry);
+ expect(subscribe).toHaveBeenCalledTimes(subscriptionsPerDispatcher);
+ expect(ensureThreadOutboxLoaded).toHaveBeenCalledTimes(1);
+
+ releaseBackground();
+ expect(ensureThreadOutboxLoaded).toHaveBeenCalledTimes(1);
+ releaseReplacementUi();
+
+ const releaseNextOwner = acquireThreadOutboxDrain(registry);
+ expect(subscribe).toHaveBeenCalledTimes(subscriptionsPerDispatcher * 2);
+ expect(ensureThreadOutboxLoaded).toHaveBeenCalledTimes(2);
+ releaseNextOwner();
+
+ await Promise.resolve();
+ registry.dispose();
+ });
+});
diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts
index d06a4098aab..b9148c209a2 100644
--- a/apps/mobile/src/state/use-thread-outbox-drain.ts
+++ b/apps/mobile/src/state/use-thread-outbox-drain.ts
@@ -1,9 +1,9 @@
-import { useAtomValue } from "@effect/atom-react";
import type {
EnvironmentProject,
EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
-import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
+import { RegistryContext } from "@effect/atom-react";
+import { type AtomCommandResult, runAtomCommand } from "@t3tools/client-runtime/state/runtime";
import {
CommandId,
DEFAULT_PROVIDER_INTERACTION_MODE,
@@ -12,53 +12,53 @@ import {
} from "@t3tools/contracts";
import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
import * as Cause from "effect/Cause";
-import { AsyncResult, Atom } from "effect/unstable/reactivity";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity";
+import { useContext, useEffect } from "react";
-import { scopedThreadKey } from "../lib/scopedEntities";
-import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn";
import { toUploadChatImageAttachments } from "../lib/composerImages";
+import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn";
+import { scopedThreadKey } from "../lib/scopedEntities";
import { randomHex } from "../lib/uuid";
-import { appAtomRegistry } from "./atom-registry";
-import { useProjects, useThreadShells } from "./entities";
+import { environmentPresentations } from "./presentation";
+import { environmentProjects } from "./projects";
import {
confirmThreadOutboxMessageQueued,
ensureThreadOutboxLoaded,
removeThreadOutboxMessage,
+ threadOutboxManager,
} from "./thread-outbox";
import {
isQueuedThreadCreationSendable,
modelSelectionsEqual,
+ resolveQueuedThreadSettings,
resolveThreadOutboxDeliveryAction,
resolveThreadOutboxFailureAction,
- resolveQueuedThreadSettings,
threadOutboxRetryDelayMs,
type QueuedThreadCreation,
type QueuedThreadMessage,
type ThreadOutboxCommandStage,
} from "./thread-outbox-model";
import { environmentThreadShells, threadEnvironment } from "./threads";
-import { useAtomCommand } from "./use-atom-command";
-import {
- editingQueuedMessageIdsAtom,
- useThreadOutboxMessages,
- useThreadOutboxShellStatuses,
-} from "./use-thread-outbox";
-import { useRemoteConnectionStatus } from "./use-remote-environment-registry";
+import { editingQueuedMessageIdsAtom, threadOutboxShellStatusesAtom } from "./use-thread-outbox";
export const dispatchingQueuedMessageIdAtom = Atom.make(null).pipe(
Atom.keepAlive,
Atom.withLabel("mobile:thread-outbox:dispatching-message-id"),
);
-function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void {
- appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId);
+interface ThreadOutboxDrainState {
+ readonly registry: AtomRegistry.AtomRegistry;
+ owners: number;
+ stopped: boolean;
+ drainScheduled: boolean;
+ activeDelivery: Promise | null;
+ readonly retryAttempt: Map;
+ readonly retryNotBefore: Map;
+ readonly retryTimers: Map>;
+ readonly releases: Array<() => void>;
}
-function finishDispatchingQueuedMessage(queuedMessageId: MessageId): void {
- const current = appAtomRegistry.get(dispatchingQueuedMessageIdAtom);
- appAtomRegistry.set(dispatchingQueuedMessageIdAtom, current === queuedMessageId ? null : current);
-}
+const drainStates = new WeakMap();
function findThread(
threads: ReadonlyArray,
@@ -85,139 +85,134 @@ function settingsCommandId(message: QueuedThreadMessage, setting: string): Comma
return CommandId.make(`${message.commandId}:${setting}`);
}
-export function useThreadOutboxDrain(): void {
- const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
- const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
- reportFailure: false,
- });
- const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, {
- reportFailure: false,
- });
- const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, {
- reportFailure: false,
- });
- const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom);
- const editingQueuedMessageIds = useAtomValue(editingQueuedMessageIdsAtom);
- const queuedMessagesByThreadKey = useThreadOutboxMessages();
- const shellStatuses = useThreadOutboxShellStatuses();
- const threads = useThreadShells();
- const projects = useProjects();
- const { connectedEnvironments } = useRemoteConnectionStatus();
- const [retryTick, setRetryTick] = useState(0);
- const retryAttemptRef = useRef(new Map());
- const retryNotBeforeRef = useRef(new Map());
- const retryTimersRef = useRef(new Map>());
-
- useEffect(() => {
- ensureThreadOutboxLoaded();
- return () => {
- for (const timer of retryTimersRef.current.values()) {
- clearTimeout(timer);
- }
- retryTimersRef.current.clear();
- };
- }, []);
+function makeDeliveryHelpers(queuedMessage: QueuedThreadMessage): {
+ readonly reportFailure: (
+ result: AtomCommandResult,
+ stage: ThreadOutboxCommandStage,
+ ) => boolean;
+ readonly completeDelivery: (result: AtomCommandResult) => Promise;
+} {
+ const reportFailure = (
+ commandResult: AtomCommandResult,
+ stage: ThreadOutboxCommandStage,
+ ): boolean => {
+ if (!AsyncResult.isFailure(commandResult)) {
+ return false;
+ }
+ const action = resolveThreadOutboxFailureAction({
+ stage,
+ error: Cause.squash(commandResult.cause),
+ interrupted: Cause.hasInterruptsOnly(commandResult.cause),
+ });
+ const retry = action === "retry";
+ console.warn("[thread-outbox] queued message delivery failed", {
+ environmentId: queuedMessage.environmentId,
+ threadId: queuedMessage.threadId,
+ messageId: queuedMessage.messageId,
+ stage,
+ cause: commandResult.cause,
+ retry,
+ });
+ return retry;
+ };
- const makeDeliveryHelpers = useCallback((queuedMessage: QueuedThreadMessage) => {
- const reportFailure = (
- commandResult: AtomCommandResult,
- stage: ThreadOutboxCommandStage,
- ): boolean => {
- if (!AsyncResult.isFailure(commandResult)) {
- return false;
- }
- const action = resolveThreadOutboxFailureAction({
- stage,
- error: Cause.squash(commandResult.cause),
- interrupted: Cause.hasInterruptsOnly(commandResult.cause),
- });
- const retry = action === "retry";
- console.warn("[thread-outbox] queued message delivery failed", {
+ const completeDelivery = async (
+ deliveryResult: AtomCommandResult,
+ ): Promise => {
+ if (reportFailure(deliveryResult, "start-turn")) {
+ return false;
+ }
+ try {
+ await removeThreadOutboxMessage(queuedMessage);
+ return true;
+ } catch (error) {
+ console.warn("[thread-outbox] failed to remove delivered queued message", {
environmentId: queuedMessage.environmentId,
threadId: queuedMessage.threadId,
messageId: queuedMessage.messageId,
- stage,
- cause: commandResult.cause,
- retry,
+ error,
});
- return retry;
- };
- const completeDelivery = async (
- deliveryResult: AtomCommandResult,
- ): Promise => {
- if (reportFailure(deliveryResult, "start-turn")) {
- return false;
- }
-
- try {
- await removeThreadOutboxMessage(queuedMessage);
- return true;
- } catch (error) {
- console.warn("[thread-outbox] failed to remove delivered queued message", {
- environmentId: queuedMessage.environmentId,
- threadId: queuedMessage.threadId,
- messageId: queuedMessage.messageId,
- error,
- });
- return false;
- }
- };
- return { reportFailure, completeDelivery };
- }, []);
+ return false;
+ }
+ };
+ return { reportFailure, completeDelivery };
+}
- const sendQueuedMessage = useCallback(
- async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => {
- const settings = resolveQueuedThreadSettings(queuedMessage, thread);
- const { reportFailure, completeDelivery } = makeDeliveryHelpers(queuedMessage);
+async function sendQueuedMessage(
+ registry: AtomRegistry.AtomRegistry,
+ queuedMessage: QueuedThreadMessage,
+ thread: EnvironmentThreadShell,
+): Promise {
+ const settings = resolveQueuedThreadSettings(queuedMessage, thread);
+ const { reportFailure, completeDelivery } = makeDeliveryHelpers(queuedMessage);
- if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) {
- const updateResult = await updateThreadMetadata({
- environmentId: queuedMessage.environmentId,
- input: {
- commandId: settingsCommandId(queuedMessage, "model-selection"),
- threadId: queuedMessage.threadId,
- modelSelection: settings.modelSelection,
- },
- });
- if (AsyncResult.isFailure(updateResult)) {
- reportFailure(updateResult, "settings-sync");
- return false;
- }
- }
+ if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) {
+ const updateResult = await runAtomCommand(
+ registry,
+ threadEnvironment.updateMetadata,
+ {
+ environmentId: queuedMessage.environmentId,
+ input: {
+ commandId: settingsCommandId(queuedMessage, "model-selection"),
+ threadId: queuedMessage.threadId,
+ modelSelection: settings.modelSelection,
+ },
+ },
+ { reportFailure: false },
+ );
+ if (AsyncResult.isFailure(updateResult)) {
+ reportFailure(updateResult, "settings-sync");
+ return false;
+ }
+ }
- if (settings.runtimeMode !== thread.runtimeMode) {
- const runtimeResult = await setThreadRuntimeMode({
- environmentId: queuedMessage.environmentId,
- input: {
- commandId: settingsCommandId(queuedMessage, "runtime-mode"),
- threadId: queuedMessage.threadId,
- runtimeMode: settings.runtimeMode,
- createdAt: queuedMessage.createdAt,
- },
- });
- if (AsyncResult.isFailure(runtimeResult)) {
- reportFailure(runtimeResult, "settings-sync");
- return false;
- }
- }
+ if (settings.runtimeMode !== thread.runtimeMode) {
+ const runtimeResult = await runAtomCommand(
+ registry,
+ threadEnvironment.setRuntimeMode,
+ {
+ environmentId: queuedMessage.environmentId,
+ input: {
+ commandId: settingsCommandId(queuedMessage, "runtime-mode"),
+ threadId: queuedMessage.threadId,
+ runtimeMode: settings.runtimeMode,
+ createdAt: queuedMessage.createdAt,
+ },
+ },
+ { reportFailure: false },
+ );
+ if (AsyncResult.isFailure(runtimeResult)) {
+ reportFailure(runtimeResult, "settings-sync");
+ return false;
+ }
+ }
- if (settings.interactionMode !== thread.interactionMode) {
- const interactionResult = await setThreadInteractionMode({
- environmentId: queuedMessage.environmentId,
- input: {
- commandId: settingsCommandId(queuedMessage, "interaction-mode"),
- threadId: queuedMessage.threadId,
- interactionMode: settings.interactionMode,
- createdAt: queuedMessage.createdAt,
- },
- });
- if (AsyncResult.isFailure(interactionResult)) {
- reportFailure(interactionResult, "settings-sync");
- return false;
- }
- }
+ if (settings.interactionMode !== thread.interactionMode) {
+ const interactionResult = await runAtomCommand(
+ registry,
+ threadEnvironment.setInteractionMode,
+ {
+ environmentId: queuedMessage.environmentId,
+ input: {
+ commandId: settingsCommandId(queuedMessage, "interaction-mode"),
+ threadId: queuedMessage.threadId,
+ interactionMode: settings.interactionMode,
+ createdAt: queuedMessage.createdAt,
+ },
+ },
+ { reportFailure: false },
+ );
+ if (AsyncResult.isFailure(interactionResult)) {
+ reportFailure(interactionResult, "settings-sync");
+ return false;
+ }
+ }
- const deliveryResult = await startTurn({
+ return completeDelivery(
+ await runAtomCommand(
+ registry,
+ threadEnvironment.startTurn,
+ {
environmentId: queuedMessage.environmentId,
input: {
commandId: queuedMessage.commandId,
@@ -233,203 +228,328 @@ export function useThreadOutboxDrain(): void {
interactionMode: settings.interactionMode,
createdAt: queuedMessage.createdAt,
},
- });
- return completeDelivery(deliveryResult);
- },
- [
- makeDeliveryHelpers,
- setThreadInteractionMode,
- setThreadRuntimeMode,
- startTurn,
- updateThreadMetadata,
- ],
+ },
+ { reportFailure: false },
+ ),
);
+}
- const sendQueuedCreation = useCallback(
- async (
- queuedMessage: QueuedThreadMessage,
- creation: QueuedThreadCreation,
- projectCwd: string,
- ) => {
- const modelSelection = queuedMessage.modelSelection;
- if (modelSelection === undefined) {
- return false;
- }
- const { completeDelivery } = makeDeliveryHelpers(queuedMessage);
- const deliveryResult = await startTurn({
- environmentId: queuedMessage.environmentId,
- input: buildProjectThreadStartTurnInput({
- projectId: creation.projectId,
- projectCwd,
- threadId: queuedMessage.threadId,
- commandId: queuedMessage.commandId,
- messageId: queuedMessage.messageId,
- createdAt: queuedMessage.createdAt,
- text: queuedMessage.text.trim(),
- attachments: queuedMessage.attachments,
- modelSelection,
- runtimeMode: queuedMessage.runtimeMode ?? DEFAULT_RUNTIME_MODE,
- interactionMode: queuedMessage.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE,
- workspaceMode: creation.workspaceMode,
- branch: creation.branch,
- worktreePath: creation.worktreePath,
- startFromOrigin: creation.startFromOrigin ?? false,
- worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex),
- }),
- });
- return completeDelivery(deliveryResult);
+async function sendQueuedCreation(
+ registry: AtomRegistry.AtomRegistry,
+ queuedMessage: QueuedThreadMessage,
+ creation: QueuedThreadCreation,
+ projectCwd: string,
+): Promise {
+ const modelSelection = queuedMessage.modelSelection;
+ if (modelSelection === undefined) {
+ return false;
+ }
+ const { completeDelivery } = makeDeliveryHelpers(queuedMessage);
+ const deliveryResult = await runAtomCommand(
+ registry,
+ threadEnvironment.startTurn,
+ {
+ environmentId: queuedMessage.environmentId,
+ input: buildProjectThreadStartTurnInput({
+ projectId: creation.projectId,
+ projectCwd,
+ threadId: queuedMessage.threadId,
+ commandId: queuedMessage.commandId,
+ messageId: queuedMessage.messageId,
+ createdAt: queuedMessage.createdAt,
+ text: queuedMessage.text.trim(),
+ attachments: queuedMessage.attachments,
+ modelSelection,
+ runtimeMode: queuedMessage.runtimeMode ?? DEFAULT_RUNTIME_MODE,
+ interactionMode: queuedMessage.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE,
+ workspaceMode: creation.workspaceMode,
+ branch: creation.branch,
+ worktreePath: creation.worktreePath,
+ startFromOrigin: creation.startFromOrigin ?? false,
+ worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex),
+ }),
},
- [makeDeliveryHelpers, startTurn],
+ { reportFailure: false },
);
+ return completeDelivery(deliveryResult);
+}
- useEffect(() => {
- if (dispatchingQueuedMessageId !== null) {
- return;
+function requestDrain(state: ThreadOutboxDrainState): void {
+ if (state.stopped || state.drainScheduled) {
+ return;
+ }
+ state.drainScheduled = true;
+ queueMicrotask(() => {
+ state.drainScheduled = false;
+ drainOnce(state);
+ });
+}
+
+function clearRetry(state: ThreadOutboxDrainState, messageId: MessageId): void {
+ state.retryAttempt.delete(messageId);
+ state.retryNotBefore.delete(messageId);
+ const timer = state.retryTimers.get(messageId);
+ if (timer !== undefined) {
+ clearTimeout(timer);
+ state.retryTimers.delete(messageId);
+ }
+}
+
+function scheduleRetry(state: ThreadOutboxDrainState, messageId: MessageId): void {
+ // An in-flight delivery can settle after the final owner releases. Do not
+ // recreate timers that stopDrain already cleared while nobody owns the
+ // dispatcher; a later owner will perform a fresh drain immediately.
+ if (state.stopped) {
+ return;
+ }
+ const retryAttempt = (state.retryAttempt.get(messageId) ?? 0) + 1;
+ state.retryAttempt.set(messageId, retryAttempt);
+ const retryDelayMs = threadOutboxRetryDelayMs(retryAttempt);
+ state.retryNotBefore.set(messageId, Date.now() + retryDelayMs);
+ const pendingTimer = state.retryTimers.get(messageId);
+ if (pendingTimer !== undefined) {
+ clearTimeout(pendingTimer);
+ }
+ const timer = setTimeout(() => {
+ state.retryTimers.delete(messageId);
+ requestDrain(state);
+ }, retryDelayMs);
+ state.retryTimers.set(messageId, timer);
+}
+
+function drainOnce(state: ThreadOutboxDrainState): void {
+ if (
+ state.stopped ||
+ state.activeDelivery !== null ||
+ state.registry.get(dispatchingQueuedMessageIdAtom) !== null
+ ) {
+ return;
+ }
+
+ const queuedMessagesByThreadKey = state.registry.get(
+ threadOutboxManager.queuedMessagesByThreadKeyAtom,
+ );
+ const editingQueuedMessageIds = state.registry.get(editingQueuedMessageIdsAtom);
+ const shellStatuses = state.registry.get(threadOutboxShellStatusesAtom);
+ const threads = state.registry.get(environmentThreadShells.threadShellsAtom);
+ const projects = state.registry.get(environmentProjects.projectsAtom);
+ const presentations = state.registry.get(environmentPresentations.presentationsAtom);
+
+ for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) {
+ const nextQueuedMessage = queuedMessages[0];
+ if (!nextQueuedMessage || editingQueuedMessageIds[nextQueuedMessage.messageId]) {
+ continue;
+ }
+ if ((state.retryNotBefore.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) {
+ continue;
}
- for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) {
- const nextQueuedMessage = queuedMessages[0];
- if (!nextQueuedMessage) {
- continue;
- }
- if (editingQueuedMessageIds[nextQueuedMessage.messageId]) {
- continue;
- }
- if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) {
+ const thread = findThread(threads, nextQueuedMessage);
+ if (thread && scopedThreadKey(thread.environmentId, thread.id) !== threadKey) {
+ continue;
+ }
+ const creation = nextQueuedMessage.creation;
+ const shellStatus = shellStatuses.get(nextQueuedMessage.environmentId) ?? "empty";
+ const deliveryAction = resolveThreadOutboxDeliveryAction({
+ isCreation: creation !== undefined,
+ threadExists: thread !== undefined,
+ shellStatus,
+ environmentConnected:
+ presentations.get(nextQueuedMessage.environmentId)?.connection.phase === "connected",
+ threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting",
+ });
+ if (deliveryAction === "wait") {
+ continue;
+ }
+
+ // Prefer the live project shell, but retain the enqueue-time path so a
+ // pending task remains deliverable while its project shell is unloaded.
+ const creationProjectCwd =
+ creation !== undefined
+ ? (findCreationProject(projects, nextQueuedMessage)?.workspaceRoot ??
+ creation.projectCwd ??
+ null)
+ : null;
+ if (deliveryAction === "send" && creation !== undefined) {
+ // Incomplete worktree drafts remain queued until editing finishes.
+ if (!isQueuedThreadCreationSendable(nextQueuedMessage)) {
continue;
}
-
- const thread = findThread(threads, nextQueuedMessage);
- if (thread && scopedThreadKey(thread.environmentId, thread.id) !== threadKey) {
+ if (creationProjectCwd === null && shellStatus !== "live") {
continue;
}
+ }
- const creation = nextQueuedMessage.creation;
- const environment = connectedEnvironments.find(
- (candidate) => candidate.environmentId === nextQueuedMessage.environmentId,
+ state.registry.set(dispatchingQueuedMessageIdAtom, nextQueuedMessage.messageId);
+ const removeQueuedMessage = (warning: string) =>
+ removeThreadOutboxMessage(nextQueuedMessage).then(
+ () => true,
+ (error) => {
+ console.warn(warning, {
+ environmentId: nextQueuedMessage.environmentId,
+ threadId: nextQueuedMessage.threadId,
+ messageId: nextQueuedMessage.messageId,
+ error,
+ });
+ return false;
+ },
);
- const shellStatus = shellStatuses.get(nextQueuedMessage.environmentId) ?? "empty";
- const deliveryAction = resolveThreadOutboxDeliveryAction({
- isCreation: creation !== undefined,
- threadExists: thread !== undefined,
- shellStatus,
- environmentConnected: environment?.connectionState === "connected",
- threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting",
- });
- if (deliveryAction === "wait") {
- continue;
+
+ // Enqueue publishes optimistically before its durable write settles.
+ // Confirm persistence before dispatch so a rolled-back write cannot send.
+ const delivery = confirmThreadOutboxMessageQueued(nextQueuedMessage).then((queued) => {
+ if (!queued) {
+ return true;
}
- // The live project shell is preferred for the workspace path, with the
- // snapshot taken at enqueue time as the fallback so a task never dies
- // just because its project shell is not loaded.
- const creationProjectCwd =
- creation !== undefined
- ? (findCreationProject(projects, nextQueuedMessage)?.workspaceRoot ??
- creation.projectCwd ??
- null)
- : null;
- // An incomplete pending task (e.g. worktree mode without a branch) stays
- // queued until the user finishes it in the editor.
- if (deliveryAction === "send" && creation !== undefined) {
- if (!isQueuedThreadCreationSendable(nextQueuedMessage)) {
- continue;
- }
- if (creationProjectCwd === null && shellStatus !== "live") {
- continue;
- }
+ // These guards may have changed while persistence was settling. Re-read
+ // them before sending and let the next atom change restart the drain.
+ if (state.registry.get(editingQueuedMessageIdsAtom)[nextQueuedMessage.messageId]) {
+ return true;
+ }
+ const freshThread = findThread(
+ state.registry.get(environmentThreadShells.threadShellsAtom),
+ nextQueuedMessage,
+ );
+ const freshThreadBusy =
+ freshThread?.session?.status === "running" || freshThread?.session?.status === "starting";
+ if (deliveryAction === "send" && creation === undefined && freshThreadBusy) {
+ return true;
}
+ return deliveryAction === "remove"
+ ? removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread")
+ : creation !== undefined
+ ? creationProjectCwd !== null
+ ? sendQueuedCreation(state.registry, nextQueuedMessage, creation, creationProjectCwd)
+ : removeQueuedMessage("[thread-outbox] dropped pending task for a missing project")
+ : thread !== undefined
+ ? sendQueuedMessage(state.registry, nextQueuedMessage, thread)
+ : Promise.resolve(false);
+ });
- beginDispatchingQueuedMessage(nextQueuedMessage.messageId);
- const removeQueuedMessage = (warning: string) =>
- removeThreadOutboxMessage(nextQueuedMessage).then(
- () => true,
- (error) => {
- console.warn(warning, {
- environmentId: nextQueuedMessage.environmentId,
- threadId: nextQueuedMessage.threadId,
- messageId: nextQueuedMessage.messageId,
- error,
- });
- return false;
- },
- );
- // Enqueues publish optimistically before their durable write settles.
- // Confirm the write landed (and the message wasn't rolled back) before
- // sending, so a failed write can never chase an already-delivered turn.
- const delivery = confirmThreadOutboxMessageQueued(nextQueuedMessage).then((queued) => {
- if (!queued) {
- // Rolled back by a failed write; nothing to deliver or retry.
- return true;
+ const completion = delivery
+ .then((sent) => {
+ if (sent) {
+ clearRetry(state, nextQueuedMessage.messageId);
+ } else {
+ scheduleRetry(state, nextQueuedMessage.messageId);
}
- // The guards evaluated before the confirmation await are stale by now:
- // the thread may have gone busy, or the user may have opened this
- // message in the editor. Re-read both and defer to the next drain pass
- // (returning true skips the failure/backoff path) rather than sending
- // a payload the user is editing or racing an active turn.
- if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[nextQueuedMessage.messageId]) {
- return true;
- }
- const freshThread = findThread(
- appAtomRegistry.get(environmentThreadShells.threadShellsAtom),
- nextQueuedMessage,
- );
- const freshThreadBusy =
- freshThread?.session?.status === "running" || freshThread?.session?.status === "starting";
- if (deliveryAction === "send" && creation === undefined && freshThreadBusy) {
- return true;
+ })
+ .catch((error) => {
+ console.warn("[thread-outbox] queued message drain failed", {
+ environmentId: nextQueuedMessage.environmentId,
+ threadId: nextQueuedMessage.threadId,
+ messageId: nextQueuedMessage.messageId,
+ error,
+ });
+ scheduleRetry(state, nextQueuedMessage.messageId);
+ })
+ .finally(() => {
+ if (state.registry.get(dispatchingQueuedMessageIdAtom) === nextQueuedMessage.messageId) {
+ state.registry.set(dispatchingQueuedMessageIdAtom, null);
}
- return deliveryAction === "remove"
- ? removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread")
- : creation !== undefined
- ? creationProjectCwd !== null
- ? sendQueuedCreation(nextQueuedMessage, creation, creationProjectCwd)
- : removeQueuedMessage("[thread-outbox] dropped pending task for a missing project")
- : thread !== undefined
- ? sendQueuedMessage(nextQueuedMessage, thread)
- : Promise.resolve(false);
+ state.activeDelivery = null;
+ requestDrain(state);
});
- void delivery
- .then((sent) => {
- if (sent) {
- retryAttemptRef.current.delete(nextQueuedMessage.messageId);
- retryNotBeforeRef.current.delete(nextQueuedMessage.messageId);
- const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId);
- if (pendingTimer !== undefined) {
- clearTimeout(pendingTimer);
- retryTimersRef.current.delete(nextQueuedMessage.messageId);
- }
- return;
- }
+ state.activeDelivery = completion;
+ return;
+ }
+}
- const retryAttempt = (retryAttemptRef.current.get(nextQueuedMessage.messageId) ?? 0) + 1;
- retryAttemptRef.current.set(nextQueuedMessage.messageId, retryAttempt);
- const retryDelayMs = threadOutboxRetryDelayMs(retryAttempt);
- retryNotBeforeRef.current.set(nextQueuedMessage.messageId, Date.now() + retryDelayMs);
- const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId);
- if (pendingTimer !== undefined) {
- clearTimeout(pendingTimer);
- }
- const retryTimer = setTimeout(() => {
- retryTimersRef.current.delete(nextQueuedMessage.messageId);
- setRetryTick((current) => current + 1);
- }, retryDelayMs);
- retryTimersRef.current.set(nextQueuedMessage.messageId, retryTimer);
- })
- .finally(() => {
- finishDispatchingQueuedMessage(nextQueuedMessage.messageId);
- });
+function startDrain(state: ThreadOutboxDrainState): void {
+ if (!state.stopped) {
+ return;
+ }
+ state.stopped = false;
+ try {
+ ensureThreadOutboxLoaded();
+ const request = () => requestDrain(state);
+ // Store each release as it is acquired so a later subscription failure can
+ // still unwind every subscription that was already installed.
+ state.releases.push(
+ state.registry.subscribe(threadOutboxManager.queuedMessagesByThreadKeyAtom, request),
+ );
+ state.releases.push(state.registry.subscribe(editingQueuedMessageIdsAtom, request));
+ state.releases.push(state.registry.subscribe(threadOutboxShellStatusesAtom, request));
+ state.releases.push(
+ state.registry.subscribe(environmentThreadShells.threadShellsAtom, request),
+ );
+ state.releases.push(state.registry.subscribe(environmentProjects.projectsAtom, request));
+ state.releases.push(
+ state.registry.subscribe(environmentPresentations.presentationsAtom, request),
+ );
+ state.releases.push(state.registry.subscribe(dispatchingQueuedMessageIdAtom, request));
+ requestDrain(state);
+ } catch (error) {
+ stopDrain(state);
+ throw error;
+ }
+}
+
+function stopDrain(state: ThreadOutboxDrainState): void {
+ if (state.stopped) {
+ return;
+ }
+ state.stopped = true;
+ for (const release of state.releases.splice(0)) {
+ try {
+ release();
+ } catch (error) {
+ console.warn("[thread-outbox] failed to release drain subscription", error);
+ }
+ }
+ for (const timer of state.retryTimers.values()) {
+ clearTimeout(timer);
+ }
+ state.retryTimers.clear();
+ state.retryAttempt.clear();
+ state.retryNotBefore.clear();
+}
+
+/**
+ * Acquires the one process-wide outbox dispatcher for a registry. UI and
+ * Headless JS owners share this lease, so mounting both cannot double-send.
+ */
+export function acquireThreadOutboxDrain(registry: AtomRegistry.AtomRegistry): () => void {
+ let state = drainStates.get(registry);
+ if (state === undefined) {
+ state = {
+ registry,
+ owners: 0,
+ stopped: true,
+ drainScheduled: false,
+ activeDelivery: null,
+ retryAttempt: new Map(),
+ retryNotBefore: new Map(),
+ retryTimers: new Map(),
+ releases: [],
+ };
+ drainStates.set(registry, state);
+ }
+ state.owners += 1;
+ try {
+ startDrain(state);
+ } catch (error) {
+ state.owners -= 1;
+ if (state.owners === 0) {
+ drainStates.delete(registry);
+ }
+ throw error;
+ }
+
+ let released = false;
+ return () => {
+ if (released) {
return;
}
- }, [
- connectedEnvironments,
- dispatchingQueuedMessageId,
- editingQueuedMessageIds,
- projects,
- queuedMessagesByThreadKey,
- retryTick,
- sendQueuedCreation,
- sendQueuedMessage,
- shellStatuses,
- threads,
- ]);
+ released = true;
+ state.owners -= 1;
+ if (state.owners === 0) {
+ stopDrain(state);
+ }
+ };
+}
+
+export function useThreadOutboxDrain(): void {
+ const registry = useContext(RegistryContext);
+ useEffect(() => acquireThreadOutboxDrain(registry), [registry]);
}
diff --git a/apps/mobile/src/state/use-thread-outbox.ts b/apps/mobile/src/state/use-thread-outbox.ts
index 542c2d401c3..55e0ccba16d 100644
--- a/apps/mobile/src/state/use-thread-outbox.ts
+++ b/apps/mobile/src/state/use-thread-outbox.ts
@@ -7,7 +7,7 @@ import { appAtomRegistry } from "./atom-registry";
import { environmentShell } from "./shell";
import { threadOutboxManager } from "./thread-outbox";
-const threadOutboxShellStatusesAtom = Atom.make(
+export const threadOutboxShellStatusesAtom = Atom.make(
(get): ReadonlyMap => {
const statuses = new Map();
for (const queue of Object.values(get(threadOutboxManager.queuedMessagesByThreadKeyAtom))) {
From 6d50eae9f675bae1b7b98ce9317e213b756c70f3 Mon Sep 17 00:00:00 2001
From: Mike <84413695+snipemanmike@users.noreply.github.com>
Date: Sat, 1 Aug 2026 01:49:09 -0700
Subject: [PATCH 4/7] feat(mobile): retain relay auth for headless connections
---
.../liveActivityPreferences.test.ts | 3 +
.../features/cloud/CloudAuthProvider.test.ts | 62 ++++-
.../src/features/cloud/CloudAuthProvider.tsx | 34 ++-
.../cloud/backgroundManagedRelayAuth.test.ts | 251 ++++++++++++++++++
.../cloud/backgroundManagedRelayAuth.ts | 216 +++++++++++++++
.../features/cloud/linkEnvironment.test.ts | 3 +
.../managedRelaySessionOwnership.test.ts | 108 ++++++++
.../cloud/managedRelaySessionOwnership.ts | 98 +++++++
8 files changed, 766 insertions(+), 9 deletions(-)
create mode 100644 apps/mobile/src/features/cloud/backgroundManagedRelayAuth.test.ts
create mode 100644 apps/mobile/src/features/cloud/backgroundManagedRelayAuth.ts
create mode 100644 apps/mobile/src/features/cloud/managedRelaySessionOwnership.test.ts
create mode 100644 apps/mobile/src/features/cloud/managedRelaySessionOwnership.ts
diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts
index 0ee3e59b982..902984e40a2 100644
--- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts
+++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts
@@ -62,6 +62,9 @@ const testLayer = Layer.mergeAll(
clearAgentAwarenessRegistrationRecord: Effect.void,
loadRecentThreadShortcuts: Effect.succeed([]),
saveRecentThreadShortcuts: () => Effect.void,
+ loadBackgroundConnectionRetainedThread: Effect.succeed(null),
+ saveBackgroundConnectionRetainedThread: () => Effect.void,
+ clearBackgroundConnectionRetainedThread: Effect.void,
}),
),
);
diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts b/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts
index 2bc62d2a34e..92457cd1929 100644
--- a/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts
+++ b/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts
@@ -2,8 +2,17 @@ import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import { appAtomRegistry } from "../../state/atom-registry";
-import { activateCloudRelayAccount, deactivateCloudRelayAccount } from "./CloudAuthProvider";
import { setAgentAwarenessRelayTokenProvider } from "../agent-awareness/remoteRegistration";
+import {
+ activateCloudRelayAccount,
+ deactivateCloudRelayAccount,
+ releaseCloudRelayUiAccount,
+} from "./CloudAuthProvider";
+import {
+ invalidateBackgroundManagedRelayAuth,
+ refreshBackgroundManagedRelayAuth,
+} from "./backgroundManagedRelayAuth";
+import { acquireBackgroundManagedRelaySession } from "./managedRelaySessionOwnership";
vi.mock("@clerk/expo", () => ({
ClerkProvider: vi.fn(),
@@ -14,6 +23,11 @@ vi.mock("@clerk/expo/token-cache", () => ({
tokenCache: {},
}));
+vi.mock("./backgroundManagedRelayAuth", () => ({
+ invalidateBackgroundManagedRelayAuth: vi.fn(),
+ refreshBackgroundManagedRelayAuth: vi.fn(async () => undefined),
+}));
+
vi.mock("../../lib/runtime", () => ({
runtime: {
runPromiseExit: vi.fn(),
@@ -35,10 +49,19 @@ vi.mock("./publicConfig", () => ({
}));
vi.mock("../agent-awareness/remoteRegistration", () => ({
+ releaseAgentAwarenessRelayTokenProvider: vi.fn(),
setAgentAwarenessRelayTokenProvider: vi.fn(),
unregisterAgentAwarenessDeviceForCurrentUser: vi.fn(),
}));
+function deferred() {
+ let resolve!: () => void;
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+}
+
afterEach(() => {
deactivateCloudRelayAccount();
vi.clearAllMocks();
@@ -49,12 +72,49 @@ describe("CloudAuthProvider relay account isolation", () => {
const tokenProvider = async () => "account-1-token";
activateCloudRelayAccount("account-1", tokenProvider);
expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-1");
+ expect(refreshBackgroundManagedRelayAuth).toHaveBeenCalledOnce();
deactivateCloudRelayAccount();
const cleanup = Promise.reject(new Error("Persistence removal failed.")).catch(() => undefined);
expect(appAtomRegistry.get(managedRelaySessionAtom)).toBeNull();
expect(vi.mocked(setAgentAwarenessRelayTokenProvider)).toHaveBeenLastCalledWith(null);
+ expect(invalidateBackgroundManagedRelayAuth).toHaveBeenCalledOnce();
await cleanup;
});
+
+ it("preserves background relay ownership when the UI bridge unmounts", () => {
+ const releaseBackground = acquireBackgroundManagedRelaySession({
+ accountId: "account-1",
+ readClerkToken: async () => "background-token",
+ });
+ activateCloudRelayAccount("account-1", async () => "ui-token");
+
+ releaseCloudRelayUiAccount();
+
+ expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-1");
+ releaseBackground();
+ expect(appAtomRegistry.get(managedRelaySessionAtom)).toBeNull();
+ });
+
+ it("defers the background refresh for an account switch until cleanup resolves", async () => {
+ activateCloudRelayAccount("account-1", async () => "account-1-token");
+ vi.mocked(refreshBackgroundManagedRelayAuth).mockClear();
+
+ const cleanup = deferred();
+ deactivateCloudRelayAccount({ refreshBackground: false });
+ const transition = cleanup.promise.then(() => {
+ activateCloudRelayAccount("account-2", async () => "account-2-token");
+ });
+
+ expect(appAtomRegistry.get(managedRelaySessionAtom)).toBeNull();
+ expect(invalidateBackgroundManagedRelayAuth).toHaveBeenCalledOnce();
+ expect(refreshBackgroundManagedRelayAuth).not.toHaveBeenCalled();
+
+ cleanup.resolve();
+ await transition;
+
+ expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-2");
+ expect(refreshBackgroundManagedRelayAuth).toHaveBeenCalledOnce();
+ });
});
diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
index f7ece97cbaa..2b40334597c 100644
--- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
+++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
@@ -1,6 +1,6 @@
import { ClerkProvider, useAuth } from "@clerk/expo";
import { tokenCache } from "@clerk/expo/token-cache";
-import { ManagedRelay, setManagedRelaySession } from "@t3tools/client-runtime/relay";
+import { ManagedRelay } from "@t3tools/client-runtime/relay";
import {
reportAtomCommandResult,
settleAsyncResult,
@@ -11,7 +11,6 @@ import { type ReactNode, useEffect, useRef } from "react";
import { environmentCatalog } from "../../connection/catalog";
import { runtime } from "../../lib/runtime";
-import { appAtomRegistry } from "../../state/atom-registry";
import { useAtomCommand } from "../../state/use-atom-command";
import {
releaseAgentAwarenessRelayTokenProvider,
@@ -19,6 +18,11 @@ import {
unregisterAgentAwarenessDeviceForCurrentUser,
} from "../agent-awareness/remoteRegistration";
import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding";
+import {
+ invalidateBackgroundManagedRelayAuth,
+ refreshBackgroundManagedRelayAuth,
+} from "./backgroundManagedRelayAuth";
+import { managedRelaySessionOwnership } from "./managedRelaySessionOwnership";
import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
function resetManagedRelayTokenCache() {
@@ -29,9 +33,15 @@ function resetManagedRelayTokenCache() {
);
}
-export function deactivateCloudRelayAccount(): void {
+export function deactivateCloudRelayAccount(
+ options: { readonly refreshBackground?: boolean } = {},
+): void {
setAgentAwarenessRelayTokenProvider(null);
- setManagedRelaySession(appAtomRegistry, null);
+ invalidateBackgroundManagedRelayAuth();
+ managedRelaySessionOwnership.clear();
+ if (options.refreshBackground !== false) {
+ void refreshBackgroundManagedRelayAuth();
+ }
}
export function activateCloudRelayAccount(
@@ -39,10 +49,16 @@ export function activateCloudRelayAccount(
tokenProvider: () => Promise,
): void {
setAgentAwarenessRelayTokenProvider(tokenProvider, accountId);
- setManagedRelaySession(appAtomRegistry, {
+ managedRelaySessionOwnership.setOwner("ui", {
accountId,
readClerkToken: tokenProvider,
});
+ void refreshBackgroundManagedRelayAuth();
+}
+
+export function releaseCloudRelayUiAccount(): void {
+ releaseAgentAwarenessRelayTokenProvider();
+ managedRelaySessionOwnership.releaseOwner("ui");
}
function CloudAuthBridge(props: { readonly children: ReactNode }) {
@@ -147,7 +163,10 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
previousObservedAccount !== userId
) {
previousTokenProviderRef.current = null;
- deactivateCloudRelayAccount();
+ // Clerk already exposes the new account here. Keep background auth
+ // invalidated until the previous account's cleanup has completed;
+ // activateCloudRelayAccount refreshes it after the transition settles.
+ deactivateCloudRelayAccount({ refreshBackground: false });
activateAfterTransition(queueAccountCleanup(previous));
} else {
activateAfterTransition(accountTransitionRef.current ?? Promise.resolve());
@@ -164,8 +183,7 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
// Unmounting is not a sign-out: the user is usually still signed in, so
// detach the provider without ending lock-screen activities or wiping the
// persisted registration (a remount reuses both).
- releaseAgentAwarenessRelayTokenProvider();
- setManagedRelaySession(appAtomRegistry, null);
+ releaseCloudRelayUiAccount();
},
[],
);
diff --git a/apps/mobile/src/features/cloud/backgroundManagedRelayAuth.test.ts b/apps/mobile/src/features/cloud/backgroundManagedRelayAuth.test.ts
new file mode 100644
index 00000000000..ccdd49f8cf5
--- /dev/null
+++ b/apps/mobile/src/features/cloud/backgroundManagedRelayAuth.test.ts
@@ -0,0 +1,251 @@
+import { describe, expect, it, vi } from "vite-plus/test";
+
+import {
+ bootstrapBackgroundManagedRelayAuth,
+ invalidateBackgroundManagedRelayAuth,
+ refreshBackgroundManagedRelayAuth,
+ startBackgroundManagedRelayAuth,
+} from "./backgroundManagedRelayAuth";
+
+vi.mock("@clerk/expo", () => ({ getClerkInstance: vi.fn() }));
+vi.mock("@clerk/expo/token-cache", () => ({ tokenCache: {} }));
+vi.mock("expo-constants", () => ({
+ default: { expoConfig: { extra: {} } },
+}));
+
+const configuredPublicConfig = {
+ clerk: {
+ publishableKey: "pk_test_example",
+ jwtTemplate: "relay-template",
+ },
+ relay: { url: "https://relay.example.com/" },
+ observability: {
+ tracesUrl: null,
+ tracesDataset: null,
+ tracesToken: null,
+ },
+} as const;
+
+function deferred() {
+ let resolve!: (value: A) => void;
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+}
+
+describe("background managed relay authentication", () => {
+ it("loads Clerk and acquires a renewable session for the active account", async () => {
+ const load = vi.fn(async () => undefined);
+ const getToken = vi.fn(async () => "relay-token");
+ const release = vi.fn();
+ const acquireSession = vi.fn(
+ (_session: {
+ readonly accountId: string;
+ readonly readClerkToken: () => Promise;
+ }) => release,
+ );
+ const resolveTokenOptions = vi.fn(() => ({
+ template: "relay-template",
+ skipCache: true as const,
+ }));
+
+ const result = await bootstrapBackgroundManagedRelayAuth({
+ resolveConfig: () => configuredPublicConfig,
+ createClerk: () => ({
+ loaded: false,
+ load,
+ session: { user: { id: "account-1" }, getToken },
+ }),
+ resolveTokenOptions,
+ acquireSession,
+ });
+
+ expect(result.state).toEqual({ status: "active", accountId: "account-1" });
+ expect(load).toHaveBeenCalledOnce();
+ const session = acquireSession.mock.calls[0]?.[0];
+ expect(session?.accountId).toBe("account-1");
+ await expect(session?.readClerkToken()).resolves.toBe("relay-token");
+ expect(getToken).toHaveBeenCalledWith({
+ template: "relay-template",
+ skipCache: true,
+ });
+ });
+
+ it("treats a loaded Clerk instance without a session as signed out", async () => {
+ const acquireSession = vi.fn(
+ (_session: {
+ readonly accountId: string;
+ readonly readClerkToken: () => Promise;
+ }) => vi.fn(),
+ );
+
+ const result = await bootstrapBackgroundManagedRelayAuth({
+ resolveConfig: () => configuredPublicConfig,
+ createClerk: () => ({ loaded: true, load: vi.fn(), session: null }),
+ resolveTokenOptions: vi.fn(() => ({
+ template: "relay-template",
+ skipCache: true as const,
+ })),
+ acquireSession,
+ });
+
+ expect(result.state).toEqual({ status: "signed-out" });
+ expect(acquireSession).not.toHaveBeenCalled();
+ });
+
+ it("returns immediately for missing public configuration", async () => {
+ const createClerk = vi.fn();
+ const result = await bootstrapBackgroundManagedRelayAuth({
+ resolveConfig: () => ({
+ ...configuredPublicConfig,
+ clerk: { publishableKey: null, jwtTemplate: null },
+ }),
+ createClerk,
+ resolveTokenOptions: vi.fn(() => ({
+ template: "relay-template",
+ skipCache: true as const,
+ })),
+ acquireSession: vi.fn(
+ (_session: {
+ readonly accountId: string;
+ readonly readClerkToken: () => Promise;
+ }) => vi.fn(),
+ ),
+ });
+
+ expect(result.state).toEqual({ status: "unconfigured" });
+ expect(createClerk).not.toHaveBeenCalled();
+ });
+
+ it("reports transient Clerk initialization failures without throwing", async () => {
+ const error = new Error("Network unavailable");
+ const result = await bootstrapBackgroundManagedRelayAuth({
+ resolveConfig: () => configuredPublicConfig,
+ createClerk: () => {
+ throw error;
+ },
+ resolveTokenOptions: vi.fn(() => ({
+ template: "relay-template",
+ skipCache: true as const,
+ })),
+ acquireSession: vi.fn(
+ (_session: {
+ readonly accountId: string;
+ readonly readClerkToken: () => Promise;
+ }) => vi.fn(),
+ ),
+ });
+
+ expect(result.state).toEqual({ status: "retrying", error });
+ });
+
+ it("cannot restore a stale account after sign-out invalidates an in-flight load", async () => {
+ const load = deferred();
+ const acquireSession = vi.fn(() => vi.fn());
+ const attempt = bootstrapBackgroundManagedRelayAuth({
+ resolveConfig: () => configuredPublicConfig,
+ createClerk: () => ({
+ loaded: false,
+ load: () => load.promise,
+ session: {
+ user: { id: "old-account" },
+ getToken: vi.fn(async () => "old-token"),
+ },
+ }),
+ resolveTokenOptions: vi.fn(() => ({
+ template: "relay-template",
+ skipCache: true as const,
+ })),
+ acquireSession,
+ });
+
+ invalidateBackgroundManagedRelayAuth();
+ load.resolve();
+
+ await expect(attempt).resolves.toEqual({ state: { status: "superseded" } });
+ expect(acquireSession).not.toHaveBeenCalled();
+ });
+
+ it("keeps the controller alive and retries transient Clerk failures", async () => {
+ const error = new Error("Clerk is temporarily unavailable");
+ const release = vi.fn();
+ const bootstrap = vi
+ .fn()
+ .mockRejectedValueOnce(error)
+ .mockResolvedValueOnce({
+ state: { status: "active", accountId: "account-1" },
+ release,
+ });
+ const scheduledRetries: Array<{ retry: () => void; delayMs: number }> = [];
+ const controller = startBackgroundManagedRelayAuth({
+ bootstrap,
+ scheduleRetry: (retry, delayMs) => {
+ scheduledRetries.push({ retry, delayMs });
+ return vi.fn();
+ },
+ });
+
+ await expect(controller.firstAttempt).resolves.toEqual({ status: "retrying", error });
+ expect(scheduledRetries).toHaveLength(1);
+ expect(scheduledRetries[0]?.delayMs).toBe(1_000);
+ await expect(controller.retryNow()).resolves.toEqual({
+ status: "active",
+ accountId: "account-1",
+ });
+
+ controller.stop();
+ expect(release).toHaveBeenCalledOnce();
+ });
+
+ it("retries a signed-out bootstrap when UI ownership changes", async () => {
+ const release = vi.fn();
+ const bootstrap = vi
+ .fn()
+ .mockResolvedValueOnce({ state: { status: "signed-out" } })
+ .mockResolvedValueOnce({
+ state: { status: "active", accountId: "account-1" },
+ release,
+ });
+ const controller = startBackgroundManagedRelayAuth({ bootstrap });
+ await expect(controller.firstAttempt).resolves.toEqual({ status: "signed-out" });
+
+ await refreshBackgroundManagedRelayAuth();
+
+ expect(bootstrap).toHaveBeenCalledTimes(2);
+ controller.stop();
+ expect(release).toHaveBeenCalledOnce();
+ });
+
+ it("queues a fresh bootstrap when refresh is requested during an in-flight attempt", async () => {
+ const first = deferred<{ state: { status: "superseded" } }>();
+ const bootstrap = vi
+ .fn()
+ .mockImplementationOnce(() => first.promise)
+ .mockResolvedValueOnce({ state: { status: "signed-out" } });
+ const controller = startBackgroundManagedRelayAuth({ bootstrap });
+
+ const refreshed = controller.retryNow();
+ expect(bootstrap).toHaveBeenCalledOnce();
+ first.resolve({ state: { status: "superseded" } });
+
+ await expect(refreshed).resolves.toEqual({ status: "signed-out" });
+ expect(bootstrap).toHaveBeenCalledTimes(2);
+ controller.stop();
+ });
+
+ it("stops retrying and releases ownership idempotently", async () => {
+ const release = vi.fn();
+ const controller = startBackgroundManagedRelayAuth({
+ bootstrap: async () => ({
+ state: { status: "active", accountId: "account-1" },
+ release,
+ }),
+ });
+ await controller.firstAttempt;
+
+ controller.stop();
+ controller.stop();
+ expect(release).toHaveBeenCalledOnce();
+ });
+});
diff --git a/apps/mobile/src/features/cloud/backgroundManagedRelayAuth.ts b/apps/mobile/src/features/cloud/backgroundManagedRelayAuth.ts
new file mode 100644
index 00000000000..0e713346b84
--- /dev/null
+++ b/apps/mobile/src/features/cloud/backgroundManagedRelayAuth.ts
@@ -0,0 +1,216 @@
+import { getClerkInstance } from "@clerk/expo";
+import { tokenCache } from "@clerk/expo/token-cache";
+
+import { acquireBackgroundManagedRelaySession } from "./managedRelaySessionOwnership";
+import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
+
+const INITIAL_RETRY_DELAY_MS = 1_000;
+const MAX_RETRY_DELAY_MS = 30_000;
+
+export type BackgroundManagedRelayAuthState =
+ | { readonly status: "active"; readonly accountId: string }
+ | { readonly status: "signed-out" }
+ | { readonly status: "unconfigured" }
+ | { readonly status: "superseded" }
+ | { readonly status: "retrying"; readonly error: unknown };
+
+interface BackgroundClerkSession {
+ readonly user: { readonly id: string };
+ readonly getToken: (
+ options: ReturnType,
+ ) => Promise;
+}
+
+interface BackgroundClerk {
+ readonly loaded: boolean;
+ readonly session: BackgroundClerkSession | null | undefined;
+ readonly load: () => Promise;
+}
+
+interface BackgroundManagedRelayAuthDependencies {
+ readonly resolveConfig: typeof resolveCloudPublicConfig;
+ readonly createClerk: (publishableKey: string) => BackgroundClerk;
+ readonly resolveTokenOptions: typeof resolveRelayClerkTokenOptions;
+ readonly acquireSession: typeof acquireBackgroundManagedRelaySession;
+}
+
+interface BackgroundManagedRelayAuthAttempt {
+ readonly state: BackgroundManagedRelayAuthState;
+ readonly release?: () => void;
+}
+
+const defaultDependencies: BackgroundManagedRelayAuthDependencies = {
+ resolveConfig: resolveCloudPublicConfig,
+ createClerk: (publishableKey) => getClerkInstance({ publishableKey, tokenCache }),
+ resolveTokenOptions: resolveRelayClerkTokenOptions,
+ acquireSession: acquireBackgroundManagedRelaySession,
+};
+
+let authenticationEpoch = 0;
+
+/** Prevent an in-flight Clerk load from restoring ownership after sign-out. */
+export function invalidateBackgroundManagedRelayAuth(): void {
+ authenticationEpoch += 1;
+}
+
+export async function bootstrapBackgroundManagedRelayAuth(
+ dependencies: BackgroundManagedRelayAuthDependencies = defaultDependencies,
+): Promise {
+ const attemptEpoch = authenticationEpoch;
+ try {
+ const config = dependencies.resolveConfig();
+ if (!config.clerk.publishableKey || !config.clerk.jwtTemplate || !config.relay.url) {
+ return { state: { status: "unconfigured" } };
+ }
+
+ const clerk = dependencies.createClerk(config.clerk.publishableKey);
+ if (!clerk.loaded) {
+ await clerk.load();
+ }
+ const session = clerk.session;
+ if (!session?.user.id) {
+ return { state: { status: "signed-out" } };
+ }
+
+ // Clerk loading crosses an async boundary. Sign-out or account switching
+ // can invalidate this result while it is in flight; never let that stale
+ // account reacquire the background owner after ownership was cleared.
+ if (attemptEpoch !== authenticationEpoch) {
+ return { state: { status: "superseded" } };
+ }
+
+ const release = dependencies.acquireSession({
+ accountId: session.user.id,
+ readClerkToken: () => session.getToken(dependencies.resolveTokenOptions()),
+ });
+ return {
+ state: { status: "active", accountId: session.user.id },
+ release,
+ };
+ } catch (error) {
+ return { state: { status: "retrying", error } };
+ }
+}
+
+interface BackgroundManagedRelayAuthControllerOptions {
+ readonly bootstrap?: () => Promise;
+ readonly scheduleRetry?: (retry: () => void, delayMs: number) => () => void;
+ readonly onStateChange?: (state: BackgroundManagedRelayAuthState) => void;
+}
+
+export interface BackgroundManagedRelayAuthController {
+ readonly firstAttempt: Promise;
+ readonly retryNow: () => Promise;
+ readonly stop: () => void;
+}
+
+const backgroundManagedRelayAuthRefreshers = new Set<
+ () => Promise
+>();
+
+export async function refreshBackgroundManagedRelayAuth(): Promise {
+ await Promise.all([...backgroundManagedRelayAuthRefreshers].map((refresh) => refresh()));
+}
+
+function defaultScheduleRetry(retry: () => void, delayMs: number): () => void {
+ const timeout = setTimeout(retry, delayMs);
+ return () => clearTimeout(timeout);
+}
+
+export function startBackgroundManagedRelayAuth(
+ options: BackgroundManagedRelayAuthControllerOptions = {},
+): BackgroundManagedRelayAuthController {
+ const bootstrap = options.bootstrap ?? (() => bootstrapBackgroundManagedRelayAuth());
+ const scheduleRetry = options.scheduleRetry ?? defaultScheduleRetry;
+ let stopped = false;
+ let failureCount = 0;
+ let releaseSession: (() => void) | null = null;
+ let cancelRetry: (() => void) | null = null;
+ let inFlight: Promise | null = null;
+ let refreshRequested = false;
+
+ const cancelScheduledRetry = () => {
+ cancelRetry?.();
+ cancelRetry = null;
+ };
+
+ const runAttempt = (): Promise => {
+ if (inFlight) {
+ return inFlight;
+ }
+ cancelScheduledRetry();
+ const operation = (async () => {
+ const attempt = await bootstrap().catch(
+ (error): BackgroundManagedRelayAuthAttempt => ({
+ state: { status: "retrying", error },
+ }),
+ );
+ if (stopped) {
+ attempt.release?.();
+ return attempt.state;
+ }
+
+ if (attempt.state.status === "active") {
+ failureCount = 0;
+ const previousRelease = releaseSession;
+ releaseSession = attempt.release ?? null;
+ previousRelease?.();
+ } else if (attempt.state.status === "retrying") {
+ const retryDelay = Math.min(INITIAL_RETRY_DELAY_MS * 2 ** failureCount, MAX_RETRY_DELAY_MS);
+ failureCount += 1;
+ cancelRetry = scheduleRetry(() => {
+ cancelRetry = null;
+ void runAttempt();
+ }, retryDelay);
+ } else {
+ releaseSession?.();
+ releaseSession = null;
+ }
+
+ options.onStateChange?.(attempt.state);
+ return attempt.state;
+ })();
+ const trackedOperation = operation.finally(() => {
+ if (inFlight === trackedOperation) {
+ inFlight = null;
+ }
+ });
+ inFlight = trackedOperation;
+ return trackedOperation;
+ };
+
+ const requestAttempt = (): Promise => {
+ const currentAttempt = inFlight;
+ if (currentAttempt === null) {
+ return runAttempt();
+ }
+ // Coalesce refreshes, but guarantee one fresh bootstrap after the current
+ // attempt. Returning the in-flight promise alone would let a sign-out
+ // invalidation be consumed by the stale attempt without rereading Clerk.
+ refreshRequested = true;
+ return currentAttempt.then((state) => {
+ if (stopped || !refreshRequested) {
+ return state;
+ }
+ refreshRequested = false;
+ return runAttempt();
+ });
+ };
+
+ backgroundManagedRelayAuthRefreshers.add(requestAttempt);
+ const firstAttempt = runAttempt();
+ return {
+ firstAttempt,
+ retryNow: requestAttempt,
+ stop() {
+ if (stopped) {
+ return;
+ }
+ stopped = true;
+ backgroundManagedRelayAuthRefreshers.delete(requestAttempt);
+ cancelScheduledRetry();
+ releaseSession?.();
+ releaseSession = null;
+ },
+ };
+}
diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts
index c75d60d5fdf..3c1a5ba86da 100644
--- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts
+++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts
@@ -96,6 +96,9 @@ function cloudClientLayer() {
clearAgentAwarenessRegistrationRecord: Effect.void,
loadRecentThreadShortcuts: Effect.succeed([]),
saveRecentThreadShortcuts: () => Effect.void,
+ loadBackgroundConnectionRetainedThread: Effect.succeed(null),
+ saveBackgroundConnectionRetainedThread: () => Effect.void,
+ clearBackgroundConnectionRetainedThread: Effect.void,
}),
),
ManagedRelay.layer({
diff --git a/apps/mobile/src/features/cloud/managedRelaySessionOwnership.test.ts b/apps/mobile/src/features/cloud/managedRelaySessionOwnership.test.ts
new file mode 100644
index 00000000000..7ba0e3fbed6
--- /dev/null
+++ b/apps/mobile/src/features/cloud/managedRelaySessionOwnership.test.ts
@@ -0,0 +1,108 @@
+import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay";
+import { AtomRegistry } from "effect/unstable/reactivity";
+import { describe, expect, it, vi } from "vite-plus/test";
+
+import { createManagedRelaySessionOwnership } from "./managedRelaySessionOwnership";
+
+function createHarness() {
+ const registry = AtomRegistry.make();
+ const ownership = createManagedRelaySessionOwnership(registry);
+ const provider = (token: string) => vi.fn(async () => token);
+ return { ownership, provider, registry };
+}
+
+describe("managed relay session ownership", () => {
+ it("keeps the UI owner effective while a background owner is present", () => {
+ const { ownership, provider, registry } = createHarness();
+ const backgroundProvider = provider("background");
+ const uiProvider = provider("ui");
+
+ ownership.setOwner("background", {
+ accountId: "account-1",
+ readClerkToken: backgroundProvider,
+ });
+ ownership.setOwner("ui", { accountId: "account-1", readClerkToken: uiProvider });
+
+ const session = registry.get(managedRelaySessionAtom);
+ expect(session?.accountId).toBe("account-1");
+ ownership.releaseOwner("ui");
+ expect(registry.get(managedRelaySessionAtom)?.accountId).toBe("account-1");
+ });
+
+ it("releasing one owner preserves the other owner", () => {
+ const { ownership, provider, registry } = createHarness();
+ const releaseBackground = ownership.setOwner("background", {
+ accountId: "account-1",
+ readClerkToken: provider("background"),
+ });
+ ownership.setOwner("ui", {
+ accountId: "account-1",
+ readClerkToken: provider("ui"),
+ });
+
+ releaseBackground();
+ expect(registry.get(managedRelaySessionAtom)?.accountId).toBe("account-1");
+ ownership.releaseOwner("ui");
+ expect(registry.get(managedRelaySessionAtom)).toBeNull();
+ });
+
+ it("does not let an obsolete release remove a newer lease", () => {
+ const { ownership, provider, registry } = createHarness();
+ const releaseFirst = ownership.setOwner("background", {
+ accountId: "account-1",
+ readClerkToken: provider("first"),
+ });
+ ownership.setOwner("background", {
+ accountId: "account-1",
+ readClerkToken: provider("second"),
+ });
+
+ releaseFirst();
+ expect(registry.get(managedRelaySessionAtom)?.accountId).toBe("account-1");
+ });
+
+ it("invalidates a stale background owner on a UI account switch", () => {
+ const { ownership, provider, registry } = createHarness();
+ ownership.setOwner("background", {
+ accountId: "account-1",
+ readClerkToken: provider("background"),
+ });
+ ownership.setOwner("ui", {
+ accountId: "account-2",
+ readClerkToken: provider("ui"),
+ });
+
+ ownership.releaseOwner("ui");
+ expect(registry.get(managedRelaySessionAtom)).toBeNull();
+ });
+
+ it("rejects a stale background bootstrap that completes after a UI switch", () => {
+ const { ownership, provider, registry } = createHarness();
+ ownership.setOwner("ui", {
+ accountId: "account-2",
+ readClerkToken: provider("ui"),
+ });
+ ownership.setOwner("background", {
+ accountId: "account-1",
+ readClerkToken: provider("background"),
+ });
+
+ ownership.releaseOwner("ui");
+ expect(registry.get(managedRelaySessionAtom)).toBeNull();
+ });
+
+ it("clears both owners for sign-out", () => {
+ const { ownership, provider, registry } = createHarness();
+ ownership.setOwner("background", {
+ accountId: "account-1",
+ readClerkToken: provider("background"),
+ });
+ ownership.setOwner("ui", {
+ accountId: "account-1",
+ readClerkToken: provider("ui"),
+ });
+
+ ownership.clear();
+ expect(registry.get(managedRelaySessionAtom)).toBeNull();
+ });
+});
diff --git a/apps/mobile/src/features/cloud/managedRelaySessionOwnership.ts b/apps/mobile/src/features/cloud/managedRelaySessionOwnership.ts
new file mode 100644
index 00000000000..a7e1bc76c15
--- /dev/null
+++ b/apps/mobile/src/features/cloud/managedRelaySessionOwnership.ts
@@ -0,0 +1,98 @@
+import {
+ type ManagedRelaySessionInput,
+ setManagedRelaySession,
+} from "@t3tools/client-runtime/relay";
+import type { AtomRegistry } from "effect/unstable/reactivity";
+
+import { appAtomRegistry } from "../../state/atom-registry";
+
+export type ManagedRelaySessionOwner = "ui" | "background";
+
+interface OwnerLease extends ManagedRelaySessionInput {
+ readonly id: number;
+}
+
+export interface ManagedRelaySessionOwnership {
+ readonly setOwner: (
+ owner: ManagedRelaySessionOwner,
+ session: ManagedRelaySessionInput,
+ ) => () => void;
+ readonly releaseOwner: (owner: ManagedRelaySessionOwner) => void;
+ readonly clear: () => void;
+}
+
+export function createManagedRelaySessionOwnership(
+ registry: AtomRegistry.AtomRegistry,
+): ManagedRelaySessionOwnership {
+ let nextLeaseId = 0;
+ let ui: OwnerLease | null = null;
+ let background: OwnerLease | null = null;
+ let appliedLeaseId: number | null = null;
+
+ const applyEffectiveOwner = () => {
+ const effective = ui ?? background;
+ const effectiveLeaseId = effective?.id ?? null;
+ if (effectiveLeaseId === appliedLeaseId) {
+ return;
+ }
+ appliedLeaseId = effectiveLeaseId;
+ setManagedRelaySession(registry, effective);
+ };
+
+ const releaseLease = (owner: ManagedRelaySessionOwner, leaseId: number) => {
+ if (owner === "ui") {
+ if (ui?.id !== leaseId) {
+ return;
+ }
+ ui = null;
+ } else {
+ if (background?.id !== leaseId) {
+ return;
+ }
+ background = null;
+ }
+ applyEffectiveOwner();
+ };
+
+ return {
+ setOwner(owner, session) {
+ const lease: OwnerLease = { ...session, id: ++nextLeaseId };
+ if (owner === "ui") {
+ ui = lease;
+ if (background?.accountId !== session.accountId) {
+ background = null;
+ }
+ } else {
+ // A headless bootstrap may finish after the user switches accounts in
+ // the UI. Never retain that stale result for a later UI unmount.
+ if (ui && ui.accountId !== session.accountId) {
+ return () => undefined;
+ }
+ background = lease;
+ }
+ applyEffectiveOwner();
+ return () => releaseLease(owner, lease.id);
+ },
+ releaseOwner(owner) {
+ if (owner === "ui") {
+ ui = null;
+ } else {
+ background = null;
+ }
+ applyEffectiveOwner();
+ },
+ clear() {
+ ui = null;
+ background = null;
+ applyEffectiveOwner();
+ },
+ };
+}
+
+export const managedRelaySessionOwnership = createManagedRelaySessionOwnership(appAtomRegistry);
+
+export function acquireBackgroundManagedRelaySession(
+ session: ManagedRelaySessionInput,
+): () => void {
+ return managedRelaySessionOwnership.setOwner("background", session);
+}
From 6378a609d5bb4efa97f7518e1895eb3869ef902e Mon Sep 17 00:00:00 2001
From: Mike <84413695+snipemanmike@users.noreply.github.com>
Date: Sat, 1 Aug 2026 01:49:09 -0700
Subject: [PATCH 5/7] fix(mobile): preserve healthy background sessions on
resume
---
.../src/connection/app-state-wakeups.test.ts | 30 +++++++++
.../src/connection/app-state-wakeups.ts | 11 +++-
apps/mobile/src/connection/platform.ts | 17 ++++-
.../src/connection/supervisor.test.ts | 62 ++++++++++++++++---
.../src/connection/supervisor.ts | 17 ++++-
.../src/connection/wakeups.test.ts | 14 +++++
.../client-runtime/src/connection/wakeups.ts | 2 +
7 files changed, 141 insertions(+), 12 deletions(-)
create mode 100644 packages/client-runtime/src/connection/wakeups.test.ts
diff --git a/apps/mobile/src/connection/app-state-wakeups.test.ts b/apps/mobile/src/connection/app-state-wakeups.test.ts
index 4e8bdf1edf4..2cbb50e1cfb 100644
--- a/apps/mobile/src/connection/app-state-wakeups.test.ts
+++ b/apps/mobile/src/connection/app-state-wakeups.test.ts
@@ -18,4 +18,34 @@ describe("mobileApplicationActiveWakeup", () => {
mobileApplicationActiveWakeup(20_000, 20_000 + MOBILE_BACKGROUND_RECONNECT_AFTER_MS),
).toBe("application-active-reconnect");
});
+
+ it("preserves a protected background connection regardless of elapsed time", () => {
+ const protection = { serviceRunning: true, runtimeReady: true };
+
+ expect(mobileApplicationActiveWakeup(null, 20_000, protection)).toBe(
+ "application-active-preserved",
+ );
+ expect(
+ mobileApplicationActiveWakeup(
+ 20_000,
+ 20_000 + MOBILE_BACKGROUND_RECONNECT_AFTER_MS * 10,
+ protection,
+ ),
+ ).toBe("application-active-preserved");
+ });
+
+ it("keeps the existing fallback unless both native protection signals are healthy", () => {
+ expect(
+ mobileApplicationActiveWakeup(20_000, 20_000 + MOBILE_BACKGROUND_RECONNECT_AFTER_MS, {
+ serviceRunning: true,
+ runtimeReady: false,
+ }),
+ ).toBe("application-active-reconnect");
+ expect(
+ mobileApplicationActiveWakeup(20_000, 20_000 + MOBILE_BACKGROUND_RECONNECT_AFTER_MS, {
+ serviceRunning: false,
+ runtimeReady: true,
+ }),
+ ).toBe("application-active-reconnect");
+ });
});
diff --git a/apps/mobile/src/connection/app-state-wakeups.ts b/apps/mobile/src/connection/app-state-wakeups.ts
index 185ff744958..fec3b444e84 100644
--- a/apps/mobile/src/connection/app-state-wakeups.ts
+++ b/apps/mobile/src/connection/app-state-wakeups.ts
@@ -4,13 +4,22 @@ export const MOBILE_BACKGROUND_RECONNECT_AFTER_MS = 10_000;
export type MobileApplicationActiveWakeup = Extract<
Wakeups.ConnectionWakeup,
- "application-active-probe" | "application-active-reconnect"
+ "application-active-preserved" | "application-active-probe" | "application-active-reconnect"
>;
+export interface MobileBackgroundConnectionProtection {
+ readonly serviceRunning: boolean;
+ readonly runtimeReady: boolean;
+}
+
export function mobileApplicationActiveWakeup(
backgroundedAtMs: number | null,
activeAtMs: number,
+ protection?: MobileBackgroundConnectionProtection,
): MobileApplicationActiveWakeup {
+ if (protection?.serviceRunning === true && protection.runtimeReady === true) {
+ return "application-active-preserved";
+ }
return backgroundedAtMs !== null &&
activeAtMs - backgroundedAtMs >= MOBILE_BACKGROUND_RECONNECT_AFTER_MS
? "application-active-reconnect"
diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts
index 852535d9d10..9bd25b7dacc 100644
--- a/apps/mobile/src/connection/platform.ts
+++ b/apps/mobile/src/connection/platform.ts
@@ -26,11 +26,15 @@ import { AppState } from "react-native";
import { authClientMetadata } from "../lib/authClientMetadata";
import * as Runtime from "../lib/runtime";
+import { getBackgroundConnectionStatus } from "../native/backgroundConnection";
import * as MobileStorage from "../persistence/mobile-storage";
import { appAtomRegistry } from "../state/atom-registry";
import { clearThreadOutboxEnvironment } from "../state/thread-outbox";
import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts";
-import { mobileApplicationActiveWakeup } from "./app-state-wakeups";
+import {
+ type MobileApplicationActiveWakeup,
+ mobileApplicationActiveWakeup,
+} from "./app-state-wakeups";
import { connectionStorageLayer } from "./storage";
function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" {
@@ -87,7 +91,7 @@ const connectivityLayer = Connectivity.layer({
const wakeupsLayer = Wakeups.layer({
changes: Stream.merge(
- Stream.callback<"application-active-probe" | "application-active-reconnect">((queue) =>
+ Stream.callback((queue) =>
Effect.acquireRelease(
Effect.sync(() => {
let backgroundedAtMs = AppState.currentState === "background" ? Date.now() : null;
@@ -97,7 +101,14 @@ const wakeupsLayer = Wakeups.layer({
return;
}
if (state === "active") {
- Queue.offerUnsafe(queue, mobileApplicationActiveWakeup(backgroundedAtMs, Date.now()));
+ Queue.offerUnsafe(
+ queue,
+ mobileApplicationActiveWakeup(
+ backgroundedAtMs,
+ Date.now(),
+ getBackgroundConnectionStatus(),
+ ),
+ );
backgroundedAtMs = null;
}
});
diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts
index a925859049f..3d4736245d5 100644
--- a/packages/client-runtime/src/connection/supervisor.test.ts
+++ b/packages/client-runtime/src/connection/supervisor.test.ts
@@ -873,6 +873,37 @@ describe("EnvironmentSupervisor", () => {
}),
);
+ it.effect("probes a preserved mobile session without changing its generation", () =>
+ Effect.gen(function* () {
+ const probeCount = yield* Ref.make(0);
+ const probeCalled = yield* Deferred.make();
+ const harness = yield* makeHarness({
+ probe: () =>
+ Ref.update(probeCount, (count) => count + 1).pipe(
+ Effect.andThen(Deferred.succeed(probeCalled, undefined)),
+ ),
+ });
+ const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
+ initiallyDesired: true,
+ }).pipe(Effect.provide(harness.dependencies));
+
+ yield* awaitState(
+ supervisor.state,
+ (state) => state.phase === "connected" && state.generation === 1,
+ );
+ yield* harness.wake("application-active-preserved");
+ yield* Deferred.await(probeCalled);
+
+ expect(yield* Ref.get(probeCount)).toBe(1);
+ expect(yield* Ref.get(harness.sessionCount)).toBe(1);
+ expect(yield* Ref.get(harness.releaseCount)).toBe(0);
+ expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({
+ phase: "connected",
+ generation: 1,
+ });
+ }),
+ );
+
it.effect("immediately replaces a mobile session after a long background resume", () =>
Effect.gen(function* () {
const probeCount = yield* Ref.make(0);
@@ -949,23 +980,40 @@ describe("EnvironmentSupervisor", () => {
}).pipe(Effect.provide(TestClock.layer())),
);
- it.effect("quickly times out a stalled mobile foreground liveness probe", () =>
+ it.effect("immediately reconnects when a preserved-session probe fails", () =>
Effect.gen(function* () {
const harness = yield* makeHarness({
- probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void),
+ probe: (attempt) =>
+ attempt === 1 ? Effect.fail(transient("The preserved session is stale.")) : Effect.void,
});
const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
initiallyDesired: true,
}).pipe(Effect.provide(harness.dependencies));
yield* awaitState(supervisor.state, (state) => state.phase === "connected");
- yield* harness.wake("application-active-probe");
- yield* TestClock.adjust("3 seconds");
- yield* awaitState(
+ yield* harness.wake("application-active-preserved");
+ yield* eventuallyState(
supervisor.state,
- (state) => state.phase === "backoff" && state.lastFailure?.reason === "timeout",
+ (state) => state.phase === "connected" && state.generation === 2,
);
- yield* TestClock.adjust("1 second");
+
+ expect(yield* Ref.get(harness.sessionCount)).toBe(2);
+ expect(yield* Ref.get(harness.releaseCount)).toBe(1);
+ }),
+ );
+
+ it.effect("quickly times out a stalled preserved-session liveness probe", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeHarness({
+ probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void),
+ });
+ const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
+ initiallyDesired: true,
+ }).pipe(Effect.provide(harness.dependencies));
+
+ yield* awaitState(supervisor.state, (state) => state.phase === "connected");
+ yield* harness.wake("application-active-preserved");
+ yield* TestClock.adjust("3 seconds");
yield* eventuallyState(
supervisor.state,
(state) => state.phase === "connected" && state.generation === 2,
diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts
index 2a9c7519072..976b7f74f3a 100644
--- a/packages/client-runtime/src/connection/supervisor.ts
+++ b/packages/client-runtime/src/connection/supervisor.ts
@@ -414,10 +414,15 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
// replaces that lease and starts a fresh attempt without backoff.
return true;
}
- if (next.reason === "application-active" || next.reason === "application-active-probe") {
+ if (
+ next.reason === "application-active" ||
+ next.reason === "application-active-preserved" ||
+ next.reason === "application-active-probe"
+ ) {
const probe = yield* lease.session.probe.pipe(
Effect.timeoutOrElse({
duration:
+ next.reason === "application-active-preserved" ||
next.reason === "application-active-probe"
? MOBILE_CONNECTION_PROBE_TIMEOUT
: CONNECTION_PROBE_TIMEOUT,
@@ -441,6 +446,16 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
),
);
if (probeEvent._tag === "ProbeCompleted") {
+ if (
+ next.reason === "application-active-preserved" &&
+ Exit.isFailure(probeEvent.exit)
+ ) {
+ // Protection means this lease was expected to remain live.
+ // A failed bounded probe is therefore definitive stale-socket
+ // evidence: replace it immediately instead of adding the
+ // ordinary transient-failure backoff to foreground resume.
+ return true;
+ }
yield* probeEvent.exit;
break;
}
diff --git a/packages/client-runtime/src/connection/wakeups.test.ts b/packages/client-runtime/src/connection/wakeups.test.ts
new file mode 100644
index 00000000000..0817134e803
--- /dev/null
+++ b/packages/client-runtime/src/connection/wakeups.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "@effect/vitest";
+
+import { isApplicationActiveWakeup, shouldResubscribeAfterWakeup } from "./wakeups.ts";
+
+describe("connection wakeups", () => {
+ it("recognizes preserved resumes as application activation", () => {
+ expect(isApplicationActiveWakeup("application-active-preserved")).toBe(true);
+ });
+
+ it("does not resubscribe snapshots after a preserved resume", () => {
+ expect(shouldResubscribeAfterWakeup("application-active-preserved")).toBe(false);
+ expect(shouldResubscribeAfterWakeup("application-active-probe")).toBe(true);
+ });
+});
diff --git a/packages/client-runtime/src/connection/wakeups.ts b/packages/client-runtime/src/connection/wakeups.ts
index 8573a49c147..8159f3dd72c 100644
--- a/packages/client-runtime/src/connection/wakeups.ts
+++ b/packages/client-runtime/src/connection/wakeups.ts
@@ -4,6 +4,7 @@ import type * as Stream from "effect/Stream";
export type ConnectionWakeup =
| "application-active"
+ | "application-active-preserved"
| "application-active-probe"
| "application-active-reconnect"
| "credentials-changed";
@@ -11,6 +12,7 @@ export type ConnectionWakeup =
export function isApplicationActiveWakeup(reason: ConnectionWakeup): boolean {
return (
reason === "application-active" ||
+ reason === "application-active-preserved" ||
reason === "application-active-probe" ||
reason === "application-active-reconnect"
);
From f6f5f230edd6457ac8a1333b7fc599488f2a20f6 Mon Sep 17 00:00:00 2001
From: Mike <84413695+snipemanmike@users.noreply.github.com>
Date: Sat, 1 Aug 2026 01:49:10 -0700
Subject: [PATCH 6/7] docs(mobile): document the Android background runtime
---
apps/mobile/README.md | 22 +++++++++++
docs/internals/connection-runtime.md | 57 +++++++++++++++++++++++++++-
docs/user/remote-access.md | 27 +++++++++++++
3 files changed, 104 insertions(+), 2 deletions(-)
diff --git a/apps/mobile/README.md b/apps/mobile/README.md
index 0eb865cb79b..091ecc9151f 100644
--- a/apps/mobile/README.md
+++ b/apps/mobile/README.md
@@ -87,6 +87,28 @@ node ../../scripts/mobile-native-static-check.ts
The native lint task runs SwiftLint for Swift plus ktlint and detekt for Kotlin. Missing native tools are reported as warnings and skipped locally. CI installs the default toolset from `apps/mobile/Brewfile` before running the native checks.
+## Android background connection
+
+Android builds expose an opt-in **Keep connected in background** switch in Settings. When enabled,
+the app runs one silent `remoteMessaging` foreground service and one React Native Headless JS task.
+That task keeps the existing connection supervisors, environment shells, active thread details, and
+outbox dispatcher alive; it does not introduce a second WebSocket or synchronization protocol.
+
+Android requires the ongoing `T3 Code · Connected in background` service notification. Granting the
+one-time unrestricted-battery exemption makes recovery more reliable under vendor power management,
+but declining it leaves the feature enabled with a degraded status. A user-initiated Android
+force-stop is the platform boundary: launch the app once before automatic service, update, or boot
+restoration can resume.
+
+The implementation lives in `modules/t3-background-connection`; generated files under `android/`
+are not authoritative. After native changes, regenerate and verify a development project with:
+
+```bash
+APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android
+cd android
+./gradlew :t3-background-connection:compileDebugKotlin lintDebug
+```
+
## EAS Builds
CI uses Expo fingerprinting with the `preview:dev` profile to reuse an existing compatible build when possible, or start a new internal EAS build when native runtime inputs change. Production and default local builds continue to use the `appVersion` runtime policy.
diff --git a/docs/internals/connection-runtime.md b/docs/internals/connection-runtime.md
index 46fe0c82716..5f31c627b44 100644
--- a/docs/internals/connection-runtime.md
+++ b/docs/internals/connection-runtime.md
@@ -78,8 +78,12 @@ Wakeup handling differs by phase, in [supervisor.ts][supervisor]:
- Once connected, `monitorConnectedLease` handles plain activation by probing
the existing session (`lease.session.probe`, with a shorter timeout for
mobile's `application-active-probe`) rather than reconnecting; a healthy
- session survives foregrounding. `application-active-reconnect` skips the probe
- and replaces the lease outright.
+ session survives foregrounding. Android emits `application-active-preserved`
+ when its foreground service and Headless JS runtime both report ready. That
+ reason uses the same bounded mobile probe, preserves the session generation on
+ success, and replaces the lease immediately if the probe fails. It does not
+ force shell or thread subscriptions to restart. `application-active-reconnect`
+ skips the probe and replaces the lease outright.
The UI derives `available`, `offline`, `connecting`, `reconnecting`,
`connected`, and `error` from supervisor state plus explicit data-sync state.
@@ -157,6 +161,55 @@ Application code must not construct RPC clients, retry loops, or raw
orchestration commands. Persistence paths belong to the platform registration
and cache stores, with explicit migration or invalidation policy.
+### Android background ownership
+
+Android can add a second application-root owner without adding a second
+connection runtime. The opt-in **Keep connected in background** setting starts a
+`remoteMessaging` foreground service in the normal application process. React
+Native Headless JS cold-starts the existing mobile runtime and acquires
+reference-counted leases against the same process-wide `appAtomRegistry` used by
+the UI. Mounting the UI and background task together therefore still produces
+one supervisor and one transport per saved environment.
+
+The headless root retains:
+
+- the environment catalog, server configurations, and shell state for every
+ saved environment;
+- aggregate thread-shell state;
+- full detail for the last-opened thread and threads whose sessions are
+ `starting` or `running`; and
+- the shared, reference-counted thread-outbox drain worker.
+
+It does not retain every historical thread body. Once running work settles and
+is not the last-opened thread, the existing idle lifetime and persistence rules
+remain authoritative.
+
+T3 Connect authentication has matching `ui` and `background` owners. UI
+ownership wins while the app is visible. A cold headless start loads the
+persisted Clerk session and installs its token provider into the existing
+`managedRelaySessionAtom`; direct and Tailscale startup is not blocked when
+Clerk is signed out, unconfigured, or temporarily unavailable. There is no
+background-only relay transport or authentication path.
+
+The service is deliberately opt-in and defaults off. While enabled, Android
+requires a silent ongoing notification. React Native owns a partial CPU wake
+lock for the headless task, and the native service holds a best-effort
+high-performance Wi-Fi lock. These locks and the continuously active network
+connections have an intentional battery and data cost. A battery-optimization
+exemption improves survival under device power management, but declining it
+does not silently turn the feature off.
+
+The service uses sticky restart behavior and restores an enabled preference
+after package replacement or boot once credential-protected storage is
+available. Android force-stop remains absolute: no receiver or service may
+restart the app until the user launches it again. The app also cannot restart a
+separately stopped Tailscale VPN.
+
+At introduction, the direct/Tailscale path was exercised on a physical Android
+device across lock, Doze, task removal, network transitions, package replacement,
+and reboot. Cold T3 Connect ownership and token refresh have automated coverage,
+but were not live-validated against a configured relay account on that device.
+
## Verification
Core state-machine tests use `@effect/vitest` and deterministic service layers.
diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md
index 05418022d23..0b1535345b3 100644
--- a/docs/user/remote-access.md
+++ b/docs/user/remote-access.md
@@ -32,6 +32,33 @@ That gives you:
- transport security at the network layer
- less exposure than opening the server to the public internet
+## Keeping the Android App Connected
+
+On Android, **Keep connected in background** keeps saved environments and active work synchronized
+while the phone is locked or another app is open. The setting is off by default. Enable it from the
+mobile app under **Settings** → **Background connection**.
+
+This setting keeps the mobile connection runtime running; it does not change how the phone reaches
+an environment. Direct LAN, Tailscale, and T3 Connect environments continue using their existing
+connection methods. If a Tailscale environment depends on the Tailscale Android app, that VPN must
+also remain connected.
+
+Android requires an ongoing foreground-service notification while this mode is enabled. T3 Code's
+notification is silent and contains no thread content, but Android does not allow the app to hide it.
+The service also holds a partial CPU wake lock and, on Wi-Fi, a best-effort high-performance Wi-Fi
+lock. This can increase battery use and mobile-data use compared with the default on-demand
+connection behavior.
+
+When prompted, allowing unrestricted battery use makes the connection more resistant to Android or
+device-vendor power management. If you decline, the feature remains enabled but Settings reports
+**Battery optimization enabled**, and Android may still suspend it. You can tap that status to try
+again.
+
+Android force-stop is the hard boundary. After force-stopping T3 Code, launch it once before
+background connection can run again. A normal task swipe-away, process restart, app update, or reboot
+does not disable an enabled background connection; after a reboot it resumes once Android permits
+the app to start and the device has been unlocked.
+
## Enabling Network Access
There are three ways to reach your server from another device: expose the desktop app's backend,
From 81c65f781b2cdb23b38a53a019f4fc281b0561f6 Mon Sep 17 00:00:00 2001
From: snipemanmike <84413695+snipemanmike@users.noreply.github.com>
Date: Sat, 1 Aug 2026 03:04:54 -0700
Subject: [PATCH 7/7] fix(mobile): close background connection races
---
.../background-root.test.ts | 55 ++++++++++++++++---
.../background-connection/background-root.ts | 14 +++--
.../features/cloud/CloudAuthProvider.test.ts | 22 +++++++-
.../src/features/cloud/CloudAuthProvider.tsx | 15 ++++-
.../cloud/backgroundManagedRelayAuth.test.ts | 21 +++++++
.../cloud/backgroundManagedRelayAuth.ts | 3 +
.../managedRelaySessionOwnership.test.ts | 9 ++-
.../cloud/managedRelaySessionOwnership.ts | 6 +-
8 files changed, 124 insertions(+), 21 deletions(-)
diff --git a/apps/mobile/src/features/background-connection/background-root.test.ts b/apps/mobile/src/features/background-connection/background-root.test.ts
index 0cc0e754957..5ef711eaeb0 100644
--- a/apps/mobile/src/features/background-connection/background-root.test.ts
+++ b/apps/mobile/src/features/background-connection/background-root.test.ts
@@ -3,6 +3,14 @@ import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contract
import { AsyncResult, type AtomRegistry } from "effect/unstable/reactivity";
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
+function deferred() {
+ let resolve!: () => void;
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+}
+
const atoms = vi.hoisted(() => ({
catalog: { name: "catalog" },
serverConfigs: { name: "server-configs" },
@@ -22,15 +30,17 @@ const retained = vi.hoisted(() => {
releaseCount: 0,
};
const listeners = new Set<() => void>();
+ const publish = (thread: ScopedThreadRef | null) => {
+ state.snapshot = { loaded: true, thread };
+ for (const listener of listeners) {
+ listener();
+ }
+ };
return {
state,
listeners,
- clear: vi.fn(async () => {
- state.snapshot = { loaded: true, thread: null };
- for (const listener of listeners) {
- listener();
- }
- }),
+ publish,
+ clear: vi.fn(async () => publish(null)),
ensureLoaded: vi.fn(async () => state.snapshot.thread),
};
});
@@ -186,7 +196,8 @@ beforeEach(() => {
retained.state.subscribeCount = 0;
retained.state.releaseCount = 0;
retained.listeners.clear();
- retained.clear.mockClear();
+ retained.clear.mockReset();
+ retained.clear.mockImplementation(async () => retained.publish(null));
retained.ensureLoaded.mockClear();
atoms.shellStates.clear();
atoms.detailStates.clear();
@@ -267,4 +278,34 @@ describe("background connection root", () => {
expect(harness.subscribeReleaseCount(`detail:${detailKey}`)).toBe(1);
root.stop();
});
+
+ it("re-clears a deleted ref saved while its first clear is pending", async () => {
+ const firstClear = deferred();
+ let clearCount = 0;
+ retained.clear.mockImplementation(() => {
+ retained.publish(null);
+ clearCount += 1;
+ return clearCount === 1 ? firstClear.promise : Promise.resolve();
+ });
+ const detailKey = `${environmentId}:${threadId}`;
+ const harness = createRegistry({
+ catalog: { isReady: true, entries: new Map([[environmentId, {}]]) },
+ deletedThreadKeys: new Set([detailKey]),
+ });
+ const root = createBackgroundConnectionRoot(harness.registry);
+
+ root.start();
+ expect(retained.clear).toHaveBeenCalledOnce();
+
+ retained.publish(retainedThread);
+ expect(retained.clear).toHaveBeenCalledOnce();
+
+ firstClear.resolve();
+ await firstClear.promise;
+ await Promise.resolve();
+
+ expect(retained.clear).toHaveBeenCalledTimes(2);
+ expect(retained.state.snapshot.thread).toBeNull();
+ root.stop();
+ });
});
diff --git a/apps/mobile/src/features/background-connection/background-root.ts b/apps/mobile/src/features/background-connection/background-root.ts
index 3e165d153dd..16d41d7bbea 100644
--- a/apps/mobile/src/features/background-connection/background-root.ts
+++ b/apps/mobile/src/features/background-connection/background-root.ts
@@ -57,7 +57,7 @@ export function createBackgroundConnectionRoot(
let threadShellsRelease: (() => void) | null = null;
let serverConfigsRelease: (() => void) | null = null;
let retainedThreadRelease: (() => void) | null = null;
- let clearingDeletedKey: string | null = null;
+ const clearingDeletedKeys = new Set();
const shellLeases = new Map void>();
const detailLeases = new Map();
@@ -66,13 +66,17 @@ export function createBackgroundConnectionRoot(
return;
}
const key = refKey(ref);
- if (clearingDeletedKey === key) {
+ if (clearingDeletedKeys.has(key)) {
return;
}
- clearingDeletedKey = key;
+ clearingDeletedKeys.add(key);
void clearBackgroundConnectionRetainedThread().finally(() => {
- if (clearingDeletedKey === key) {
- clearingDeletedKey = null;
+ clearingDeletedKeys.delete(key);
+ // Saving publishes before its persistence operation is queued. If the
+ // same deleted ref was saved while this clear was pending, clear it once
+ // more now so the final persistence operation cannot restore it.
+ if (started && refsEqual(retainedThread, ref)) {
+ clearRetainedIfCurrent(ref);
}
});
};
diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts b/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts
index 92457cd1929..7af8ee9ca42 100644
--- a/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts
+++ b/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts
@@ -89,14 +89,34 @@ describe("CloudAuthProvider relay account isolation", () => {
readClerkToken: async () => "background-token",
});
activateCloudRelayAccount("account-1", async () => "ui-token");
+ vi.mocked(refreshBackgroundManagedRelayAuth).mockClear();
releaseCloudRelayUiAccount();
expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-1");
- releaseBackground();
+ expect(refreshBackgroundManagedRelayAuth).toHaveBeenCalledOnce();
+ expect(releaseBackground).not.toBeNull();
+ releaseBackground?.();
expect(appAtomRegistry.get(managedRelaySessionAtom)).toBeNull();
});
+ it("waits for account cleanup before refreshing background ownership", async () => {
+ activateCloudRelayAccount("account-1", async () => "ui-token");
+ vi.mocked(refreshBackgroundManagedRelayAuth).mockClear();
+ const cleanup = deferred();
+
+ releaseCloudRelayUiAccount({ refreshAfter: cleanup.promise });
+
+ expect(appAtomRegistry.get(managedRelaySessionAtom)).toBeNull();
+ expect(refreshBackgroundManagedRelayAuth).not.toHaveBeenCalled();
+
+ cleanup.resolve();
+ await cleanup.promise;
+ await Promise.resolve();
+
+ expect(refreshBackgroundManagedRelayAuth).toHaveBeenCalledOnce();
+ });
+
it("defers the background refresh for an account switch until cleanup resolves", async () => {
activateCloudRelayAccount("account-1", async () => "account-1-token");
vi.mocked(refreshBackgroundManagedRelayAuth).mockClear();
diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
index 2b40334597c..8e0feea7ffb 100644
--- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
+++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
@@ -56,9 +56,20 @@ export function activateCloudRelayAccount(
void refreshBackgroundManagedRelayAuth();
}
-export function releaseCloudRelayUiAccount(): void {
+export function releaseCloudRelayUiAccount(
+ options: { readonly refreshAfter?: Promise | null } = {},
+): void {
releaseAgentAwarenessRelayTokenProvider();
managedRelaySessionOwnership.releaseOwner("ui");
+ const refreshBackground = () => {
+ void refreshBackgroundManagedRelayAuth();
+ };
+ const refreshAfter = options.refreshAfter ?? null;
+ if (refreshAfter === null) {
+ refreshBackground();
+ return;
+ }
+ void refreshAfter.then(refreshBackground, refreshBackground);
}
function CloudAuthBridge(props: { readonly children: ReactNode }) {
@@ -183,7 +194,7 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
// Unmounting is not a sign-out: the user is usually still signed in, so
// detach the provider without ending lock-screen activities or wiping the
// persisted registration (a remount reuses both).
- releaseCloudRelayUiAccount();
+ releaseCloudRelayUiAccount({ refreshAfter: accountTransitionRef.current });
},
[],
);
diff --git a/apps/mobile/src/features/cloud/backgroundManagedRelayAuth.test.ts b/apps/mobile/src/features/cloud/backgroundManagedRelayAuth.test.ts
index ccdd49f8cf5..c4d9de13e51 100644
--- a/apps/mobile/src/features/cloud/backgroundManagedRelayAuth.test.ts
+++ b/apps/mobile/src/features/cloud/backgroundManagedRelayAuth.test.ts
@@ -94,6 +94,27 @@ describe("background managed relay authentication", () => {
expect(acquireSession).not.toHaveBeenCalled();
});
+ it("does not report active when a stale background owner is rejected", async () => {
+ const result = await bootstrapBackgroundManagedRelayAuth({
+ resolveConfig: () => configuredPublicConfig,
+ createClerk: () => ({
+ loaded: true,
+ load: vi.fn(),
+ session: {
+ user: { id: "stale-account" },
+ getToken: vi.fn(async () => "stale-token"),
+ },
+ }),
+ resolveTokenOptions: vi.fn(() => ({
+ template: "relay-template",
+ skipCache: true as const,
+ })),
+ acquireSession: vi.fn(() => null),
+ });
+
+ expect(result).toEqual({ state: { status: "superseded" } });
+ });
+
it("returns immediately for missing public configuration", async () => {
const createClerk = vi.fn();
const result = await bootstrapBackgroundManagedRelayAuth({
diff --git a/apps/mobile/src/features/cloud/backgroundManagedRelayAuth.ts b/apps/mobile/src/features/cloud/backgroundManagedRelayAuth.ts
index 0e713346b84..8950e380ed5 100644
--- a/apps/mobile/src/features/cloud/backgroundManagedRelayAuth.ts
+++ b/apps/mobile/src/features/cloud/backgroundManagedRelayAuth.ts
@@ -83,6 +83,9 @@ export async function bootstrapBackgroundManagedRelayAuth(
accountId: session.user.id,
readClerkToken: () => session.getToken(dependencies.resolveTokenOptions()),
});
+ if (release === null) {
+ return { state: { status: "superseded" } };
+ }
return {
state: { status: "active", accountId: session.user.id },
release,
diff --git a/apps/mobile/src/features/cloud/managedRelaySessionOwnership.test.ts b/apps/mobile/src/features/cloud/managedRelaySessionOwnership.test.ts
index 7ba0e3fbed6..4a44f6ae824 100644
--- a/apps/mobile/src/features/cloud/managedRelaySessionOwnership.test.ts
+++ b/apps/mobile/src/features/cloud/managedRelaySessionOwnership.test.ts
@@ -40,7 +40,8 @@ describe("managed relay session ownership", () => {
readClerkToken: provider("ui"),
});
- releaseBackground();
+ expect(releaseBackground).not.toBeNull();
+ releaseBackground?.();
expect(registry.get(managedRelaySessionAtom)?.accountId).toBe("account-1");
ownership.releaseOwner("ui");
expect(registry.get(managedRelaySessionAtom)).toBeNull();
@@ -57,7 +58,8 @@ describe("managed relay session ownership", () => {
readClerkToken: provider("second"),
});
- releaseFirst();
+ expect(releaseFirst).not.toBeNull();
+ releaseFirst?.();
expect(registry.get(managedRelaySessionAtom)?.accountId).toBe("account-1");
});
@@ -82,11 +84,12 @@ describe("managed relay session ownership", () => {
accountId: "account-2",
readClerkToken: provider("ui"),
});
- ownership.setOwner("background", {
+ const rejected = ownership.setOwner("background", {
accountId: "account-1",
readClerkToken: provider("background"),
});
+ expect(rejected).toBeNull();
ownership.releaseOwner("ui");
expect(registry.get(managedRelaySessionAtom)).toBeNull();
});
diff --git a/apps/mobile/src/features/cloud/managedRelaySessionOwnership.ts b/apps/mobile/src/features/cloud/managedRelaySessionOwnership.ts
index a7e1bc76c15..27be5d58014 100644
--- a/apps/mobile/src/features/cloud/managedRelaySessionOwnership.ts
+++ b/apps/mobile/src/features/cloud/managedRelaySessionOwnership.ts
@@ -16,7 +16,7 @@ export interface ManagedRelaySessionOwnership {
readonly setOwner: (
owner: ManagedRelaySessionOwner,
session: ManagedRelaySessionInput,
- ) => () => void;
+ ) => (() => void) | null;
readonly releaseOwner: (owner: ManagedRelaySessionOwner) => void;
readonly clear: () => void;
}
@@ -66,7 +66,7 @@ export function createManagedRelaySessionOwnership(
// A headless bootstrap may finish after the user switches accounts in
// the UI. Never retain that stale result for a later UI unmount.
if (ui && ui.accountId !== session.accountId) {
- return () => undefined;
+ return null;
}
background = lease;
}
@@ -93,6 +93,6 @@ export const managedRelaySessionOwnership = createManagedRelaySessionOwnership(a
export function acquireBackgroundManagedRelaySession(
session: ManagedRelaySessionInput,
-): () => void {
+): (() => void) | null {
return managedRelaySessionOwnership.setOwner("background", session);
}