Skip to content

[Bug]: iOS app stays on "Loading environments" after iOS launches it before the first unlock #15718

Description

@nekohasekai

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/mobile

Steps to reproduce

On a real device (seen on iPad Pro 11-inch (M5), iPadOS 27.2, TestFlight 2.0.0 (103)):

  1. Have a turn running, so T3 Code shows a Live Activity.
  2. Restart the device. Do not unlock it right away.
  3. iOS opens T3 Code in the background for the Live Activity before the first unlock. The device logs below show this.
  4. Unlock the device and open T3 Code.

Simulator repro. The simulator has no keychain lock, so this uses a repro-only patch: a launch argument makes every expo-secure-store call in MobileSecureStorage fail for the first N seconds with the same error a locked device returns ("User interaction is not allowed."). The patch is at the end of this section.

  1. On main @ 4ee6bfd, apply the patch, build the dev client, serve production JS (expo start --dev-client --no-dev) and pair with a server that has a project and threads.
  2. xcrun simctl launch <udid> com.t3tools.t3code.dev shows the threads.
  3. xcrun simctl terminate <udid> com.t3tools.t3code.dev && xcrun simctl launch <udid> com.t3tools.t3code.dev -T3SimulateLockedLaunchSeconds 8
  4. Wait well past 8 seconds. The keychain is readable again, but the app never recovers. It shows the same screens as the device. The JS log has only the injected rejections, then nothing. The failed startup is not logged.
01:14:04.163 [repro] keychain locked for 8s after launch
01:14:05.480 [repro] getValueWithKeyAsync rejected: keychain locked
01:14:05.482 [repro] getValueWithKeyAsync rejected: keychain locked
01:14:05.488 [repro] setValueWithKeyAsync rejected: keychain locked
01:14:05.495 [repro] getValueWithKeyAsync rejected: keychain locked
01:14:05.496 WARN 'Could not load mobile preferences.' MobilePreferencesLoadError
Repro-only patch
diff --git a/apps/mobile/src/persistence/mobile-secure-storage.ts b/apps/mobile/src/persistence/mobile-secure-storage.ts
index 44ad2900d8..0fc77b0b36 100644
--- a/apps/mobile/src/persistence/mobile-secure-storage.ts
+++ b/apps/mobile/src/persistence/mobile-secure-storage.ts
@@ -3,6 +3,25 @@ import * as Effect from "effect/Effect";
 import * as Layer from "effect/Layer";
 import * as Schema from "effect/Schema";
 import * as SecureStore from "expo-secure-store";
+import { Settings } from "react-native";
+
+// REPRO ONLY, never commit. Launch with `-T3SimulateLockedLaunchSeconds N` to make every keychain
+// call fail for the first N seconds, the way iOS rejects them when it launches the app in the
+// background before the first unlock after a reboot (errSecInteractionNotAllowed).
+const simulatedLockedSeconds = Number(Settings.get("T3SimulateLockedLaunchSeconds") ?? 0);
+const simulatedUnlockAt = Date.now() + simulatedLockedSeconds * 1000;
+if (simulatedLockedSeconds > 0) {
+  console.warn(`[repro] keychain locked for ${simulatedLockedSeconds}s after launch`);
+}
+function lockedKeychain<A>(name: string, run: () => Promise<A>): Promise<A> {
+  if (Date.now() < simulatedUnlockAt) {
+    console.warn(`[repro] ${name} rejected: keychain locked`);
+    return Promise.reject(
+      new Error(`Calling the '${name}' function has failed\n→ Caused by: User interaction is not allowed.`),
+    );
+  }
+  return run();
+}
 
 const MobileSecureStorageOperation = Schema.Literals(["read", "write", "delete"]);
 
