cultmesh-kotlin: sendFailed vectors, a tracker that ends with its session, White_Space blank
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 6m53s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m12s

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 <noreply@anthropic.com>
This commit is contained in:
Libby 2026-11-09 10:37:48 +00:00
commit 6ca9c455b7
3 changed files with 50 additions and 5 deletions

View file

@ -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<String>) {
rudpClientBindsLoopbackOnlyForALoopbackEndpoint()
rudpSocketTransportHandshakesAndCarriesReliableOrderedSchemaFrames()
rudpSocketTransportCarriesFragmentedReliableOrderedSchemaFrames()
rawDocumentRecordDropsOnlyWhiteSpaceSourceIds()
return
}

View file

@ -163,11 +163,16 @@ class WriteTracker(private val windowMs: Long, private val onSettle: (String, Wr
}
private val writes = linkedMapOf<String, Entry>()
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<Int> = (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" }
}
}

View file

@ -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<String, Any?>
@ -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()