fix(marmot): a Welcome that ran out of retries waits for the next app start

Relays re-deliver two days of gift wraps on every re-subscription, and each delivery of a
Welcome that kept failing started a fresh 30s/2m/10m chain, so a Welcome that can never
apply was re-run through MLS for as long as relays kept it. Exhausted Welcomes are now
remembered for the session: re-deliveries skip them, and the next app start still gives
them one more try.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-28 10:48:31 -04:00
co-authored by Claude Opus 5.5
parent 90264a815d
commit 0c9f4cbf0b
2 changed files with 23 additions and 3 deletions
@@ -94,10 +94,25 @@ class AccountMarmotActions(
// Welcome rumor ids with a retry already scheduled, so a relay re-delivering the same
// wrap while one waits does not start a second chain of retries.
private val welcomeRetries = mutableSetOf<HexKey>()
// Welcome rumor ids whose retries ran out this session. Relays re-deliver two days of gift
// wraps on every re-subscription, and each delivery used to start a fresh chain, so a
// Welcome that can never apply was re-run through MLS for as long as relays kept it.
// In memory on purpose: the next app start still gives it one more try.
private val welcomeRetriesExhausted = mutableSetOf<HexKey>()
private val welcomeRetriesLock = KmpLock()
/** True when [welcomeId] had no retry pending and now has one. */
fun claimWelcomeRetry(welcomeId: HexKey): Boolean = welcomeRetriesLock.withLock { welcomeRetries.add(welcomeId) }
/** True when [welcomeId] had no retry pending, has retries left, and now has one pending. */
fun claimWelcomeRetry(welcomeId: HexKey): Boolean =
welcomeRetriesLock.withLock {
welcomeId !in welcomeRetriesExhausted && welcomeRetries.add(welcomeId)
}
fun markWelcomeRetriesExhausted(welcomeId: HexKey) {
welcomeRetriesLock.withLock { welcomeRetriesExhausted.add(welcomeId) }
}
fun welcomeRetriesExhausted(welcomeId: HexKey): Boolean = welcomeRetriesLock.withLock { welcomeId in welcomeRetriesExhausted }
fun releaseWelcomeRetry(welcomeId: HexKey) {
welcomeRetriesLock.withLock { welcomeRetries.remove(welcomeId) }
@@ -406,6 +406,8 @@ private suspend fun processMarmotWelcomeFlow(
// A Welcome that already joined (or never can) is done. Replays of its wrap are routine:
// every re-subscription re-delivers the last two days of gift wraps.
if (manager.isTerminallyIngested(innerEvent.id)) return
// Out of retries this session: a re-delivered wrap waits for the next app start.
if (attempt == 0 && account.marmot.welcomeRetriesExhausted(innerEvent.id)) return
// "h" tag is optional per MIP-02 — some senders (e.g. whitenoise-rs) omit it.
// nostrGroupId is derived from the MLS GroupContext's NostrGroupData extension instead.
@@ -480,7 +482,10 @@ private fun scheduleWelcomeRetry(
account: Account,
attempt: Int,
) {
if (attempt >= WELCOME_RETRY_DELAYS_MS.size) return
if (attempt >= WELCOME_RETRY_DELAYS_MS.size) {
account.marmot.markWelcomeRetriesExhausted(welcome.id)
return
}
if (!account.marmot.claimWelcomeRetry(welcome.id)) return
account.scope.launch(Dispatchers.IO) {
delay(WELCOME_RETRY_DELAYS_MS[attempt])