@@ -31,19 +50,19 @@ export class MobileSecureStorage extends Context.Service<
 export const make = MobileSecureStorage.of({
   getItem: Effect.fn("MobileSecureStorage.getItem")((key) =>
     Effect.tryPromise({
-      try: () => SecureStore.getItemAsync(key),
+      try: () => lockedKeychain("getValueWithKeyAsync", () => SecureStore.getItemAsync(key)),
       catch: (cause) => new MobileSecureStorageError({ operation: "read", key, cause }),
     }),
   ),
   setItem: Effect.fn("MobileSecureStorage.setItem")((key, value) =>
     Effect.tryPromise({
-      try: () => SecureStore.setItemAsync(key, value),
+      try: () => lockedKeychain("setValueWithKeyAsync", () => SecureStore.setItemAsync(key, value)),
       catch: (cause) => new MobileSecureStorageError({ operation: "write", key, cause }),
     }),
   ),
   removeItem: Effect.fn("MobileSecureStorage.removeItem")((key) =>
     Effect.tryPromise({
-      try: () => SecureStore.deleteItemAsync(key),
+      try: () => lockedKeychain("deleteValueWithKeyAsync", () => SecureStore.deleteItemAsync(key)),
       catch: (cause) => new MobileSecureStorageError({ operation: "delete", key, cause }),
     }),
   ),

Expected behavior

Once the device is unlocked, the app loads its saved environments. A keychain read that fails at launch should be retried. It should not leave the app stuck until the user force quits it.

Actual behavior

The app stays stuck until it is force quit:

  • Home and the new-task screen show "Loading environments — Checking saved environments on this device."
  • The sidebar shows "Loading threads…".
  • Settings shows Environments 0 and T3 Account "Checking".

Force quitting and reopening fixes it, which is why it looks like a random cold-start hang.

Device logs from the stuck process (sysdiagnose, PID 259):

23:19:21.870 chronod      Has unlocked since boot: false
23:19:23.969 SpringBoard  Received trusted open application request for "com.t3tools.t3code" from <FBProcess: ...; osservice<com.apple.liveactivitiesd>:140(v180)>.
23:19:23.984 SpringBoard  Executing suspended-activation immediately: OpenApplication(sceneID:com.t3tools.t3code-default)ForRequester(liveactivitiesd.140)
23:19:23.989 runningboardd Creating and launching job for: app<com.t3tools.t3code(...)>
23:19:24.990 runningboardd [app<com.t3tools.t3code(...)>:259] Set darwin role to: NonUserInteractive
23:19:25.515 T3Code[259]  Failed to open '.../Library/Application Support/.expo-internal/expo-v11.db' for read/write access (Operation not permitted).
23:19:25.829 T3Code[259]  Failed to open '.../Library/HTTPStorages/com.t3tools.t3code/httpstorages.sqlite' for read/write access (Operation not permitted).
23:19:25.830 T3Code[259]  ... failed resolver (unsatisfied (No network route))
23:20:24.046 runningboardd [app<com.t3tools.t3code(...)>:259] Set darwin role to: UserInteractiveFocal   <- user opens the app, stuck

"Operation not permitted" on the app's own files is iOS data protection before the first unlock. Keychain items are unavailable at the same point. The process stayed in this state until it was quit at 23:42. The relaunched process works.

Why it never recovers. The simulator repro shows that one failed keychain read at launch is enough. The chain below comes from reading the code:

  • isLoadingConnections is !catalog.isReady (HomeScreen.tsx#L156). isReady only becomes true after the EnvironmentRegistry service is built (connections.ts#L59). Otherwise the atom falls back to the empty, not-ready state (connections.ts#L69-L70).
  • Building the registry reads the saved connections (registry.ts#L176-L177). That read goes through MobileSecureStorage (storage.ts#L50-L56) into SecureStore.getItemAsync(key) with the default WHEN_UNLOCKED accessibility (mobile-secure-storage.ts#L34). Before the first unlock this rejects with errSecInteractionNotAllowed, so the layer fails.
  • In release builds the connection runtime is a plain Atom.runtime(layer) (hot-swappable-atom-runtime.ts#L33). Nothing rebuilds it after a failed build. The shared ManagedRuntime in apps/mobile/src/lib/runtime.ts also keeps its first build result, including a failure.
  • "T3 Account: Checking" means Clerk never finished loading. Its token cache also reads the keychain, and the network was down at that point, so it probably failed the same way. I did not confirm Clerk's retry behavior.

The Live Activity is how I caught it, but any background launch while the device is locked should hit the same path.

Impact

Minor bug or occasional failure

Version or commit

TestFlight 2.0.0 (103); code references are main @ 4ee6bfd

Environment

iPad Pro 11-inch (M5), iPadOS 27.2 (24B5089g), passcode set

Logs or stack traces

See "Actual behavior". I can share more of the sysdiagnose on request.

Screenshots, recordings, or supporting files

Simulator: normal cold launch vs launch with the keychain locked for 8 s, then Settings in the same process

Workaround

Force quit T3 Code and open it again.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions