Files
amethyst/quartz/build.gradle.kts
T
Claude 50486341a6 fix(quartz): back Apple LargeCache with StripedHashMap, not CacheMap
NostrSignerRemotePrivateZapTest failed on iosSimulatorArm64 with
kotlin.ConcurrentModificationException. Apple's LargeCache wrapped
charlietap's CacheMap, a left-right map whose entries/keys/values return
live views of an inner HashMap and drop the read guard before the caller
iterates. Every bulk op (filter, map, keys(), even forEach's
entries.toList() "snapshot") therefore walked a HashMap a writer could be
mutating, and Kotlin/Native's HashMap throws CME when that happens. The
bunker round-trip has NostrClient's pool scanning its LargeCaches while
subscribe/publish mutate them from other threads.

Reproduced on linuxX64 (same Kotlin/Native HashMap) by racing CacheMap
scans against put/remove workers: kotlin.ConcurrentModificationException.

Linux already had a purpose-built replacement, StripedHashMap: lock-free,
weakly consistent reads and striped-lock writes, so scans never throw and
never copy. It only needs PlatformLock, which has a parking
(NSRecursiveLock) Apple actual. Move it and the LargeCache /
ConcurrentHashCache actuals from linuxMain to nativeMain so Apple uses
them too, move their tests to nativeTest so they run on iOS, and drop the
now-unused cachemap dependency.

Adds LargeCacheConcurrencyTest.scansNeverThrowWhileKeysComeAndGo, which
covers the filter/map/keys/values/forEach scans against concurrent puts
and removes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcC7xe3bTb9fnJhESGukuD
2026-10-02 15:43:13 +00:00

