Files
mantra-kmp/composeApp/src/commonMain/kotlin
Kgothatso Ngako 0d6cefbe21 fix: stop the sync pumps opening unbounded relay subscriptions
Relays were answering with "too many concurrent REQs" and the emulator
log was full of it. Two independent defects, compounding.

## The subscription flow never completed

RelayPool.queryAsFlow returned a filtered view of the socket's hot
`incomingMessages`, with the terminating operator commented out:

    return this.incomingMessages
        .filterBySubscriptionId(id = subscriptionId)
    //  .transformWhileEventsAreIncoming()

A filter over a hot flow has no terminal event, so every collector
started for a sync request stayed alive for the life of the app --
accumulating one per request ever made, long after EOSE and CLOSE had
been sent. Neither pump's EOSE branch ended collection either;
`return@collect` only ends handling of the message in hand, as the
CLOSED branch's own comment already noted.

That is also why the log *flooded* rather than merely warning.
`filterBySubscriptionId` admits NoticeMessage on every subscription id
(a NOTICE carries none), so a single "too many concurrent REQs" notice
was delivered to every accumulated collector and logged once per
collector. Volume grew as notices x live collectors.

## Nothing bounded how many were open

Both pumps mark the request "sent" and then launch the subscription
detached:

    nostrRepository.negentropySynchronizeRequestProcessed(request)
    launch(Dispatchers.IO) { relaysSocketManager.negentropySync(...).collect { ... } }

The DAO query is `WHERE status = :status ... LIMIT 1`, so flipping the
row changes the head row, Room re-emits, and the collector body runs for
the next request while the previous subscription is still open. The
mutex covers only the setup block and publishSlots guards publishes, not
REQs, so the number of simultaneously open REQ/NEG-OPEN subscriptions
was bounded only by backlog depth.

## And back-pressure amplified itself

The negentropy ClosedMessage branch -- CLOSED being exactly what a relay
sends when refusing for too many concurrent REQs -- answered by queuing
the request again as a plain REQ. Each refusal therefore produced
another subscription. That branch also skipped the close its EOSE and
NEG-MSG siblings performed, leaking a slot precisely when the slot was
most needed.

## The fix, in the order it has to be applied

1. sockets/NostrIncomingMessageExt.kt gains isTerminalFor() and
   completeOnSubscriptionEnd(), which emits the terminal message and
   then completes. NOTICE is deliberately not terminal: with no
   subscription id it reaches every collector on the socket, so treating
   it as terminal would tear down every unrelated subscription at once.
2. RelayPool.queryAsFlow applies it, replacing the commented-out call.
3. SynchronizationViewModel gains subscriptionSlots =
   Semaphore(MAX_CONCURRENT_SUBSCRIPTIONS = 4), acquired inside each
   pump's launch before the socket call, so the backlog still drains but
   queues on the semaphore rather than opening all at once.
4. Both pumps close in a `finally` under NonCancellable, replacing the
   hand-rolled closes in the EOSE and NEG-MSG branches, so CLOSED and
   NEG-ERR exits close too.
5. The negentropy CLOSED branch consults isBackPressure() -- NIP-01's
   `rate-limited:` prefix plus the free-text forms relays actually send
   -- and declines to retry, instead of answering back-pressure by
   opening another subscription.

Order is load-bearing: capping slots before the flow could complete
would have deadlocked the pump on permits that never came back.

## A negentropy assumption that would have deadlocked it anyway

Capping slots nearly stalled the queue on a wrong assumption about what
ends a negentropy exchange. EOSE does not: the NegentropyMessage branch
reconciles ONCE, queues the ids it needs as a plain REQ, schedules what
the relay is missing, and stops -- this client does single-round
reconciliation. Waiting on an EOSE the exchange need not send would have
held all four slots forever. NegentropyMessage is therefore terminal
too, with the reasoning recorded at isTerminalFor().

Worth knowing separately, and left alone here: single-round
reconciliation may not converge on large sets, since negentropy is
normally iterative. A large divergence is closed by the plain-REQ
fallback rather than by negentropy itself.

Live collectors go from one per sync request ever made to at most four.
MAX_CONCURRENT_SUBSCRIPTIONS is the dial if sync feels slow -- relays
commonly allow around 20 per connection, so there is headroom.

Verified:
  ./gradlew :composeApp:compileCommonMainKotlinMetadata
  ./gradlew :composeApp:compileDebugKotlinAndroid

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:04:06 +02:00
..