Skip to content

fix(mobile): retry transient keychain reads so the profile and language survive backgrounding - #6357

Open
iscekic wants to merge 1 commit into
mainfrom
kwf/owner-profile-and-language-revert-3991
Open

iscekic wants to merge 1 commit into
mainfrom
kwf/owner-profile-and-language-revert-3991

Conversation

@iscekic

@iscekic iscekic commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

Changelog for users

  • The app keeps your chosen language after backgrounding and after a cold start; only you change it.
  • Returning to the app after a long background loads the profile on the first try.
  • The profile no longer shows the retry error "Failed to load balance. Tap to retry." after a background.

Changelog for maintainers

  • The shared cause was a transient SecureStore (keychain/keystore) read rejection, not the SQLCipher database handle: a rejected database-key read made the encrypted-KV open memoize its failure for the process.
  • Every persisted read now uses the existing readStoredValueWithRetry, which retries only a rejection with 250/500/1000 ms backoff and returns immediately on null.
  • The language preference, refresh token, request auth token, cold-start identity hint, and database key all read through it.
  • A rejected refresh-token read no longer ends the refresh as transient; the token rotates instead of stranding the profile on a terminal 401.
  • Each changed read has a unit test that fails when the read reverts to a single SecureStore.getItemAsync call.
  • Both Vitest projects load vitest.setup.ts, which stubs @/lib/config for the retry helper; the per-file config mocks are removed.
  • Reviewer hint: a genuinely down keychain now delays the existing error state by up to 1.75 s; error copy and states are unchanged.

E2E proof — log excerpts

[e1] profile loads on first foreground after a background and the chosen language survives background/foreground and cold start (android) — e2e-mobile-app/e1-foreground-1.png
[e1] profile loads on first foreground after a background and the chosen language survives background/foreground and cold start (android) — e2e-mobile-app/e1-coldstart-profile.png

[e1] profile loads on first foreground after a background and the chosen languag -> pass :: [e1] pass on android emulator-5554: after two 60 s backgrounds and a cold start the Profile rendered on the first foreground with the Credits balance (0,00 $ / $0.00) and no 'Failed to load balance. Tap to retry.' in the digest, and the chosen language stayed Deutsch and never reverted to the device language (evidence e1-behavior.log, e1-english.log; digests e1-english-foreground.digest.log, e1-coldstart-language-row.digest.log, e1-linked-accounts.digest.log; screenshots e1-foreground-1.png, e1-coldstart-profile.png, e1-coldstart-language-row.png, e1-english-foreground.png, e1-lang-deutsch-set.png, e1-linked-accounts.png); the PR's advisory fault branches (E2E_SECURE_STORE_FAULT_MS, a bundle
/home/igor_kilocode_ai/.local/share/kwf/sections/kwf-fix-proof-c3a69a0-6eb4/e2e-mobile-app/e1-behavior.log
  Button Code-Prüfer, Automatische PR-Prüfungen
  android.view.View Start, Tab, 1 von 3 [checked]
-> the app starts in German after a cold start; the language did not revert to the device language.
STEP 6 — Profile after the cold start.
  tap Profil, Tab, 3 von 3
Digest (Profile, cold start):
  android.view.View Profil
  TextView GUTHABEN
  Button Persönlich
  TextView 0,00 $
  TextView Lade Guthaben auf, um Kilo weiter zu nutzen.
  Button Guthaben aufladen
  android.view.View Profil, Tab, 3 von 3 [checked]
screenshot: e1-coldstart-profile.png
-> Profile renders its content on the cold start too; balance shown; no retry error.
STEP 7 — language row after the cold start.
  Profil -> Einstellungen -> Konto
Digest (Account, cold start) — also e1-coldstart-language-row.digest.log:
  android.widget.Button Sprache, Deutsch tappable
  android.widget.TextView Sprache tappable
  android.widget.TextView Deutsch tappable
  android.widget.Button Gerätesitzungen, Andere Geräte prüfen und abmelden tappable
screenshot: e1-coldstart-language-row.png
-> the chosen language is still "Deutsch" after two background/foreground cycles and a cold start; only the user changed it.
/home/igor_kilocode_ai/.local/share/kwf/sections/kwf-fix-proof-c3a69a0-6eb4/e2e-mobile-app/e1-english.log
  $KILO_WORKFLOW/e2e/appium.sh emulator-5554 digest --out $OUT  > e1-english-foreground.digest.log
