From 6ca9c455b71c07591603e7b07fd08dfc762dae7b Mon Sep 17 00:00:00 2001 From: Libby Date: Mon, 9 Nov 2026 10:37:48 +0000 Subject: [PATCH] cultmesh-kotlin: sendFailed vectors, a tracker that ends with its session, White_Space blank The replay runner handles the sendFailed event, fails on an event key it does not know and on a vector count the file did not declare. WriteTracker settles a write sent after sessionEnd as session_ended. Source ids on a raw document record are dropped when blank by Unicode White_Space (isBlankText), not Kotlin's isNullOrBlank, which keeps U+0085 and drops U+001C to U+001F; the vector file's JSON reader skips JSON whitespace only. Co-Authored-By: Claude Sonnet 5.5 --- .../kotlin/org/gamecult/cultmesh/CultMesh.kt | 7 ++--- .../org/gamecult/cultmesh/WriteReceipt.kt | 27 ++++++++++++++++++- .../gamecult/cultmesh/WriteReceiptVectors.kt | 21 ++++++++++++++- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt index d9cdfad7..4a06d7f5 100644 --- a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt +++ b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt @@ -1100,9 +1100,9 @@ data class CultNetRawDocumentRecord( "payloadEncoding" to payloadEncoding, "payload" to payload.copyOf(), ) - if (!sourceRuntimeId.isNullOrBlank()) wire["sourceRuntimeId"] = sourceRuntimeId - if (!sourceAgentId.isNullOrBlank()) wire["sourceAgentId"] = sourceAgentId - if (!sourceRole.isNullOrBlank()) wire["sourceRole"] = sourceRole + if (!isBlankText(sourceRuntimeId)) wire["sourceRuntimeId"] = sourceRuntimeId + if (!isBlankText(sourceAgentId)) wire["sourceAgentId"] = sourceAgentId + if (!isBlankText(sourceRole)) wire["sourceRole"] = sourceRole if (tags.isNotEmpty()) wire["tags"] = tags return wire } @@ -3413,6 +3413,7 @@ fun main(args: Array) { rudpClientBindsLoopbackOnlyForALoopbackEndpoint() rudpSocketTransportHandshakesAndCarriesReliableOrderedSchemaFrames() rudpSocketTransportCarriesFragmentedReliableOrderedSchemaFrames() + rawDocumentRecordDropsOnlyWhiteSpaceSourceIds() return } diff --git a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/WriteReceipt.kt b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/WriteReceipt.kt index 4060f08a..4b38d942 100644 --- a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/WriteReceipt.kt +++ b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/WriteReceipt.kt @@ -163,11 +163,16 @@ class WriteTracker(private val windowMs: Long, private val onSettle: (String, Wr } private val writes = linkedMapOf() + private var ended = false /** Registers a write sent at [atMs]. Re-sending a known id is the same write and does not restart its window. */ fun send(messageId: String, atMs: Long) { tick(atMs) - writes.getOrPut(messageId) { Entry(atMs) } + if (messageId in writes) return + val entry = Entry(atMs) + writes[messageId] = entry + // On a session that already ended nothing can answer this write. + if (ended) settle(messageId, entry, WriteSettlement.Unconfirmed(WriteUnconfirmedReason.SESSION_ENDED)) } /** Feeds one received message. Only write_admitted, error.v1 and error.v0 answer; anything else is ignored. */ @@ -200,8 +205,10 @@ class WriteTracker(private val windowMs: Long, private val onSettle: (String, Wr } } + /** The session ended: every unanswered write settles unconfirmed, and so does any write sent afterwards. */ fun sessionEnd(atMs: Long) { tick(atMs) + ended = true for ((id, entry) in writes) if (entry.settlement == null) settle(id, entry, WriteSettlement.Unconfirmed(WriteUnconfirmedReason.SESSION_ENDED)) } @@ -230,3 +237,21 @@ class WriteTracker(private val windowMs: Long, private val onSettle: (String, Wr onSettle(messageId, settlement) } } + +/** Unicode White_Space (PropList), the one definition of "blank" in write-receipt.md rule 5; Kotlin's own isBlank takes U+001C to U+001F. */ +private val WHITE_SPACE_CODE_POINTS: Set = (0x0009..0x000D).toSet() + 0x0020 + 0x0085 + 0x00A0 + 0x1680 + + (0x2000..0x200A).toSet() + 0x2028 + 0x2029 + 0x202F + 0x205F + 0x3000 + +/** True for null, empty, or only White_Space code points. */ +fun isBlankText(text: String?): Boolean = text == null || text.codePoints().allMatch { it in WHITE_SPACE_CODE_POINTS } + +/** Self-test: blank is White_Space, not Kotlin's isBlank (rule 5): U+001C to U+001F name something, U+00A0 and U+0085 do not. */ +fun rawDocumentRecordDropsOnlyWhiteSpaceSourceIds() { + fun wireKeys(id: String) = CultNetRawDocumentRecord("note.v0", "note:1", "2026-11-08T00:00:00Z", payload = byteArrayOf(1), sourceRuntimeId = id, sourceAgentId = id, sourceRole = id).toWireMap().keys + for (blank in listOf("", "\u0085", "\u00a0\u2003", "\u3000", "\t\r\n ")) { + check(isBlankText(blank) && "sourceRuntimeId" !in wireKeys(blank)) { "blank ${blank.map { it.code }} must be dropped" } + } + for (named in listOf("\u001c", "\u001f", "\u200b", "\ufeff", "\u180e", "a")) { + check(!isBlankText(named) && "sourceRuntimeId" in wireKeys(named) && "sourceRole" in wireKeys(named)) { "${named.map { it.code }} names something and must be kept" } + } +} diff --git a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/WriteReceiptVectors.kt b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/WriteReceiptVectors.kt index 033ef8fd..e527692d 100644 --- a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/WriteReceiptVectors.kt +++ b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/WriteReceiptVectors.kt @@ -8,6 +8,9 @@ package org.gamecult.cultmesh import java.io.File import java.io.IOException +// The event actions scenarios.semantics names; anything else is refused, not skipped. +private val EVENT_KEYS = listOf("send", "frame", "tick", "sessionEnd", "sendFailed", "transportAck") + fun replayWriteReceiptVectors(path: String): Boolean { @Suppress("UNCHECKED_CAST") val vectors = JsonReader(File(path).readText(Charsets.UTF_8)).readDocument() as Map @@ -87,12 +90,15 @@ fun replayWriteReceiptVectors(path: String): Boolean { } for (event in list(scenario["events"]).map(::obj).sortedBy { it["atMs"] as Long }) { val at = event["atMs"] as Long + val actions = event.keys - "atMs" + if (actions.size != 1 || actions.first() !in EVENT_KEYS) { problems += "event at $at has keys $actions, wanted atMs and exactly one of $EVENT_KEYS"; continue } when { "send" in event -> tracker.send(event["send"] as String, at) // A frame reaches the client as bytes: the fake server encodes it, the client decodes it. "frame" in event -> tracker.frame(parseCultNetMessage(MessagePackWriter().value(event["frame"]).toByteArray()), at) "tick" in event -> tracker.tick(at) "sessionEnd" in event -> tracker.sessionEnd(at) + "sendFailed" in event -> tracker.sendFailed(event["sendFailed"] as String, at) // transportAck: delivery is not an answer; the tracker has no such input and must not need one. } } @@ -116,6 +122,19 @@ fun replayWriteReceiptVectors(path: String): Boolean { } } + // A runner that read fewer vectors than the file declares has silently skipped some. + check("counts") { + val declared = obj(vectors["counts"]) + fun ran(prefix: String) = results.keys.count { it.startsWith(prefix) } + val loaded = mapOf( + "wire" to list(vectors["wire"]).size, "schemaInvalid" to list(vectors["schemaInvalid"]).size, + "contentHashCases" to list(obj(vectors["contentHash"])["cases"]).size, "scenarioCases" to list(obj(vectors["scenarios"])["cases"]).size, + ) + val executed = mapOf("wire" to ran("wire/"), "schemaInvalid" to ran("schemaInvalid/"), "contentHashCases" to ran("contentHash/"), "scenarioCases" to ran("scenario/")) + loaded.keys.filter { loaded[it] != (declared[it] as Long).toInt() || executed[it] != loaded[it] } + .map { "$it: declared ${declared[it]}, loaded ${loaded[it]}, ran ${executed[it]}" } + } + for ((name, problems) in results) { println(if (problems.isEmpty()) "PASS $name" else "FAIL $name") problems.forEach { println(" $it") } @@ -144,7 +163,7 @@ private class JsonReader(private val text: String) { fun readDocument(): Any? = readValue().also { skipSpace(); if (at != text.length) throw IOException("trailing JSON") } - private fun skipSpace() { while (at < text.length && text[at].isWhitespace()) at++ } + private fun skipSpace() { while (at < text.length && text[at] in " \t\r\n") at++ } private fun readValue(): Any? { skipSpace()