test: pin the two invariants this session left unguarded

Both are silent when broken, which is why they are worth asserting rather
than reasoning about.

**The cache's reuse decision.** MlsGroupCache exists because quartz drops
a secret tree's skipped-generation keys on save, so rebuilding a group
between two messages loses any that arrives late. Its safety argument is
one comparison: reuse while the stored state is still what the cache last
wrote, rebuild when it is not. Get that wrong in either direction and
nothing complains -- reuse too eagerly and a group carries on from a
ratchet another writer already moved, which corrupts decryption rather
than failing it; reuse too rarely and the cache does nothing and the
original bug is back with no symptom.

That decision is now a generic LiveInstanceCache with MlsGroupCache as a
typed facade over it, so it can be tested without standing up an MLS
group. Splitting it also made two behaviours explicit that were previously
incidental: a failed build no longer leaves the old instance behind, and
an instance whose use threw is deliberately not cached -- it is
half-advanced and never persisted, so the next caller has to start from
disk.

**Rumor and row ids agreeing.** MantraDao writes an entity whose id comes
from fromXEventTemplate and separately builds the rumor it submits with
rumorOf, which hashes the template itself. Both are meant to produce one
id and nothing checked it. Diverging would mean submissions naming an
event nobody has, deleteByPayloadEventId silently un-queuing nothing so
superseded translations go out anyway, and every receiver creating a
second row instead of converging on the sender's -- all of it invisible,
since the ids are opaque hex either way. Asserted per kind, plus the whole
chain out through the submission envelope.

Both suites were mutation-checked rather than trusted: inverting the
staleness comparison fails one cache test, recording the pre-block state
fails another, and hashing the rumor under a different author fails all
six id tests.

Still uncovered, and not cheaply fixable: FrostSigningManager's and
MantraDao's state machines both need a Room harness, and commonTest has
none. The FROST crypto path is covered by FrostSigningRoundTest; the
message-driven parts around it are not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-05 23:18:54 +02:00
parent d110737f9a
commit 42dd38cfc4
3 changed files with 446 additions and 40 deletions

View File

