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
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:
parent
1136080aaf
commit
6ca9c455b7
3 changed files with 50 additions and 5 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue