Skip to content

fix(mobile): wait out SQLite locks and use WAL in encrypted store - #6090

Open
iscekic wants to merge 1 commit into
mainfrom
kwf/surface-the-mobile-app-apps-4563
Open

fix(mobile): wait out SQLite locks and use WAL in encrypted store#6090
iscekic wants to merge 1 commit into
mainfrom
kwf/surface-the-mobile-app-apps-4563

Conversation

@iscekic

@iscekic iscekic commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Changelog for users

  • Session and draft writes no longer fail with database is locked while another connection holds the lock; the encrypted store waits up to five seconds instead of failing immediately.
  • The encrypted store now uses WAL journaling, so a reader no longer blocks a writer on the same database file.
  • When a stale database handle cannot be closed, recovery reports the original error and keeps the database file instead of deleting and recreating it.

Changelog for maintainers

  • Connection setup now runs in order: PRAGMA cipher_version probe, PRAGMA key, PRAGMA busy_timeout = 5000, PRAGMA journal_mode = WAL verified from the returned row, then the first schema probe and migrations.
  • BUSY_TIMEOUT_MS is 5000; a WAL switch that does not return wal throws and fails the open.
  • closeQuietly now returns whether the handle closed, and recovery aborts delete-and-recreate when the previous handle stays open.
  • A failed open whose handle will not close is tracked by error identity; recovery reports that original error under error.subsystem=encrypted-kv, error.operation=reset, then rethrows.
  • Review the recovery guard first — it is the only path that can now throw without attempting a reopen; confirm no second connection reaches the same file.
  • Unchanged: single-flight open, key validation, MissingSQLCipherError, delete-and-recreate when close succeeds, plus schema, migrations, storage keys, exported API, and dependencies.
  • encrypted-kv.test.ts gains journalMode and failClose seams covering the SQL order, the 5000 ms value, a rejected WAL switch, and abort-on-unclosed-handle.

E2E proof

Owner request

Surface: the mobile app (apps/mobile).

Problem: Sentry KILO-APP-7K (https://kilo-code.sentry.io/issues/7707832591/) reports Call to function 'NativeStatement.runSync' has been rejected. -> Caused by: Error code : database is locked, mixed frames with the in-app frame at apps/mobile/src/lib/persist/encrypted-kv.ts (26 events, 2 users, last seen 2026-09-11). The same error is the project's largest unresolved crash: KILO-APP-5J (https://kilo-code.sentry.io/issues/7686568573/, 748 events, 84 users) and KILO-APP-5H (https://kilo-code.sentry.io/issues/7686568565/, 66 events, 23 users), both system-only frames at expo-sqlite's NativeStatement.runSync. Every write in the encrypted store goes through that call: setItem (encrypted-kv.ts:265-268), removeItem (275-277), clearScope (284), clearScopePrefix (291-293), plus the probe and migration (167-170).

Cause evidence: the connection is opened and keyed but never configured to wait out a lock. openWithKey calls SQLite.openDatabaseSync(DATABASE_NAME) (encrypted-kv.ts:152) and, after assertSQLCipher (154) and PRAGMA key (155), returns the Drizzle handle with no PRAGMA busy_timeout and no PRAGMA journal_mode = WAL. In SQLite's default rollback-journal mode a synchronous write that meets a held lock fails immediately with SQLITE_BUSY instead of waiting. The store also swallows a failed close: closeQuietly catches and discards every error (132-138), and the recovery path then calls deleteDatabaseAsync and reopens over a handle that may still be open (194-202), i.e. a second connection to the same file - the exact old connection still open, new one conflicts shape reported for this error string. Because all statements are synchronous and un-retried, one lost lock rejects the whole operation and Sentry records it.

Requested behavior:

  • Configure the single connection before any read or write: after PRAGMA key, issue PRAGMA busy_timeout = <ms> and then PRAGMA journal_mode = WAL (SQLCipher supports WAL), and verify WAL was accepted from the returned row. This must run before the sqlite_master probe and the migrations.
  • In recovery, do not proceed to delete-and-reopen when the previous handle did not close: track the close result and abort the reset, reporting the original error, instead of opening a second connection.
  • Keep the existing single-flight open, key validation, MissingSQLCipherError behavior, and delete-and-recreate recovery semantics unchanged.

Exclusions:

  • Do not change the schema, migrations, storage keys, or the exported API.
  • Do not add a dependency or a dependency patch.
  • Do not catch and retry arbitrary errors.

Acceptance checks:

  • Unit test in encrypted-kv.test.ts, using the existing fake client, asserting the SQL order: PRAGMA cipher_version, PRAGMA key, PRAGMA busy_timeout, PRAGMA journal_mode = WAL, then the first probe/migration statement.
  • Unit test asserting the busy-timeout value is present and that journal_mode WAL is verified from the result.
  • Unit test that when the recovery close fails, openDatabaseSync is called once and deleteDatabaseAsync is not called, and the original open error is reported with the existing error.subsystem tag.
  • Existing encrypted-kv tests still pass.
  • From apps/mobile run pnpm format && pnpm typecheck && pnpm lint && pnpm check:unused and pnpm test.

End-to-end proof (required, both iOS and Android):

  • Before the fix, reproduce on a dev build by driving the store to a contended state (hammer session/draft writes and relaunch while a write is in flight) and capture a database is locked rejection at the encrypted-kv boundary.
  • After the fix, the same drive completes. There is no visible screen, so attach decisive sanitized log lines showing the busy_timeout/WAL pragmas issued and no database is locked over the run, on both platforms.
  • Do not commit debug logging, test-only runtime flags, or follow-ups; let the workflow open and maintain the PR and finish only after current-head CI is green.

Surface: the mobile app (apps/mobile).

Problem: Sentry KILO-APP-7K (https://kilo-code.sentry.io/issues/7707832591/) reports `Call to function 'NativeStatement.runSync' has been rejected. -> Caused by: Error code : database is locked`, mixed frames with the in-app frame at apps/mobile/src/lib/persist/encrypted-kv.ts (26 events, 2 users, last seen 2026-09-11). The same error is the project's largest unresolved crash: KILO-APP-5J (https://kilo-code.sentry.io/issues/7686568573/, 748 events, 84 users) and KILO-APP-5H (https://kilo-code.sentry.io/issues/7686568565/, 66 events, 23 users), both system-only frames at expo-sqlite's NativeStatement.runSync. Every write in the encrypted store goes through that call: setItem (encrypted-kv.ts:265-268), removeItem (275-277), clearScope (284), clearScopePrefix (291-293), plus the probe and migration (167-170).

Cause evidence: the connection is opened and keyed but never configured to wait out a lock. `openWithKey` calls `SQLite.openDatabaseSync(DATABASE_NAME)` (encrypted-kv.ts:152) and, after `assertSQLCipher` (154) and `PRAGMA key` (155), returns the Drizzle handle with no `PRAGMA busy_timeout` and no `PRAGMA journal_mode = WAL`. In SQLite's default rollback-journal mode a synchronous write that meets a held lock fails immediately with SQLITE_BUSY instead of waiting. The store also swallows a failed close: `closeQuietly` catches and discards every error (132-138), and the recovery path then calls `deleteDatabaseAsync` and reopens over a h
@kilo-code-bot

kilo-code-bot Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (2 files)
  • apps/mobile/src/lib/persist/encrypted-kv.ts
  • apps/mobile/src/lib/persist/encrypted-kv.test.ts

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

Review guidance: REVIEW.md from base branch main

@iscekic iscekic added the human-ready The PR is ready for human review. label Sep 11, 2026
@iscekic iscekic self-assigned this Sep 11, 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