Lines in e1-english-foreground.digest.log:
  android.view.View Profile tappable [37,149][1045,255]
  android.widget.TextView CREDITS tappable [55,348][192,392]
  android.widget.Button Personal tappable [868,320][1025,421]
  android.widget.TextView $0.00 tappable [83,485][228,559]
  android.widget.TextView Add credits to keep usage running. tappable [82,679][793,716]
  android.widget.Button Add credits tappable [793,651][997,746]
  android.widget.Button Kilo Pass. Monthly credits with bonus progress. Subscribe tappable [55,800][1025,953]
  android.widget.TextView AGENTS tappable [55,1009][1025,1053]
-> the Profile rendered on the first foreground with the Credits row loaded ($0.00, "Add credits to keep usage running.").
   grep of the same file for the retry copy returned nothing:
   "Failed to load balance. Tap to retry." is absent, and so is "Could not load".
screenshot: e1-english-foreground.png
STEP 9 — linked accounts section (scrolled Profile).
  $KILO_WORKFLOW/e2e/appium.sh emulator-5554 digest --out $OUT  > e1-linked-accounts.digest.log
Lines in e1-linked-accounts.digest.log:
  android.widget.TextView LINKED ACCOUNTS tappable [55,1061][1025,1105]
  android.widget.TextView Email tappable [189,1161][998,1207]
  android.widget.TextView Test Account tappable [189,1332][998,1378]
-> the linked-accounts section renders its row; "Could not load accounts" is absent.
   (This account has an Email provider, so the "no linked providers" state is not reachable here;
    the negative assertion — no accounts error — holds.)
