Before submitting
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)):
- Have a turn running, so T3 Code shows a Live Activity.
- Restart the device. Do not unlock it right away.
- iOS opens T3 Code in the background for the Live Activity before the first unlock. The device logs below show this.
- 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.
- 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.
xcrun simctl launch <udid> com.t3tools.t3code.dev shows the threads.
xcrun simctl terminate <udid> com.t3tools.t3code.dev && xcrun simctl launch <udid> com.t3tools.t3code.dev -T3SimulateLockedLaunchSeconds 8
- 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

Workaround
Force quit T3 Code and open it again.
Before submitting
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)):
Simulator repro. The simulator has no keychain lock, so this uses a repro-only patch: a launch argument makes every
expo-secure-storecall inMobileSecureStoragefail 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.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.xcrun simctl launch <udid> com.t3tools.t3code.devshows the threads.xcrun simctl terminate <udid> com.t3tools.t3code.dev && xcrun simctl launch <udid> com.t3tools.t3code.dev -T3SimulateLockedLaunchSeconds 8Repro-only patch
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:
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):
"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:
isLoadingConnectionsis!catalog.isReady(HomeScreen.tsx#L156).isReadyonly becomestrueafter theEnvironmentRegistryservice is built (connections.ts#L59). Otherwise the atom falls back to the empty, not-ready state (connections.ts#L69-L70).MobileSecureStorage(storage.ts#L50-L56) intoSecureStore.getItemAsync(key)with the defaultWHEN_UNLOCKEDaccessibility (mobile-secure-storage.ts#L34). Before the first unlock this rejects with errSecInteractionNotAllowed, so the layer fails.Atom.runtime(layer)(hot-swappable-atom-runtime.ts#L33). Nothing rebuilds it after a failed build. The sharedManagedRuntimeinapps/mobile/src/lib/runtime.tsalso keeps its first build result, including a failure.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@ 4ee6bfdEnvironment
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
Workaround
Force quit T3 Code and open it again.