From fc12aaa59166424a714f404ba8a18156fa723f1c Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sun, 30 Aug 2026 21:32:54 -0400 Subject: [PATCH] Bound the group-name claim queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../io/flamenet/client/Conversations.kt | 22 ++++++++++++++++++- .../io/flamenet/client/GroupNameTest.kt | 21 ++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) 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 ------------------------------------------------- /**