diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnKeyPackagesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnKeyPackagesScreen.kt index 19ca0c0b59..a84da8a6e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnKeyPackagesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnKeyPackagesScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R @@ -57,6 +58,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.model.cordn.CordnKeyPackageRow import com.vitorpamplona.amethyst.model.cordn.CordnRuntime +import com.vitorpamplona.amethyst.ui.pluralStringRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.coroutines.launch @@ -178,7 +180,7 @@ private fun CoordinatorKeyPackages( val hasLastResort = loaded.any { it.lastResort } Text( - text = stringRes(R.string.cordn_keypackages_summary, single), + text = pluralStringRes(LocalContext.current, R.plurals.cordn_keypackages_summary, single, single), style = MaterialTheme.typography.bodyMedium, ) Text( @@ -199,7 +201,7 @@ private fun CoordinatorKeyPackages( ) { Column(Modifier.padding(12.dp)) { Text( - text = stringRes(R.string.cordn_keypackages_orphans, orphans.size), + text = pluralStringRes(LocalContext.current, R.plurals.cordn_keypackages_orphans, orphans.size, orphans.size), style = MaterialTheme.typography.titleSmall, ) Text( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5e5d6c9eb0..2016789bd3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -406,10 +406,16 @@ Purge this coordinator? Removing keeps the groups on this device, so adding the coordinator back brings them with it. Purging deletes them: the group keys, the read positions and your key packages all go, and those groups can only be re-entered by a fresh invitation.\n\nThe coordinator is not told and keeps whatever it already had. Others can only add you to a cordn group by taking a key package you published to that coordinator. cordn has no other place to keep one, so with none published nobody can invite you — and nothing tells them why. - %1$d single-use packages available. + + %1$d single-use package available. + %1$d single-use packages available. + A last-resort package is published, so an invitation can still be made once the single-use ones run out. No last-resort package. Once the single-use ones run out, invitations fail. - %1$d packages this device cannot open + + %1$d package this device cannot open + %1$d packages this device cannot open + The coordinator will hand these to anyone inviting you, but the private half is not on this device — it belongs to another install. If that install is gone, an invitation using one of these produces a welcome nobody can ever open. Withdraw those Publishing signs a record under your own account key. That coordinator then knows this account exists and is invitable — permanently, and whether or not anyone invites you. diff --git a/cli/tests/cordn/migrate.sh b/cli/tests/cordn/migrate.sh index d56dd05c03..1a7bec33b5 100755 --- a/cli/tests/cordn/migrate.sh +++ b/cli/tests/cordn/migrate.sh @@ -28,7 +28,9 @@ CONTAINER="cordn-migrate" export AMY_PASSPHRASE="${AMY_PASSPHRASE:-migrate}" -BLOB_PORT="${BLOB_PORT:-877}" +# Above 1024: binding below it needs root, and this test has no business +# asking for that. 877 was the default and bound for nobody. +BLOB_PORT="${BLOB_PORT:-8877}" BLOB="http://127.0.0.1:$BLOB_PORT" BLOB_DIR="$WORK/blobs" BLOB_PID="" @@ -49,7 +51,20 @@ blob_up() { mkdir -p "$BLOB_DIR" python3 - "$BLOB_PORT" "$BLOB_DIR" >"$WORK/blob.log" 2>&1 & BLOB_PID=$! - sleep 1 + # Wait for the port, and say so here if it never opens. Letting a dead blob + # server through costs three misleading failures later — export reports "no + # server accepted the document", and the group/blob assertions all fall over + # behind it — none of which name the thing that is actually wrong. + for _ in $(seq 20); do + if python3 -c "import socket,sys; s=socket.socket(); s.settimeout(0.3); sys.exit(0 if s.connect_ex(('127.0.0.1',$BLOB_PORT))==0 else 1)"; then + return 0 + fi + kill -0 "$BLOB_PID" 2>/dev/null || break + sleep 0.5 + done + echo "the blob server never came up on $BLOB — see $WORK/blob.log" + tail -3 "$WORK/blob.log" 2>/dev/null + exit 2 } <<'PYEOF' import hashlib, os, sys from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer diff --git a/cli/tests/cordn/stack.sh b/cli/tests/cordn/stack.sh index d74fcdbeb4..ca8005b95a 100644 --- a/cli/tests/cordn/stack.sh +++ b/cli/tests/cordn/stack.sh @@ -60,15 +60,32 @@ stack_up() { sleep 1 done - # --network host so the container reaches a relay on the host's loopback. + # The container has to reach geode, which is on the host's loopback. + # + # On Linux `--network host` puts it there. On Docker Desktop (macOS, + # Windows) the daemon is inside a VM, so `--network host` is the *VM's* + # loopback and 127.0.0.1:$PORT is nothing at all: the coordinator retries + # "Relay connection error" until stack_up gives up on a pubkey that was + # never going to arrive. There the host is reachable by name instead, over + # the default bridge. + # # A stable key so the coordinator pubkey survives a re-run against the # same WORK directory. + if [ "$(uname -s)" = "Linux" ]; then + COORD_NET="--network host" + COORD_RELAY="$RELAY" + else + COORD_NET="--add-host=host.docker.internal:host-gateway" + COORD_RELAY="ws://host.docker.internal:$PORT" + fi + [ -f "$WORK/coordinator.key" ] || openssl rand -hex 32 >"$WORK/coordinator.key" docker rm -f "$CONTAINER" >/dev/null 2>&1 - docker run -d --name "$CONTAINER" --network host \ + # shellcheck disable=SC2086 # COORD_NET is two words on purpose + docker run -d --name "$CONTAINER" $COORD_NET \ -e CORDN_STORAGE_BACKEND=memory \ -e CORDN_ANNOUNCED=false \ - -e CORDN_RELAY_URLS="$RELAY" \ + -e CORDN_RELAY_URLS="$COORD_RELAY" \ -e CORDN_SERVER_PRIVATE_KEY="$(cat "$WORK/coordinator.key")" \ -e CORDN_SERVER_NAME="cordn-test" \ "$IMAGE" >/dev/null || { echo "could not start $CONTAINER"; exit 1; } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorRegistry.kt index 29cf8c16e9..14491795ef 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorRegistry.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.cordn import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorServerInfo import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -70,6 +71,8 @@ class CordnSession( val keyPackages: CordnKeyPackages, val health: CoordinatorHealth, private val scope: CordnCoordinatorScope, + /** Seconds. Injected so tests are not at the mercy of the wall clock. */ + private val clock: () -> Long = { TimeUtils.now() }, ) { val coordinatorPubKey: HexKey get() = config.pubKey @@ -85,8 +88,28 @@ class CordnSession( */ suspend fun exposure(gid: String): GroupExposure = manager.exposure(gid, publishedKeyPackage = keyPackages.hasPublished()) - /** What this coordinator says about itself. Claims, never identity (§8.5). */ - suspend fun serverInfo(): CoordinatorServerInfo? = scope.serverInfo() + /** + * What this coordinator says about itself. Claims, never identity (§8.5). + * + * Counts against [health] like any other call. It is a real round trip to + * the coordinator, and it is usually the *first* one a reader makes — the + * settings screen offers it precisely so someone can find out whether a + * coordinator they just added answers at all. Leaving it out had that + * screen say "Nothing asked of it yet" directly underneath what the + * coordinator had just answered. + * + * An answer counts as a success and a throw as a failure; `null` counts as + * neither. A scope returns `null` when it did no handshake at all, so + * treating that as a success would report a coordinator as reachable on + * the strength of a call that never left the device. + */ + suspend fun serverInfo(): CoordinatorServerInfo? = + try { + scope.serverInfo()?.also { health.recordSuccess(clock()) } + } catch (e: Exception) { + health.recordFailure(clock(), e.message) + throw e + } /** How many of this session's responses arrived reassembled over CEP-22. */ val oversizedTransfers: Int get() = scope.coordinator.oversizedTransfers diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorRegistryTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorRegistryTest.kt index 5853b0d702..6557f632c9 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorRegistryTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorRegistryTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.cordn +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorServerInfo import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -255,4 +256,78 @@ class CordnCoordinatorRegistryTest : CordnTransportHarness() { assertTrue(registry.coordinators.value.isEmpty()) assertNull(registry.sessionOrNull(config.pubKey)) } + + /** + * A scope whose handshake the test decides: an answer, silence, or a throw. + */ + private inner class InfoFactory( + private val info: CoordinatorServerInfo?, + private val blowUp: Boolean = false, + ) : CordnCoordinatorScopeFactory { + override suspend fun open( + accountPubKey: HexKey, + config: CoordinatorConfig, + ): CordnCoordinatorScope = + object : CordnCoordinatorScope { + override val coordinator: ICoordinator = account.clientFor(config.pubKey) + override val groupStore: CordnGroupStore = InMemoryCordnGroupStore() + override val keyPackageStore: CordnKeyPackageStore = InMemoryCordnKeyPackageStore() + + override suspend fun serverInfo(): CoordinatorServerInfo? { + if (blowUp) throw IllegalStateException("coordinator unreachable") + return info + } + + override suspend fun close() = Unit + } + } + + private val anInfo = CoordinatorServerInfo(name = "cordn-server", version = "0.1.0", protocolVersion = "2025-11-25", capabilities = null) + + /** + * The settings screen offers "ask who it is" so someone can find out + * whether a coordinator answers. Before this, a successful handshake left + * the health line reading "Nothing asked of it yet" directly above the + * answer it had just printed. + */ + @Test + fun `a handshake that answers counts as a healthy call`() = + runTest { + val registry = registry(InfoFactory(anInfo)) + val session = driving { registry.session(config) } + + assertTrue(session.health.state.value.isUnknown, "nothing has been asked yet") + + assertNotNull(driving { session.serverInfo() }) + + assertTrue(!session.health.state.value.isUnknown, "the handshake should have been observed") + assertNotNull(session.health.state.value.lastSuccessAt) + } + + @Test + fun `a handshake that throws counts against health`() = + runTest { + val registry = registry(InfoFactory(null, blowUp = true)) + val session = driving { registry.session(config) } + + runCatching { driving { session.serverInfo() } } + + assertEquals(1, session.health.state.value.consecutiveFailures) + assertEquals("coordinator unreachable", session.health.state.value.lastFailure) + } + + /** + * A scope that did no handshake returns null, and a call that never left + * the device must not report the coordinator as reachable. + */ + @Test + fun `silence is neither a success nor a failure`() = + runTest { + val registry = registry(InfoFactory(null)) + val session = driving { registry.session(config) } + + assertNull(driving { session.serverInfo() }) + + assertTrue(session.health.state.value.isUnknown, "silence should leave health unknown") + } }