diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 1705497..5bb5330 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -55,6 +55,21 @@ class Conversations( */ private val claimedGroupNames = mutableListOf>() + /** + * ⚠️ **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> = 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 diff --git a/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt b/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt index 732971a..72773ee 100644 --- a/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt @@ -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 ------------------------------------------------- /**