@@ -51,43 +51,14 @@ import kotlinx.coroutines.sync.withLock
* before this existed, so the fallback is never worse than not caching.
*/
object MlsGroupCache {
private const val TAG = "MlsGroupCache"
private val logger = Logger.withTag(TAG)
private class Entry(
val group: MlsGroup,
/** The state hex this cache last wrote, for spotting another writer. */
var stateHex: String,
)
private val entries = mutableMapOf<String, Entry>()
/**
* Serialises use of one room's group.
*
* The group is mutable and decryption advances it, so two events for the
* same room being decrypted at once would corrupt the ratchet. One lock per
* room rather than one overall, so a busy room cannot hold up a quiet one.
*
* Held across database work, which is safe here because a caller only ever
* takes this lock while it is already running -- it never waits on a
* resource the holder is waiting for.
*/
private val locks = mutableMapOf<String, Mutex>()
private val locksGuard = Mutex()
private suspend fun lockFor(chatRoomId: String): Mutex =
locksGuard.withLock { locks.getOrPut(chatRoomId) { Mutex() } }
private val cache = LiveInstanceCache<MlsGroup> { it.saveState().encodeTls().toHex() }
/**
* Runs [block] against the room's live group, then stores whatever state it
* left behind.
*
* [storedStateHex] is the room's state as the database currently has it, and
* [build] turns it into a group. [save] is handed the state to persist; it
* runs inside the lock so the stored state and the cached instance cannot
* disagree.
* [build] turns it into a group. [save] is handed the state to persist.
*
* Returns null without calling [block] when the room has no usable group
* state, which is the same thing a failed `toMlsGroup()` meant before.
@@ -98,24 +69,84 @@ object MlsGroupCache {
build: () -> MlsGroup?,
save: suspend (String) -> Unit,
block: suspend (MlsGroup) -> T,
): T? = lockFor(chatRoomId).withLock {
val cached = entries[chatRoomId]
): T? = cache.withInstance(
key = chatRoomId,
storedState = storedStateHex,
build = build,
save = save,
block = block
)
}
val group = if (cached != null && cached.stateHex == storedStateHex) {
cached.group
/**
* One live instance per key, reused only while the stored state is still the one
* this cache last wrote.
*
* Split out from [MlsGroupCache] so the decision it makes can be tested without
* standing up an MLS group. That decision is the whole safety argument: reuse
* when nothing else has written, rebuild when something has, and never carry on
* with an instance whose last use failed part-way through.
*/
internal class LiveInstanceCache<T : Any>(
/** The persisted form of an instance, for spotting another writer. */
private val stateOf: (T) -> String,
) {
private val logger = Logger.withTag("LiveInstanceCache")
private class Entry<T>(val instance: T, val state: String)
private val entries = mutableMapOf<String, Entry<T>>()
/**
* Serialises use of one key's instance.
*
* The instance is mutable and [block] advances it, so two callers running at
* once would corrupt it. One lock per key rather than one overall, so a busy
* key cannot hold up a quiet one.
*
* Held across [block], which may touch the database. Safe here because a
* caller only ever takes this lock while it is already running -- it never
* waits on a resource the holder is itself waiting for.
*/
private val locks = mutableMapOf<String, Mutex>()
private val locksGuard = Mutex()
private suspend fun lockFor(key: String): Mutex =
locksGuard.withLock { locks.getOrPut(key) { Mutex() } }
suspend fun <R> withInstance(
key: String,
storedState: String?,
build: () -> T?,
save: suspend (String) -> Unit,
block: suspend (T) -> R,
): R? = lockFor(key).withLock {
val cached = entries[key]
val instance = if (cached != null && cached.state == storedState) {
cached.instance
} else {
if (cached != null) {
logger.d("Room $chatRoomId was written elsewhere; rebuilding its group")
logger.d("$key was written elsewhere; rebuilding")
}
// Dropped before the block runs, so a build that fails does not leave
// the old instance behind to be picked up by the next caller.
entries.remove(key)
build() ?: return@withLock null
}
val result = block(group)
// Deliberately not in a finally: an instance whose use threw part-way is
// in an unknown state, and the next caller should rebuild from whatever
// was last persisted rather than carry on with it.
val result = block(instance)
val stateHex = group.saveState().encodeTls().toHex()
save(stateHex)
entries[chatRoomId] = Entry(group = group, stateHex = stateHex)
val state = stateOf(instance)
save(state)
entries[key] = Entry(instance = instance, state = state)
result
}
/** How many instances are held. For tests. */
internal fun size(): Int = entries.size
}

View File

@@ -0,0 +1,174 @@
package press.mantra.compose.database.model
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import kotlin.test.Test
import kotlin.test.assertEquals
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.ChunkEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.SubmissionEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
/**
* The row on disk and the payload on the wire have to be the same event.
*
* `MantraDao` writes an entity whose id comes from `MantraX.fromXEventTemplate`,
* and separately builds the rumor it submits with `rumorOf`, which hashes the
* template itself. Both are supposed to produce one id. Nothing checks that they
* do, and nothing would notice if they stopped:
*
* - the submission would carry a `payloadId` naming an event nobody has,
* - `MarmotInnerEvent.payloadEventId` would stop matching the row it carries,
* so `deleteByPayloadEventId` would silently un-queue nothing and superseded
* translations would go out anyway,
* - and every receiver would create a *second* row rather than converging on
* the sender's, because entity ids are content hashes and the two sides would
* be hashing different things.
*
* All of that is silent. The ids are opaque hex either way.
*/
class RumorIdAgreementTest {
private val author = "a".repeat(64)
private val chatRoomId = "room"
private val other = "b".repeat(64)
/** Exactly what `MantraDao.rumorOf` does, and it must stay exactly that. */
private fun rumorIdOf(template: EventTemplate<*>): String = EventHasher.hashId(
pubKey = author,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content
)
@Test
fun `a dialect's row and its rumor agree`() {
val template = DialectEvent.build(name = "Sesotho", country = "Lesotho", language = "st")
val entity = MantraDialect.fromDialectEventTemplate(
dialectEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = author
)
assertEquals(rumorIdOf(template), entity?.id)
}
@Test
fun `an artifact's row and its rumor agree`() {
val template = ArtifactEvent.build(
name = "In Detention",
url = "example.com",
visibility = "private",
license = "cc",
dialectId = other
)
val entity = MantraArtifact.fromArtifactEventTemplate(
artifactEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = author
)
assertEquals(rumorIdOf(template), entity?.id)
}
@Test
fun `an artifact version's row and its rumor agree`() {
val template = ArtifactVersionEvent.build(content = "1.0") {
addUnique(ArtifactIdTag.assemble(other))
}
val entity = MantraArtifactVersion.fromArtifactVersionEventTemplate(
artifactVersionEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = author
)
assertEquals(rumorIdOf(template), entity?.id)
}
@Test
fun `a chapter's and a chunk's rows agree with their rumors`() {
val chapter = ChapterEvent.build(
artifactVersionId = other,
name = "Chapter 1",
originalText = "some text",
index = 0,
wordCount = 2,
characterCount = 9
)
val chunk = ChunkEvent.build(
chapterId = other,
text = "some text",
index = 0,
wordCount = 2,
characterCount = 9
)
assertEquals(
rumorIdOf(chapter),
MantraChapter.fromChapterEventTemplate(chapter, chatRoomId, author)?.id
)
assertEquals(
rumorIdOf(chunk),
MantraChunk.fromChunkEventTemplate(chunk, chatRoomId, author)?.id
)
}
@Test
fun `a translation version's row and its rumor agree`() {
val template = TranslationArtifactVersionEvent.build(
artifactVersionId = other,
dialectId = other,
name = "Sesotho",
visibility = "private",
license = "cc"
)
val entity = MantraTranslationArtifactVersion.fromTranslationArtifactVersionEventTemplate(
translationArtifactVersionEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = author
)
assertEquals(rumorIdOf(template), entity?.id)
}
@Test
fun `the submission names the id the row was written under`() {
// The end of the chain the rest of this file checks a link of: what a
// receiver reads out of the envelope has to be the id the sender stored.
val template = DialectEvent.build(name = "Sesotho", country = "Lesotho", language = "st")
val entity = MantraDialect.fromDialectEventTemplate(template, chatRoomId, author)
val payload = Event(
id = rumorIdOf(template),
pubKey = author,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
sig = ""
)
val submission = SubmissionEvent.build(payload = payload)
val readBack = SubmissionEvent(
id = "f".repeat(64),
pubKey = author,
createdAt = submission.createdAt,
tags = submission.tags,
content = submission.content,
sig = ""
)
assertEquals(entity?.id, readBack.payloadId())
assertEquals(entity?.id, readBack.payload()?.id)
assertEquals(DialectEvent.KIND, readBack.payloadKind())
}
}

View File

@@ -0,0 +1,201 @@
package press.mantra.compose.managers
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlinx.coroutines.runBlocking
/**
* The decision behind keeping an MLS group alive between messages.
*
* This cache exists because quartz drops a secret tree's skipped-generation keys
* on save, so rebuilding a group between two messages loses any message that
* arrives late -- permanently, and silently. See `docs/mls-skipped-keys.md`.
*
* Every one of these failures is invisible at runtime. Reuse too eagerly and a
* group carries on from a ratchet another writer has already moved, which
* corrupts decryption rather than failing it. Reuse too rarely and the cache
* does nothing at all, and the bug it was written for comes straight back with
* no symptom to notice. So the rule is asserted rather than reasoned about.
*/
class LiveInstanceCacheTest {
/** Stands in for an MlsGroup: mutable, and its persisted form is its content. */
private class Group(var state: String) {
/** How many times this particular instance was handed to a caller. */
var uses: Int = 0
}
private fun cache() = LiveInstanceCache<Group> { it.state }
@Test
fun `reuses the instance while nothing else has written`() = runBlocking {
val cache = cache()
var stored: String? = "start"
var built = 0
val first = cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { it.uses++; it }
)
val second = cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { it.uses++; it }
)
// The same object, not merely an equal one: what has to survive is the
// in-memory skipped-key map, which no amount of rebuilding recovers.
assertSame(first, second)
assertEquals(1, built)
assertEquals(2, second?.uses)
}
@Test
fun `rebuilds when something else wrote the stored state`() = runBlocking {
val cache = cache()
var stored: String? = "start"
var built = 0
val first = cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { it }
)
// Sending a message advances the sender ratchet and saves; adding a
// member does too. Carrying on from an instance that has been overtaken
// would diverge the ratchet, which is worse than not caching at all.
stored = "written by someone else"
val second = cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("written by someone else") },
save = { stored = it },
block = { it }
)
assertEquals(2, built)
assertEquals(false, first === second)
}
@Test
fun `persists whatever the block left behind`() = runBlocking {
val cache = cache()
var stored: String? = "start"
cache.withInstance(
key = "room",
storedState = stored,
build = { Group("start") },
save = { stored = it },
block = { it.state = "advanced" }
)
assertEquals("advanced", stored)
}
@Test
fun `the state it records is the one it compares against next time`() = runBlocking {
val cache = cache()
var stored: String? = "start"
var built = 0
repeat(3) {
cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
// Every use moves the instance on, as decrypting a message does.
block = { group -> group.state = "advanced ${group.uses++}" }
)
}
// Recording the pre-block state instead would make every call look like
// somebody else had written, quietly turning the cache off.
assertEquals(1, built)
}
@Test
fun `does not run the block, or cache anything, when there is nothing to build`() = runBlocking {
val cache = cache()
var ran = false
val result = cache.withInstance<Unit>(
key = "room",
storedState = null,
build = { null },
save = { },
block = { ran = true }
)
assertNull(result)
assertEquals(false, ran)
assertEquals(0, cache.size())
}
@Test
fun `an instance whose use threw is not handed to the next caller`() = runBlocking {
val cache = cache()
var stored: String? = "start"
var built = 0
assertFailsWith<IllegalStateException> {
cache.withInstance<Unit>(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { error("decryption blew up half way") }
)
}
cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { it }
)
// Half-advanced and never persisted: the next caller has to start from
// what is actually on disk, not from whatever the failure left in memory.
assertEquals(2, built)
}
@Test
fun `rooms are cached independently`() = runBlocking {
val cache = cache()
var storedA: String? = "a"
var storedB: String? = "b"
val a = cache.withInstance(
key = "roomA",
storedState = storedA,
build = { Group("a") },
save = { storedA = it },
block = { it }
)
val b = cache.withInstance(
key = "roomB",
storedState = storedB,
build = { Group("b") },
save = { storedB = it },
block = { it }
)
assertEquals(2, cache.size())
assertEquals(false, a === b)
}
}