Quartz now lives in github.com/vitorpamplona/quartz, split into quartz-core, quartz-social, quartz-relay-client and the other quartz-* libraries, with history kept. Amethyst depends on the `quartz` umbrella artifact at a released version (1.20.0) instead of the :quartz project. - Remove the quartz/ module, jitpack.yml and the Quartz CI, release and pre-push steps; tools/unicode-nfkc moved with it. - Point every module at libs.quartz. - Move the device-test fixtures the benchmark and relayBench still read to testdata/. - NostrConnectLoginUseCase parses bunker messages with BunkerJson, which now owns NIP-46 JSON. - Repoint docs and skills at the Quartz repository; the nostr-expert scripts read a Quartz checkout (QUARTZ_REPO, default ../quartz). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ZR5ZBfhFzRiXubJc5Evyv
4.2 KiB
name: kotlin-coroutines description: Coroutines/Flow in Amethyst: picking IO vs Default, scopes and cancellation (CancellationException, withTimeout), StateFlow/SharedFlow/Channel and stateIn choices, runTest. Use when writing or reviewing async Kotlin code.
Kotlin coroutines in Amethyst
Repo-specific rules only. Textbook coroutines/Flow knowledge is assumed.
Dispatchers
Dispatchers.IO is the default; Default only for provably lock-free CPU work.
The full rule, with the reasons and examples (ParallelEventVerifier,
PoWMiner.MiningDispatcher, BagSweep, NostrSignerExternal), is
commons/ARCHITECTURE.md › "Choosing a dispatcher". Read it before writing
flowOn/withContext. In commonMain, Dispatchers.IO needs
import kotlinx.coroutines.IO.
Relay work: reuse, don't hand-roll
Never write your own callbackFlow { openReqSubscription(...); awaitClose { ... } }
REQ loop. One-shot and paged fetches, publish-and-confirm, COUNT and negentropy sync
already exist as INostrClient extensions in
relay-client/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/
(catalog: its README.md; flow helpers in ../reqs/). Their timeouts are idle
windows (idleTimeoutMs), not deadlines. For loads tied to a composable's lifetime,
see the relay-client skill.
callbackFlow is still the right bridge for platform callbacks; copy
amethyst/.../service/connectivity/ConnectivityFlow.kt (initial emit, awaitClose
unregister, distinctUntilChanged().debounce(200).flowOn(Dispatchers.IO)).
Scopes
- Stored scopes are fine for lifecycle owners.
Accounttakesscope: CoroutineScope; Android services buildCoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)andcancel()it inonDestroy(PushNotificationReceiverService). Hundreds of such fields are legitimate; don't flag them on sight. - New non-owner classes (helpers, repositories, parsers) expose
suspendAPIs and do notlaunchfrominit. Let the owner pick the scope. - No
runBlockingoutside tests, previews and the CLI entry point. NoGlobalScope.
Cancellation and timeouts
- A broad
catch (e: Exception)/runCatchingaround a suspend call must rethrowCancellationException. TimeoutCancellationExceptionIS aCancellationException. A blanket rethrow turns one slow RPC into a dead loop: rethrowing it ended the cordn sync loop for the whole session. Either catch the timeout narrowly, right at thewithTimeout, or rethrow only when the coroutine itself is cancelled (if (!isActive) throw e). Seecommons/.../cordn/CordnSyncLoop.ktfor both.
State and events
MutableStateFlow.update { }instead of_s.value = _s.value.copy(...). Keep the block pure and cheap (it can retry); build expensive values before it.stateIn/shareInbelong on aval, never inside a function (each call starts a new sharing coroutine).WhileSubscribedstops upstream with no collectors, so.valuegoes stale or stays at the initial value. If code reads.valuesynchronously, useEagerly.stateFlow.map { }is a plainFlow; terminate withstateInif consumers need.value.- One-shot events for a single consumer (navigation, a toast): a
Channel(Channel.BUFFERED).receiveAsFlow(), not a no-replayMutableSharedFlowthat drops emissions with no collector. AChanneldelivers each item to ONE collector; if every observer must see it, model it as state. - Don't invent fake domain sentinels as the initial value; use
nullor a sealed loading state.
Tests
runTestfromkotlinx-coroutines-test, notrunBlocking. Turbine is not a dependency; collect into a list inbackgroundScope.launch { flow.collect { } }and drive time withadvanceUntilIdle(),runCurrent(),advanceTimeBy().- Inject the dispatcher/scope under test (
StandardTestDispatcher(testScheduler)orUnconfinedTestDispatcher) so delays run on virtual time. - Never wait on wall-clock time (
delay(350)racing a 200 ms timeout flaked on loaded CI). Wait on the test scheduler or a condition.