bug: SQLCipher UnsatisfiedLinkError — encrypted cache nativeOpen fails (likely 16 KB page size) on SDK 37 #359

Closed
opened 2026-07-05 20:57:01 +00:00 by JMR-dev · 2 comments
JMR-dev commented 2026-07-05 20:57:01 +00:00 (Migrated from github.com)

On-device crash investigation (physical Pixel 10 Pro XL, Android 17 / SDK 37) surfaced a SECOND crash cluster distinct from #354: a SQLCipher UnsatisfiedLinkError opening the encrypted Room cache.

Crash

java.lang.UnsatisfiedLinkError … SQLiteConnection.nativeOpen — x4 on 2026-07-03 09:02:22-57 (dropbox data_app_crash, in the on-device bugreport). The SQLCipher native library fails to load when opening the encrypted cache DB; settings later show encryptCache=false (ran unencrypted).

Likely root cause: 16 KB page size

Android 15+ devices (incl. this Pixel on SDK 37; the API-37 emulator image is literally google_apis_ps16k) use 16 KB memory pages. A native .so not built/aligned for 16 KB pages fails to load with exactly this UnsatisfiedLinkError. The bundled SQLCipher/sqlcipher-android native lib is the prime suspect — verify its 16 KB-alignment / bump to a 16 KB-compatible build. (Worth auditing any other bundled .so too.)

Why it matters

This is the opt-in SQLCipher cache-encryption path (security-posture work). On affected devices, enabling encryption crashes instead of degrading, and encryption silently does not apply. Fix: rebuild/upgrade the native lib for 16 KB pages, AND make the open path catch UnsatisfiedLinkError and fail gracefully (clear state, no crash) rather than throw.

Evidence

