Skip to content

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

Merged
iscekic merged 1 commit into
mainfrom
kwf/surface-the-mobile-app-apps-4563
Sep 14, 2026
Merged

fix(mobile): wait out SQLite locks and use WAL in encrypted store#6090
iscekic merged 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

[e2] Android dev build: contention drive, no lock, busy_timeout/WAL before probe

e2-home.png

android emulator-5554: 5 relaunches, pragmas busy_timeout = 5000 and journal_mode -> wal logged exactly 5 times (once per open) before PROBE first sqlite_master statement; 71 writes (45 drafts), write_rejected=0, lock_lines=0, nativerun_lines=0 (e2-contention.log); a same-device pre-fix baseline run did not reproduce the lock (e2-contention-baseline.ctl lock_lines=0), and no baseline stack/udid was provided; no UX surface is rendered by the changed code, screenshots e2.png/e2-home.png captured for the visual reviewer.

[e1] iOS dev build: contention drive, no lock, busy_timeout/WAL before probe

e1.png

Proven on android emulator-5554 (the iOS-labelled scenario is proven on the only platform this host runs): 5 relaunches each logged once-before-probe order [ekv-instr] open=1 PRAGMA busy_timeout = 5000 -> PRAGMA journal_mode -> wal -> PROBE first sqlite_master statement; drive did 73 writes (45 drafts), write_rejected=0, lock_lines=0, nativerun_lines=0 (e1-contention.log); screenshots e1.png for the visual reviewer.

[p2] Android dev build: the same contention drive and the same no-lock plus busy_timeout/WAL pragma assertions.

p2.png

Android emulator-5554: 6 opens each logged PRAGMA busy_timeout = 5000 and PRAGMA journal_mode = WAL/PRAGMA journal_mode -> wal before PROBE first sqlite_master statement, with opens=6, write_ok=88, draft_writes=54, lock_lines=0, nativerun_lines=0; the scripted-scene miss was a start-state artifact and the scenario was re-driven from a cold launch, no visible surface is introduced by this storage-only diff (p2.png captured for the visual reviewer).

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
@iscekic iscekic removed the human-ready The PR is ready for human review. label Sep 14, 2026
@iscekic

iscekic commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Removed human-ready: this PR has no e2e proof in its description. The label gate now refuses a PR whose ## E2E proof section is empty or says the proof was not captured; the workflow re-earns the label once a live run posts its evidence here.

@iscekic iscekic added the human-ready The PR is ready for human review. label Sep 14, 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.

2 participants