446 lines
15 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import com.vanniktech.maven.publish.KotlinMultiplatform
import com.vanniktech.maven.publish.SourcesJar
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary)
alias(libs.plugins.serialization)
alias(libs.plugins.vanniktech.mavenPublish)
}
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xexpect-actual-classes")
}
jvm {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
android {
namespace = "com.vitorpamplona.quartz"
compileSdk =
libs.versions.android.compileSdk
.get()
.toInt()
minSdk =
libs.versions.android.minSdk
.get()
.toInt()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
optimization {
consumerKeepRules.apply {
publish = true
file("consumer-rules.pro")
}
}
withHostTest {}
withDeviceTest {
instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
}
// For iOS targets, this is also where you should
// configure native binary output. For more information, see:
// https://kotlinlang.org/docs/multiplatform-build-native-binaries.html#build-xcframeworks
// A step-by-step guide on how to include this library in an XCode
// project can be found here:
// https://developer.android.com/kotlin/multiplatform/migrate
val xcfName = "quartz-kmpKit"
iosArm64 {
binaries.framework {
baseName = xcfName
isStatic = true
binaryOption("bundleId", "com.vitorpamplona.quartz")
}
}
iosSimulatorArm64 {
binaries.framework {
baseName = xcfName
isStatic = true
binaryOption("bundleId", "com.vitorpamplona.quartz")
}
}
linuxX64()
macosArm64()
// This makes sure that the resource file directory is visible for iOS tests.
val rootDir = "${rootProject.rootDir.path}/quartz/src/commonTest/resources"
tasks.withType<Test>().configureEach {
maxHeapSize = "4g"
environment("TEST_RESOURCES_ROOT", rootDir)
// Opt-in gate for ProductionReceiverBenchmark (opens sockets to live
// relays). `-PprodRelayBench=1` reaches the daemon even when an env
// var would not survive daemon reuse.
(project.findProperty("prodRelayBench") as? String)?.let {
environment("PROD_RELAY_BENCH", it)
}
// Forward the negentropy-benchmark corpus size to the test JVM.
System.getProperty("negBenchN")?.let { systemProperty("negBenchN", it) }
System.getProperty("followBenchScale")?.let { systemProperty("followBenchScale", it) }
System.getProperty("tagBenchScale")?.let { systemProperty("tagBenchScale", it) }
System.getProperty("fsBenchScale")?.let { systemProperty("fsBenchScale", it) }
// Opt-in JFR profiling of a benchmark run (-PnegProfile=/tmp/neg.jfr).
(project.findProperty("negProfile") as? String)?.let {
jvmArgs("-XX:+FlightRecorder", "-XX:StartFlightRecording=filename=$it,settings=profile,dumponexit=true")
}
}
tasks.withType<KotlinNativeTest>().configureEach {
environment("TEST_RESOURCES_ROOT", rootDir)
// This is necessary to have the variable propagated on iOS
environment("SIMCTL_CHILD_TEST_RESOURCES_ROOT", rootDir)
}
// Enable LLVM optimizations for native test binaries (benchmark accuracy).
// Without opt, K/N test binaries compile in debug mode (~20× slower).
// Use the binary-level API instead of -opt freeCompilerArg to avoid the
// "Unsupported combination of flags: -opt and -g" conflict in Kotlin/Native 2.1+.
targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget>().configureEach {
binaries.withType<org.jetbrains.kotlin.gradle.plugin.mpp.TestExecutable>().configureEach {
optimized = true
debuggable = false
}
}
// Source set declarations.
// Declaring a target automatically creates a source set with the same name. By default, the
// Kotlin Gradle Plugin creates additional source sets that depend on each other, since it is
// common to share sources between related targets.
// See: https://kotlinlang.org/docs/multiplatform-hierarchy.html
sourceSets {
commonMain {
dependencies {
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.core)
// For LruCache
implementation(libs.androidx.collection)
// @Immutable and @Stable
implementation(libs.androidx.compose.runtime.annotation)
// Bitcoin secp256k1 bindings
api(libs.secp256k1.kmp.common)
// Kotlin serialization for the times where we need the Json tree and performance is not that important.
implementation(libs.kotlinx.serialization.json)
// CBOR serialization for the NUT-00 v4 (cashuB) token wire format.
implementation(libs.kotlinx.serialization.cbor)
// immutable collections to avoid recomposition
implementation(libs.kotlinx.collections.immutable)
// SQLite KMP driver for event store
api(libs.androidx.sqlite)
implementation(libs.androidx.sqlite.bundled)
// Negentropy set reconciliation (NIP-77)
api(libs.negentropy.kmp)
implementation("io.github.andreypfau:kotlinx-crypto-hmac:0.0.4")
implementation("io.github.andreypfau:kotlinx-crypto-sha2:0.0.4")
}
}
commonTest {
dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
// SQLite bundled driver for tests
api(libs.androidx.sqlite)
implementation(libs.androidx.sqlite.bundled)
}
}
// Must be defined before androidMain and jvmMain
val jvmAndroid =
create("jvmAndroid") {
dependsOn(commonMain.get())
dependencies {
// Performant Parser of JSONs into Events.
//
// jackson-databind, NOT jackson-module-kotlin: the module exists to bind
// Kotlin classes reflectively off their constructor parameter names, and
// nothing in the app does that any more — every wire format goes through a
// hand-written serializer or kotlinx. Dropping it takes kotlin-reflect with
// it, which is ~1,000 classes of DEX the app never called. `cli` still
// declares the module itself: it genuinely binds its config files
// reflectively, and is never minified.
api(libs.jackson.databind)
// Websockets API
implementation(libs.okhttp)
implementation(libs.okhttpCoroutines)
// Chess engine for move validation and legal move generation
// NOTE: 1.0.4+ uses Java 21's removeLast() which crashes on Android API < 34
// TODO: Test if 1.0.0 works, or fork library to fix
implementation(libs.kchesslib)
}
}
// Must be defined before androidMain and jvmMain
val jvmAndroidTest =
create("jvmAndroidTest") {
dependsOn(commonTest.get())
dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
// In-process Nostr relay (geode) so JVM/Android host
// tests don't need network access or a Rust toolchain.
// testFixtures (RelayClientTest base, fixtures,
// collectUntilEose) are wired below at the top-level
// `dependencies` block — the KMP source-set DSL
// doesn't expose the `testFixtures(...)` consumer.
implementation(project(":geode"))
}
}
jvmMain {
dependsOn(jvmAndroid)
dependencies {
// Bitcoin secp256k1 bindings
implementation(libs.secp256k1.kmp.jni.jvm)
}
}
jvmTest {
dependsOn(jvmAndroidTest)
dependencies {
// Bitcoin secp256k1 bindings
implementation(libs.secp256k1.kmp.jni.jvm)
// Custom C secp256k1 (libschnorr256k1) for cross-validation + 3-way benchmark
implementation(libs.schnorr256k1.kmp)
// SQLite bundled driver for JVM tests
implementation(libs.androidx.sqlite.bundled.jvm)
}
}
androidMain {
dependsOn(jvmAndroid)
dependencies {
implementation(libs.androidx.core.ktx)
// Bitcoin secp256k1 bindings to Android
api(libs.secp256k1.kmp.jni.android)
}
}
getByName("androidHostTest") {
dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
// Bitcoin secp256k1 bindings
implementation(libs.secp256k1.kmp.jni.jvm)
// SQLite bundled driver for Host tests
implementation(libs.androidx.sqlite.bundled.jvm)
}
}
getByName("androidDeviceTest") {
dependencies {
implementation(libs.androidx.runner)
implementation(libs.androidx.core)
implementation(libs.androidx.junit)
implementation(libs.androidx.espresso.core)
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
// Bitcoin secp256k1 bindings to Android
api(libs.secp256k1.kmp.jni.android)
// Custom C secp256k1 (libschnorr256k1) for cross-validation + 3-way benchmark
implementation(libs.schnorr256k1.kmp)
}
}
// Must be defined before appleMain, linuxMain, etc.
val nativeMain =
create("nativeMain") {
dependsOn(commonMain.get())
}
val nativeTest =
create("nativeTest") {
dependsOn(commonTest.get())
}
// Must be defined before iosMain and macosMain
val appleMain =
create("appleMain") {
dependsOn(nativeMain)
dependencies {
implementation(libs.dev.whyoleg.cryptography.provider.apple.optimal)
implementation("io.github.andreypfau:kotlinx-crypto-hmac:0.0.4")
implementation("io.github.andreypfau:kotlinx-crypto-sha2:0.0.4")
}
}
val appleTest =
create("appleTest") {
dependsOn(nativeTest)
}
iosMain {
dependsOn(appleMain)
}
getByName("iosArm64Main") {
dependsOn(iosMain.get())
}
getByName("iosSimulatorArm64Main") {
dependsOn(iosMain.get())
}
iosTest {
dependsOn(appleTest)
}
getByName("iosArm64Test") {
dependsOn(iosTest.get())
}
getByName("iosSimulatorArm64Test") {
dependsOn(iosTest.get())
}
val macosMain =
create("macosMain") {
dependsOn(appleMain)
}
val macosTest =
create("macosTest") {
dependsOn(appleTest)
}
getByName("macosArm64Main") {
dependsOn(macosMain)
}
getByName("macosArm64Test") {
dependsOn(macosTest)
}
val linuxMain =
create("linuxMain") {
dependsOn(nativeMain)
dependencies {
implementation(libs.dev.whyoleg.cryptography.provider.apple.optimal)
}
}
val linuxTest =
create("linuxTest") {
dependsOn(nativeTest)
}
getByName("linuxX64Main") {
dependsOn(linuxMain)
}
getByName("linuxX64Test") {
dependsOn(linuxTest)
}
}
}
// testFixtures(...) consumer lives outside the KMP source-set DSL —
// the KMP source-set `dependencies { }` block uses
// `KotlinDependencyHandler`, which does not expose the
// `testFixtures(...)` projection. The standard Gradle dependency
// configuration name (`jvmAndroidTestImplementation`) does work here.
dependencies {
"jvmAndroidTestImplementation"(testFixtures(project(":geode")))
}
// iOS purity gate. Jackson and OkHttp are JVM-only and must never appear
// in source sets that compile for iOS / native / common targets. The KMP
// build would eventually fail with an "unresolved reference" if these
// leaked in, but that requires a macOS runner. This task is a fast Linux
// linter that fails with a clear message before iOS CI ever spins up.
//
// Scope: source sets whose code is compiled for at least one non-JVM
// target. Excludes jvmAndroid, jvmMain, androidMain (and their tests),
// where Jackson and OkHttp are legitimately used.
//
// The task itself is shared with :commons and :commonsUI — see
// gradle/kmp-purity.gradle.kts for the pattern table and checked dirs.
apply(from = rootProject.file("gradle/kmp-purity.gradle.kts"))
mavenPublishing {
// sources publishing is always enabled by the Kotlin Multiplatform plugin
configure(
KotlinMultiplatform(
// whether to publish a sources jar
sourcesJar = SourcesJar.Sources(),
),
)
coordinates(
groupId = "com.vitorpamplona.quartz",
artifactId = "quartz",
// Library version tracks the app version in gradle/libs.versions.toml,
// bumped in lockstep with each release.
version = libs.versions.app.get(),
)
// Configure publishing to Maven Central
publishToMavenCentral(automaticRelease = true)
// Enable GPG signing for all publications
signAllPublications()
pom {
name = "Quartz: Nostr Library for Kotlin Multiplatform"
description = "Nostr library ported to Kotlin/Multiplatform for JVM, Android, iOS & Linux"
inceptionYear = "2025"
url = "https://github.com/vitorpamplona/amethyst/"
licenses {
license {
name = "MIT License"
url = "https://github.com/vitorpamplona/amethyst/blob/main/LICENSE"
}
}
developers {
developer {
id = "vitorpamplona"
name = "Vitor Pamplona"
url = "http://vitorpamplona.com"
email = "vitor@vitorpamplona.com"
}
}
scm {
url = "https://github.com/vitorpamplona/amethyst/"
connection = "https://github.com/vitorpamplona/amethyst/.git"
}
}
}