fix(cashu): warmup must be at-most-once per process

The previous warmup change introduced a startup crash: each account's
CashuWalletState.start() spawned a coroutine on Dispatchers.Default
that ran Bdhke.warmup() (32 blind+unblind cycles) AND
MintApiSerializerWarmup.warmup() (decode 32-element synthetic
RestoreResponseDto). With two accounts, that's 128 BDHKE calls + 64
deserializations all in flight at once — far worse JIT pressure than
the original problem, since the warmup explicitly tries to make
methods hot.

The trace showed it clearly — multiple unblind/blind logs from
different threads interleaving mid-call ("unblind: parseAffinePointInto k"
appearing without a preceding "unblind: parseAffinePointInto cTick"
from the same logical call). ART's optimizer then crashed on the
flood.

Add a @Volatile flag to both warmups. First caller does the work;
every subsequent caller returns immediately. Plain volatile (not
atomic CAS or synchronized) because:
  - The race window is tiny (microseconds between read and write)
  - Two extra 32-cycle warmups in the worst case isn't a correctness
    or performance issue
  - Stays commonMain-portable without expect/actual or atomicfu
This commit is contained in:
Claude
2026-05-28 02:13:16 +00:00
parent 84dbf90076
commit 6b3bcdfa53
2 changed files with 36 additions and 2 deletions
@@ -621,6 +621,27 @@ object Bdhke {
*/
fun randomSecret(): ByteArray = randomScalar()
/**
* Tracks whether [warmup] has already been invoked this process.
* At-most-once: the FIRST caller does the JIT-warming work; every
* subsequent caller (including parallel ones from different
* accounts' [com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState.start])
* sees the flag and returns immediately.
*
* Multiple warmups running concurrently was itself a crash trigger
* — two accounts each doing 32 blind+unblind cycles on
* Dispatchers.Default at startup put ~128 concurrent BDHKE calls
* in flight, contending for the JIT compiler and reproducing the
* Android 15+ ART optimizer bug we were trying to dodge.
*
* Plain @Volatile + check is sufficient — the harm from a tiny
* race window (two callers both seeing `false` before either
* flips the flag) is one extra 32-cycle warmup, not a correctness
* issue. We avoid `synchronized` to stay commonMain-portable.
*/
@Volatile
private var warmupDone: Boolean = false
/**
* Pre-warm the JIT for [blind] / [unblind] by running them N times
* with synthetic data. Forces ART's optimizing compiler to do its
@@ -630,13 +651,14 @@ object Bdhke {
* the whole process down.
*
* Called from `CashuWalletState.start()` on a background coroutine.
* Safe to call multiple times — the JIT will deduplicate. Cheap if
* the methods are already compiled.
* At-most-once per process — see [warmupDone].
*
* The synthetic data uses a fixed mint pubkey / random blinding
* factors. It does NOT touch any wallet state or network.
*/
fun warmup() {
if (warmupDone) return
warmupDone = true
val scratch = BdhkeScratchpad()
// Fixed public key for warmup — generator point G's compressed form.
// G is always on the curve and parses cleanly; nothing we do
@@ -64,7 +64,19 @@ object MintApiSerializerWarmup {
explicitNulls = false
}
/**
* At-most-once flag, mirrors [com.vitorpamplona.quartz.nip60Cashu.bdhke.Bdhke.warmup]'s
* gate. Multiple accounts' `CashuWalletState.start()` racing to
* warm the serializer simultaneously defeats the purpose — it
* stacks N parallel decodes into the JIT's queue at exactly the
* moment we wanted things calm.
*/
@Volatile
private var warmupDone: Boolean = false
fun warmup() {
if (warmupDone) return
warmupDone = true
val payload = buildSyntheticRestorePayload(WARMUP_ELEMENT_COUNT)
// Decode + re-encode. The encode path is also hot (every mint
// request body serializes a List<BlindedMessageDto>), so warm