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/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/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/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/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/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/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/background-root.test.ts b/apps/mobile/src/features/background-connection/background-root.test.ts new file mode 100644 index 00000000000..5ef711eaeb0 --- /dev/null +++ b/apps/mobile/src/features/background-connection/background-root.test.ts @@ -0,0 +1,311 @@ +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"; + +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" }, + 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>(); + const publish = (thread: ScopedThreadRef | null) => { + state.snapshot = { loaded: true, thread }; + for (const listener of listeners) { + listener(); + } + }; + return { + state, + listeners, + publish, + clear: vi.fn(async () => publish(null)), + 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.mockReset(); + retained.clear.mockImplementation(async () => retained.publish(null)); + 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(); + }); + + 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 new file mode 100644 index 00000000000..16d41d7bbea --- /dev/null +++ b/apps/mobile/src/features/background-connection/background-root.ts @@ -0,0 +1,254 @@ +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; + const clearingDeletedKeys = new Set(); + const shellLeases = new Map void>(); + const detailLeases = new Map(); + + const clearRetainedIfCurrent = (ref: ScopedThreadRef) => { + if (!refsEqual(retainedThread, ref)) { + return; + } + const key = refKey(ref); + if (clearingDeletedKeys.has(key)) { + return; + } + clearingDeletedKeys.add(key); + void clearBackgroundConnectionRetainedThread().finally(() => { + 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); + } + }); + }; + + 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/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/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/cloud/CloudAuthProvider.test.ts b/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts index 2bc62d2a34e..7af8ee9ca42 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,69 @@ 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"); + vi.mocked(refreshBackgroundManagedRelayAuth).mockClear(); + + releaseCloudRelayUiAccount(); + + expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-1"); + 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(); + + 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..8e0feea7ffb 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,27 @@ export function activateCloudRelayAccount( tokenProvider: () => Promise, ): void { setAgentAwarenessRelayTokenProvider(tokenProvider, accountId); - setManagedRelaySession(appAtomRegistry, { + managedRelaySessionOwnership.setOwner("ui", { accountId, readClerkToken: tokenProvider, }); + void refreshBackgroundManagedRelayAuth(); +} + +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 }) { @@ -147,7 +174,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 +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). - releaseAgentAwarenessRelayTokenProvider(); - setManagedRelaySession(appAtomRegistry, null); + releaseCloudRelayUiAccount({ refreshAfter: accountTransitionRef.current }); }, [], ); 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..c4d9de13e51 --- /dev/null +++ b/apps/mobile/src/features/cloud/backgroundManagedRelayAuth.test.ts @@ -0,0 +1,272 @@ +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("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({ + 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..8950e380ed5 --- /dev/null +++ b/apps/mobile/src/features/cloud/backgroundManagedRelayAuth.ts @@ -0,0 +1,219 @@ +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()), + }); + if (release === null) { + return { state: { status: "superseded" } }; + } + 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..4a44f6ae824 --- /dev/null +++ b/apps/mobile/src/features/cloud/managedRelaySessionOwnership.test.ts @@ -0,0 +1,111 @@ +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"), + }); + + expect(releaseBackground).not.toBeNull(); + 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"), + }); + + expect(releaseFirst).not.toBeNull(); + 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"), + }); + const rejected = ownership.setOwner("background", { + accountId: "account-1", + readClerkToken: provider("background"), + }); + + expect(rejected).toBeNull(); + 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..27be5d58014 --- /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) | null; + 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 null; + } + 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) | null { + return managedRelaySessionOwnership.setOwner("background", session); +} 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/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() {}, +}; 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, + ), }); }); 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))) { 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, 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" );