Summary
P4OC crashes when opening a large OpenCode session — OutOfMemoryError or StackOverflowError during JSON parsing of a single API response that can exceed 90 MB on the server's default GET /session/:id/message (no limit applied). The combination of an unbounded server response, kotlinx-serialization materialising a JsonObject tree of summary.diffs[].patch, and Compose recomputing the grouping of every message on the main thread is a guaranteed crash on any mid-range Android phone once a session accumulates enough summary.diffs blobs.
Reproduction (verified against a local OpenCode server)
- Server: OpenCode 1.17.18, SQLite at the default user data path.
- An affected session contains ~2,000 messages and ~8,000 parts.
- One user message in that session is itself ~63 MB in
message.data, with summary.diffs[] holding 4,302 entries totalling ~60 MB of patch strings. The largest single patch is ~1.9 MB and comes from a node_modules/ file.
- The OpenCode server stored a unified diff of every
node_modules/ file the agent touched. P4OC pulls the entire response back on every open.
Live HTTP reproducer (verified against OpenCode 1.17.18 on http://127.0.0.1:4096):
limit=null -> 91 MB response body (P4OC's current behaviour)
limit=100 -> 2.27 MB
limit=300 -> 4.42 MB
So the default path returns a 91 MB body. Even limit=100 is risky on heap.
Response shape
A normal assistant turn produces an info.summary like:
{
"info": {
"id": "msg_...",
"role": "user",
"summary": {
"diffs": [
{ "file": "amui/src/foo.kt", "patch": "Index: ...\n@@ ...", "additions": 4, "deletions": 1, "status": "modified" }
]
}
},
"parts": [...]
}
Each summary.diffs[] element has file, patch, additions, deletions, status.
Where the crash comes from (P4OC code path)
ChatViewModel.kt calls sessionRepository.loadMessages(id, limit = null).
SessionRepositoryImpl.kt forwards limit = null straight through Retrofit; the server returns the entire session.
OpenCodeApi.getMessages(...) is @GET("session/{sessionId}/message") with @Query("limit") limit: Int?.
MessageInfoDto.summary is typed JsonElement?, not MessageSummaryDto, so kotlinx-serialization stores the whole tree (thousands of JsonObject nodes per fat message) as a generic JsonElement tree. The mapper then walks the full summary.diffs[] array even though it drops the patch field afterwards.
ChatScreen does messageBlocks = remember(messages) { groupMessagesIntoBlocks(messages) }, so even if parsing succeeds, recomputing block grouping on the main thread for ~2,000 messages x ~8,000 parts is very expensive.
Why it crashes (in order of likelihood, several can fire at once)
- OutOfMemoryError during JSON parsing. A 91 MB response is buffered into one Response body -> kotlinx-serialization builds a
JsonObject tree holding every patch string as its own JsonPrimitive -> 3-5x the source body in heap. Mid-range Android phones die in the JSON parser long before the value reaches P4OC code.
- StackOverflowError on the diff tree. With
summary typed as JsonElement, recursion through kotlinx-serialization's lexer blows the default stack when parsing a deeply nested array of long string elements.
- StrictMode / ANR on the main thread.
groupMessagesIntoBlocks(messages) runs on the main thread over thousands of messages with thousands of parts and 4,000+ decoders per call.
Why hiding the crash is easy
AppLog.kt only writes to android.util.Log (logcat). The release build gates d/i behind BuildConfig.DEBUG, so only w/e reach logcat on release. There is no file-based log sink. After a hard crash the logcat buffer is usually already recycled, leaving nothing for the user to attach. Add a file sink (a rotating file in getExternalFilesDir(null)/logs/...).
Suggested fix
Client (P4OC):
- Stop defaulting
loadMessages to limit = null. Default to a sane cap (e.g. 200) and paginate with the cursor the server already returns (Link: <...?before=...>; rel="next" or X-Next-Cursor: ...).
- Don't type
summary as JsonElement if you don't need to walk it. Type it as MessageSummaryDto? (already defined) and either drop patch or attach it lazily (String? on FileDiffDto) only when the user opens a diff view.
- In
ChatScreen, use messageBlocks = remember(messages, versionKey) { ... } and process the grouping in a LaunchedEffect on a default dispatcher, publishing a StateFlow<List<MessageBlock>> to the UI.
- Add a file-based
AppLog sink for debug + release so future crashes have artefacts.
Server (worth filing upstream separately): P4OC isn't the only client that will hit this. Two changes would help everyone:
- Default
GET /session/:id/message to a paginated limit (e.g. 50) when limit is omitted.
- Strip
node_modules/, .git/, dist/, build/ (or any path matching user-configured ignore) from summary.diffs[].patch before persisting, or store only file + diffstat (additions/deletions) and reconstruct the patch on demand from the actual file contents.
Workaround for now
Open the affected session from the OpenCode web UI instead of the Android client, or restore from a snapshot of opencode.db taken before the offending large message.
Diagnostics
The crash signature on device is usually an obfuscated kotlinx.serialization.json.JsonDecodingException / OutOfMemoryError / StackOverflowError chain deep inside mapMessageSummaryToDomain, often appearing as StrictMode disk-read violations during the response read. Because no file log sink exists, the practical way to debug is to add a file sink, set --log-level DEBUG on opencode serve, and watch outbound HTTP via okhttp.eventListener on a debug build.
Environment
- OpenCode server 1.17.18 (running locally)
- P4OC latest main (Kotlin 2.3, Compose BOM 2026.01.01, Room 2.8, OkHttp 5.3, Retrofit 3.0, kotlinx-serialization 1.10)
- Android 16
Summary
P4OC crashes when opening a large OpenCode session —
OutOfMemoryErrororStackOverflowErrorduring JSON parsing of a single API response that can exceed 90 MB on the server's defaultGET /session/:id/message(nolimitapplied). The combination of an unbounded server response, kotlinx-serialization materialising aJsonObjecttree ofsummary.diffs[].patch, and Compose recomputing the grouping of every message on the main thread is a guaranteed crash on any mid-range Android phone once a session accumulates enoughsummary.diffsblobs.Reproduction (verified against a local OpenCode server)
message.data, withsummary.diffs[]holding 4,302 entries totalling ~60 MB of patch strings. The largest single patch is ~1.9 MB and comes from anode_modules/file.node_modules/file the agent touched. P4OC pulls the entire response back on every open.Live HTTP reproducer (verified against OpenCode 1.17.18 on
http://127.0.0.1:4096):So the default path returns a 91 MB body. Even
limit=100is risky on heap.Response shape
A normal assistant turn produces an
info.summarylike:{ "info": { "id": "msg_...", "role": "user", "summary": { "diffs": [ { "file": "amui/src/foo.kt", "patch": "Index: ...\n@@ ...", "additions": 4, "deletions": 1, "status": "modified" } ] } }, "parts": [...] }Each
summary.diffs[]element hasfile,patch,additions,deletions,status.Where the crash comes from (P4OC code path)
ChatViewModel.ktcallssessionRepository.loadMessages(id, limit = null).SessionRepositoryImpl.ktforwardslimit = nullstraight through Retrofit; the server returns the entire session.OpenCodeApi.getMessages(...)is@GET("session/{sessionId}/message")with@Query("limit") limit: Int?.MessageInfoDto.summaryis typedJsonElement?, notMessageSummaryDto, so kotlinx-serialization stores the whole tree (thousands ofJsonObjectnodes per fat message) as a genericJsonElementtree. The mapper then walks the fullsummary.diffs[]array even though it drops thepatchfield afterwards.ChatScreendoesmessageBlocks = remember(messages) { groupMessagesIntoBlocks(messages) }, so even if parsing succeeds, recomputing block grouping on the main thread for ~2,000 messages x ~8,000 parts is very expensive.Why it crashes (in order of likelihood, several can fire at once)
JsonObjecttree holding every patch string as its ownJsonPrimitive-> 3-5x the source body in heap. Mid-range Android phones die in the JSON parser long before the value reaches P4OC code.summarytyped asJsonElement, recursion through kotlinx-serialization's lexer blows the default stack when parsing a deeply nested array of long string elements.groupMessagesIntoBlocks(messages)runs on the main thread over thousands of messages with thousands of parts and 4,000+ decoders per call.Why hiding the crash is easy
AppLog.ktonly writes toandroid.util.Log(logcat). The release build gatesd/ibehindBuildConfig.DEBUG, so onlyw/ereach logcat on release. There is no file-based log sink. After a hard crash the logcat buffer is usually already recycled, leaving nothing for the user to attach. Add a file sink (a rotating file ingetExternalFilesDir(null)/logs/...).Suggested fix
Client (P4OC):
loadMessagestolimit = null. Default to a sane cap (e.g. 200) and paginate with the cursor the server already returns (Link: <...?before=...>; rel="next"orX-Next-Cursor: ...).summaryasJsonElementif you don't need to walk it. Type it asMessageSummaryDto?(already defined) and either droppatchor attach it lazily (String?onFileDiffDto) only when the user opens a diff view.ChatScreen, usemessageBlocks = remember(messages, versionKey) { ... }and process the grouping in aLaunchedEffecton a default dispatcher, publishing aStateFlow<List<MessageBlock>>to the UI.AppLogsink for debug + release so future crashes have artefacts.Server (worth filing upstream separately): P4OC isn't the only client that will hit this. Two changes would help everyone:
GET /session/:id/messageto a paginatedlimit(e.g. 50) whenlimitis omitted.node_modules/,.git/,dist/,build/(or any path matching user-configured ignore) fromsummary.diffs[].patchbefore persisting, or store onlyfile+ diffstat (additions/deletions) and reconstruct the patch on demand from the actual file contents.Workaround for now
Open the affected session from the OpenCode web UI instead of the Android client, or restore from a snapshot of
opencode.dbtaken before the offending large message.Diagnostics
The crash signature on device is usually an obfuscated
kotlinx.serialization.json.JsonDecodingException/OutOfMemoryError/StackOverflowErrorchain deep insidemapMessageSummaryToDomain, often appearing as StrictMode disk-read violations during the response read. Because no file log sink exists, the practical way to debug is to add a file sink, set--log-level DEBUGonopencode serve, and watch outbound HTTP viaokhttp.eventListeneron a debug build.Environment