diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt index eaa69731b0..472b6d8f11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt @@ -44,10 +44,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.marmot.MarmotAgentStreamWatcher import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.marmot_group_default_name import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation @@ -78,7 +76,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @Composable @@ -123,31 +120,19 @@ fun MarmotGroupChatView( } } - // The live agent-preview watcher. It follows the newest kind:1200 in the - // group and folds the QUIC records behind it; a group with no stream, no - // broker candidate or no reachable broker simply never shows a preview, - // and the durable kind:9 still arrives as ordinary chat either way. - val marmot = accountViewModel.account.marmotManager - val streamScope = rememberCoroutineScope() - val streamWatcher = - remember(nostrGroupId, marmot) { - marmot?.let { - MarmotAgentStreamWatcher(it, accountViewModel.account.marmotStreamTransport, streamScope) - } - } - val streamPreview by (streamWatcher?.preview ?: remember { MutableStateFlow(null) }).collectAsStateWithLifecycle() - - // Re-check on every feed change: a kind:1200 arrives as an ordinary group - // message, so "the feed moved" is exactly when a new stream may have been - // anchored. watchLatest is idempotent for a stream already being followed. - val feedState by feedViewModel.feedState.feedContent.collectAsStateWithLifecycle() - LaunchedEffect(feedState, streamWatcher) { - streamWatcher?.watchLatest(nostrGroupId) - } - - DisposableEffect(streamWatcher) { - onDispose { streamWatcher?.stop() } - } + // The live agent-preview watcher is NOT started here. + // + // Opening the chat used to build a [MarmotAgentStreamWatcher] and call + // `watchLatest` on every feed change, which dials the QUIC brokers a + // kind:1200 advertises. Nothing in the deployed network publishes those + // streams, so that was a UDP connection attempt to a third-party endpoint + // on behalf of a feature no one is using — a service we start, not a + // capability we hold. + // + // The watcher, the transport and the banner all still exist and are still + // tested; `amy marmot stream watch` drives the same code on demand. Wiring + // it back is re-adding the watcher, the LaunchedEffect and the banner + // below, once there is something to watch. Column(Modifier.fillMaxHeight()) { Column( @@ -166,8 +151,6 @@ fun MarmotGroupChatView( ) } - AgentStreamPreviewBanner(streamPreview) - Spacer(modifier = DoubleVertSpacer) MarmotGroupMessageComposer( diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/MirrorSyncThroughputTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/MirrorSyncThroughputTest.kt index 4df0baf4b5..63b775029e 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/MirrorSyncThroughputTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/MirrorSyncThroughputTest.kt @@ -81,6 +81,15 @@ import kotlin.test.Test * * Size with `-DsyncN` (default 1,000,000). Timed from first byte to the * downstream reaching the target (or plateauing). + * + * **Opt-in.** This is a benchmark, not a regression test: it preloads a + * million events and pulls them over a real WebSocket, which took 4,584 s of + * `:geode:test`'s 4,636 s total — 98.9% of the module's test time for one test + * that asserts nothing about correctness. Every other benchmark in this module + * is already gated the same way (see `perf.LoadBenchmark`), and every + * invocation documented above passes `-DsyncN` or `-DsyncSourceUrl`, so those + * still run it. A plain `./gradlew test` — which is what the pre-push hook + * runs — now skips it in milliseconds. */ class MirrorSyncThroughputTest { // geode's real relay config (deferred FTS, live negentropy index) — not the @@ -148,9 +157,30 @@ class MirrorSyncThroughputTest { return String(out) } + /** + * True when someone actually asked for a throughput number: either the + * module-wide benchmark switch, or any of this test's own sizing/source + * properties. Naming a size IS the opt-in — a run that says `-DsyncN=…` + * plainly wants the measurement and should not need a second flag. + */ + private val enabled = + System.getProperty("runLoadBenchmark") == "true" || + System.getProperty("syncN") != null || + System.getProperty("syncSourceUrl") != null + @Test fun mirrorSyncThroughput() = runBlocking { + // Bail before building anything. The old code decided nothing up + // front and spent over an hour preloading and syncing a million + // events on every ordinary test run. + if (!enabled) { + println( + "[skip] mirrorSyncThroughput — benchmark. Enable with -DrunLoadBenchmark=true, " + + "or size it directly with -DsyncN=… / -DsyncSourceUrl=…", + ) + return@runBlocking + } val n = System.getProperty("syncN")?.toInt() ?: 1_000_000 val externalUrl = System.getProperty("syncSourceUrl") val expect = System.getProperty("syncExpect")?.toInt() ?: n diff --git a/marmotQuic/README.md b/marmotQuic/README.md index 5153ec4204..f7958a27ee 100644 --- a/marmotQuic/README.md +++ b/marmotQuic/README.md @@ -107,6 +107,24 @@ amy marmot stream watch GID --stream-id … amy marmot stream finish GID --stream-id … --transcript-hash … --chunk-count N "hello" ``` +## Not wired into the app + +The implementation is complete and tested, and nothing in the app starts it. + +Nothing in the deployed network publishes agent text stream previews, so the +Android chat screen no longer builds a watcher and dials the brokers a kind:1200 +advertises, and our published KeyPackage no longer advertises component `0x8006` +or the `receive`/`send`/`fanout` role capabilities. A capability is a standing +promise to every peer that reads the KeyPackage; making one for a path nobody +exercises costs something and buys nothing. + +What that leaves: the codecs, this module, the CLI (`amy marmot stream …`) and +the interop tests all still work and still run. Turning the feature back on is +re-adding `AppComponentIds.AGENT_TEXT_STREAM_QUIC_V1` to +`CurrentProfileGroupFactory.SUPPORTED_COMPONENTS`, the three roles to +`MlsGroup.currentProfileLeafCapabilities()`, and the watcher to +`MarmotGroupChatView`. + ## Not done - The Android GUI renders previews but does not originate a stream — that is diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactory.kt index 25263a5c0c..da7bc32fd5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactory.kt @@ -64,10 +64,13 @@ object CurrentProfileGroupFactory { * later as a group we cannot actually participate in. Add an id here only * when the component is implemented. * - * `0x8006` (agent-text-stream over QUIC) is listed for every role - * [MlsGroup.currentProfileLeafCapabilities] advertises — receive, send and - * fanout. Publishing needs durable per-stream sequence state so a restart - * cannot reuse an AEAD nonce, and that store exists. + * `0x8006` (agent-text-stream over QUIC) is deliberately absent even + * though it is implemented. Nothing in the deployed network uses the QUIC + * preview path, and this list is a promise rather than a description: a + * group may require any id we advertise, and we would then owe every peer + * behaviour for a feature no one exercises. The code stays (`:marmotQuic`, + * the codecs, `amy marmot stream`), and the id goes back on the list the + * day the feature is actually used. */ val SUPPORTED_COMPONENTS: List = listOf( @@ -78,7 +81,6 @@ object CurrentProfileGroupFactory { AppComponentIds.ADMIN_POLICY_V1, AppComponentIds.NOSTR_ROUTING_V1, AppComponentIds.MESSAGE_RETENTION_V1, - AppComponentIds.AGENT_TEXT_STREAM_QUIC_V1, AppComponentIds.ACCOUNT_IDENTITY_PROOF_V2, AppComponentIds.GROUP_ENCRYPTED_MEDIA_V2, AppComponentIds.GROUP_LIFECYCLE_V1, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamQuicPolicyV1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamQuicPolicyV1.kt index 4ccaae6604..cb9e29d73a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamQuicPolicyV1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamQuicPolicyV1.kt @@ -210,6 +210,12 @@ object AgentTextStreamRoles { const val SEND_CAPABILITY = 0xF2D2 const val FANOUT_CAPABILITY = 0xF2D4 + /** + * Every role capability, for callers that need to ask "does this leaf + * advertise any of them" without enumerating the three by hand. + */ + val ALL_CAPABILITIES = listOf(RECEIVE_CAPABILITY, SEND_CAPABILITY, FANOUT_CAPABILITY) + fun capabilityFor(role: Int): Int = when (role) { RECEIVE -> RECEIVE_CAPABILITY diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt index bd24d33d1a..68e0ce27eb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt @@ -25,7 +25,6 @@ import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds import com.vitorpamplona.quartz.marmot.appComponents.MarmotGroupState import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamCrypto import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamQuicPolicyV1 -import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamRoles import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter @@ -3442,21 +3441,21 @@ class MlsGroup private constructor( listOf( AppDataDictionary.EXTENSION_TYPE, MarmotGroupData.EXTENSION_ID_INT, - // All three agent-stream roles, matching what MDK puts - // on every KeyPackage it publishes. `receive` is the - // baseline compatibility role; `send` says we can - // originate preview records, which we can now that the - // publisher, the raw-QUIC binding and the app wiring - // exist; `fanout` says records may be forwarded on our - // behalf, which is what using a broker at all means. + // The agent-stream roles (`0xF2D1` receive, `0xF2D2` + // send, `0xF2D4` fanout) are deliberately NOT here. // - // A capability is only a claim about what we support, - // not a duty to stream: a group that requires `send` - // wants members that COULD originate, and a member that - // never does is a quiet member, not a broken one. - AgentTextStreamRoles.RECEIVE_CAPABILITY, - AgentTextStreamRoles.SEND_CAPABILITY, - AgentTextStreamRoles.FANOUT_CAPABILITY, + // The implementation exists and stays — see + // [AgentTextStreamRoles] and the `:marmotQuic` module — + // but nothing in the deployed network uses the QUIC + // preview path, and an advertised capability is a + // standing promise to every peer that reads our + // KeyPackage. Advertising a role no one exercises buys + // nothing and commits us to answering for it; the + // reference KeyPackage in our own conformance vector + // does not advertise it either. + // + // Re-adding them is a one-line change once the feature + // is actually in use. ), proposals = listOf(APP_DATA_UPDATE_PROPOSAL_TYPE, SELF_REMOVE_PROPOSAL_TYPE), ) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt index d99adc5d01..0a75465c1b 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.marmot.appComponents import com.vitorpamplona.quartz.TestResourceLoader import com.vitorpamplona.quartz.marmot.appComponents.accountIdentityProof.AccountIdentityProofV2 -import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamRoles import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader @@ -111,21 +110,22 @@ class CurrentProfileGroupFactoryTest { // agent-text-stream roles — the same set MDK puts on every // KeyPackage it publishes. // - // The extra entries are deliberate and are NOT drift from the MDK - // reference. A capability says "this client can handle it", and a - // group that REQUIRES 0xF2EE (legacy) or a role (any group MDK - // creates) refuses to add a leaf that does not advertise it — so - // without these a current-profile KeyPackage would be un-addable - // to every legacy group that already exists and to every group MDK - // makes. Advertising more than a group requires is always - // acceptable; advertising less is what gets a leaf rejected. + // `0xF2EE` is deliberate and is NOT drift from the MDK reference: + // a legacy group REQUIRES it, and a group refuses to add a leaf + // that does not advertise what it requires, so without it a + // current-profile KeyPackage would be un-addable to every legacy + // group that already exists. + // + // The agent-stream roles are deliberately absent. Advertising more + // than a group requires is harmless to that group but is not free: + // it is a standing claim to every peer that reads this KeyPackage, + // and nothing in the deployed network uses the QUIC preview path. + // The reference KeyPackage in `mls/marmot-current-profile.json` + // does not advertise `0x8006` either. assertEquals( listOf( AppDataDictionary.EXTENSION_TYPE, MarmotGroupData.EXTENSION_ID_INT, - AgentTextStreamRoles.RECEIVE_CAPABILITY, - AgentTextStreamRoles.SEND_CAPABILITY, - AgentTextStreamRoles.FANOUT_CAPABILITY, ), kp.leafNode.capabilities.extensions, ) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/CurrentProfileWelcomeTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/CurrentProfileWelcomeTest.kt index 8b4288df3c..65bb2887cd 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/CurrentProfileWelcomeTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/CurrentProfileWelcomeTest.kt @@ -135,7 +135,7 @@ class CurrentProfileWelcomeTest { * admits us. */ @Test - fun aJoinerFillsEveryRoleTheProfileDefines() = + fun aLeafAdvertisingEveryRoleFillsAGroupThatRequiresThem() = runBlocking { val group = aGroup( @@ -147,7 +147,7 @@ class CurrentProfileWelcomeTest { paddingBucketBytes = 0, ), ) - val invitee = CurrentProfileGroupFactory.createKeyPackage(signer(0x66)) + val invitee = allRolesKeyPackage(signer(0x66)) group.proposeAdd(invitee.keyPackage.toTlsBytes()) val welcome = assertNotNull(group.commit().welcomeBytes) @@ -155,13 +155,71 @@ class CurrentProfileWelcomeTest { assertEquals(nostrGroupId.toHexKey(), joined.currentNostrGroupId()) } - /** A current-profile leaf with the `send` and `fanout` roles stripped. */ - private suspend fun receiveOnlyKeyPackage(signer: NostrSignerInternal): KeyPackageBundle { + /** + * Our published KeyPackage advertises NO agent-stream role, and is + * therefore refused by a group that requires one. + * + * That refusal is the deliberate cost of not advertising, so it is asserted + * rather than discovered: the implementation is still here and still + * tested, but a capability is a standing promise to every peer that reads + * the KeyPackage, and we do not make one for a path nothing uses. If this + * test starts failing because the default advertises a role again, that is + * a decision to take on purpose, not a drift to absorb. + */ + @Test + fun ourDefaultLeafAdvertisesNoStreamRoleAndIsRefusedByAGroupThatNeedsOne() = + runBlocking { + val group = aGroup(AgentTextStreamQuicPolicyV1.userToAgentDefault()) + val invitee = CurrentProfileGroupFactory.createKeyPackage(signer(0x77)) + + assertTrue( + invitee.keyPackage.leafNode.capabilities.extensions + .none { it in AgentTextStreamRoles.ALL_CAPABILITIES }, + "the default leaf must carry no agent-stream role, got ${invitee.keyPackage.leafNode.capabilities.extensions}", + ) + + group.proposeAdd(invitee.keyPackage.toTlsBytes()) + val welcome = assertNotNull(group.commit().welcomeBytes) + val failure = assertFailsWith { MlsGroup.processWelcome(welcome, invitee) } + assertTrue( + failure.message.orEmpty().contains("agent text stream roles"), + "expected a role-capability refusal, got: ${failure.message}", + ) + } + + /** A current-profile leaf carrying the `receive` role and nothing beyond it. */ + private suspend fun receiveOnlyKeyPackage(signer: NostrSignerInternal): KeyPackageBundle = keyPackageAdvertising(signer, listOf(AgentTextStreamRoles.RECEIVE_CAPABILITY)) + + /** A current-profile leaf carrying every role the profile defines. */ + private suspend fun allRolesKeyPackage(signer: NostrSignerInternal): KeyPackageBundle = + keyPackageAdvertising( + signer, + listOf( + AgentTextStreamRoles.RECEIVE_CAPABILITY, + AgentTextStreamRoles.SEND_CAPABILITY, + AgentTextStreamRoles.FANOUT_CAPABILITY, + ), + ) + + /** + * A current-profile KeyPackage whose leaf advertises exactly [roles] on top + * of the default capability set. + * + * The default set no longer carries any agent-stream role, so these tests + * build the leaf they need instead of relying on it. That is the right + * shape regardless: a test that asserted the gate through OUR default was + * really asserting the default, and stopped testing the gate the moment the + * default changed — which is exactly what happened. + */ + private suspend fun keyPackageAdvertising( + signer: NostrSignerInternal, + roles: List, + ): KeyPackageBundle { val full = CurrentProfileGroupFactory.createKeyPackage(signer) val reduced = MlsGroup.currentProfileLeafCapabilities().let { Capabilities( - extensions = it.extensions.filterNot { ext -> ext == AgentTextStreamRoles.SEND_CAPABILITY || ext == AgentTextStreamRoles.FANOUT_CAPABILITY }, + extensions = it.extensions + roles, proposals = it.proposals, ) } @@ -188,10 +246,10 @@ class CurrentProfileWelcomeTest { } @Test - fun aJoinerAcceptsAGroupRequiringOnlyTheReceiveRole() = + fun aLeafAdvertisingReceiveJoinsAGroupRequiringOnlyThatRole() = runBlocking { val group = aGroup(AgentTextStreamQuicPolicyV1.userToAgentDefault()) - val invitee = CurrentProfileGroupFactory.createKeyPackage(signer(0x55)) + val invitee = receiveOnlyKeyPackage(signer(0x55)) group.proposeAdd(invitee.keyPackage.toTlsBytes()) val welcome = assertNotNull(group.commit().welcomeBytes)