screenshot: e1-linked-accounts.png
/home/igor_kilocode_ai/.local/share/kwf/sections/kwf-fix-proof-c3a69a0-6eb4/e2e-mobile-app/e1-english-foreground.digest.log
android.widget.TextView AGENTS tappable [55,1009][1025,1053]
android.widget.Button Code Reviewer, Automatic PR reviews tappable [55,1081][1025,1225]
android.widget.TextView Code Reviewer tappable [189,1109][933,1155]
android.widget.TextView Automatic PR reviews tappable [189,1159][933,1196]
android.widget.Button Security Agent, Find and remediate vulnerabilities tappable [55,1252][1025,1395]
android.widget.TextView Security Agent tappable [189,1280][933,1326]
android.widget.TextView Find and remediate vulnerabilities tappable [189,1330][933,1367]
android.widget.TextView REVIEWS tappable [55,1450][1025,1494]
android.widget.Button PR Review, Review pull requests on mobile tappable [55,1522][1025,1664]
android.widget.TextView PR Review tappable [189,1550][933,1596]
android.widget.TextView Review pull requests on mobile tappable [189,1600][933,1637]
android.widget.TextView APP tappable [55,1719][1025,1763]
android.widget.Button Preferences, Appearance, notifications, thinking, and screen behavior tappable [55,1791][1025,1935]
android.widget.TextView Preferences tappable [189,1819][933,1865]
android.widget.TextView Appearance, notifications, thinking, and screen behavior tappable [189,1869][933,1906]
android.widget.Button Tutorial tappable [55,1962][1025,2096]
android.widget.TextView Tutorial tappable [189,2006][933,2052]
android.widget.TextView LINKED ACCOUNTS tappable [55,2151][1025,2153]
android.view.View Home, tab, 1 of 3 tappable [0,2195][360,2337]
android.widget.TextView HOME tappable [13,2281][347,2320]
android.view.View Agents, tab, 2 of 3 tappable [360,2195][720,2337]
android.widget.TextView AGENTS tappable [373,2281][707,2320]
android.view.View Profile, tab, 3 of 3 tappable [720,2195][1080,2337]
android.widget.TextView PROFILE tappable [733,2281][1067,2320]
/home/igor_kilocode_ai/.local/share/kwf/sections/kwf-fix-proof-c3a69a0-6eb4/e2e-mobile-app/e1-coldstart-language-row.digest.log
hierarchy: /tmp/kilo-hierarchy.sPmC7K
android.widget.LinearLayout com.kilocode.kiloapp:id/action_bar_root tappable [0,0][1080,2400]
android.widget.FrameLayout android:id/content tappable [0,0][1080,2400]
android.widget.Button Zurück tappable [0,149][101,250]
android.view.View Konto tappable [111,167][1044,232]
android.widget.Button Sprache, Deutsch tappable [55,315][1025,459]
android.widget.TextView Sprache tappable [189,343][933,389]
android.widget.TextView Deutsch tappable [189,393][933,430]
android.widget.Button Vertrauenswürdige Hosts, Vertrauenswürdige Hosts, deren Links sich ohne Rückfrage öffnen tappable [55,486][1025,667]
android.widget.TextView Vertrauenswürdige Hosts tappable [189,514][933,560]
android.widget.TextView Vertrauenswürdige Hosts, deren Links sich ohne Rückfrage öffnen tappable [189,564][933,638]
android.widget.Button Gerätesitzungen, Andere Geräte prüfen und abmelden tappable [55,695][1025,837]
android.widget.TextView Gerätesitzungen tappable [189,723][933,769]
android.widget.TextView Andere Geräte prüfen und abmelden tappable [189,773][933,810]
android.view.View Start, Tab, 1 von 3 tappable [0,2195][360,2337]
android.widget.TextView START tappable [13,2281][347,2320]
android.view.View Agenten, Tab, 2 von 3 tappable [360,2195][720,2337]
android.widget.TextView AGENTEN tappable [373,2281][707,2320]
android.view.View Profil, Tab, 3 von 3 tappable [720,2195][1080,2337]
android.widget.TextView PROFIL tappable [733,2281][1067,2320]
/home/igor_kilocode_ai/.local/share/kwf/sections/kwf-fix-proof-c3a69a0-6eb4/e2e-mobile-app/e1-linked-accounts.digest.log
android.widget.TextView Preferences tappable [189,729][933,775]
android.widget.TextView Appearance, notifications, thinking, and screen behavior tappable [189,779][933,816]
android.widget.Button Tutorial tappable [55,872][1025,1006]
android.widget.TextView Tutorial tappable [189,916][933,962]
android.widget.TextView LINKED ACCOUNTS tappable [55,1061][1025,1105]
android.widget.TextView Email tappable [189,1161][998,1207]
android.widget.TextView e2e-mobile-kwf-fix-proof-c3a69a0-6eb4-android@example.com tappable [189,1211][998,1248]
android.widget.TextView Test Account tappable [189,1332][998,1378]
android.widget.TextView e2e-mobile-kwf-fix-proof-c3a69a0-6eb4-android@example.com tappable [189,1382][998,1419]
android.widget.Button Feedback tappable [55,1502][1025,1628]
android.widget.TextView Feedback tappable [171,1542][989,1588]
android.widget.Button Privacy choices tappable [55,1656][1025,1782]
android.widget.TextView Privacy choices tappable [171,1696][989,1742]
android.widget.Button Sign out tappable [55,1809][1025,1935]
android.widget.TextView Sign out tappable [171,1849][989,1895]
android.widget.Button Delete Account tappable [55,1963][1025,2089]
android.widget.TextView Delete Account tappable [171,2003][989,2049]
android.widget.TextView v1.0.11 (1) tappable [55,2116][1025,2153]
android.view.View Home, tab, 1 of 3 tappable [0,2195][360,2337]
android.widget.TextView HOME tappable [13,2281][347,2320]
android.view.View Agents, tab, 2 of 3 tappable [360,2195][720,2337]
android.widget.TextView AGENTS tappable [373,2281][707,2320]
android.view.View Profile, tab, 3 of 3 tappable [720,2195][1080,2337]
android.widget.TextView PROFILE tappable [733,2281][1067,2320]

