Bound the group-name claim queue

The sender decides how many claims land in it: anyone who can reach us can
add one per envelope, and it is drained by a layer that may not run on
every path — a poll that never reaches Session.receive, or a path added
later that forgets. An unbounded list whose growth a stranger controls is
a slow leak with somebody else's hand on the tap.

Capped at 256, oldest dropped first, well above any real group count: a
claim is only ever acted on for a group we are actually in, so the cap
bounds the queue rather than rationing legitimate traffic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-30 21:32:54 -04:00
co-authored by Claude Opus 4.8
parent 41533baae6
commit fc12aaa591
2 changed files with 42 additions and 1 deletions
@@ -55,6 +55,21 @@ class Conversations(
*/
private val claimedGroupNames = mutableListOf<Triple<Long, String, String>>()
/**
* ⚠️ **A cap, because the sender chooses how many of these to send.**
* Anyone who can reach us can put one claim in this list per envelope, and
* the list is only drained by a layer that may not run — a poll that never
* reaches `Session.receive`, a code path added later that forgets. An
* unbounded list whose growth a stranger controls is a slow memory leak
* with somebody else's hand on the tap.
*
* Well above any real group count: a claim is only ever ACTED on for a
* group we are actually in, and the cap exists to bound the queue, not to
* ration legitimate traffic. Oldest goes first, since a newer claim is the
* one more likely to still be true.
*/
private val maxClaimedGroupNames = 256
/** Take the pending claims, leaving none behind. */
fun takeClaimedGroupNames(): List<Triple<Long, String, String>> =
synchronized(claimedGroupNames) {
@@ -1339,7 +1354,12 @@ class Conversations(
val gid = content.raw?.optString("gid")?.takeIf { it.isNotEmpty() }
val gname = content.raw?.optString("gname")?.takeIf { it.isNotEmpty() }
if (gid != null && gname != null) {
synchronized(claimedGroupNames) { claimedGroupNames += Triple(fromUser, gid, gname) }
synchronized(claimedGroupNames) {
claimedGroupNames += Triple(fromUser, gid, gname)
while (claimedGroupNames.size > maxClaimedGroupNames) {
claimedGroupNames.removeAt(0)
}
}
}
// 🔴 **The batch is stored first, and independently.** Peer
// delivery keys live inside our OWN delivery record, so the
@@ -232,6 +232,27 @@ class GroupNameTest {
assertEquals(0, server.requestCount)
}
/**
* ⚠️ **The sender decides how many claims to send, so the queue is capped.**
* It is drained by a layer that may not run on every path, and an unbounded
* list whose growth a stranger controls is a slow leak with someone else's
* hand on the tap.
*/
@Test
fun `the claim queue is bounded`() {
val conversations = conversations(vault())
repeat(400) { i ->
conversations.consumeControl(99L, Content.parse(
SealedSender.deliveryKeyEnvelope(
ByteArray(32), groupId = "g$i", groupName = "n$i")))
}
val drained = conversations.takeClaimedGroupNames()
assertEquals(256, drained.size)
// Oldest dropped, newest kept: a later claim is likelier to still hold.
assertEquals("g399", drained.last().second)
}
// -- Surviving a reinstall -------------------------------------------------
/**