fix: CursorWindow overflow crash in MessageDao.observeAll() (SELECT * pulls full body into 2MB window) #51

Closed
opened 2026-07-01 18:32:55 +00:00 by JMR-dev · 0 comments
JMR-dev commented 2026-07-01 18:32:55 +00:00 (Migrated from github.com)

Summary

The app crashes with a fatal IllegalStateException: Couldn't read row 14, col 0 from CursorWindow when the message list is being observed. The mailbox query MessageDao.observeAll() does SELECT * FROM messages, which pulls every message's full body column into a single 2 MB CursorWindow. Once enough large (HTML) bodies are cached, a row no longer fits the window and any read of it throws, taking down the process.

Environment

  • App: org.libremail.app v0.1.0 (versionCode 1) — release/minified build
  • Device: Pixel 10 Pro XL, Android 17 (SDK 37), build CP2A.260605.012
  • Observed: 2026-07-01 ~13:24 (crashed twice back-to-back)
  • Captured from adb logcat -b crash on the connected device.

Crash signature

java.lang.IllegalStateException: Couldn't read row 14, col 0 from CursorWindow.
Make sure the Cursor is initialized correctly before accessing data from it.

Deobfuscated stack trace

Retraced against app/build/outputs/mapping/release/mapping.txt (pg_map_id matches the crash's r8-map-id):

java.lang.IllegalStateException: Couldn't read row 14, col 0 from CursorWindow.
	at android.database.CursorWindow.nativeGetString(Native Method)
	at android.database.CursorWindow.getString(CursorWindow.java:505)
	at android.database.AbstractWindowedCursor.getString(AbstractWindowedCursor.java:54)
	at androidx.sqlite.driver.SupportSQLiteStatement$RowSQLiteStatement.getText(SupportSQLiteStatement.android.kt:370)
	at org.libremail.data.local.dao.MessageDao_Impl.observeAll$lambda$0(MessageDao_Impl.kt:89)
	at androidx.room.util.DBUtil...performSuspending$...internalPerform(DBUtil__DBUtil_android.kt:173)
	at androidx.room.coroutines.PassthroughConnectionPool$useConnection$2.invokeSuspend(PassthroughConnectionPool.java:59)
	at androidx.room.RoomDatabase.useConnection(RoomDatabase.android.kt:619)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1100)

Root cause

MessageDao.observeAll() backs the mailbox list:

// MessageDao.kt:13
@Query("SELECT * FROM messages ORDER BY timestampMillis DESC")
fun observeAll(): Flow<List<MessageEntity>>

MessageEntity (MessageEntity.kt) carries the full email body as a column:

val snippet: String,
val body: String,   // full (often HTML) message body — can be very large

The list consumer only needs header/preview fields — MailRepositoryImpl.observeMessages() maps rows straight to the list domain model and the body is separately (lazily) fetched via getById/openMessage when a message is opened. But because the observe query is SELECT *, every row drags its entire body through the shared 2 MB CursorWindow. With ~14+ rows of cached HTML bodies, the cumulative/row size exceeds the window and the read faults. col 0 is incidental (first column read once the window is already over capacity); the real problem is the oversized body payload in a list query.

Suggested fix

Stop selecting body (and any other large columns) in the list/observe path — project only the columns the list renders:

  • Add a lightweight list projection (a @Query selecting explicit columns into a small DTO, or a @DatabaseView / partial-entity data class) and point observeMessages() at it, e.g.
    SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched FROM messages ORDER BY timestampMillis DESC.
  • Keep loading the full body only in the detail path (getById), which is already how openMessage works.

This both fixes the CursorWindow overflow and reduces memory churn for the list. A regression test with a large (>2 MB total across rows) cached body would guard it.

Repro

  1. Sync/open enough messages so that several have large HTML bodies cached (bodyFetched = 1).
  2. Return to the mailbox list so observeAll() re-emits.
  3. App crashes as the cursor walks past the row that overflows the window.