Follow-ups (not changed here)

  • not proved live: [advisory] bootstrap-restore-error-recovers-when-the-keychain-comes-back: needs:fault:secure-store — a bundle built with E2E_SECURE_STORE_FAULT_MS=3000 (bundle-time env, not settable from the worktree alone). The startup credential read exhausts the 250/500/1000 ms retry budget and the app shows the retryable restore-error surface (Retry / Sign out); once the window closes, Retry opens Home and the Profile renders its balance with no further retry. Trigger is the process-start fault window, not a tap, so no steps; the verifier cold-launches, waits out the window, taps Retry and captures. (no capture cited it)
  • not proved live: [advisory] language-loads-through-a-transient-keychain-fault: needs:fault:secure-store — a cold launch with E2E_SECURE_STORE_FAULT_MS=1000 (shorter than the 1.75 s retry budget; bundle-time env, not settable from the worktree alone) still resolves the chosen language instead of the device language; the Preferences language row reads the chosen language, and 'Device language' is the unchosen option. Non-tap process-start trigger, so no steps. (no capture cited it)
  • not proved live: [advisory] session-refused-goes-to-login: the session is genuinely refused (the refresh token revoked server-side), so the 401 is not retryable and the app returns to the login screen instead of stranding the profile in the balance error. Procedure from the worktree: sign in on the device, revoke the device session in the dev stack (the revoke used by e2e/logout.sh / the sessions table via dev/db.sh), background and foreground the app, capture the screen after the foreground. Non-tap trigger. (no capture cited it)
  • not proved live: language-persists-across-background-foreground-and-cold-start: with a language that differs from the device language chosen (Deutsch) the Preferences language row still reads Deutsch and not 'Device language' after several background/foreground cycles and after a force-quit cold start; only the user changes it. Trigger is a non-tap lifecycle, so no steps: the verifier backgrounds/foregrounds, force-quits and cold-launches, and captures. platform:both (the preference lives in SecureStore on both OSes). (no capture cited it)
  • not proved live: language-persists-after-cold-start: after setting Deutsch, a cold launch still starts in German (the language row reads Deutsch, not Device language). needs:seed (signed-in account) plus a non-tap cold start: the verifier force-quits and cold-launches, then captures. (no capture cited it)
  • not proved live: language-persists-after-foreground: with a language that differs from the device language set (Deutsch), the app is still German after a long background and a foreground; the Preferences language row still reads Deutsch, never Device language. Lifecycle trigger is a non-tap background/foreground: the verifier backgrounds the app long enough for the OS to reclaim it, foregrounds it, and captures the screen after the foreground. (no capture cited it)
  • not proved live: profile-empty-linked-accounts: a signed-in account with no linked providers shows the profile with no accounts error — 'Could not load accounts' is absent and the Credits row renders. needs:seed (an account with no providers). (no capture cited it)
  • not proved live: profile-loads-on-first-foreground: after a long background the Profile screen renders its content on the first foreground with no manual retry — the Credits row shows a balance and the text 'Failed to load balance. Tap to retry.' is absent. Trigger is a non-tap lifecycle (background long enough for the OS to reclaim resources, then foreground), so no steps: the verifier opens Profile, backgrounds, foregrounds and captures the screen after the foreground. platform:both (the transient keychain read exists on both OSes; Android is the fleet default). (no capture cited it)
  • not proved live: profile-loads-on-first-foreground: after a long background the Profile screen renders its content on the first foreground with no manual retry — the Credits row shows the balance, and 'Failed to load balance. Tap to retry.' is absent. Lifecycle trigger is a non-tap background/foreground: the verifier backgrounds the app long enough for the OS to reclaim it, foregrounds it, opens Profile and captures the screen after the foreground. (no capture cited it)
  • not proved live: profile-shows-a-retryable-error-when-the-keychain-stays-down: a cold launch with the E2E secure-store fault window open for 3000 ms (longer than the 1.75 s retry budget) shows the retryable 'Failed to load balance. Tap to retry.' state, and tapping it after the window closes loads the profile. needs:fault:secure-store (bundle built with E2E_SECURE_STORE_FAULT_MS=3000). [advisory]: requires a bundle built with that env, not settable from the worktree alone. (no capture cited it)
  • not proved live: ux-check: Background the app until the OS terminates it, then reopen: the profile tab renders its data on the first foreground with no error card and no Retry tap. (no capture cited it)
  • not proved live: ux-check: Set the app language to one different from the device language, force-terminate, reopen, and repeat background/foreground several times: the first rendered screen is in the chosen language and it does not change to the device language. (no capture cited it)

Open findings (not fixed here)

  • mobile-device: verifier passed
    mobile-device: spot skips 11 still(s) of missed scripted scene(s)
    mobile-device: spot check clean
    mobile-device: signed-in app data restored on emulator-5554
    mobile-device: signed-in app data frozen on emulator-5554 (66540544 bytes)
    mobile-device: shard1 judged by jev
  • the '## E2E proof' section carries no log excerpt, so nothing shows the change was driven end to end

