fix(marmot): handle being removed mid-commit without OOM (test 14)
When wn admin-removes A in interop test 14, A processed the proposal
locally — `tree.removeLeaf(A.leafIndex)` blanked her leaf and shrank
`tree.leafCount` past `myLeafIndex` — and then immediately walked into
BinaryTree.directPath(myLeafIndex, tree.leafCount)
at MlsGroup.processCommitInner. With `myLeafIndex >= leafCount`, the
left-balanced parent walk had no valid stopping point: each recursion
step doubled the candidate parent index, integer-overflowed past 2^31,
and either returned garbage or kept appending to the result list until
the JVM OOM'd. The OOM bubbled up as a `runBlocking` failure that
rolled back the whole commit — so A also stayed locally convinced she
was still a member, and the test timed out waiting for `not_member`.
Three layered fixes so the failure mode can't recur:
* `BinaryTree.parent`/`directPath`/`copath` now `require` an in-range
input. The root and any node ≥ nodeCount used to silently loop or
return garbage; now they throw `IllegalArgumentException` immediately.
This is defense-in-depth — a future caller passing an invalid index
gets a stack trace at the boundary instead of an OOM five frames
deep.
* `MlsGroup.processCommitInner` short-circuits the path-decrypt + epoch
advancement when the proposals in the commit just removed *us*. We
preserve the proposal-side tree mutations so the caller (and the
outer state machine) can observe that we're out, clear pending
proposals + sent keys, and return. Without this short-circuit the
function would derive a bogus all-zero `commit_secret`, fail
`confirmation_tag` verification, throw, and roll the snapshot back —
leaving us still convinced we were a live member.
* `MlsGroupManager.isMember` and a new `MlsGroup.isLocalMember()`
helper now return false once our leaf is null or past `leafCount`.
The post-Remove group entry still lives in `groups` so callers can
inspect the final tree, but every cli command (`group show`,
`message send`, etc.) sees `not_member` and returns the right error.
Also add a comprehensive `BinaryTreeTest` covering non-power-of-2
trees (3, 5, …, 32 leaves) and the boundary cases (root has no
parent, leafIndex ≥ leafCount must throw). The pre-existing tests
only exercised the 4-leaf example from RFC 9420 Appendix C; nothing
hit the `parentInRange` branch, which is exactly where the OOM lived.
Test 14's bash polling needed a small companion fix: it captured
amy's stderr through `2>&1` and fed the result to `jq`, but the
captured stream is interleaved with quartz `Log.d(…)` debug lines,
so the JSON parse always failed. Switch to a `grep` for the
`"error":"not_member"` literal — that signature only appears in
the JSON payload and survives the debug noise. Also tee the
captured output into `$LOG_FILE` so post-mortem logs include each
polling iteration's `[cli] ingest …` traces.
Marmot interop score: 11/16 → 14/16 (tests 9, 15 are unrelated MLS
issues — see follow-up).
https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
This commit is contained in:
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.marmot.mls
|
||||
import com.vitorpamplona.quartz.marmot.mls.tree.BinaryTree
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFails
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@@ -160,4 +161,121 @@ class BinaryTreeTest {
|
||||
// Subtree of leaf 0 is just [0]
|
||||
assertEquals(listOf(0), BinaryTree.subtreeLeaves(0, 4))
|
||||
}
|
||||
|
||||
// ----- Non-power-of-2 tree shapes ----------------------------------------
|
||||
//
|
||||
// RFC 9420 Appendix C only worked example is the 4-leaf tree, but every
|
||||
// group with ≠ a power-of-2 members exercises the parentInRange branch in
|
||||
// BinaryTree.parent. Those branches were entirely uncovered before, and a
|
||||
// bug there (infinite recursion / infinite loop in directPath) is what
|
||||
// OOM'd amy when wn removed her in marmot-interop test 14.
|
||||
|
||||
@Test
|
||||
fun testRoot_nonPowerOfTwo() {
|
||||
// root = (1 << ceil(log2(n))) - 1
|
||||
assertEquals(3, BinaryTree.root(3)) // ceil(log2(3))=2, 2^2-1 = 3
|
||||
assertEquals(7, BinaryTree.root(5)) // ceil(log2(5))=3, 2^3-1 = 7
|
||||
assertEquals(7, BinaryTree.root(6))
|
||||
assertEquals(7, BinaryTree.root(7))
|
||||
assertEquals(15, BinaryTree.root(9))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDirectPath3Leaves() {
|
||||
// 3 leaves, n=5, root=3:
|
||||
// 3
|
||||
// / \
|
||||
// 1 4
|
||||
// / \ \
|
||||
// 0 2 (leaf 2 sits at node 4; node 5 doesn't exist)
|
||||
// Node 4 is a leaf node here because the tree is not full — its
|
||||
// "parent slot" 5 would be ≥ nodeCount and is collapsed away by
|
||||
// parentInRange.
|
||||
assertEquals(listOf(1, 3), BinaryTree.directPath(0, 3))
|
||||
assertEquals(listOf(1, 3), BinaryTree.directPath(1, 3))
|
||||
assertEquals(listOf(3), BinaryTree.directPath(2, 3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDirectPath5Leaves() {
|
||||
// 5 leaves, n=9, root=7:
|
||||
// 7
|
||||
// / \
|
||||
// 3 8 ← leaf 4 collapsed up
|
||||
// / \
|
||||
// 1 5
|
||||
// / \ / \
|
||||
// 0 2 4 6
|
||||
assertEquals(listOf(1, 3, 7), BinaryTree.directPath(0, 5))
|
||||
assertEquals(listOf(1, 3, 7), BinaryTree.directPath(1, 5))
|
||||
assertEquals(listOf(5, 3, 7), BinaryTree.directPath(2, 5))
|
||||
assertEquals(listOf(5, 3, 7), BinaryTree.directPath(3, 5))
|
||||
assertEquals(listOf(7), BinaryTree.directPath(4, 5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDirectPathTerminatesForAllLeafCountsUpTo32() {
|
||||
// Property: every valid leaf in any tree size 1..32 has a directPath
|
||||
// that ends at root(leafCount) and has length == log2-ish. We don't
|
||||
// assert exact lengths — we just want to catch any future regression
|
||||
// where some (leafCount, leafIndex) triggers the parent() infinite
|
||||
// loop. Each call is wrapped in a generous timeout via the kotlin
|
||||
// test runner (default test timeout is fine: a non-terminating call
|
||||
// would OOM long before any test deadline).
|
||||
for (leafCount in 1..32) {
|
||||
val rootIdx = BinaryTree.root(leafCount)
|
||||
for (leafIndex in 0 until leafCount) {
|
||||
val dp = BinaryTree.directPath(leafIndex, leafCount)
|
||||
if (leafCount == 1) {
|
||||
assertTrue(dp.isEmpty(), "leafCount=1 has no path")
|
||||
} else {
|
||||
assertEquals(rootIdx, dp.last(), "directPath($leafIndex, $leafCount) should end at root")
|
||||
// copath must align with the directPath one-for-one
|
||||
assertEquals(dp.size, BinaryTree.copath(leafIndex, leafCount).size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Out-of-range inputs -----------------------------------------------
|
||||
//
|
||||
// Marmot-interop test 14 fails with a Java OOM because amy calls
|
||||
// `BinaryTree.directPath(myLeafIndex, tree.leafCount)` with `myLeafIndex
|
||||
// == tree.leafCount` (her own leaf was just removed and the tree shrank
|
||||
// past it). `parent()` then walks node indices ≥ nodeCount forever and
|
||||
// the result list explodes.
|
||||
//
|
||||
// These tests pin down that boundary so the failure becomes a clean
|
||||
// IllegalArgumentException instead of an OOM, AND so the MLS code path
|
||||
// that hits it has a deterministic regression.
|
||||
|
||||
@Test
|
||||
fun testDirectPathRejectsOutOfRangeLeafIndex() {
|
||||
// leafIndex == leafCount — the exact shape the post-Remove path hits.
|
||||
assertFails { BinaryTree.directPath(2, 2) }
|
||||
// leafIndex > leafCount.
|
||||
assertFails { BinaryTree.directPath(5, 3) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDirectPathRejectsNegativeLeafIndex() {
|
||||
assertFails { BinaryTree.directPath(-1, 4) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCopathRejectsOutOfRangeLeafIndex() {
|
||||
assertFails { BinaryTree.copath(2, 2) }
|
||||
assertFails { BinaryTree.copath(-1, 4) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParentRejectsRootOrAbove() {
|
||||
// The root has no parent. Asking anyway is the symptom of a tree
|
||||
// accounting bug — surface it loudly instead of looping.
|
||||
val n = BinaryTree.nodeCount(4)
|
||||
assertFails { BinaryTree.parent(BinaryTree.root(4), n) }
|
||||
// Index strictly above nodeCount also has no defined parent.
|
||||
assertFails { BinaryTree.parent(n, n) }
|
||||
assertFails { BinaryTree.parent(n + 5, n) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user