mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #3958 from vitorpamplona/claude/quartz-issue-evaluation-odhxjr
Fix registry leak in subscription lifecycle
This commit is contained in:
+18
@@ -143,6 +143,18 @@ interface INostrClient : AutoCloseable {
|
||||
|
||||
fun activeCounts(url: NormalizedRelayUrl): Map<String, List<Filter>>
|
||||
|
||||
/**
|
||||
* Size of the pool's REQ / COUNT state registries — the maps every relay
|
||||
* connect, disconnect and connection failure scans in full.
|
||||
*
|
||||
* These track LIVE subscriptions, so a healthy client keeps them near the number of
|
||||
* subscriptions it believes it holds. A host that drives many relays from one client
|
||||
* should alarm on unbounded growth here: registry size multiplies the cost of every
|
||||
* relay lifecycle event, so a leak shows up as connect-path CPU long before it shows
|
||||
* up as memory.
|
||||
*/
|
||||
fun registrySizes(): RegistrySizes = RegistrySizes(0, 0)
|
||||
|
||||
fun activeOutboxCache(url: NormalizedRelayUrl): Set<HexKey>
|
||||
|
||||
/** The events still pending delivery to [url] (full events, not just ids). */
|
||||
@@ -205,3 +217,9 @@ class EmptyNostrClient : INostrClient {
|
||||
|
||||
override fun close() {}
|
||||
}
|
||||
|
||||
/** @see INostrClient.registrySizes */
|
||||
data class RegistrySizes(
|
||||
val liveRequests: Int,
|
||||
val liveCounts: Int,
|
||||
)
|
||||
|
||||
+21
-4
@@ -336,10 +336,21 @@ class NostrClient(
|
||||
val relaysToUpdateReqs = activeRequests.remove(subId)
|
||||
val relaysToUpdateCounts = activeCounts.remove(subId)
|
||||
|
||||
if (isActive()) {
|
||||
activeRequests.sendToRelayIfChanged(subId, relaysToUpdateReqs, relayPool::sendIfConnected)
|
||||
activeCounts.sendToRelayIfChanged(subId, relaysToUpdateCounts, relayPool::sendIfConnected)
|
||||
}
|
||||
// The flush must run even while INACTIVE, because it is also what releases the
|
||||
// subscription's state row. The app backgrounds constantly and tears feeds down
|
||||
// while it is down there; gating this on isActive() would leak a row per teardown
|
||||
// for the whole background stretch, and every one of them would then be scanned
|
||||
// on every relay connect once the app comes back.
|
||||
//
|
||||
// What is gated is the wire traffic: while inactive the pool is disconnected, so
|
||||
// sending is both impossible and pointless (the relay dropped the subscription
|
||||
// when the socket died) -- and routing through the pool would materialize a relay
|
||||
// client for a relay we deliberately stopped talking to.
|
||||
val send: (NormalizedRelayUrl, Command) -> Unit =
|
||||
if (isActive()) relayPool::sendIfConnected else { _, _ -> }
|
||||
|
||||
activeRequests.sendToRelayIfChanged(subId, relaysToUpdateReqs, send)
|
||||
activeCounts.sendToRelayIfChanged(subId, relaysToUpdateCounts, send)
|
||||
}
|
||||
|
||||
override fun syncFilters(relay: IRelayClient) {
|
||||
@@ -458,6 +469,12 @@ class NostrClient(
|
||||
|
||||
override fun activeCounts(url: NormalizedRelayUrl): Map<String, List<Filter>> = activeCounts.activeFiltersFor(url)
|
||||
|
||||
override fun registrySizes() =
|
||||
RegistrySizes(
|
||||
liveRequests = activeRequests.activeSubscriptionCount(),
|
||||
liveCounts = activeCounts.activeQueryCount(),
|
||||
)
|
||||
|
||||
override fun activeOutboxCache(url: NormalizedRelayUrl): Set<HexKey> = eventOutbox.activeOutboxCacheFor(url)
|
||||
|
||||
override fun activeOutboxEvents(url: NormalizedRelayUrl): List<Event> = eventOutbox.activeOutboxEventsFor(url)
|
||||
|
||||
+36
-5
@@ -38,9 +38,26 @@ class PoolCounts {
|
||||
private var queries = LargeCache<String, Map<NormalizedRelayUrl, List<Filter>>>()
|
||||
val relays = MutableStateFlow(setOf<NormalizedRelayUrl>())
|
||||
|
||||
/**
|
||||
* **Invariant: LIVE queries only** -- a row exists only while [queries] still wants
|
||||
* the id. [onConnecting] and [onDisconnected] scan this whole map on every relay
|
||||
* lifecycle event, so a registry that accumulated dead ids would make each connect
|
||||
* O(all queries ever made). See the matching note in [PoolRequests].
|
||||
*/
|
||||
private val relayState = LargeCache<String, CountQueryState<NormalizedRelayUrl>>()
|
||||
|
||||
fun subState(subId: String): CountQueryState<NormalizedRelayUrl> = relayState.getOrCreate(subId) { CountQueryState() }
|
||||
private fun subState(subId: String): CountQueryState<NormalizedRelayUrl> = relayState.getOrCreate(subId) { CountQueryState() }
|
||||
|
||||
/**
|
||||
* State row for a query that is still desired, creating it on first use. Returns
|
||||
* null once the query is removed, so a frame still on the wire (or a CLOSE ack for
|
||||
* a REQ id, which NostrClient also routes through this registry) can never
|
||||
* resurrect a dead row.
|
||||
*/
|
||||
private fun liveState(queryId: String): CountQueryState<NormalizedRelayUrl>? = if (queries.containsKey(queryId)) subState(queryId) else null
|
||||
|
||||
/** Live query count. Exposed so a host can alarm on registry growth. */
|
||||
fun activeQueryCount(): Int = relayState.size()
|
||||
|
||||
private fun updateRelays() {
|
||||
val myRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
@@ -85,8 +102,12 @@ class PoolCounts {
|
||||
val oldRelays = queries.get(queryId)?.keys ?: emptySet()
|
||||
queries.remove(queryId)
|
||||
updateRelays()
|
||||
// The state row survives until [sendToRelayIfChanged] flushes the CLOSE --
|
||||
// that decision compares against the filters this relay still has in flight.
|
||||
oldRelays
|
||||
} else {
|
||||
// Not ours (a REQ id, or a double remove): drop any dead row.
|
||||
relayState.remove(queryId)
|
||||
emptySet()
|
||||
}
|
||||
|
||||
@@ -118,8 +139,10 @@ class PoolCounts {
|
||||
cmd: Command,
|
||||
) {
|
||||
when (cmd) {
|
||||
is CountCmd -> subState(cmd.queryId).onQuery(relay, cmd.filters)
|
||||
is CloseCmd -> subState(cmd.subId).onCloseQuery(relay)
|
||||
is CountCmd -> liveState(cmd.queryId)?.onQuery(relay, cmd.filters)
|
||||
// NostrClient routes every CLOSE through both registries, so the vast
|
||||
// majority of these ids are REQ subs this class has never heard of.
|
||||
is CloseCmd -> liveState(cmd.subId)?.onCloseQuery(relay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,14 +152,14 @@ class PoolCounts {
|
||||
) {
|
||||
when (msg) {
|
||||
is CountMessage -> {
|
||||
subState(msg.queryId).onCountReply(relay.url)
|
||||
liveState(msg.queryId)?.onCountReply(relay.url)
|
||||
sendToRelayIfChanged(msg.queryId, relay.url) { cmd ->
|
||||
relay.sendOrConnectAndSync(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
is ClosedMessage -> {
|
||||
subState(msg.subId).onClosed(relay.url)
|
||||
liveState(msg.subId)?.onClosed(relay.url)
|
||||
sendToRelayIfChanged(msg.subId, relay.url) { cmd ->
|
||||
relay.sendOrConnectAndSync(cmd)
|
||||
}
|
||||
@@ -167,6 +190,14 @@ class PoolCounts {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query is gone and every relay has been told: drop the row (see [relayState]).
|
||||
if (!queries.containsKey(queryId)) {
|
||||
relayState.remove(queryId)
|
||||
// A concurrent count() may have re-added this id; leave it the empty row it
|
||||
// expects rather than a missing one.
|
||||
if (queries.containsKey(queryId)) subState(queryId)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendToRelayIfChanged(
|
||||
|
||||
+52
-4
@@ -76,10 +76,22 @@ class PoolRequests(
|
||||
*
|
||||
* This is important to link responses with which filter was a sub replying to and
|
||||
* to figure out if we need to update the relay if a REQ has changed.
|
||||
*
|
||||
* **Invariant: this holds LIVE subscriptions only.** A row exists between
|
||||
* [addOrUpdate] and the [sendToRelayIfChanged] that flushes the CLOSE for a
|
||||
* [remove]d sub, and nothing else may create one (see [onSent]). That invariant is
|
||||
* what bounds [onConnecting] / [onDisconnected] / [onCannotConnect], which scan the
|
||||
* whole map on every relay lifecycle event: a registry that instead accumulated
|
||||
* every sub id ever used makes each connect O(all subs ever created), and on a
|
||||
* server-side pool driving thousands of relays those scans saturate the dispatcher
|
||||
* and starve every other plane sharing the client.
|
||||
*/
|
||||
private val relayState = LargeCache<String, RequestSubscriptionState<NormalizedRelayUrl>>()
|
||||
|
||||
fun subState(subId: String): RequestSubscriptionState<NormalizedRelayUrl> = relayState.getOrCreate(subId) { RequestSubscriptionState() }
|
||||
private fun subState(subId: String): RequestSubscriptionState<NormalizedRelayUrl> = relayState.getOrCreate(subId) { RequestSubscriptionState() }
|
||||
|
||||
/** Live subscription count. Exposed so a host can alarm on registry growth. */
|
||||
fun activeSubscriptionCount(): Int = relayState.size()
|
||||
|
||||
/*
|
||||
* Locking model: every compound access to a subscription's state machine
|
||||
@@ -184,9 +196,17 @@ class PoolRequests(
|
||||
desiredSubListeners.remove(subId)
|
||||
// update relays for pool
|
||||
updateRelays()
|
||||
// NOTE: the state row deliberately survives until [sendToRelayIfChanged]
|
||||
// flushes the CLOSE -- that decision reads the filters this relay still
|
||||
// believes are in flight. Dropping the row here makes the CLOSE frame
|
||||
// disappear (the sub then leaks on the RELAY instead).
|
||||
// return all affected relays
|
||||
oldRelays
|
||||
} else {
|
||||
// Nothing desired under this id, so any row left behind is dead: a double
|
||||
// unsubscribe, or the same id being removed from the sibling registry
|
||||
// (NostrClient.unsubscribe hits both this and PoolCounts).
|
||||
relayState.remove(subId)
|
||||
emptySet()
|
||||
}
|
||||
|
||||
@@ -215,7 +235,12 @@ class PoolRequests(
|
||||
) {
|
||||
when (cmd) {
|
||||
is ReqCmd -> {
|
||||
subState(cmd.subId).let { state ->
|
||||
// Non-creating on purpose: both send paths ([decideCommandLocked] and
|
||||
// [syncState]) pre-mark the row under the lock before the frame leaves,
|
||||
// so a row always exists for a REQ we actually sent. A null here means
|
||||
// the sub was unsubscribed while the frame was in flight -- recreating
|
||||
// the row would resurrect a dead subscription in the registry.
|
||||
relayState.get(cmd.subId)?.let { state ->
|
||||
state.withLock(relay) { state.onOpenReq(relay, cmd.filters) }
|
||||
}
|
||||
desiredSubListeners.get(cmd.subId)?.onSubscriptionStarted(
|
||||
@@ -225,7 +250,7 @@ class PoolRequests(
|
||||
}
|
||||
|
||||
is CloseCmd -> {
|
||||
subState(cmd.subId).let { state ->
|
||||
relayState.get(cmd.subId)?.let { state ->
|
||||
state.withLock(relay) { state.onSubscriptionClosed(relay) }
|
||||
}
|
||||
desiredSubListeners.get(cmd.subId)?.onSubscriptionClosed(
|
||||
@@ -398,7 +423,18 @@ class PoolRequests(
|
||||
relaysToUpdate: Set<NormalizedRelayUrl>,
|
||||
sync: (NormalizedRelayUrl, Command) -> Unit,
|
||||
) {
|
||||
val state = subState(subId)
|
||||
// Don't create a row for an id that isn't ours: NostrClient.unsubscribe calls this
|
||||
// on BOTH registries with the same id, so a COUNT id would otherwise materialize a
|
||||
// permanent (and never-scanned-past) row in the REQ registry, and vice-versa.
|
||||
//
|
||||
// A DESIRED sub always gets one, though: [addOrUpdate] created it, and if a
|
||||
// concurrent unsubscribe of the same id released it in between, re-creating is
|
||||
// exactly right -- an empty row means "nothing in flight", so the REQ below is
|
||||
// still decided and sent. Returning early there would leave a wanted feed silent.
|
||||
val state =
|
||||
relayState.get(subId)
|
||||
?: if (desiredSubs.containsKey(subId)) subState(subId) else return
|
||||
|
||||
relaysToUpdate.forEach { relay ->
|
||||
// Decide + pre-mark atomically under the sub's lock, then send outside it.
|
||||
val cmd = state.withLock(relay) { decideCommandLocked(state, subId, relay) }
|
||||
@@ -406,6 +442,18 @@ class PoolRequests(
|
||||
sync(relay, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// The sub is gone and every affected relay has now been told. Drop the row so
|
||||
// the registry keeps holding live subscriptions only. Frames that were already
|
||||
// on the wire when the CLOSE went out land in [onIncomingMessage], find no row
|
||||
// and no listener, and correctly no-op.
|
||||
if (!desiredSubs.containsKey(subId)) {
|
||||
relayState.remove(subId)
|
||||
// A concurrent subscribe() may have re-added this id between the check and
|
||||
// the removal. Leave it the empty row it expects rather than a missing one:
|
||||
// empty means "nothing in flight", so its REQ is still decided and sent.
|
||||
if (desiredSubs.containsKey(subId)) subState(subId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+454
@@ -0,0 +1,454 @@
|
||||
/*
|
||||
* 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.relay.client
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* End-to-end audit of the subscription lifecycle through a real [NostrClient] and a
|
||||
* recording socket, added alongside the registry-release fix in [PoolRequests].
|
||||
*
|
||||
* Two things must hold together, and it is easy to fix one by breaking the other:
|
||||
* - **nothing leaks**: the pool's state registries must return to zero once the app
|
||||
* stops wanting a subscription, on every path that can drop one;
|
||||
* - **nothing is dropped**: every frame the relay needs to stay in sync — the REQ, the
|
||||
* CLOSE, the replay after a reconnect — must still reach the wire.
|
||||
*
|
||||
* So every test here asserts BOTH the wire traffic and the registry size.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class NostrClientSubscriptionLifecycleTest {
|
||||
private val url = NormalizedRelayUrl("wss://lifecycle.example.com")
|
||||
private val url2 = NormalizedRelayUrl("wss://lifecycle2.example.com")
|
||||
|
||||
private class RecordingSocket(
|
||||
val sent: MutableList<String>,
|
||||
) : WebSocket {
|
||||
override fun needsReconnect() = false
|
||||
|
||||
override fun connect() {}
|
||||
|
||||
override fun disconnect() {}
|
||||
|
||||
override fun send(msg: String): Boolean {
|
||||
sent.add(msg)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks every relay separately: the pool drops a relay as soon as nothing wants it
|
||||
* and redials it when something does, so a single `lastListener` would only ever
|
||||
* describe the most recent socket and quietly hide frames sent to the others.
|
||||
*/
|
||||
private class RecordingBuilder : WebsocketBuilder {
|
||||
val sent = mutableListOf<String>()
|
||||
val perRelay = mutableMapOf<NormalizedRelayUrl, MutableList<String>>()
|
||||
val listeners = mutableMapOf<NormalizedRelayUrl, WebSocketListener>()
|
||||
|
||||
/** Sockets dialed but not yet driven to onOpen — drained by `openAll`. */
|
||||
val unopened = mutableListOf<WebSocketListener>()
|
||||
var connectAttempts = 0
|
||||
|
||||
override fun build(
|
||||
url: NormalizedRelayUrl,
|
||||
out: WebSocketListener,
|
||||
): WebSocket {
|
||||
connectAttempts++
|
||||
listeners[url] = out
|
||||
unopened.add(out)
|
||||
val perRelaySink = perRelay.getOrPut(url) { mutableListOf() }
|
||||
return RecordingSocket(
|
||||
object : MutableList<String> by sent {
|
||||
override fun add(element: String): Boolean {
|
||||
perRelaySink.add(element)
|
||||
return sent.add(element)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<String>.reqs() = count { it.startsWith("[\"REQ\"") }
|
||||
|
||||
private fun List<String>.closes() = count { it.startsWith("[\"CLOSE\"") }
|
||||
|
||||
private fun List<String>.counts() = count { it.startsWith("[\"COUNT\"") }
|
||||
|
||||
/**
|
||||
* Brings every socket the pool has dialed but not yet opened to a ready state.
|
||||
* Only newly dialed sockets are opened: re-opening a live one would make the client
|
||||
* treat it as a fresh connection and replay every filter, inflating REQ counts.
|
||||
*/
|
||||
private fun TestScope.openAll(builder: RecordingBuilder) {
|
||||
// Loop: opening one relay can make the pool dial another.
|
||||
repeat(3) {
|
||||
val batch = builder.unopened.toList()
|
||||
builder.unopened.clear()
|
||||
if (batch.isEmpty() && it > 0) return
|
||||
batch.forEach { listener -> listener.onOpen(50, false) }
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.settle() {
|
||||
advanceTimeBy(1_000)
|
||||
runCurrent()
|
||||
}
|
||||
|
||||
private fun filters() = listOf(Filter(kinds = listOf(1)))
|
||||
|
||||
@Test
|
||||
fun subscribeThenUnsubscribeSendsBothFramesAndReleasesTheRegistry() =
|
||||
runTest {
|
||||
val builder = RecordingBuilder()
|
||||
val client = NostrClient(builder, this)
|
||||
try {
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
|
||||
client.subscribe("sub", mapOf(url to filters()))
|
||||
openAll(builder)
|
||||
assertEquals(1, builder.sent.reqs(), "the REQ must reach the relay")
|
||||
assertEquals(1, client.registrySizes().liveRequests, "a live sub is tracked")
|
||||
|
||||
client.unsubscribe("sub")
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(1, builder.sent.closes(), "the relay must be told to CLOSE")
|
||||
assertEquals(0, client.registrySizes().liveRequests, "the row must be released")
|
||||
assertEquals(0, client.registrySizes().liveCounts)
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The app backgrounds (host calls [NostrClient.disconnect]) and a ViewModel or a
|
||||
* composable then tears its subscription down. No CLOSE can be sent — the socket is
|
||||
* gone and the relay already dropped the sub — but the row must still be released,
|
||||
* or it survives the whole background stretch and inflates every later connect scan.
|
||||
*/
|
||||
@Test
|
||||
fun unsubscribeWhileInactiveStillReleasesTheRegistry() =
|
||||
runTest {
|
||||
val builder = RecordingBuilder()
|
||||
val client = NostrClient(builder, this)
|
||||
try {
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
|
||||
client.subscribe("sub", mapOf(url to filters()))
|
||||
openAll(builder)
|
||||
assertEquals(1, client.registrySizes().liveRequests)
|
||||
|
||||
client.disconnect()
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
|
||||
client.unsubscribe("sub")
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(0, client.registrySizes().liveRequests, "an inactive client must still release the row")
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Many subs torn down while backgrounded must not accumulate. */
|
||||
@Test
|
||||
fun churnWhileInactiveDoesNotAccumulate() =
|
||||
runTest {
|
||||
val builder = RecordingBuilder()
|
||||
val client = NostrClient(builder, this)
|
||||
try {
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
client.disconnect()
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
|
||||
repeat(200) { i ->
|
||||
client.subscribe("bg-$i", mapOf(url to filters()))
|
||||
client.unsubscribe("bg-$i")
|
||||
}
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(0, client.registrySizes().liveRequests, "background churn must not accumulate rows")
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The core app pattern: [SubscriptionController.updateRelaysIfNeeded] unsubscribes a
|
||||
* feed whose relay set went empty and re-subscribes THE SAME id when it comes back.
|
||||
* The second subscribe must produce a fresh REQ — a stale row that made the client
|
||||
* think a REQ was already in flight would silently leave the feed empty.
|
||||
*/
|
||||
@Test
|
||||
fun resubscribingTheSameIdAfterUnsubscribeSendsAFreshReq() =
|
||||
runTest {
|
||||
val builder = RecordingBuilder()
|
||||
val client = NostrClient(builder, this)
|
||||
try {
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
|
||||
repeat(5) {
|
||||
client.subscribe("feed", mapOf(url to filters()))
|
||||
// The pool drops the relay when nothing wants it and redials on the
|
||||
// next subscribe, so each cycle needs its socket brought up.
|
||||
openAll(builder)
|
||||
client.unsubscribe("feed")
|
||||
settle()
|
||||
}
|
||||
|
||||
assertEquals(5, builder.sent.reqs(), "every re-subscribe must re-REQ")
|
||||
assertEquals(5, builder.sent.closes(), "every unsubscribe must CLOSE")
|
||||
assertEquals(0, client.registrySizes().liveRequests)
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Same cycle driven through SubscriptionController, which is what the app uses. */
|
||||
@Test
|
||||
fun subscriptionControllerDismissAndRecreateStaysInSync() =
|
||||
runTest {
|
||||
val builder = RecordingBuilder()
|
||||
val client = NostrClient(builder, this)
|
||||
try {
|
||||
val controller = SubscriptionController(client)
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
|
||||
repeat(4) {
|
||||
val sub = controller.requestNewSubscription("feed", object : SubscriptionListener {})
|
||||
client.subscribe("feed", mapOf(url to filters()), sub.listener)
|
||||
openAll(builder)
|
||||
controller.dismissSubscription(sub)
|
||||
settle()
|
||||
}
|
||||
|
||||
assertEquals(4, builder.sent.reqs())
|
||||
assertEquals(4, builder.sent.closes())
|
||||
assertEquals(0, client.registrySizes().liveRequests, "dismissed subs must not linger")
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** A live sub whose filters change keeps exactly one row and re-REQs. */
|
||||
@Test
|
||||
fun filterChangesOnALiveSubKeepOneRowAndResend() =
|
||||
runTest {
|
||||
val builder = RecordingBuilder()
|
||||
val client = NostrClient(builder, this)
|
||||
try {
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
|
||||
client.subscribe("feed", mapOf(url to listOf(Filter(kinds = listOf(1)))))
|
||||
openAll(builder)
|
||||
builder.listeners.getValue(url).onMessage("[\"EOSE\",\"feed\"]")
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
|
||||
client.subscribe("feed", mapOf(url to listOf(Filter(kinds = listOf(7)))))
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(1, client.registrySizes().liveRequests, "a filter change is one sub, not two")
|
||||
assertTrue(builder.sent.reqs() >= 2, "the changed filters must be re-REQed")
|
||||
assertEquals(0, builder.sent.closes(), "a filter change must not CLOSE the sub")
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** A reconnect must replay every live sub — and only the live ones. */
|
||||
@Test
|
||||
fun reconnectReplaysLiveSubsOnly() =
|
||||
runTest {
|
||||
val builder = RecordingBuilder()
|
||||
val client = NostrClient(builder, this)
|
||||
try {
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
|
||||
client.subscribe("keep", mapOf(url to filters()))
|
||||
client.subscribe("drop", mapOf(url to filters()))
|
||||
openAll(builder)
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
|
||||
client.unsubscribe("drop")
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
assertEquals(1, client.registrySizes().liveRequests)
|
||||
|
||||
builder.sent.clear()
|
||||
builder.listeners.getValue(url).onClosed(1000, "server closed")
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
client.connect()
|
||||
advanceTimeBy(1_000)
|
||||
runCurrent()
|
||||
openAll(builder)
|
||||
|
||||
assertEquals(1, builder.sent.reqs(), "exactly the live sub is replayed")
|
||||
assertTrue(builder.sent.any { it.contains("\"keep\"") }, "the live sub must be replayed")
|
||||
assertTrue(builder.sent.none { it.contains("\"drop\"") }, "the dropped sub must not be replayed")
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** A COUNT query must release its row too, and not touch the REQ registry. */
|
||||
@Test
|
||||
fun countQueriesReleaseTheirOwnRegistryOnly() =
|
||||
runTest {
|
||||
val builder = RecordingBuilder()
|
||||
val client = NostrClient(builder, this)
|
||||
try {
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
|
||||
client.subscribe("req", mapOf(url to filters()))
|
||||
client.count("cnt", mapOf(url to filters()))
|
||||
openAll(builder)
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(1, client.registrySizes().liveRequests)
|
||||
assertEquals(1, client.registrySizes().liveCounts)
|
||||
assertEquals(1, builder.sent.counts(), "the COUNT must reach the relay")
|
||||
|
||||
client.unsubscribe("cnt")
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(1, client.registrySizes().liveRequests, "closing a COUNT must not disturb the REQ")
|
||||
assertEquals(0, client.registrySizes().liveCounts)
|
||||
|
||||
client.unsubscribe("req")
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
assertEquals(0, client.registrySizes().liveRequests)
|
||||
assertEquals(0, client.registrySizes().liveCounts)
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Releasing a sub's row also drops that sub's per-filter refusal memory, so this pins
|
||||
* what must NOT be dropped: the relay-WIDE capability block lives in
|
||||
* [com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayReqRefusals], keyed by
|
||||
* relay and never by sub, so a relay that refuses to serve reads at all stays blocked
|
||||
* across any number of unsubscribe/re-subscribe cycles. That is the guard that stops
|
||||
* a relay being hammered; the per-sub counter only sharpens it.
|
||||
*/
|
||||
@Test
|
||||
fun relayWideRefusalSurvivesUnsubscribeAndResubscribe() =
|
||||
runTest {
|
||||
val builder = RecordingBuilder()
|
||||
val client = NostrClient(builder, this)
|
||||
try {
|
||||
settle()
|
||||
|
||||
// Two refusals of the "no reads" class block the relay pool-wide.
|
||||
repeat(2) { round ->
|
||||
client.subscribe("feed-$round", mapOf(url to filters()))
|
||||
openAll(builder)
|
||||
builder.listeners
|
||||
.getValue(url)
|
||||
.onMessage("[\"CLOSED\",\"feed-$round\",\"unsupported: this relay does not accept REQ\"]")
|
||||
settle()
|
||||
client.unsubscribe("feed-$round")
|
||||
settle()
|
||||
}
|
||||
|
||||
assertEquals(0, client.registrySizes().liveRequests, "cycled subs must not linger")
|
||||
|
||||
// A brand-new sub on the same relay must now be suppressed, even though
|
||||
// every earlier sub's own row is long gone.
|
||||
builder.sent.clear()
|
||||
client.subscribe("feed-new", mapOf(url to filters()))
|
||||
openAll(builder)
|
||||
settle()
|
||||
|
||||
assertEquals(
|
||||
0,
|
||||
builder.sent.reqs(),
|
||||
"a relay blocked pool-wide must not be re-offered a REQ after the sub rows were released",
|
||||
)
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** A multi-relay sub must CLOSE on every relay that got a REQ before releasing. */
|
||||
@Test
|
||||
fun multiRelaySubClosesOnEveryRelayBeforeRelease() =
|
||||
runTest {
|
||||
val builder = RecordingBuilder()
|
||||
val client = NostrClient(builder, this)
|
||||
try {
|
||||
settle()
|
||||
|
||||
client.subscribe("multi", mapOf(url to filters(), url2 to filters()))
|
||||
openAll(builder)
|
||||
settle()
|
||||
|
||||
val reqRelays = builder.perRelay.filterValues { it.reqs() > 0 }.keys
|
||||
assertEquals(setOf(url, url2), reqRelays, "both relays must get the REQ")
|
||||
|
||||
client.unsubscribe("multi")
|
||||
settle()
|
||||
|
||||
val closeRelays = builder.perRelay.filterValues { it.closes() > 0 }.keys
|
||||
assertEquals(reqRelays, closeRelays, "every relay that got a REQ must get a CLOSE")
|
||||
assertEquals(0, client.registrySizes().liveRequests)
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
+405
@@ -0,0 +1,405 @@
|
||||
/*
|
||||
* 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.relay.client.pool
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The request registry must hold LIVE subscriptions only.
|
||||
*
|
||||
* [PoolRequests.onConnecting], [PoolRequests.onDisconnected] and
|
||||
* [PoolRequests.onCannotConnect] scan the whole registry on every relay lifecycle
|
||||
* event. `remove()` used to drop the sub from `desiredSubs` but leave its
|
||||
* [com.vitorpamplona.quartz.nip01Core.relay.client.reqs.RequestSubscriptionState]
|
||||
* behind forever, so the registry grew with every one-shot fetch and each connect
|
||||
* became O(all subs ever created) — reported downstream as a pool whose monitor plane
|
||||
* never completed a pass because the dispatcher was pinned scanning dead rows
|
||||
* (NosFabrica/vespa-relay#154).
|
||||
*
|
||||
* The naive fix — dropping the row inside `remove()` — is worse than the leak: the
|
||||
* CLOSE frame is decided from that row, so it silently stops being sent and the
|
||||
* subscription leaks on the *relay* instead. Hence [closesAreStillSentForEveryRemoval].
|
||||
*/
|
||||
class PoolRequestsRegistryLeakTest {
|
||||
private val relayA = NormalizedRelayUrl("wss://a.example/")
|
||||
private val relayB = NormalizedRelayUrl("wss://b.example/")
|
||||
|
||||
private fun filters() = listOf(Filter(kinds = listOf(1)))
|
||||
|
||||
private fun event() =
|
||||
Event(
|
||||
id = "00".repeat(32),
|
||||
pubKey = "11".repeat(32),
|
||||
createdAt = 1,
|
||||
kind = 1,
|
||||
tags = emptyArray(),
|
||||
content = "hi",
|
||||
sig = "22".repeat(64),
|
||||
)
|
||||
|
||||
private class FakeRelayClient(
|
||||
override val url: NormalizedRelayUrl,
|
||||
) : IRelayClient {
|
||||
override fun connect() = Unit
|
||||
|
||||
override fun needsToReconnect() = false
|
||||
|
||||
override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) = Unit
|
||||
|
||||
override fun isConnected() = true
|
||||
|
||||
override fun disconnect() = Unit
|
||||
|
||||
override fun sendIfConnected(cmd: Command) = Unit
|
||||
|
||||
override fun sendOrConnectAndSync(cmd: Command) = Unit
|
||||
}
|
||||
|
||||
/** Mimics NostrClient.unsubscribe: remove from BOTH registries, then flush. */
|
||||
private fun unsubscribe(
|
||||
reqs: PoolRequests,
|
||||
counts: PoolCounts,
|
||||
subId: String,
|
||||
sent: MutableList<Command>,
|
||||
) {
|
||||
val reqRelays = reqs.remove(subId)
|
||||
val countRelays = counts.remove(subId)
|
||||
reqs.sendToRelayIfChanged(subId, reqRelays) { r, cmd ->
|
||||
sent.add(cmd)
|
||||
// NostrClient.onSent fans every frame out to both registries.
|
||||
reqs.onSent(r, cmd)
|
||||
counts.onSent(r, cmd)
|
||||
}
|
||||
counts.sendToRelayIfChanged(subId, countRelays) { r, cmd ->
|
||||
sent.add(cmd)
|
||||
reqs.onSent(r, cmd)
|
||||
counts.onSent(r, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribe(
|
||||
reqs: PoolRequests,
|
||||
counts: PoolCounts,
|
||||
subId: String,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
sent: MutableList<Command> = mutableListOf(),
|
||||
listener: SubscriptionListener? = null,
|
||||
) {
|
||||
val toUpdate = reqs.addOrUpdate(subId, relays.associateWith { filters() }, listener)
|
||||
reqs.sendToRelayIfChanged(subId, toUpdate) { r, cmd ->
|
||||
sent.add(cmd)
|
||||
reqs.onSent(r, cmd)
|
||||
counts.onSent(r, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun registryHoldsLiveSubscriptionsOnly() {
|
||||
val reqs = PoolRequests()
|
||||
val counts = PoolCounts()
|
||||
val sent = mutableListOf<Command>()
|
||||
|
||||
repeat(1000) { i ->
|
||||
val subId = "one-shot-$i"
|
||||
subscribe(reqs, counts, subId, setOf(relayA, relayB), sent)
|
||||
assertEquals(1, reqs.activeSubscriptionCount(), "sub $i must be live while subscribed")
|
||||
unsubscribe(reqs, counts, subId, sent)
|
||||
}
|
||||
|
||||
assertEquals(0, reqs.activeSubscriptionCount(), "REQ registry must not retain removed subscriptions")
|
||||
assertEquals(0, counts.activeQueryCount(), "CLOSEs for REQ subs must not materialize COUNT rows")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closesAreStillSentForEveryRemoval() {
|
||||
val reqs = PoolRequests()
|
||||
val counts = PoolCounts()
|
||||
val sent = mutableListOf<Command>()
|
||||
|
||||
repeat(100) { i ->
|
||||
val subId = "one-shot-$i"
|
||||
subscribe(reqs, counts, subId, setOf(relayA, relayB), sent)
|
||||
unsubscribe(reqs, counts, subId, sent)
|
||||
}
|
||||
|
||||
assertEquals(200, sent.count { it is ReqCmd }, "one REQ per (sub, relay)")
|
||||
assertEquals(200, sent.count { it is CloseCmd }, "one CLOSE per (sub, relay) — the relay must be told")
|
||||
assertEquals(0, sent.count { it is CountCmd })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun countQueriesAreAlsoReleased() {
|
||||
val reqs = PoolRequests()
|
||||
val counts = PoolCounts()
|
||||
val sent = mutableListOf<Command>()
|
||||
|
||||
repeat(500) { i ->
|
||||
val queryId = "count-$i"
|
||||
val toUpdate = counts.addOrUpdate(queryId, mapOf(relayA to filters()))
|
||||
counts.sendToRelayIfChanged(queryId, toUpdate) { r, cmd ->
|
||||
sent.add(cmd)
|
||||
reqs.onSent(r, cmd)
|
||||
counts.onSent(r, cmd)
|
||||
}
|
||||
assertEquals(1, counts.activeQueryCount())
|
||||
unsubscribe(reqs, counts, queryId, sent)
|
||||
}
|
||||
|
||||
// NB: a COUNT still awaiting its reply is deliberately NOT closed (the reply
|
||||
// ends the query on its own) — that guard is untouched here; what must change
|
||||
// is that its state row is released.
|
||||
assertEquals(500, sent.count { it is CountCmd }, "one COUNT per query")
|
||||
assertEquals(0, counts.activeQueryCount(), "COUNT registry must not retain removed queries")
|
||||
assertEquals(0, reqs.activeSubscriptionCount(), "CLOSEs for COUNT ids must not materialize REQ rows")
|
||||
}
|
||||
|
||||
/** Frames already on the wire when the CLOSE went out must no-op, not resurrect. */
|
||||
@Test
|
||||
fun lateFramesAfterCloseDoNotResurrectTheRegistry() =
|
||||
runTest {
|
||||
val reqs = PoolRequests()
|
||||
val counts = PoolCounts()
|
||||
val sent = mutableListOf<Command>()
|
||||
val relay = FakeRelayClient(relayA)
|
||||
|
||||
var callbacks = 0
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
callbacks++
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
message: String,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
callbacks++
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(reqs, counts, "sub", setOf(relayA), sent, listener)
|
||||
unsubscribe(reqs, counts, "sub", sent)
|
||||
assertEquals(0, reqs.activeSubscriptionCount())
|
||||
|
||||
// The relay answers the REQ after we already sent the CLOSE.
|
||||
reqs.onIncomingMessage(relay, EoseMessage("sub"))
|
||||
reqs.onIncomingMessage(relay, ClosedMessage("sub", "closed"))
|
||||
counts.onIncomingMessage(relay, ClosedMessage("sub", "closed"))
|
||||
|
||||
assertEquals(0, reqs.activeSubscriptionCount(), "late frames must not recreate a dead sub")
|
||||
assertEquals(0, counts.activeQueryCount())
|
||||
assertEquals(0, callbacks, "the listener is gone; late frames must not fire it")
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry is the *believed relay state*, deliberately distinct from the desired
|
||||
* state: while a sub is still desired its row survives everything — including a
|
||||
* disconnect, which wipes the per-connection wire state but keeps `lastKnownFilters`
|
||||
* so events still arriving can be linked to the filters the relay was actually
|
||||
* running. The leak fix must not touch that, and doesn't: it only releases rows
|
||||
* whose sub is no longer desired.
|
||||
*/
|
||||
@Test
|
||||
fun aStillDesiredSubKeepsLinkingLateEventsToTheFiltersTheRelayWasRunning() =
|
||||
runTest {
|
||||
val reqs = PoolRequests()
|
||||
val counts = PoolCounts()
|
||||
val sent = mutableListOf<Command>()
|
||||
val relay = FakeRelayClient(relayA)
|
||||
val subFilters = filters()
|
||||
|
||||
val seen = mutableListOf<List<Filter>?>()
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
seen.add(forFilters)
|
||||
}
|
||||
}
|
||||
|
||||
val add = reqs.addOrUpdate("sub", mapOf(relayA to subFilters), listener)
|
||||
reqs.sendToRelayIfChanged("sub", add) { r, cmd ->
|
||||
sent.add(cmd)
|
||||
reqs.onSent(r, cmd)
|
||||
}
|
||||
|
||||
// The socket drops: per-connection wire state is wiped, the row is not.
|
||||
reqs.onDisconnected(relayA)
|
||||
|
||||
// An event that was already on the wire still resolves to the filters the
|
||||
// relay was running for this sub.
|
||||
reqs.onIncomingMessage(relay, EventMessage("sub", event()))
|
||||
|
||||
assertEquals(1, reqs.activeSubscriptionCount(), "a desired sub keeps its row across a disconnect")
|
||||
assertEquals(1, seen.size, "the listener is still wired")
|
||||
assertTrue(seen[0] === subFilters, "the late event is linked to the filters the relay was running")
|
||||
}
|
||||
|
||||
/**
|
||||
* The counterpart: once a sub is *removed*, `remove()` drops its listener in the
|
||||
* same call, so nothing is left to process a late frame with. Holding the row past
|
||||
* that point buys no behaviour — it only inflates the map every connect scans. This
|
||||
* passes both before and after the fix; it is here to keep that true.
|
||||
*/
|
||||
@Test
|
||||
fun aRemovedSubHasNoListenerLeftToServeLateFrames() =
|
||||
runTest {
|
||||
val reqs = PoolRequests()
|
||||
val counts = PoolCounts()
|
||||
val sent = mutableListOf<Command>()
|
||||
val relay = FakeRelayClient(relayA)
|
||||
|
||||
var callbacks = 0
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
callbacks++
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
callbacks++
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(reqs, counts, "sub", setOf(relayA), sent, listener)
|
||||
unsubscribe(reqs, counts, "sub", sent)
|
||||
|
||||
reqs.onIncomingMessage(relay, EventMessage("sub", event()))
|
||||
reqs.onIncomingMessage(relay, EoseMessage("sub"))
|
||||
|
||||
assertEquals(0, callbacks, "remove() already dropped the listener; the row has no consumer")
|
||||
}
|
||||
|
||||
/** The scan cost of a relay lifecycle event must track live subs, not history. */
|
||||
@Test
|
||||
fun connectScanStaysBoundedByLiveSubscriptions() {
|
||||
val reqs = PoolRequests()
|
||||
val counts = PoolCounts()
|
||||
val sent = mutableListOf<Command>()
|
||||
|
||||
subscribe(reqs, counts, "tail", setOf(relayA), sent)
|
||||
|
||||
repeat(5000) { i ->
|
||||
val subId = "churn-$i"
|
||||
subscribe(reqs, counts, subId, setOf(relayA), sent)
|
||||
unsubscribe(reqs, counts, subId, sent)
|
||||
}
|
||||
|
||||
assertEquals(1, reqs.activeSubscriptionCount(), "only the long-lived tail should remain")
|
||||
|
||||
reqs.onConnecting(relayA)
|
||||
reqs.onDisconnected(relayA)
|
||||
reqs.onCannotConnect(relayA, "nope")
|
||||
|
||||
assertTrue(reqs.getSubscriptionFiltersOrNull("tail") != null, "the live tail must survive the churn")
|
||||
}
|
||||
|
||||
/**
|
||||
* A subscribe and an unsubscribe of the SAME id racing on different threads. The
|
||||
* release must never swallow the new subscription's REQ: a feed the app still wants
|
||||
* that never gets a REQ is silent forever, which is far worse than a leaked row.
|
||||
*
|
||||
* Honest caveat: the window this protects (a release landing between a concurrent
|
||||
* subscribe's row creation and its send decision) is a few instructions wide, and
|
||||
* this test does NOT reliably reproduce it — it passes with the guard removed. It is
|
||||
* a regression guard on the invariant "registry membership tracks desired-ness", not
|
||||
* proof that the guard fires. The guard is kept because it is two comparisons and the
|
||||
* failure it prevents is a permanently silent subscription.
|
||||
*/
|
||||
@Test
|
||||
fun subscribeRacingAnUnsubscribeOfTheSameIdStillSendsItsReq() {
|
||||
repeat(300) { episode ->
|
||||
val reqs = PoolRequests()
|
||||
val sent = java.util.concurrent.ConcurrentLinkedQueue<Command>()
|
||||
val subId = "raced-$episode"
|
||||
|
||||
// A live sub about to be torn down.
|
||||
reqs.addOrUpdate(subId, mapOf(relayA to filters()), null)
|
||||
reqs.sendToRelayIfChanged(subId, setOf(relayA)) { r, cmd -> reqs.onSent(r, cmd) }
|
||||
|
||||
val start = java.util.concurrent.CountDownLatch(1)
|
||||
val unsub =
|
||||
Thread {
|
||||
start.await()
|
||||
val relays = reqs.remove(subId)
|
||||
reqs.sendToRelayIfChanged(subId, relays) { r, cmd ->
|
||||
sent.add(cmd)
|
||||
reqs.onSent(r, cmd)
|
||||
}
|
||||
}
|
||||
val resub =
|
||||
Thread {
|
||||
start.await()
|
||||
val relays = reqs.addOrUpdate(subId, mapOf(relayA to filters()), null)
|
||||
reqs.sendToRelayIfChanged(subId, relays) { r, cmd ->
|
||||
sent.add(cmd)
|
||||
reqs.onSent(r, cmd)
|
||||
}
|
||||
}
|
||||
unsub.start()
|
||||
resub.start()
|
||||
start.countDown()
|
||||
unsub.join()
|
||||
resub.join()
|
||||
|
||||
// Whoever won, the invariant is: if the sub is still desired it must have a
|
||||
// row (so later filter changes and reconnect replays work), and if it is not
|
||||
// desired it must have none.
|
||||
val desired = reqs.getSubscriptionFiltersOrNull(subId) != null
|
||||
assertEquals(
|
||||
if (desired) 1 else 0,
|
||||
reqs.activeSubscriptionCount(),
|
||||
"episode $episode: registry must match desired-ness (desired=$desired)",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user