From 0e23f2cde56e46c969caee8a1a292b270eba3378 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 07:12:22 +0000 Subject: [PATCH] fix(marmot): surface real decrypt exception instead of stale epoch echo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MlsGroupManager.decrypt` used to swallow the current-epoch exception via `decryptOrNull`, try retained epochs, then retry `group.decrypt`. After our inline-processCommit change this means a mid-commit throw leaves the group half-advanced, and the retry then reports a misleading "Message epoch N doesn't match current epoch N+1" — masking the real cause (unable-to-decrypt path, path-mismatch, etc.). Swap the order: call `group.decrypt` directly first, capture any exception, fall through to retained epochs, and re-raise the original exception if every epoch fails. Retains same semantics for messages that DO decrypt on the first try. https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484 --- .../marmot/mls/group/MlsGroupManager.kt | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt index 45cee7230..0bdf8e206 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt @@ -333,19 +333,27 @@ class MlsGroupManager( mutex.withLock { val group = requireGroup(nostrGroupId) - // Try current epoch - val current = group.decryptOrNull(messageBytes) - if (current != null) return@withLock current + // Try current epoch. If we hit an exception here we MUST surface + // it — commits that throw mid-processCommit leave the in-memory + // group half-mutated, and a retry via `group.decrypt(...)` will + // just report a stale "epoch mismatch" from the partial advance, + // hiding the real bug. Capture the original throwable, try + // retained epochs as a fallback, and re-raise the captured one + // if nothing decrypts. + val currentFailure: Throwable? = + try { + return@withLock group.decrypt(messageBytes) + } catch (t: Throwable) { + t + } - // Try retained epochs val retained = retainedEpochs[nostrGroupId] ?: emptyList() for (epochSecrets in retained) { val result = tryDecryptWithRetainedEpoch(messageBytes, epochSecrets) if (result != null) return@withLock result } - // No epoch could decrypt — rethrow from current epoch for diagnostics - group.decrypt(messageBytes) + throw currentFailure ?: IllegalStateException("Decrypt failed without captured cause") } /**