Logs pulled from the connected device via adb logcat -b crash and retraced with the release R8 mapping.

## Summary The app crashes with a fatal `IllegalStateException: Couldn't read row 14, col 0 from CursorWindow` when the message list is being observed. The mailbox query `MessageDao.observeAll()` does `SELECT * FROM messages`, which pulls every message's **full `body`** column into a single 2 MB `CursorWindow`. Once enough large (HTML) bodies are cached, a row no longer fits the window and any read of it throws, taking down the process. ## Environment - **App:** `org.libremail.app` v0.1.0 (versionCode 1) — release/minified build - **Device:** Pixel 10 Pro XL, Android 17 (SDK 37), build `CP2A.260605.012` - **Observed:** 2026-07-01 ~13:24 (crashed twice back-to-back) - Captured from `adb logcat -b crash` on the connected device. ## Crash signature ``` java.lang.IllegalStateException: Couldn't read row 14, col 0 from CursorWindow. Make sure the Cursor is initialized correctly before accessing data from it. ``` ## Deobfuscated stack trace Retraced against `app/build/outputs/mapping/release/mapping.txt` (`pg_map_id` matches the crash's `r8-map-id`): ``` java.lang.IllegalStateException: Couldn't read row 14, col 0 from CursorWindow. at android.database.CursorWindow.nativeGetString(Native Method) at android.database.CursorWindow.getString(CursorWindow.java:505) at android.database.AbstractWindowedCursor.getString(AbstractWindowedCursor.java:54) at androidx.sqlite.driver.SupportSQLiteStatement$RowSQLiteStatement.getText(SupportSQLiteStatement.android.kt:370) at org.libremail.data.local.dao.MessageDao_Impl.observeAll$lambda$0(MessageDao_Impl.kt:89) at androidx.room.util.DBUtil...performSuspending$...internalPerform(DBUtil__DBUtil_android.kt:173) at androidx.room.coroutines.PassthroughConnectionPool$useConnection$2.invokeSuspend(PassthroughConnectionPool.java:59) at androidx.room.RoomDatabase.useConnection(RoomDatabase.android.kt:619) at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34) at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1100) ``` ## Root cause `MessageDao.observeAll()` backs the mailbox list: ```kotlin // MessageDao.kt:13 @Query("SELECT * FROM messages ORDER BY timestampMillis DESC") fun observeAll(): Flow<List<MessageEntity>> ``` `MessageEntity` (`MessageEntity.kt`) carries the full email body as a column: ```kotlin val snippet: String, val body: String, // full (often HTML) message body — can be very large ``` The list consumer only needs header/preview fields — `MailRepositoryImpl.observeMessages()` maps rows straight to the list domain model and the body is separately (lazily) fetched via `getById`/`openMessage` when a message is opened. But because the observe query is `SELECT *`, every row drags its entire `body` through the shared 2 MB `CursorWindow`. With ~14+ rows of cached HTML bodies, the cumulative/row size exceeds the window and the read faults. `col 0` is incidental (first column read once the window is already over capacity); the real problem is the oversized `body` payload in a list query. ## Suggested fix Stop selecting `body` (and any other large columns) in the list/observe path — project only the columns the list renders: - Add a lightweight list projection (a `@Query` selecting explicit columns into a small DTO, or a `@DatabaseView` / partial-entity `data class`) and point `observeMessages()` at it, e.g. `SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched FROM messages ORDER BY timestampMillis DESC`. - Keep loading the full `body` only in the detail path (`getById`), which is already how `openMessage` works. This both fixes the CursorWindow overflow and reduces memory churn for the list. A regression test with a large (>2 MB total across rows) cached body would guard it. ## Repro 1. Sync/open enough messages so that several have large HTML bodies cached (`bodyFetched = 1`). 2. Return to the mailbox list so `observeAll()` re-emits. 3. App crashes as the cursor walks past the row that overflows the window. --- _Logs pulled from the connected device via `adb logcat -b crash` and retraced with the release R8 mapping._
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: JMR-dev/LibreMail#51