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")
+ }
}