scratchpad\perf\bugreport.zip (dropbox), scratchpad\perf\crash\dropbox_head.txt. Distinct from the IdleService FGS crash loop (#354).

On-device crash investigation (physical Pixel 10 Pro XL, Android 17 / SDK 37) surfaced a SECOND crash cluster distinct from #354: a **SQLCipher `UnsatisfiedLinkError`** opening the encrypted Room cache. ## Crash `java.lang.UnsatisfiedLinkError … SQLiteConnection.nativeOpen` — **x4** on 2026-07-03 09:02:22-57 (dropbox `data_app_crash`, in the on-device bugreport). The SQLCipher native library fails to load when opening the encrypted cache DB; settings later show `encryptCache=false` (ran unencrypted). ## Likely root cause: 16 KB page size Android 15+ devices (incl. this Pixel on SDK 37; the API-37 emulator image is literally `google_apis_ps16k`) use **16 KB memory pages**. A native `.so` not built/aligned for 16 KB pages fails to load with exactly this `UnsatisfiedLinkError`. The bundled SQLCipher/`sqlcipher-android` native lib is the prime suspect — verify its 16 KB-alignment / bump to a 16 KB-compatible build. (Worth auditing any other bundled `.so` too.) ## Why it matters This is the opt-in SQLCipher cache-encryption path (security-posture work). On affected devices, enabling encryption **crashes** instead of degrading, and encryption silently does not apply. Fix: rebuild/upgrade the native lib for 16 KB pages, AND make the open path catch `UnsatisfiedLinkError` and fail gracefully (clear state, no crash) rather than throw. ## Evidence `scratchpad\perf\bugreport.zip` (dropbox), `scratchpad\perf\crash\dropbox_head.txt`. Distinct from the IdleService FGS crash loop (#354).
JMR-dev commented 2026-07-07 19:59:01 +00:00 (Migrated from github.com)

Spike result (draft PR #423) — the 16 KB-alignment hypothesis is REFUTED

On-device spike (SqlCipherOpenSpikeTest, Pixel 8 Pro / real SDK 37 / arm64-v8a):

  • Alignment is NOT the bug. Every bundled native lib — incl. arm64-v8a libsqlcipher.so (sqlcipher-android:4.16.0, pinned since the original encrypted-cache commit 96c9f71) — is already 16 KB-aligned (PT_LOAD p_align = 0x4000, ELF-verified across all 4 ABIs). CI's green e2e-preview (16 KB x86_64) corroborates. So the crashing 2026-07-03 build shipped the same aligned lib.
  • 4 KB arm64 baseline (Pixel 8): all 3 stages PASS — loadLibrary("sqlcipher") -> nativeOpen (keyed) -> full Room SupportOpenHelperFactory path.
  • 16 KB arm64: BLOCKED. The Pixel "Boot with 16 KB page size" toggle (Settings.Global enable_16k_pages) is gated behind a bootloader unlock + factory wipe (device dialog confirms) — not a simple toggle+reboot — so the Pixel 8 cannot reach arm64-16 KB without wiping it. Setting the flag + reboot is ignored by init on a locked bootloader. Device restored (4 KB, flag=0).

Redirected root-cause hypothesis

The UnsatisfiedLinkError @ SQLiteConnection.nativeOpen is most likely a library-load-ordering bug — a keyed nativeOpen reached without a preceding successful System.loadLibrary("sqlcipher") (the #221/#367 class; a data_app_crash dropbox means a nativeOpen escaped #367 fail-closed try/catch). Next: audit every keyed-cache opener (workers, AccountDatabase, AccountDataMigrator legacy path, cold-open) for load-before-open + handler coverage.

To finish the same-device A/B

Need a genuine 16 KB arm64 environment: the Pixel 10 Pro XL (real 16 KB device; SqlCipherOpenSpikeTest emits the exact failing stage + cause chain the moment it runs there), or authorization to bootloader-unlock + wipe a spare Pixel 8.

## Spike result (draft PR #423) — the 16 KB-alignment hypothesis is REFUTED On-device spike (`SqlCipherOpenSpikeTest`, Pixel 8 Pro / real SDK 37 / arm64-v8a): - **Alignment is NOT the bug.** Every bundled native lib — incl. arm64-v8a `libsqlcipher.so` (`sqlcipher-android:4.16.0`, pinned since the original encrypted-cache commit `96c9f71`) — is already **16 KB-aligned** (`PT_LOAD p_align = 0x4000`, ELF-verified across all 4 ABIs). CI's green `e2e-preview` (16 KB **x86_64**) corroborates. So the crashing 2026-07-03 build shipped the same aligned lib. - **4 KB arm64 baseline (Pixel 8):** all 3 stages PASS — `loadLibrary("sqlcipher")` -> `nativeOpen` (keyed) -> full Room `SupportOpenHelperFactory` path. - **16 KB arm64: BLOCKED.** The Pixel "Boot with 16 KB page size" toggle (`Settings.Global enable_16k_pages`) is gated behind a **bootloader unlock + factory wipe** (device dialog confirms) — not a simple toggle+reboot — so the Pixel 8 cannot reach arm64-16 KB without wiping it. Setting the flag + reboot is ignored by init on a locked bootloader. Device restored (4 KB, flag=0). ## Redirected root-cause hypothesis The `UnsatisfiedLinkError @ SQLiteConnection.nativeOpen` is most likely a **library-load-ordering** bug — a keyed `nativeOpen` reached without a preceding successful `System.loadLibrary("sqlcipher")` (the #221/#367 class; a `data_app_crash` dropbox means a `nativeOpen` escaped #367 fail-closed `try/catch`). **Next:** audit every keyed-cache opener (workers, `AccountDatabase`, `AccountDataMigrator` legacy path, cold-open) for load-before-open + handler coverage. ## To finish the same-device A/B Need a genuine **16 KB arm64** environment: the **Pixel 10 Pro XL** (real 16 KB device; `SqlCipherOpenSpikeTest` emits the exact failing stage + cause chain the moment it runs there), or authorization to bootloader-unlock + wipe a spare Pixel 8.
JMR-dev commented 2026-07-07 20:44:59 +00:00 (Migrated from github.com)

Audit verdict: PERSISTS — a fail-closed handler-COVERAGE gap (post-#367)

The UnsatisfiedLinkError @ SQLiteConnection.nativeOpen still has a live UNCAUGHT path. The audit ruled out both prior hypotheses:

  • NOT alignment (refuted by the on-device spike #423 — all native libs are 16 KB-aligned).
  • NOT load-ordering — every SQLCipher open IS preceded by ensureNativeLibraryLoaded() in the same function.
  • NOT catch-type — #367's catch (LinkageError) correctly catches UnsatisfiedLinkError.

The real bug is the handler's SCOPE: #367's try/catch in DatabaseProvisioner.runStartupSequence() wraps only resolveOpenMode(...), but the crash-prone keyed opens sit OUTSIDE it:

  1. Primary — AccountDataMigrator.migrateIfNeeded() (DatabaseProvisioner.kt:111, ~12 lines above the try): keyed SQLCipher openOrCreateDatabase+ATTACH KEY (AccountDataMigrator.kt:143-144,156) with an empty key even when encryption is OFF → EVERY pre-#111 upgrader hits the native lib; a LinkageError there → uncaught → crash-loop on every launch.
  2. Secondary — Room's deferred keyed nativeOpen (DatabaseModule DeferredOpenHelperFactory, DatabaseModule.kt:97-106) fires AFTER prepareCache() returns, outside any handler → the exact UnsatisfiedLinkError @ nativeOpen signature.
  3. Amplifier — headless components (SyncWorker/BackfillWorker/SendWorker/PruneWorker/IdleService) run with NO CacheEncryptionGate → uncaught there too.

Test blind spot: DatabaseProvisionerTest mocks the migrator as just Runs, hiding the gap.

Fix in progress (widen handler scope to the migrator, keep the LinkageError type; probe the real keyed open inside the fail-closed try; headless tolerance via EncryptedCacheGuard; regression tests). PR incoming.

## Audit verdict: PERSISTS — a fail-closed handler-COVERAGE gap (post-#367) The `UnsatisfiedLinkError @ SQLiteConnection.nativeOpen` still has a live UNCAUGHT path. The audit **ruled out** both prior hypotheses: - **NOT alignment** (refuted by the on-device spike #423 — all native libs are 16 KB-aligned). - **NOT load-ordering** — every SQLCipher open IS preceded by `ensureNativeLibraryLoaded()` in the same function. - **NOT catch-type** — #367's `catch (LinkageError)` correctly catches `UnsatisfiedLinkError`. **The real bug is the handler's SCOPE:** #367's `try/catch` in `DatabaseProvisioner.runStartupSequence()` wraps only `resolveOpenMode(...)`, but the crash-prone keyed opens sit OUTSIDE it: 1. **Primary — `AccountDataMigrator.migrateIfNeeded()`** (`DatabaseProvisioner.kt:111`, ~12 lines above the `try`): keyed SQLCipher `openOrCreateDatabase`+`ATTACH KEY` (`AccountDataMigrator.kt:143-144,156`) **with an empty key even when encryption is OFF** → EVERY pre-#111 upgrader hits the native lib; a `LinkageError` there → uncaught → **crash-loop on every launch**. 2. **Secondary — Room's deferred keyed `nativeOpen`** (`DatabaseModule` `DeferredOpenHelperFactory`, `DatabaseModule.kt:97-106`) fires AFTER `prepareCache()` returns, outside any handler → the exact `UnsatisfiedLinkError @ nativeOpen` signature. 3. **Amplifier — headless components** (`SyncWorker`/`BackfillWorker`/`SendWorker`/`PruneWorker`/`IdleService`) run with NO `CacheEncryptionGate` → uncaught there too. **Test blind spot:** `DatabaseProvisionerTest` mocks the migrator as `just Runs`, hiding the gap. **Fix in progress** (widen handler scope to the migrator, keep the `LinkageError` type; probe the real keyed open inside the fail-closed try; headless tolerance via `EncryptedCacheGuard`; regression tests). PR incoming.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: JMR-dev/LibreMail#359