Publishing the roster tags retries until the relay agrees

Same fault the iPhone had. The dirty flag was cleared before the attempt, so a
refusal was lost for good: the token was already stored, nothing asked again,
and that contact would never have seen a presence.

The set is compared against what the relay last accepted instead, and
republished until the two agree. The route replaces rather than merges, so
repeating a set that already landed changes nothing, which is what makes
retrying free.

Verified on the live relay: both accounts publish their own digest, and the
digests differ, because a token is per pair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-15 17:18:45 -04:00
co-authored by Claude Opus 5
parent 89474a1fc7
commit cf811d1bee
@@ -2130,6 +2130,14 @@ class Session(
}
}
/**
* §8.9c. What the relay last accepted, so a refusal is retried and a set
* that already landed is not sent every pass. In memory on purpose: after
* a restart, publishing once more costs a request and settles any
* disagreement, where a persisted claim could be wrong and never tested.
*/
private var publishedDigests: List<String> = emptyList()
/**
* §8.9c. Presence for the contacts this account holds a token for.
*
@@ -2139,9 +2147,18 @@ class Session(
*/
private fun presenceFromTokens(): Map<Long, String> {
val held = conversations.presenceTokensHeld()
if (conversations.rosterTagsDirty) {
conversations.rosterTagsDirty = false
runCatching { api.publishRosterTags(conversations.rosterTagDigests()) }
// Compared against what the relay last ACCEPTED, not driven by a flag
// cleared before the attempt. The flag version lost a refusal for
// good: the token was already stored, so nothing asked again and that
// contact never saw a presence. The route replaces rather than merges,
// so repeating a set that already landed changes nothing.
val digests = conversations.rosterTagDigests()
if (digests.isNotEmpty() && digests != publishedDigests) {
runCatching { api.publishRosterTags(digests) }
.onSuccess {
publishedDigests = digests
conversations.rosterTagsDirty = false
}
}
if (held.isEmpty()) return emptyMap()
val answers = runCatching { api.presenceByToken(held.keys.toList()) }