Merge branch 'main' into claude/poll-text-persistence-hFoho

This commit is contained in:
Vitor Pamplona
2026-06-08 10:31:15 -04:00
committed by GitHub
22 changed files with 1276 additions and 94 deletions
@@ -54,6 +54,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
@@ -3371,6 +3372,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeBaseReplaceable(event, relay, wasVerified)
}
is BirdexEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is CommentEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
@@ -117,6 +117,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward
import com.vitorpamplona.amethyst.ui.note.types.RenderBirdex
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarCollectionEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarRSVPEvent
@@ -216,6 +217,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
@@ -1264,6 +1266,10 @@ private fun RenderNoteRow(
RenderFundraiser(baseNote, makeItShort, accountViewModel, nav)
}
is BirdexEvent -> {
RenderBirdex(baseNote)
}
is HighlightEvent -> {
RenderHighlight(
baseNote,
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
/** How many species names to list before collapsing into a "+N more" suffix. */
private const val SPECIES_PREVIEW_LIMIT = 6
/**
* Minimal, fixed-size summary card for a Birdstar "Birdex" (kind 12473).
*
* The event has no body and no images, only a species list. To keep the card
* bounded regardless of how many species a Birdex holds, we show the count and a
* short preview of scientific names with a "+N more" suffix no images, no
* expansion, no network calls. The card is identical in the feed and the opened
* view, so it takes no makeItShort flag.
*/
@Composable
fun RenderBirdex(baseNote: Note) {
val noteEvent = baseNote.event as? BirdexEvent ?: return
val names = remember(noteEvent) { noteEvent.speciesNames() }
val preview = remember(names) { names.take(SPECIES_PREVIEW_LIMIT) }
val remaining = names.size - preview.size
val joined = remember(preview) { preview.joinToString(", ") }
Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) {
Text(
text = pluralStringResource(R.plurals.birdex_species_count, names.size, names.size),
style = MaterialTheme.typography.titleMedium,
)
if (preview.isNotEmpty()) {
Spacer(Modifier.height(6.dp))
Text(
text =
if (remaining > 0) {
pluralStringResource(R.plurals.birdex_species_preview_more, remaining, joined, remaining)
} else {
joined
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
@@ -68,6 +69,7 @@ class FollowPackFeedNewThreadFeedFilter(
NipTextEvent.KIND,
ClassifiedsEvent.KIND,
FundraiserEvent.KIND,
BirdexEvent.KIND,
LongTextNoteEvent.KIND,
)
}
@@ -137,6 +139,7 @@ class FollowPackFeedNewThreadFeedFilter(
noteEvent is TextNoteEvent ||
noteEvent is ClassifiedsEvent ||
noteEvent is FundraiserEvent ||
noteEvent is BirdexEvent ||
noteEvent.isRenderableRepost() ||
(noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) ||
(noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) ||
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
@@ -70,6 +71,7 @@ class HomeNewThreadFeedFilter(
WikiNoteEvent.KIND,
ClassifiedsEvent.KIND,
FundraiserEvent.KIND,
BirdexEvent.KIND,
LongTextNoteEvent.KIND,
LiveChessGameEndEvent.KIND,
AttestationEvent.KIND,
@@ -126,6 +128,7 @@ class HomeNewThreadFeedFilter(
noteEvent is TextNoteEvent ||
noteEvent is ClassifiedsEvent ||
noteEvent is FundraiserEvent ||
noteEvent is BirdexEvent ||
noteEvent.isRenderableRepost() ||
(noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) ||
(noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) ||
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
@@ -78,6 +79,7 @@ class UserProfileMutualFeedFilter(
it.event is TextNoteEvent ||
it.event is ClassifiedsEvent ||
it.event is FundraiserEvent ||
it.event is BirdexEvent ||
it.event.isRenderableRepost() ||
it.event is LongTextNoteEvent ||
it.event is WikiNoteEvent ||
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
@@ -84,6 +85,7 @@ class UserProfileNewThreadFeedFilter(
it.event is CommentEvent ||
it.event is ClassifiedsEvent ||
it.event is FundraiserEvent ||
it.event is BirdexEvent ||
it.event.isRenderableRepost() ||
it.event is LongTextNoteEvent ||
it.event is WikiNoteEvent ||
@@ -147,6 +147,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAttestation
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestationRequest
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorProficiency
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation
import com.vitorpamplona.amethyst.ui.note.types.RenderBirdex
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCashuMint
@@ -229,6 +230,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
@@ -769,6 +771,8 @@ private fun FullBleedNoteCompose(
RenderGoal(baseNote, accountViewModel, nav)
} else if (noteEvent is FundraiserEvent) {
RenderFundraiser(baseNote, makeItShort = false, accountViewModel, nav)
} else if (noteEvent is BirdexEvent) {
RenderBirdex(baseNote)
} else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) {
RenderRepost(baseNote, quotesLeft = 3, backgroundColor, accountViewModel, nav)
} else if (noteEvent is RelayDiscoveryEvent) {
@@ -2815,6 +2815,8 @@
<!-- NIP-75 Zap Goals -->
<string name="goal_closed">यह उद्देश्य समाप्त हो चुका है</string>
<string name="goal_progress">%1$s वित्तपोषित %2$s साट्स उद्देश्य में से</string>
<string name="fundraiser_ends">समाप्ति %1$s</string>
<string name="fundraiser_onchain_donation">खण्डश्रृंखलाबद्ध दान</string>
<string name="goal_amount_label">उद्देश्य संख्या (साट्स)</string>
<string name="goal_amount_placeholder">१०००००</string>
<string name="goal_description_label">आपके उद्देश्य का विवरण करें</string>
@@ -2853,6 +2853,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<!-- NIP-75 Zap Goals -->
<string name="goal_closed">Ta zbiórka została zamknięta</string>
<string name="goal_progress">Sfinansowano %1$s z %2$s satoszów</string>
<string name="fundraiser_ends">Kończy się %1$s</string>
<string name="fundraiser_onchain_donation">Darowizna on-chain</string>
<string name="goal_amount_label">Kwota zbiorki (w satoszach)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Opisz cel zbiórki</string>
@@ -2789,6 +2789,8 @@
<!-- NIP-75 Zap Goals -->
<string name="goal_closed">此目标已关闭</string>
<string name="goal_progress">设定目标为 %2$s sats,筹集到 %1$s</string>
<string name="fundraiser_ends">结束 %1$s</string>
<string name="fundraiser_onchain_donation">链上捐助</string>
<string name="goal_amount_label">目标金额 (sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">描述您的目标</string>
+8
View File
@@ -3138,6 +3138,14 @@
<string name="goal_progress">%1$s funded of %2$s sats goal</string>
<string name="fundraiser_ends">Ends %1$s</string>
<string name="fundraiser_onchain_donation">On-chain donation</string>
<plurals name="birdex_species_count">
<item quantity="one">Birdex · %1$d species</item>
<item quantity="other">Birdex · %1$d species</item>
</plurals>
<plurals name="birdex_species_preview_more">
<item quantity="one">%1$s +%2$d more</item>
<item quantity="other">%1$s +%2$d more</item>
</plurals>
<string name="goal_amount_label">Goal amount (sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Describe your goal</string>
@@ -0,0 +1,523 @@
---
title: "fix(quartz): NIP-46 bunker double-resume + retry id-reuse races"
type: fix
status: completed
date: 2026-06-03
origin: docs/brainstorms/2026-06-03-nip46-bunker-double-resume-brainstorm.md
---
# fix(quartz): NIP-46 bunker double-resume + retry id-reuse races
## Revision Note (2026-06-03, post-review)
Three reviews (simplicity / architecture / pattern-recognition) converged on
pivoting the approach. Original plan used atomic-remove + `tryResume` on a
cached `Continuation` map. **Revised approach: Channel-per-request (per
retry attempt) + fresh `request.id` per attempt**, matching the de facto
Quartz convention used in `NostrClientPublishExt.kt` and 4 sibling files
under `quartz/.../accessories/`.
### Why the pivot
| Driver | Detail |
|---|---|
| **Architecture review finding** | Quartz already uses Channel-per-request as house style (5+ files). The cached-`Continuation` pattern in `RemoteSignerManager` / `IntentRequestManager` is the outlier — only 2 files. |
| **4th failure mode discovered** | `RemoteSignerManager.kt:74-101` retry loop reuses the same `request.id` across attempts. Late response from attempt 1 can resume attempt 2's continuation with **stale data** (correctness bug, not just crash). Investigation verdict: HIGH probability on flaky relays. Fresh-id-per-retry naturally fixes this. |
| **Simplicity review** | Pivoting eliminates the `@InternalCoroutinesApi` opt-in surface entirely, kills the Plan C / SingleShotContinuation alternative discussions, removes the `TestLogCapture` test-infra invention, and removes the Strategy B stress-loop test. |
| **Pattern review** | "Aligns the existing outlier with the convention" — fewer correlation styles in the codebase, not more. |
### Scope unchanged from original plan
- Both managers fixed in the same PR (NIP-46 + NIP-55 sibling).
- Function-level concurrency primitives — no public API change to
`launchWaitAndParse`. 17 call sites of `launchWaitAndParse` across
`NostrSignerRemote.kt`, `ForegroundRequestHandler.kt`, and the retry test
are untouched.
- `tryAndWait` (`ParallelUtils.kt:71-79`) is **retained** — still used by
`collectSuccessfulOperationsReturning` (`ParallelUtils.kt:98`). Only its
use inside the two managers is replaced.
---
## Overview
Fix two related correctness bugs in the NIP-46 bunker signer
(`RemoteSignerManager`) and its NIP-55 Android sibling
(`IntentRequestManager`):
1. **Double-resume crash**`Continuation.resume(...)` called twice for
the same id, throwing `IllegalStateException: Already resumed`.
Triggered by (a) multi-relay delivery, (b) bunker echo/retry,
(c) late response after `tryAndWait` timeout fires.
2. **Retry id-reuse → wrong-data bug** (NIP-46 only) — retry attempts
reuse the same `request.id`, so a late response from attempt N can
resume attempt N+1's continuation with attempt N's data.
The fix replaces the cached `Continuation<T>` map with a
**Channel-per-request** correlation pattern (mirroring the convention
in `quartz/.../accessories/NostrClientPublishExt.kt`), and regenerates
`request.id` per retry attempt.
## Problem Statement
### Bug 1 — Double-resume crash
```kotlin
// RemoteSignerManager.kt:46-50
suspend fun newResponse(responseEvent: NostrConnectEvent) {
val decryptedJson = signer.decrypt(responseEvent.content, remoteKey)
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(decryptedJson)
awaitingRequests.get(bunkerResponse.id)?.resume(bunkerResponse) // ← unsafe
}
```
Non-atomic `get(id)?.resume(value)` — three races trigger double-resume:
| Race | Sequence | Result |
|---|---|---|
| **Late response after timeout** | `tryAndWait`'s `withTimeoutOrNull` completes continuation with `null`; bunker's actual response arrives ms later; `newResponse` calls `resume` on already-completed continuation. | `IllegalStateException` at `RemoteSignerManager.kt:49` |
| **Multi-relay delivery (NIP-46 only)** | `NostrSignerRemote.kt:82` fires `scope.launch { manager.newResponse(event) }` per delivered event, no dedupe. Two relays delivering same response → two concurrent `newResponse` calls → both `get` same continuation → both `resume`. | First wins, second throws. |
| **Bunker echo / retry (NIP-46 only)** | Some bunker servers re-publish on relay reconnect. Same as multi-relay but with longer time gap. | Second resume throws. |
Stack trace:
```
Exception in thread "DefaultDispatcher-worker-42" java.lang.IllegalStateException:
Already resumed, but proposed with update BunkerResponse@…
at kotlinx.coroutines.CancellableContinuationImpl.alreadyResumedError(CancellableContinuationImpl.kt:556)
at com.vitorpamplona.quartz.nip46RemoteSigner.signer.RemoteSignerManager.newResponse(RemoteSignerManager.kt:49)
at com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote$subscription$2$1.invokeSuspend(NostrSignerRemote.kt:83)
```
### Bug 2 — Retry id-reuse → wrong data (NIP-46 only)
```kotlin
// RemoteSignerManager.kt:66-101 (paraphrased)
val request = buildRequest(...) // ← request.id assigned ONCE
val event = signer.encrypt(request, ...) // event id derived once
var attempt = 0
while (true) {
val result = tryAndWait(timeout) { continuation ->
continuation.invokeOnCancellation { awaitingRequests.remove(request.id) }
awaitingRequests.put(request.id, continuation) // ← SAME id every attempt
client.publish(event, relayList = relayList)
}
when {
result != null -> return parser(result)
attempt >= maxRetries -> return SignerResult.RequestAddressed.TimedOut()
else -> { attempt++; delay(2_000L) }
}
}
```
Race sequence (default `timeout = 65_000L`):
```
T+0: Attempt 1: put(continuation_1, "req123")
T+65s: Attempt 1: timeout → invokeOnCancellation removes "req123"
T+67s: Attempt 2: put(continuation_2, "req123") ← same id
T+70s: Bunker's late response for ATTEMPT 1 arrives
newResponse() resumes continuation_2 with attempt_1's payload
⚠ wrong data delivered to caller
```
Likelihood: HIGH when relay RTT approaches timeout. Atomic-remove + `tryResume`
**does not fix this** — continuation_2 is genuinely live; `tryResume`
succeeds with stale data. Only fresh-id-per-attempt closes this race.
`IntentRequestManager.kt:119` already uses a fresh `RandomInstance.randomChars(32)` per
call and has no retry loop — Bug 2 does not apply there.
## Proposed Solution
### Mechanism: Channel-per-request
```kotlin
// after — RemoteSignerManager (paraphrased shape)
private val pending = ConcurrentHashMap<String, Channel<BunkerResponse>>()
suspend fun newResponse(responseEvent: NostrConnectEvent) {
val decryptedJson = signer.decrypt(responseEvent.content, remoteKey)
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(decryptedJson)
// Atomic remove. Multi-relay / bunker-echo duplicates: losers see null.
val channel = pending.remove(bunkerResponse.id)
if (channel == null) {
Log.d("NIP46") { "no channel for bunker response id=${bunkerResponse.id} (duplicate or unknown)" }
return
}
// capacity = 1: first delivery wins. trySend on a closed/full channel
// is a no-op — late response after timeout cannot crash.
channel.trySend(bunkerResponse)
}
private suspend fun <T : SignerResult.RequestAddressed> launchWaitAndParse(
request: BunkerRequest,
parser: (BunkerResponse) -> T,
): T {
var attempt = 0
while (true) {
// Fresh id per attempt: each attempt is a brand-new request to the bunker.
val attemptRequest = request.copy(id = RandomInstance.randomChars(32))
val event = signer.encrypt(attemptRequest, ...)
val channel = Channel<BunkerResponse>(capacity = 1)
pending[attemptRequest.id] = channel
try {
client.publish(event, relayList = relayList)
val response = withTimeoutOrNull(timeout) { channel.receive() }
when {
response != null -> return parser(response)
attempt >= maxRetries -> return SignerResult.RequestAddressed.TimedOut()
else -> { attempt++; delay(2_000L) }
}
} finally {
pending.remove(attemptRequest.id) // cleanup on both happy + timeout paths
channel.close()
}
}
}
```
### Mechanism applied to `IntentRequestManager`
Same shape, no retry loop, single attempt — `IntentResult` instead of
`BunkerResponse`, `LruCache` becomes `ConcurrentHashMap` (which is also
the structure used by the existing Channel-per-request files), and the
log tag becomes `"NIP55"`.
### Why this fix
| Property | Cached `Continuation` (today) | Atomic `remove` + `tryResume` (original plan) | Channel-per-request (this plan) |
|---|---|---|---|
| Double-resume crash | ❌ | ✅ | ✅ |
| Late response after timeout | ❌ | ✅ (`tryResume` returns null) | ✅ (`trySend` on closed channel is a no-op) |
| Multi-relay delivery | ❌ | ✅ (atomic remove) | ✅ (atomic remove) |
| Bunker echo / retry | ❌ | ✅ | ✅ |
| **Retry id-reuse → wrong data** | ❌ | ❌ | ✅ (fresh id per attempt) |
| `@InternalCoroutinesApi` | n/a | required | not required |
| House style match | outlier | outlier (still cached map) | ✅ matches `accessories/` convention |
| Memory leak on success path | leaks | incidentally fixed | fixed (finally block) |
## Technical Considerations
### Channel capacity = 1
Each request has at most one valid response (NIP-46 `auth_url` flow not
implemented in Amethyst — see "NIP-46 spec" below). `Channel(capacity = 1)`:
- First `trySend` succeeds → `receive` resumes with the value.
- Concurrent / late `trySend` after the channel has been drained returns
a `ChannelResult.Closed` once the `finally` block calls `close()`. No-op,
no throw.
- `withTimeoutOrNull(timeout) { channel.receive() }` returns `null` on
timeout; the `finally` block cleans up the map entry and closes the
channel.
### Fresh `request.id` per retry attempt
NIP-46 has no idempotency contract — each retry is a new request from the
bunker's perspective. Generating a fresh id per attempt is consistent
with the spec and is also what `IntentRequestManager` already does for
single attempts.
The user-visible cost: a slow bunker that finishes processing attempt 1
mid-way through attempt 2 will not have its attempt-1 work "rescued" —
the response is discarded and attempt 2's response is the one we return.
This is the **correct** behaviour; the alternative (rescuing attempt 1
into attempt 2's slot) is the very bug we're fixing.
### `IntentRequestManager.LruCache``ConcurrentHashMap`
`androidx.collection.LruCache` was sized at 2000 for bounded growth.
With the `finally`-block cleanup the map shrinks on every completed call,
so an unbounded `ConcurrentHashMap` matches the Quartz `accessories/`
convention without leak risk. (If we ever want a safety cap, `Caffeine`
is in the dependency graph already, but YAGNI.)
### NIP-46 spec: `auth_url` (multi-response per id)
Spec allows a second response per id when the bunker emits an `auth_url`
challenge first. **Amethyst has zero `auth_url` handling code today**;
every parser maps any non-null `error` to `Rejected`. Channel(capacity=1)
mirrors that current contract: first response is terminal. If `auth_url`
support is added later, the right fix is at the parser /
`launchWaitAndParse` layer (e.g., keep the channel open until the parser
returns `SignerResult.AwaitingAuth`, then `receive` again). Not in scope
here.
### `tryAndWait` retained for `collectSuccessfulOperationsReturning`
`tryAndWait` is still used by `ParallelUtils.kt:98`
(`collectSuccessfulOperationsReturning`). We are not deleting it —
only its uses inside the two managers go away.
### Performance implications
None measurable. One `Channel(1)` allocation + one entry in
`ConcurrentHashMap` per request, both freed in the `finally` block.
NIP-46 throughput is < 10 req/s in practice; this is noise.
### Security considerations
None. Thread-safety + correctness only; no protocol surface change,
no new trust assumptions, no new data exposed.
## System-Wide Impact
- **Interaction graph:** Relay → `INostrClient.subscribe`
`NostrSignerRemote` callback → `scope.launch``manager.newResponse`
channel `trySend``launchWaitAndParse`'s `receive` unblocks →
parser returns to Amethyst UI. The fix sits at the correlation layer;
everything downstream is unchanged. Upstream (`withTimeoutOrNull`, the
subscription callback) is unchanged.
- **`launchWaitAndParse` public signature unchanged.** All 17 call sites
(8 in `NostrSignerRemote.kt`, 9 in `ForegroundRequestHandler.kt`,
4 in tests) are untouched.
- **State lifecycle:** `pending` is the only mutable state. Atomic
`ConcurrentHashMap.put / remove` semantics + `finally`-block cleanup
guarantee no entries leak.
- **Error propagation:** Currently the `IllegalStateException` is thrown
on a `Dispatchers.Default` worker inside `scope.launch { ... }`. After
fix, no exception path remains — duplicates trigger a debug log line.
- **Integration test scenarios** (manual / amy):
1. **NIP-46 cold-start with multi-relay delivery:** subscribe on N=5 relays → send request → all 5 deliver same response → only one resume, no crash.
2. **NIP-46 late-response after timeout:** request with `timeout=100ms` against a bunker that responds at 200ms → returns `TimedOut`, debug log fires.
3. **NIP-46 retry loop with mid-flight stale response:** force `timeout < network RTT` (`timeout=100ms`, RTT=300ms) → attempt 1 times out, attempt 2 in flight, attempt 1's actual response arrives → silently discarded; attempt 2's eventual response is what we return.
4. **NIP-55 multi-result Intent:** Android signer returns Intent with multiple `results` entries that defensively repeat the same id → no crash.
## Acceptance Criteria
### Functional
- [x] `RemoteSignerManager.newResponse` never throws — late responses, duplicates from multiple relays, and bunker echoes are all silently dropped after a debug log.
- [x] `IntentRequestManager.newResponse` has the equivalent guarantee.
- [x] Each retry attempt in `RemoteSignerManager.launchWaitAndParse` uses a fresh `request.id`. A late response from attempt N cannot resume attempt N+1.
- [x] `pending` map entries are always removed in a `finally` block — no leaks on success, timeout, or thrown exception.
- [x] Caller-visible behaviour of `launchWaitAndParse` is unchanged in the happy path: same return value, same retry semantics, same `SignerResult.RequestAddressed` shapes.
### Non-functional
- [x] No `@OptIn(InternalCoroutinesApi::class)` introduced.
- [x] Channel capacity = 1; channel scoped to a single retry attempt (created inside loop, closed in `finally`).
### Quality gates
- [x] `./gradlew :quartz:compileKotlinJvm :quartz:compileKotlinAndroid` passes.
- [x] `./gradlew :quartz:jvmTest --tests "*RemoteSignerManager*"` passes, including new race-condition tests.
- [x] `./gradlew spotlessApply` clean before commit.
- [x] Existing `RemoteSignerManagerRetryTest` (5 tests) still passes.
- [x] Three new tests added covering: (a) duplicate response → no crash + one successful resume; (b) late response after timeout → no crash + caller sees `TimedOut`; (c) cross-attempt stale-response → attempt 1's late response does NOT corrupt attempt 2's result.
## Implementation Plan
### File-level changes
```
quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/
└── RemoteSignerManager.kt # MODIFY
quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/
└── IntentRequestManager.kt # MODIFY
quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/
└── RemoteSignerManagerRetryTest.kt # MODIFY (add three tests)
```
### Step 1 — `RemoteSignerManager.kt`
Replace `awaitingRequests` cache + `tryAndWait`-based loop with
Channel-per-attempt + fresh id:
- Cache type: `ConcurrentHashMap<String, Channel<BunkerResponse>>`
- `newResponse`: atomic `remove` + `trySend` on `Channel(1)` (no-op on closed/full).
- `launchWaitAndParse`: inside the retry loop, copy request with fresh
`request.id` via `RandomInstance.randomChars(32)`, create
`Channel<BunkerResponse>(capacity = 1)`, register under the new id,
`client.publish`, then
`withTimeoutOrNull(timeout) { channel.receive() }`. Cleanup in `finally`.
Imports added:
- `kotlinx.coroutines.channels.Channel`
- `kotlinx.coroutines.withTimeoutOrNull`
- `com.vitorpamplona.quartz.utils.RandomInstance`
- `java.util.concurrent.ConcurrentHashMap`
- `com.vitorpamplona.quartz.utils.Log`
Imports removed:
- `kotlin.coroutines.Continuation`
- `kotlin.coroutines.resume`
- `com.vitorpamplona.quartz.utils.cache.LargeCache`
- `com.vitorpamplona.quartz.utils.tryAndWait` (no longer used here; keep in `ParallelUtils.kt`)
### Step 2 — `IntentRequestManager.kt`
Same shape, no retry loop, single attempt. `LruCache``ConcurrentHashMap`,
`Continuation``Channel<IntentResult>(capacity = 1)`, atomic `remove` +
`trySend`. Log tag `"NIP55"`. The `forEach` over multi-result Intents
now does `pending.remove(id)?.trySend(result)` per entry.
### Step 3 — Tests (`RemoteSignerManagerRetryTest.kt`)
Three new tests. All use the existing `runTest`-based scaffolding —
no new test infra (no `TestLogCapture`, no `Dispatchers.Default` stress
loops). Each test must be observable via the public `launchWaitAndParse`
return value, not internal log state.
```kotlin
@Test
fun `duplicate response events do not crash and resume once`() = runTest {
val client = TestClient()
val manager = RemoteSignerManager(timeout = 5000L, client = client, ...)
val resultDeferred = async { manager.launchWaitAndParse(...) }
runCurrent()
val response = client.captureRequestEvent().toResponse()
// Three deliveries of the same response — only the first should reach the caller.
launch { manager.newResponse(response) }
launch { manager.newResponse(response) }
launch { manager.newResponse(response) }
advanceUntilIdle()
val result = resultDeferred.await()
assertIs<SignerResult.RequestAddressed.Result<*>>(result)
// Bug exists on main: second/third launch throws IllegalStateException → fails test.
}
@Test
fun `late response after timeout is silently discarded`() = runTest {
val client = TestClient(neverResponds = true)
val manager = RemoteSignerManager(timeout = 100L, maxRetries = 0, client = client, ...)
val resultDeferred = async { manager.launchWaitAndParse(...) }
val response = client.captureRequestEvent().toResponse()
advanceTimeBy(200L) // timeout fires
val result = resultDeferred.await()
assertIs<SignerResult.RequestAddressed.TimedOut>(result)
// Now the late response arrives — must not crash, must not affect caller.
manager.newResponse(response)
advanceUntilIdle()
// No assertion needed: lack of crash + caller already got TimedOut is the success criterion.
}
@Test
fun `late response from attempt 1 does not corrupt attempt 2 result`() = runTest {
val client = TestClient()
val manager = RemoteSignerManager(timeout = 100L, maxRetries = 1, client = client, ...)
val resultDeferred = async { manager.launchWaitAndParse(buildRequest("PAYLOAD_A")) }
runCurrent()
val attempt1Event = client.captureRequestEvent() // captures id_1
advanceTimeBy(150L) // attempt 1 times out + delay(2000) begins
advanceTimeBy(2000L) // retry kicks in
val attempt2Event = client.captureRequestEvent() // captures id_2 — must be different from id_1
assertNotEquals(attempt1Event.requestId, attempt2Event.requestId)
// Late response for attempt 1 arrives while attempt 2 is in flight
manager.newResponse(attempt1Event.toResponse(payload = "STALE_A"))
// Real response for attempt 2 arrives
manager.newResponse(attempt2Event.toResponse(payload = "FRESH_B"))
advanceUntilIdle()
val result = resultDeferred.await()
assertIs<SignerResult.RequestAddressed.Result<*>>(result)
assertEquals("FRESH_B", (result as SignerResult.RequestAddressed.Result<*>).value)
// On main: result would be "STALE_A" (Bug 2) — test fails. Also Bug 1 would crash.
}
```
### Step 4 — Verify on `main` first
Before applying Step 1, run the three new tests against `main`. Expected
failures:
- Test 1: `IllegalStateException: Already resumed` from second/third
`launch { newResponse }`.
- Test 2: `IllegalStateException` from the late `newResponse` call.
- Test 3: either `IllegalStateException` (Bug 1) or `assertEquals` failure
with `actual = "STALE_A"` (Bug 2). Both branches prove the test exercises
the race.
Apply Step 1, re-run, all three pass.
### Step 5 — Format + final build
```bash
./gradlew spotlessApply
./gradlew :quartz:build
```
## Success Metrics
- Zero `IllegalStateException: Already resumed` log entries from
`RemoteSignerManager.newResponse` or `IntentRequestManager.newResponse`
after the fix lands.
- Zero stale-data correctness reports tied to retry id reuse.
- Three new tests in `RemoteSignerManagerRetryTest` pass; same tests fail
on `main`.
- No regressions in the existing 5 retry tests.
## Dependencies & Risks
| Item | Risk | Mitigation |
|---|---|---|
| Channel migration changes the correlation primitive | If a future feature needs multi-response per id (e.g., NIP-46 `auth_url`), the channel must be re-`receive`'d after the parser yields `AwaitingAuth` | Not blocking today (no `auth_url` code). Documented as the right architectural seam. |
| Fresh id per retry attempt changes wire behaviour | Bunkers that cache responses by id will not see the second request as a duplicate | Acceptable — NIP-46 has no idempotency contract; this matches `IntentRequestManager`'s existing behaviour. |
| `LruCache` (Android) → `ConcurrentHashMap` change | `LruCache` had a 2000-entry cap; `ConcurrentHashMap` is unbounded | `finally`-block cleanup guarantees entries shrink on every completed call. Mirrors the unbounded `ConcurrentHashMap` already used in `quartz/.../accessories/`. |
| Existing 5 retry tests rely on the old continuation-cache API | Tests may not compile against the new `pending` map | Tests should only interact through the public `launchWaitAndParse` + `newResponse` surface (verified in deepen-plan research). If any directly inspect `awaitingRequests`, update to inspect `pending` or pivot to result-based assertion. |
## Alternative Approaches Considered
1. **Atomic `remove` + `tryResume` on cached `CancellableContinuation`** (original plan)
Fixes Bug 1 cleanly but leaves Bug 2 (retry id-reuse) intact. Pulls in
`@InternalCoroutinesApi`. Stays with the outlier pattern. Rejected
after architecture + simplicity reviews.
2. **try/catch `IllegalStateException` around `resume`**
Anti-pattern; doesn't fix Bug 2. Rejected.
3. **`SingleShotContinuation<T>` wrapper helper**
Reusable abstraction for a problem already solved by Channel. Doesn't
fix Bug 2. YAGNI; rejected.
4. **Subscription-level dedupe in `NostrSignerRemote.kt:82`**
Bounded LRU of seen event ids. Suppresses duplicate work upstream. Not
needed because Channel `trySend` on capacity-1 is already O(1) and
guard at the receive side is atomic. Deferred unless field telemetry
shows high duplicate volume.
5. **Fix only `RemoteSignerManager`, leave `IntentRequestManager`**
Sibling bug is identical; bundling is cheaper than a follow-up.
6. **Keep `tryAndWait` for the managers**
Rejected because `tryAndWait`'s `CancellableContinuation`-cache
pattern is the source of both bugs. Keeping `tryAndWait` for
`collectSuccessfulOperationsReturning` is fine — different use case
(no shared id, no concurrent multi-source delivery).
## Sources & References
### Origin
- **Brainstorm:** [`docs/brainstorms/2026-06-03-nip46-bunker-double-resume-brainstorm.md`](../../docs/brainstorms/2026-06-03-nip46-bunker-double-resume-brainstorm.md)
- Decisions changed during deepen-plan + review pass:
- Fix mechanism: atomic remove + `tryResume` → Channel-per-request
- Scope: added Bug 2 (retry id-reuse) as in-scope after investigation
showed HIGH probability of reaching it on flaky relays
### Internal references
- `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt:44,46-50,66-101` — the two bug sites
- `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt:69-86` — subscription handler (line 82 = source of multi-relay concurrent calls)
- `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/ParallelUtils.kt:71-79,98``tryAndWait` (retained for `collectSuccessfulOperationsReturning`)
- `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientPublishExt.kt` — house-style reference for Channel-per-request
- `quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/IntentRequestManager.kt:63,80-97,119,126` — sibling bug + existing fresh-id usage at line 119
- `quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt` — existing test scaffolding to extend
### External references
- [NIP-46 spec](https://github.com/nostr-protocol/nips/blob/master/46.md) — request/response shapes + the `auth_url` challenge flow
- [kotlinx.coroutines `Channel`](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.channels/-channel/) — capacity, `trySend`, `receive`, `close` semantics
- [`runTest` docs](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/run-test.html) — virtual time + single-threaded dispatcher
---
## Unanswered Questions
- `pending` final naming — `pending` vs `awaitingRequests` (retain old name for grep continuity)? — minor; leaning `pending` (matches `accessories/` convention)
- Whether the NIP-55 Intent multi-result branch is ever actually hit in practice — defensive fix either way; if telemetry confirms it's dead code we could collapse to single-result path in follow-up
- Whether to grep existing tests for direct `awaitingRequests` access before Step 1 — yes, do it at the start of /ce:work to flag breakage early
@@ -22,47 +22,37 @@ package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground
import android.content.ActivityNotFoundException
import android.content.Intent
import androidx.collection.LruCache
import com.vitorpamplona.quartz.nip55AndroidSigner.api.IResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.withTimeoutOrNull
/**
* This class manages the lifecycle of foreground signing requests in a NIP-55 compliant Android signer flow.
* Manages the lifecycle of foreground signing requests in a NIP-55 compliant Android signer flow.
*
* - It tracks pending signing requests using a unique ID and allows for awaiting their results via coroutines.
* - Tracks pending signing requests by a unique call id via a per-request [Channel].
* - Provides a way to launch foreground Intents (to request user approval) and wait for the response.
* - Handles timeouts on user approval using [tryAndWait].
* - Handles approval timeouts via [withTimeoutOrNull].
* - Collects results via [newResponse] when the user responds to the foreground request.
*
* Main components:
* Key usage flow: request initiated -> store channel by id -> launch intent -> withTimeoutOrNull
* receive -> finally cleanup. User responds -> atomic remove + trySend on the channel.
*
* - `awaitingRequests`: LRU cache mapping request IDs to continuations for async response handling.
* - `appLauncher`: Function reference to launch foreground Intents (typically provided by an Activity).
* - `launchAndWait`: Suspend function to send an Intent, wait for an answer, and parse the result.
* - `newResponse`: Handles incoming results from the foreground activity using a unique ID.
*
* Key usage flows:
*
* - Request initiated -> store continuation by ID -> launch intent -> wait
* - User responds -> resume continuation -> remove ID -> return parsed result
*
* The class also cleans up pending requests on timeout or cancellation.
* Duplicate, unknown, or late deliveries (e.g. if the activity surfaces multiple results for the
* same id) atomically read out as null and are dropped after a debug log line no continuation
* is ever resumed twice.
*/
class IntentRequestManager(
val foregroundApprovalTimeout: Long = 30000,
) {
val activityNotFoundIntent = Intent()
// LRU cache to store pending requests and their continuations.
private val awaitingRequests = LruCache<String, Continuation<IntentResult>>(2000)
private val pending = LargeCache<String, Channel<IntentResult>>()
// Function to launch an Intent in the foreground.
private var appLauncher: ((Intent) -> Unit)? = null
/** Call this function when the launcher becomes available on activity, fragment or compose */
@@ -82,70 +72,66 @@ class IntentRequestManager(
if (results != null) {
// This happens when the intent responds to many requests at the same time.
IntentResult.fromJsonArray(results).forEach { result ->
if (result.id != null) {
awaitingRequests[result.id]?.resume(result)
awaitingRequests.remove(result.id)
}
if (result.id != null) dispatch(result.id, result)
}
} else {
val result = IntentResult.fromIntent(data)
if (result.id != null) {
awaitingRequests[result.id]?.resume(result)
awaitingRequests.remove(result.id)
}
if (result.id != null) dispatch(result.id, result)
}
}
private fun dispatch(
id: String,
result: IntentResult,
) {
val channel = pending.remove(id)
if (channel == null) {
Log.d("NIP55") { "no channel for intent result id=$id (duplicate, unknown, or late)" }
return
}
channel.trySend(result)
}
fun hasForegroundActivity() = appLauncher != null
/**
* Launches the signer, waits and parses the result
* Launches the signer, waits and parses the result.
*
* @param requestIntent The Intent to be launched.
* @param requestIntentBuilder Builder for the Intent to be launched.
* @param parser A function that parses the response Intent into a [SignerResult.RequestAddressed<T>].
* @return The result after parsing the Intent using the provided parser.
*
* This function uses the [tryAndWait] utility to implement a timeout on the foreground approval.
* It assigns a unique ID to the request and keeps a continuation to resume once the result is received.
* If the timeout occurs or the continuation is cancelled, the request ID is cleaned up from [awaitingRequests].
* Flags are added to the Intent to ensure it is brought to the front if already running.
*/
suspend fun <T : IResult> launchWaitAndParse(
requestIntentBuilder: () -> Intent,
parser: (intent: IntentResult) -> SignerResult.RequestAddressed<T>,
): SignerResult.RequestAddressed<T> =
appLauncher?.let { launcher ->
val requestIntent = requestIntentBuilder()
val callId = RandomInstance.randomChars(32)
): SignerResult.RequestAddressed<T> {
val launcher = appLauncher ?: return SignerResult.RequestAddressed.NoActivityToLaunchFrom()
requestIntent.putExtra("id", callId)
requestIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
val requestIntent = requestIntentBuilder()
val callId = RandomInstance.randomChars(32)
requestIntent.putExtra("id", callId)
requestIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
val channel = Channel<IntentResult>(capacity = 1)
pending.put(callId, channel)
return try {
try {
val resultIntent =
tryAndWait(foregroundApprovalTimeout) { continuation ->
continuation.invokeOnCancellation {
awaitingRequests.remove(callId)
}
awaitingRequests.put(callId, continuation)
try {
launcher.invoke(requestIntent)
} catch (e: Exception) {
Log.e("ExternalSigner", "Error launching intent", e)
awaitingRequests.remove(callId)
throw e
}
}
when (resultIntent) {
null -> SignerResult.RequestAddressed.TimedOut()
else -> parser(resultIntent)
}
launcher.invoke(requestIntent)
} catch (e: ActivityNotFoundException) {
Log.e("ExternalSigner", "Error launching intent: Signer not found", e)
SignerResult.RequestAddressed.SignerNotFound()
return SignerResult.RequestAddressed.SignerNotFound()
}
} ?: SignerResult.RequestAddressed.NoActivityToLaunchFrom()
val resultIntent = withTimeoutOrNull(foregroundApprovalTimeout) { channel.receive() }
when (resultIntent) {
null -> SignerResult.RequestAddressed.TimedOut()
else -> parser(resultIntent)
}
} finally {
pending.remove(callId)
channel.close()
}
}
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.birdstar
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged
/**
* Birdstar "Birdex" species collection (kind 12473).
*
* An app-specific **replaceable** kind published by the Birdstar app
* (`birdstar.app`) a birdwatching life-list. It is **not** defined by any NIP;
* the schema below is derived from events seen in the wild. Being replaceable,
* each author keeps a single, latest Birdex.
*
* The event carries no body and no images its payload is the species list,
* one entry per observed species, as alternating tags:
*
* - `n` the species' scientific name (e.g. `Icterus galbula`).
* - `i` an external identity reference for the species, a Wikidata entity URL
* (NIP-73 style, e.g. `https://www.wikidata.org/entity/Q805774`).
* - `alt` a human-readable summary written by the publisher
* (e.g. `Birdex: 24 species`).
*
* Amethyst renders a minimal, fixed-size summary card from [speciesNames] and
* [speciesCount]; it does not resolve the Wikidata references to images.
*/
@Immutable
class BirdexEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
/** Scientific names of the collected species, in event order, from the `n` tags. */
fun speciesNames() = tags.mapValueTagged("n") { it }
/** Number of collected species (one `n` tag per species). */
fun speciesCount() = speciesNames().size
/** Publisher-provided human-readable summary, from the `alt` tag (may be null). */
fun summary() = tags.firstTagValue("alt")
companion object {
const val KIND = 12473
}
}
@@ -0,0 +1,86 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.metadata
import com.vitorpamplona.quartz.utils.Log
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.nullable
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonObject
/**
* Tolerant serializer for the kind-0 `birthday` field.
*
* NIP-24 defines `birthday` as an object `{ "year", "month", "day" }` (each field
* optional). Some clients (e.g. Ditto / divine.video) instead write a string such
* as `"10-24"`, which is not spec-compliant. With the default serializer that type
* mismatch throws, and because [MetadataEvent.contactMetaData] turns any parse
* exception into `null`, a single malformed `birthday` would discard the **entire**
* profile (name, picture, about).
*
* This serializer parses the spec object form and treats anything else as absent
* (`null`) rather than failing, so one non-conformant field can no longer break
* profile rendering. The string form is intentionally not "recovered": the spec
* has no string format, and a bare `"10-24"` is ambiguous (MM-DD vs DD-MM).
*
* Modelled on [com.vitorpamplona.quartz.nip11RelayInfo.FlexibleIntListSerializer].
*/
object BirthdayTolerantSerializer : KSerializer<Birthday?> {
private val delegate = Birthday.serializer()
// Nullable serializer ⇒ nullable descriptor, so the framework's metadata stays
// honest even on code paths that consult it (e.g. coerceInputValues).
override val descriptor: SerialDescriptor = delegate.descriptor.nullable
override fun deserialize(decoder: Decoder): Birthday? {
require(decoder is JsonDecoder) { "This serializer can only be used with Json format" }
val element = decoder.decodeJsonElement()
if (element !is JsonObject) {
// Non-spec birthday (e.g. Ditto's "10-24" string). Ignore it rather than
// failing the whole profile parse. Log the JSON kind only, not the raw
// (untrusted, network-sourced) value.
Log.w("BirthdayTolerantSerializer") { "Ignoring non-object birthday (${element::class.simpleName})" }
return null
}
return try {
decoder.json.decodeFromJsonElement(delegate, element)
} catch (e: Exception) {
Log.w("BirthdayTolerantSerializer") { "Ignoring malformed birthday object: ${e.message}" }
null
}
}
override fun serialize(
encoder: Encoder,
value: Birthday?,
) {
if (value == null) {
encoder.encodeNull()
} else {
delegate.serialize(encoder, value)
}
}
}
@@ -48,6 +48,8 @@ class UserMetadata {
var about: String? = null
var bot: Boolean? = null
var pronouns: String? = null
@Serializable(with = BirthdayTolerantSerializer::class)
var birthday: Birthday? = null
var nip05: String? = null
var domain: String? = null
@@ -27,11 +27,12 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.cache.LargeCache
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlinx.coroutines.withTimeoutOrNull
class RemoteSignerManager(
val timeout: Long = 65_000,
@@ -41,19 +42,27 @@ class RemoteSignerManager(
val relayList: Set<NormalizedRelayUrl>,
val maxRetries: Int = 1,
) {
private val awaitingRequests = LargeCache<String, Continuation<BunkerResponse>>()
private val pending = LargeCache<String, Channel<BunkerResponse>>()
suspend fun newResponse(responseEvent: NostrConnectEvent) {
val decryptedJson = signer.decrypt(responseEvent.content, remoteKey)
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(decryptedJson)
awaitingRequests.get(bunkerResponse.id)?.resume(bunkerResponse)
val channel = pending.remove(bunkerResponse.id)
if (channel == null) {
Log.d("NIP46") { "no channel for bunker response id=${bunkerResponse.id} (duplicate, unknown, or late)" }
return
}
channel.trySend(bunkerResponse)
}
/**
* Launches the signer, waits and parses the result.
*
* Builds the request once and republishes the same event on retry to ensure
* the bunker's response (keyed by request ID) can always be matched.
* Each retry attempt uses a fresh request id so a late response from a previous
* attempt cannot resume the current attempt with stale data. The bunker request
* builder is still called only once per call; the manager rewrites the id per
* attempt internally.
*
* @param bunkerRequestBuilder The BunkerRequest to be sent.
* @param parser A function that parses the BunkerResponse into a [SignerResult.RequestAddressed<T>].
@@ -63,36 +72,38 @@ class RemoteSignerManager(
bunkerRequestBuilder: () -> BunkerRequest,
parser: (response: BunkerResponse) -> SignerResult.RequestAddressed<T>,
): SignerResult.RequestAddressed<T> {
val request = bunkerRequestBuilder()
val event =
NostrConnectEvent.create(
message = request,
remoteKey = remoteKey,
signer = signer,
)
val template = bunkerRequestBuilder()
var attempt = 0
while (true) {
val result =
tryAndWait(timeout) { continuation ->
continuation.invokeOnCancellation {
awaitingRequests.remove(request.id)
}
val attemptRequest =
BunkerRequest(
id = RandomInstance.randomChars(32),
method = template.method,
params = template.params,
)
val event =
NostrConnectEvent.create(
message = attemptRequest,
remoteKey = remoteKey,
signer = signer,
)
awaitingRequests.put(request.id, continuation)
val channel = Channel<BunkerResponse>(capacity = 1)
pending.put(attemptRequest.id, channel)
val response =
try {
client.publish(event, relayList = relayList)
withTimeoutOrNull(timeout) { channel.receive() }
} finally {
pending.remove(attemptRequest.id)
channel.close()
}
when {
result != null -> {
return parser(result)
}
attempt >= maxRetries -> {
return SignerResult.RequestAddressed.TimedOut()
}
response != null -> return parser(response)
attempt >= maxRetries -> return SignerResult.RequestAddressed.TimedOut()
else -> {
attempt++
delay(2_000L)
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
@@ -341,6 +342,7 @@ class EventFactory {
AudioTrackEvent.KIND -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig)
BadgeAwardEvent.KIND -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
BadgeDefinitionEvent.KIND -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
BirdexEvent.KIND -> BirdexEvent(id, pubKey, createdAt, tags, content, sig)
BidEvent.KIND -> BidEvent(id, pubKey, createdAt, tags, content, sig)
BidConfirmationEvent.KIND -> BidConfirmationEvent(id, pubKey, createdAt, tags, content, sig)
BlockedRelayListEvent.KIND -> BlockedRelayListEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.birdstar
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
class BirdexEventTest {
private fun sampleEvent(): Event =
EventFactory.create(
id = "a099d4db563041bb289d3704f983fc148fc805860303a4f479a8264dc6a2d7cc",
pubKey = "932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d",
createdAt = 1_780_836_939L,
kind = BirdexEvent.KIND,
tags =
arrayOf(
arrayOf("alt", "Birdex: 3 species"),
arrayOf("i", "https://www.wikidata.org/entity/Q805774"),
arrayOf("n", "Icterus galbula"),
arrayOf("i", "https://www.wikidata.org/entity/Q738534"),
arrayOf("n", "Baeolophus bicolor"),
arrayOf("i", "https://www.wikidata.org/entity/Q829683"),
arrayOf("n", "Mimus polyglottos"),
arrayOf("client", "birdstar.app"),
),
content = "",
sig = "00".repeat(64),
)
@Test
fun factoryBuildsBirdexForKind12473() {
val event = sampleEvent()
assertTrue(
event is BirdexEvent,
"Expected a BirdexEvent but got ${event::class.simpleName}",
)
}
@Test
fun kind12473IsNowKnown() {
assertTrue(EventFactory.isKnownKind(BirdexEvent.KIND), "kind 12473 should be a known kind")
}
@Test
fun parsesBirdexFields() {
val event = sampleEvent()
assertIs<BirdexEvent>(event)
assertEquals(3, event.speciesCount())
assertEquals(
listOf("Icterus galbula", "Baeolophus bicolor", "Mimus polyglottos"),
event.speciesNames(),
)
assertEquals("Birdex: 3 species", event.summary())
}
}
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.metadata
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
class BirthdayTolerantSerializerTest {
private fun metaWith(content: String): MetadataEvent =
EventFactory.create(
id = "ed269c23907649461da4b0fe109eed689ed1a562d33873b97ed01496dd02b87c",
pubKey = "932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d",
createdAt = 1L,
kind = MetadataEvent.KIND,
tags = emptyArray(),
content = content,
sig = "00".repeat(64),
) as MetadataEvent
/**
* Regression for the Ditto / divine.video profile (npub1jvnpg4c, "MK Fain")
* whose `birthday` is the non-spec string "10-24". Before the tolerant
* serializer this threw and [MetadataEvent.contactMetaData] returned null,
* dropping the whole profile.
*/
@Test
fun stringBirthdayDoesNotDropTheProfile() {
val meta =
metaWith(
"""{"name":"MK Fain","about":"Team Soapbox","picture":"https://blossom.ditto.pub/x.jpg","nip05":"mk@ditto.pub","birthday":"10-24"}""",
).contactMetaData()
assertIs<UserMetadata>(meta, "profile must still parse despite the malformed birthday")
assertEquals("MK Fain", meta.name)
assertEquals("https://blossom.ditto.pub/x.jpg", meta.picture)
assertEquals("mk@ditto.pub", meta.nip05)
assertNull(meta.birthday, "non-object birthday must be ignored, not fatal")
}
@Test
fun otherNonObjectBirthdaysAreIgnored() {
// number, array, and JSON null are all non-spec for `birthday`.
listOf(
"""{"name":"A","birthday":1024}""",
"""{"name":"A","birthday":[10,24]}""",
"""{"name":"A","birthday":null}""",
).forEach { json ->
val meta = metaWith(json).contactMetaData()
assertIs<UserMetadata>(meta, "profile must survive birthday=$json")
assertEquals("A", meta.name)
assertNull(meta.birthday)
}
}
/**
* End-to-end check that a dropped birthday does not leak back into the
* serialized profile. The omission itself is the decoder's default-null
* suppression (encodeDefaults stays false on JsonMapper), not the serializer
* this just pins the real-world JsonMapper output.
*/
@Test
fun nullBirthdayIsOmittedOnSerialization() {
val meta = metaWith("""{"name":"A","birthday":"10-24"}""").contactMetaData()
assertIs<UserMetadata>(meta)
val serialized = JsonMapper.toJson(meta)
assertTrue("birthday" !in serialized, "a null birthday should not be serialized back out: $serialized")
}
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -31,21 +32,46 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.utils.Hex
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotEquals
@OptIn(ExperimentalCoroutinesApi::class)
class RemoteSignerManagerRetryTest {
private val signer = NostrSignerInternal(KeyPair())
private val remoteKeyPair = KeyPair()
private val remoteKey = Hex.encode(remoteKeyPair.pubKey)
private val bunkerSigner = NostrSignerInternal(remoteKeyPair)
private val relay = NormalizedRelayUrl("wss://relay.test")
private suspend fun decodeRequestId(event: Event): String {
val plaintext = bunkerSigner.decrypt(event.content, event.pubKey)
val request = OptimizedJsonMapper.fromJsonTo<BunkerRequest>(plaintext)
return request.id
}
private suspend fun bunkerPongFor(requestId: String): NostrConnectEvent =
NostrConnectEvent.create(
message = BunkerResponsePong(requestId),
remoteKey = signer.pubKey,
signer = bunkerSigner,
)
@Test
fun timeoutReturnsTimedOutAfterMaxRetries() =
runTest {
@@ -165,6 +191,190 @@ class RemoteSignerManagerRetryTest {
assertEquals(1, manager.maxRetries)
}
@Test
fun duplicateResponsesAreSafeAndResumeOnce() =
runTest {
val capturing = CapturingNostrClient()
val manager =
RemoteSignerManager(
timeout = 5_000,
client = capturing,
signer = signer,
remoteKey = remoteKey,
relayList = setOf(relay),
maxRetries = 0,
)
val deferred =
async {
manager.launchWaitAndParse(
bunkerRequestBuilder = { BunkerRequestPing() },
parser = PingResponse::parse,
)
}
runCurrent()
val publishedRequestId = decodeRequestId(capturing.publishedEvents.single())
val response = bunkerPongFor(publishedRequestId)
// Three deliveries of the same response — only one continuation exists,
// so the second and third would have crashed on the old `get(id)?.resume(...)`
// path. With atomic remove + Channel(1) trySend they are safe no-ops.
launch { manager.newResponse(response) }
launch { manager.newResponse(response) }
launch { manager.newResponse(response) }
advanceUntilIdle()
val result = deferred.await()
assertIs<SignerResult.RequestAddressed.Successful<PingResult>>(result)
}
@Test
fun lateResponseAfterTimeoutIsSilentlyDiscarded() =
runTest {
val capturing = CapturingNostrClient()
val manager =
RemoteSignerManager(
timeout = 100,
client = capturing,
signer = signer,
remoteKey = remoteKey,
relayList = setOf(relay),
maxRetries = 0,
)
val deferred =
async {
manager.launchWaitAndParse(
bunkerRequestBuilder = { BunkerRequestPing() },
parser = PingResponse::parse,
)
}
runCurrent()
val publishedRequestId = decodeRequestId(capturing.publishedEvents.single())
// Let the timeout fire. Caller resolves to TimedOut and the channel is closed.
val result = deferred.await()
assertIs<SignerResult.RequestAddressed.TimedOut<PingResult>>(result)
// Now the late response arrives. On the old `get(id)?.resume(...)` path the
// continuation was already completed by the timeout, so this would throw
// IllegalStateException("Already resumed"). With the fix the entry is gone
// from `pending` and trySend on the closed channel is a no-op.
val response = bunkerPongFor(publishedRequestId)
manager.newResponse(response)
advanceUntilIdle()
}
@Test
fun lateResponseFromAttempt1DoesNotCorruptAttempt2() =
runTest {
val capturing = CapturingNostrClient()
val manager =
RemoteSignerManager(
timeout = 100,
client = capturing,
signer = signer,
remoteKey = remoteKey,
relayList = setOf(relay),
maxRetries = 1,
)
val deferred =
async {
manager.launchWaitAndParse(
bunkerRequestBuilder = { BunkerRequestPing() },
parser = PingResponse::parse,
)
}
runCurrent()
// Attempt 1 publishes, then times out at T=100.
val attempt1Id = decodeRequestId(capturing.publishedEvents[0])
advanceTimeBy(150)
// delay(2_000) between attempts: attempt 2 starts at T=2_100.
// Land mid-window so attempt 2's own 100 ms timeout (T=2_200) hasn't fired yet.
advanceTimeBy(2_000)
runCurrent()
// Attempt 2 must use a different id — otherwise a late attempt-1 response
// could resume the attempt-2 channel with stale data.
assertEquals(2, capturing.publishedEvents.size)
val attempt2Id = decodeRequestId(capturing.publishedEvents[1])
assertNotEquals(attempt1Id, attempt2Id)
// Late delivery of attempt 1's response. With the fix it has no entry in
// `pending` and is silently discarded.
manager.newResponse(bunkerPongFor(attempt1Id))
// Attempt 2's real response.
manager.newResponse(bunkerPongFor(attempt2Id))
advanceUntilIdle()
val result = deferred.await()
val success = assertIs<SignerResult.RequestAddressed.Successful<PingResult>>(result)
assertEquals(attempt2Id, success.result.pong)
}
}
private class CapturingNostrClient : INostrClient {
val publishedEvents = mutableListOf<Event>()
override fun connectedRelaysFlow(): StateFlow<Set<NormalizedRelayUrl>> = MutableStateFlow(emptySet())
override fun availableRelaysFlow(): StateFlow<Set<NormalizedRelayUrl>> = MutableStateFlow(emptySet())
override fun connect() {}
override fun disconnect() {}
override fun reconnect(
onlyIfChanged: Boolean,
ignoreRetryDelays: Boolean,
) {}
override fun isActive(): Boolean = false
override fun syncFilters(relay: IRelayClient) {}
override fun subscribe(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
) {}
override fun count(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
) {}
override fun unsubscribe(subId: String) {}
override fun publish(
event: Event,
relayList: Set<NormalizedRelayUrl>,
) {
publishedEvents.add(event)
}
override fun pendingPublishRelaysFor(eventId: String): Set<NormalizedRelayUrl>? = null
override fun addConnectionListener(listener: RelayConnectionListener) {}
override fun removeConnectionListener(listener: RelayConnectionListener) {}
override fun getReqFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = null
override fun getCountFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = null
override fun activeRequests(url: NormalizedRelayUrl): Map<String, List<Filter>> = emptyMap()
override fun activeCounts(url: NormalizedRelayUrl): Map<String, List<Filter>> = emptyMap()
override fun activeOutboxCache(url: NormalizedRelayUrl): Set<HexKey> = emptySet()
override fun close() {}
}
private class CountingNostrClient(