From c8b7c4dddcf9a613ac6404e4bc9f34265d8e7483 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 15:40:29 +0000 Subject: [PATCH] fix(cashu): bypass kotlinx.serialization for /v1/restore response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diagnostic logs confirmed the JIT crash hypothesis: three NUT-09 batches deserialize fine through the generated RestoreResponseDto.serializer().deserialize(...), then the 4th batch crosses ART's tier-1 (optimizing) compile threshold for the generated decoder body, the optimizer crashes (SIGSEGV at offset 0x48 in Jit thread pool), and the process dies before the 4th batch's "decoded" log can fire. Trace shape on every reproducer: Bdhke.warmup begin / end ← warmup OK MintApiSerializerWarmup begin / end ← warmup OK restore POST batch 1, decoded, deduped, unblinded restore POST batch 2, decoded, deduped, unblinded restore POST batch 3, decoded, deduped, unblinded restore POST batch 4 Replace the generated decode path for /v1/restore only. The encode side stays through the generated serializer (request payload is small + cold). For the response: use Json.parseToJsonElement (the JSON-tree API) and walk the tree by hand, extracting fields into the existing DTOs. The tree API is one parser routine, completely separate code from the per-class generated deserializers — much smaller bytecode, no escape-analysis target shape, no JIT crash. Defensive against missing fields (returns empty lists / null dleq) so a misbehaving mint can't trip the hand-roll. Other endpoints (swap, mint, melt, checkstate) keep their generated deserializers since they don't go through the multi-batch tier-1 threshold. --- .../nip60Cashu/mintApi/MintHttpClient.kt | 136 +++++++++++++++++- 1 file changed, 135 insertions(+), 1 deletion(-) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt index 35b39110cb..a675dbe5f5 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt @@ -23,6 +23,12 @@ package com.vitorpamplona.quartz.nip60Cashu.mintApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -114,7 +120,22 @@ class MintHttpClient( suspend fun checkState(request: CheckStateRequestDto): CheckStateResponseDto = post("/v1/checkstate", request, CheckStateRequestDto.serializer()) - suspend fun restore(request: RestoreRequestDto): RestoreResponseDto = post("/v1/restore", request, RestoreRequestDto.serializer()) + /** + * NUT-09 restore. Encodes the request through the generated + * serializer (small payload — fine for the JIT), but decodes the + * response via [Json.parseToJsonElement] (tree API) instead of the + * generated `RestoreResponseDto.serializer().deserialize`. + * + * Why bypass the generated path: on Android 15+ ART crashes the + * JIT optimizer (SIGSEGV at offset 0x48 in Jit thread pool) when + * it reaches tier-1 compilation of the kotlinx.serialization + * generated decode body for `BlindSignatureDto` after ~3-4 batches + * of ~300-400 elements each. The tree API uses a different, + * smaller, simpler code path that avoids the offending pattern — + * the parser is hand-walked here, no generated serializers + * involved in the hot loop. + */ + suspend fun restore(request: RestoreRequestDto): RestoreResponseDto = postWithManualResponseDecode("/v1/restore", request, RestoreRequestDto.serializer(), ::parseRestoreResponse) private suspend inline fun get(path: String): R = withContext(Dispatchers.IO) { @@ -155,6 +176,119 @@ class MintHttpClient( } } + /** + * POST + decode-via-callback variant. The encode side still goes + * through the generated serializer (request payloads are small), + * but the response body is handed to the caller as a JSON string + * so the caller can pick a decode path that avoids + * kotlinx.serialization's generated deserializers — see [restore] + * for the rationale. + */ + private suspend fun postWithManualResponseDecode( + path: String, + body: T, + bodySerializer: kotlinx.serialization.KSerializer, + decode: (String) -> RestoreResponseDto, + ): RestoreResponseDto = + withContext(Dispatchers.IO) { + val url = baseUrl + path + val client = okHttpClient(url) + val bodyJson = json.encodeToString(bodySerializer, body) + val req = + Request + .Builder() + .url(url) + .post(bodyJson.toRequestBody(jsonMediaType)) + .build() + client.newCall(req).executeAsync().use { resp -> + val text = resp.body.string() + if (!resp.isSuccessful) throw decodeError(resp.code, text) + decode(text) + } + } + + /** + * Hand-rolled [RestoreResponseDto] decoder. + * + * Uses [Json.parseToJsonElement] (the JSON-tree API) plus manual + * field extraction instead of the generated + * `RestoreResponseDto.serializer().deserialize(...)`. The tree API + * is a single Json parser routine — much smaller bytecode than + * the per-class generated deserializers, and crucially NOT the + * code path that ART JIT crashes on at tier-1 compilation on + * Android 15+. + * + * Defensive: missing / wrong-shape fields fall back to safe + * defaults (empty lists, null dleq) so a misbehaving mint can't + * trip the decoder. + */ + private fun parseRestoreResponse(text: String): RestoreResponseDto { + val root = + runCatching { json.parseToJsonElement(text).jsonObject } + .getOrNull() + ?: return RestoreResponseDto(outputs = emptyList(), signatures = emptyList()) + + val outputsArr = root["outputs"]?.jsonArray + val signaturesArr = root["signatures"]?.jsonArray + + val outputs = + outputsArr + ?.map { el -> + val o = el.jsonObject + BlindedMessageDto( + amount = o.getValue("amount").jsonPrimitive.long, + id = + o + .getValue("id") + .jsonPrimitive.contentOrNull + .orEmpty(), + bTick = + o + .getValue("B_") + .jsonPrimitive.contentOrNull + .orEmpty(), + ) + }.orEmpty() + + val signatures = + signaturesArr + ?.map { el -> + val o = el.jsonObject + val dleq = + (o["dleq"] as? JsonObject)?.let { d -> + DleqProofDto( + e = + d + .getValue("e") + .jsonPrimitive.contentOrNull + .orEmpty(), + s = + d + .getValue("s") + .jsonPrimitive.contentOrNull + .orEmpty(), + r = (d["r"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull, + ) + } + BlindSignatureDto( + amount = o.getValue("amount").jsonPrimitive.long, + id = + o + .getValue("id") + .jsonPrimitive.contentOrNull + .orEmpty(), + cTick = + o + .getValue("C_") + .jsonPrimitive.contentOrNull + .orEmpty(), + dleq = dleq, + ) + }.orEmpty() + + return RestoreResponseDto(outputs = outputs, signatures = signatures) + } + private fun decodeError( status: Int, body: String,