e1-foreground-1

e1-coldstart-profile

…ge survive backgrounding

Surface: the mobile app (apps/mobile).

Two defects the owner sees on a real device. Find the ONE cause if they share
it, and fix both.

1. After the app has been backgrounded for a while, the profile screen fails to
   load when the app comes back to the foreground. A retry loads it.
2. The chosen language occasionally reverts to the device language. Restarting
   the app restores the chosen language.

Reproduce both first. Background the app long enough for the OS to release
resources, then foreground it and open the profile; repeat until the failure
shows. For the language, set a language that differs from the device language,
background and foreground the app several times, and watch for the revert.

The owner's hunch is the local database: a handle that does not survive the
background, a read that runs before the store is open, or a write that never
lands. Test that hunch first, and say in the pull request what the cause
actually was.

Required behaviour after the fix:
- Foregrounding after any background period loads the profile on the first
  try, with no manual retry.
- The chosen language survives backgrounding, foregrounding and a cold start,
  and only the user changes it.
- Whatever the shared cause is, the fix closes it once rather than patching
  each screen.

Cover both with a test that fails without the fix, and prove each on a device
with a capture of the screen after the foreground.
@iscekic
iscekic marked this pull request as draft September 19, 2026 09:31
@iscekic

iscekic commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

bot: Rejected, no code change (kwf kwf-fix-platform-87b7).

Why: (already implemented, verified live: no change needed: The PR already routes every persisted read through one cross-platform helper and its only added expo-secure-store import is a Vitest test import, so the platform-fork finding is refuted. Finding refuted, no change made. (1) The only added `import * as SecureStore from 'expo-secure-

@iscekic
iscekic marked this pull request as ready for review September 19, 2026 10:19
@kilo-code-bot

kilo-code-bot Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Reviewed the mobile fix that routes persisted SecureStore reads (refresh token, request auth token, cold-start identity hint, database key, and secure-store preferences incl. language) through readStoredValueWithRetry, plus the Vitest config/setup changes that stub @/lib/config for both projects. The retry call sites preserve the prior option/first-attempt semantics, no unused imports remain, the error surfaces still settle (with the documented 1.75 s worst-case delay), and the new tests exercise the transient-rejection path without depending on production config. No memory leaks or security concerns were identified in the changed code.

Files Reviewed (14 files)
  • apps/mobile/src/lib/auth/credentials.ts
  • apps/mobile/src/lib/auth/credentials.test.ts
  • apps/mobile/src/lib/auth/token-owner.ts
  • apps/mobile/src/lib/auth/token-owner.test.ts
  • apps/mobile/src/lib/hooks/secure-store-preference.ts
  • apps/mobile/src/lib/hooks/secure-store-preference.test.ts
  • apps/mobile/src/lib/persist/encrypted-kv.ts
  • apps/mobile/src/lib/persist/encrypted-kv.test.ts
  • apps/mobile/src/lib/persist/read-cache.ts
  • apps/mobile/src/lib/persist/read-cache.test.ts
  • apps/mobile/src/lib/trpc.test.ts
  • apps/mobile/vitest.mounted.config.ts
  • apps/mobile/vitest.pure.config.ts
  • apps/mobile/vitest.setup.ts

Reviewed by deepseek-v4.1-flash · Input: 0 · Output: 0 · Cached: 0

Review guidance: REVIEW.md from base branch main

@iscekic

iscekic commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

This description names a scenario the proof did not capture:

  • not proved live: [advisory] bootstrap-restore-error-recovers-when-the-keychain-comes-back: needs:fault:secure-store — a bundle built with E2E_SECURE_STORE_FAULT_MS=3000 (bundle-time env, not settable from the worktree alone). The startup credential read exhausts the 250/500/1000 ms retry budget and the app shows the retryable restore-error surface (Retry / Sign out); once the window closes, Retry opens Home and the Profile renders its balance with no further retry. Trigger is the process-start fault window, not a tap, so no steps; the verifier cold-launches, waits out the window, taps Retry and captures. (no capture cited it)

A repeated proof run rebuilds the same evidence, so no proof run is dispatched for a named gap. Merging with this gap open is your decision.

@iscekic iscekic added the human-ready The PR is ready for human review. label Sep 19, 2026
@iscekic iscekic self-assigned this Sep 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

human-ready The PR is ready for human review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant