cultmesh-kotlin: write receipt frames, hash, tracker, vector replay #65
5 changed files with 519 additions and 3 deletions
|
|
@ -474,3 +474,14 @@ powershell -ExecutionPolicy Bypass -File .\packages\cultmesh-kotlin\build.ps1
|
|||
The build writes `artifacts/cultmesh-kotlin/cultmesh-kotlin.jar`.
|
||||
It also runs the built-in RUDP packet fixture and localhost UDP socket
|
||||
self-test.
|
||||
|
||||
## Write Receipt
|
||||
|
||||
`WriteReceipt.kt` decodes `cultnet.write_admitted.v0` and `cultnet.error.v1` (and `error.v0`) strictly against their
|
||||
closed schemas (`decodeWriteAdmitted`, `decodeErrorFrame`), computes the dedupe `writeContentHash`, tests a hello with
|
||||
`helloAdvertisesWriteReceipts`, and settles sent writes as admitted, refused or unconfirmed in `WriteTracker`
|
||||
(pure state machine, abstract clock; the caller owns transport and timers). Kotlin has no write client yet, so nothing
|
||||
sends a put and waits on this. `WriteReceiptVectors.kt` replays
|
||||
`contracts/cultnet/interop/write-receipt-vectors.json` (constants, contentHash, wire, schemaInvalid, scenarios; the
|
||||
server-side `dedupe` and `blankFields` sections are not run); `build.ps1` runs it after the self-test, or run
|
||||
`CultMeshKt write-receipt-vectors <path>`.
|
||||
|
|
|
|||
|
|
@ -26,4 +26,7 @@ $sources = Get-ChildItem $src -Recurse -Filter *.kt | Select-Object -ExpandPrope
|
|||
if ($LASTEXITCODE -ne 0) { throw "kotlinc failed with exit code $LASTEXITCODE" }
|
||||
& $java -cp "$jarPath;$kotlinStdlib" org.gamecult.cultmesh.CultMeshKt
|
||||
if ($LASTEXITCODE -ne 0) { throw "cultmesh-kotlin self-test failed with exit code $LASTEXITCODE" }
|
||||
$vectors = Join-Path $repoRoot "contracts\cultnet\interop\write-receipt-vectors.json"
|
||||
& $java -cp "$jarPath;$kotlinStdlib" org.gamecult.cultmesh.CultMeshKt write-receipt-vectors $vectors
|
||||
if ($LASTEXITCODE -ne 0) { throw "write-receipt vector replay failed with exit code $LASTEXITCODE" }
|
||||
Write-Host "Built $jarPath"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -3424,6 +3425,7 @@ fun main(args: Array<String>) {
|
|||
"rudp-dial-message-once" -> rudpDialSchemaMessageOnce(options)
|
||||
"serve" -> interopServe(options)
|
||||
"dial" -> interopDial(options)
|
||||
"write-receipt-vectors" -> if (!replayWriteReceiptVectors(args.getOrNull(1) ?: error("usage: write-receipt-vectors <vectors.json>"))) kotlin.system.exitProcess(1)
|
||||
else -> error("Unknown mode ${args[0]}")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,257 @@
|
|||
package org.gamecult.cultmesh
|
||||
|
||||
// The write receipt (contracts/cultnet/interop/write-receipt.md): the two frames, their strict decode, the dedupe
|
||||
// content hash, the hello capability test and the client settle rules. Mirrors packages/cultnet-ts (contracts.ts,
|
||||
// write-tracker.ts) and packages/cultnet-rs (write_content_hash). The replay of write-receipt-vectors.json lives in
|
||||
// WriteReceiptVectors.kt. Server rules (dedupe, limits, blank fields) are not a decoder's business and are not here.
|
||||
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
|
||||
const val WRITE_ADMITTED_VERSION = "cultnet.write_admitted.v0"
|
||||
const val ERROR_V1_VERSION = "cultnet.error.v1"
|
||||
const val ERROR_V0_VERSION = "cultnet.error.v0"
|
||||
|
||||
/** A connection is receipt-capable only when its hello lists both frames. */
|
||||
val CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS: List<String> = listOf(WRITE_ADMITTED_VERSION, ERROR_V1_VERSION)
|
||||
|
||||
const val RECEIPT_RESULT_WINDOW_MS = 5000L
|
||||
const val LEGACY_RESULT_WINDOW_MS = 500L
|
||||
|
||||
fun resultWindowMs(serverAdvertisesReceipts: Boolean): Long =
|
||||
if (serverAdvertisesReceipts) RECEIPT_RESULT_WINDOW_MS else LEGACY_RESULT_WINDOW_MS
|
||||
|
||||
/** The closed refusal-code set of error.v0; error.v1 adds `limit_reached`. */
|
||||
val CULTNET_ERROR_V0_CODES: Set<String> = setOf(
|
||||
"selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported",
|
||||
"unowned_schema", "write_not_authorized", "unsupported_schema_version", "message_invalid",
|
||||
)
|
||||
val CULTNET_ERROR_V1_CODES: Set<String> = CULTNET_ERROR_V0_CODES + "limit_reached"
|
||||
|
||||
fun helloAdvertisesWriteReceipts(hello: CultNetMessage): Boolean {
|
||||
val versions = hello.body["supportedMessageVersions"] as? List<*> ?: return false
|
||||
return CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS.all { it in versions }
|
||||
}
|
||||
|
||||
data class CultNetWriteAdmitted(val messageId: String, val operation: String, val schemaId: String, val recordKey: String) {
|
||||
fun toMessage() = CultNetMessage(
|
||||
WRITE_ADMITTED_VERSION,
|
||||
linkedMapOf("messageId" to messageId, "operation" to operation, "schemaId" to schemaId, "recordKey" to recordKey),
|
||||
)
|
||||
}
|
||||
|
||||
data class CultNetErrorDetails(val field: String?, val value: String?, val asOf: Long?, val current: Long?)
|
||||
|
||||
/** error.v0 (`messageId` always null, `version` 0) or error.v1 (`version` 1). */
|
||||
data class CultNetErrorFrame(
|
||||
val version: Int,
|
||||
val error: String,
|
||||
val routingHint: Map<String, Any?>?,
|
||||
val code: String?,
|
||||
val details: CultNetErrorDetails?,
|
||||
val messageId: String?,
|
||||
) {
|
||||
fun toMessage(): CultNetMessage {
|
||||
val body = linkedMapOf<String, Any?>(
|
||||
"error" to error,
|
||||
"routingHint" to routingHint,
|
||||
"code" to code,
|
||||
"details" to details?.let {
|
||||
linkedMapOf("field" to it.field, "value" to it.value, "asOf" to it.asOf, "current" to it.current)
|
||||
},
|
||||
)
|
||||
if (version == 1) body["messageId"] = messageId
|
||||
return CultNetMessage(if (version == 1) ERROR_V1_VERSION else ERROR_V0_VERSION, body)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict decode of a write_admitted.v0 message against its closed schema: exactly five keys, a non-empty string each,
|
||||
* operation put|delete.
|
||||
*/
|
||||
fun decodeWriteAdmitted(message: CultNetMessage): CultNetWriteAdmitted {
|
||||
if (message.schemaVersion != WRITE_ADMITTED_VERSION) throw IOException("not a $WRITE_ADMITTED_VERSION message")
|
||||
closedKeys(message, setOf("messageId", "operation", "schemaId", "recordKey"))
|
||||
val operation = nonEmpty(message, "operation")
|
||||
if (operation != "put" && operation != "delete") throw IOException("operation must be put or delete")
|
||||
return CultNetWriteAdmitted(nonEmpty(message, "messageId"), operation, nonEmpty(message, "schemaId"), nonEmpty(message, "recordKey"))
|
||||
}
|
||||
|
||||
/** Strict decode of error.v0 (no messageId, no limit_reached) or error.v1 (messageId required, string or nil, never empty). */
|
||||
fun decodeErrorFrame(message: CultNetMessage): CultNetErrorFrame {
|
||||
val version = when (message.schemaVersion) {
|
||||
ERROR_V1_VERSION -> 1
|
||||
ERROR_V0_VERSION -> 0
|
||||
else -> throw IOException("not an error message")
|
||||
}
|
||||
closedKeys(message, setOf("error", "routingHint", "code", "details") + if (version == 1) setOf("messageId") else emptySet())
|
||||
val error = nonEmpty(message, "error")
|
||||
val codeValue = message.body["code"]
|
||||
if (codeValue != null && (codeValue !is String || codeValue !in (if (version == 1) CULTNET_ERROR_V1_CODES else CULTNET_ERROR_V0_CODES))) {
|
||||
throw IOException("unknown error code")
|
||||
}
|
||||
var messageId: String? = null
|
||||
if (version == 1) {
|
||||
val id = message.body["messageId"]
|
||||
if (id != null && (id !is String || id.isEmpty())) throw IOException("messageId must be a non-empty string or nil")
|
||||
messageId = id as String?
|
||||
}
|
||||
return CultNetErrorFrame(version, error, routingHintOf(message.body["routingHint"]), codeValue as String?, detailsOf(message.body["details"]), messageId)
|
||||
}
|
||||
|
||||
private fun closedKeys(message: CultNetMessage, required: Set<String>) {
|
||||
if (!message.body.keys.containsAll(required)) throw IOException("${message.schemaVersion} is missing ${required - message.body.keys}")
|
||||
val unknown = message.body.keys - required
|
||||
if (unknown.isNotEmpty()) throw IOException("${message.schemaVersion} carries unknown keys $unknown")
|
||||
}
|
||||
|
||||
private fun nonEmpty(message: CultNetMessage, key: String): String {
|
||||
val value = message.body[key]
|
||||
if (value !is String || value.isEmpty()) throw IOException("$key must be a non-empty string")
|
||||
return value
|
||||
}
|
||||
|
||||
private fun routingHintOf(value: Any?): Map<String, Any?>? {
|
||||
if (value == null) return null
|
||||
if (value !is Map<*, *> || value.keys != setOf("shard", "reason")) throw IOException("routingHint must be nil or {shard, reason}")
|
||||
if (value["shard"] != null && value["shard"] !is Map<*, *>) throw IOException("routingHint.shard must be an object or nil")
|
||||
if (value["reason"] !is String) throw IOException("routingHint.reason must be a string")
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return value as Map<String, Any?>
|
||||
}
|
||||
|
||||
private fun detailsOf(value: Any?): CultNetErrorDetails? {
|
||||
if (value == null) return null
|
||||
if (value !is Map<*, *> || value.keys != setOf("field", "value", "asOf", "current")) {
|
||||
throw IOException("details must be nil or {field, value, asOf, current}")
|
||||
}
|
||||
fun text(key: String): String? = value[key].let { if (it == null || it is String) it as String? else throw IOException("details.$key must be a string or nil") }
|
||||
fun cursor(key: String): Long? = value[key].let {
|
||||
if (it == null) null else if (it is Long && it >= 0) it else throw IOException("details.$key must be a non-negative integer or nil")
|
||||
}
|
||||
return CultNetErrorDetails(text("field"), text("value"), cursor("asOf"), cursor("current"))
|
||||
}
|
||||
|
||||
/** sha256 of the MessagePack array [operation, schemaId, recordKey, payload]: payload a bin for a put, nil for a delete. Lowercase hex. */
|
||||
fun writeContentHash(operation: String, schemaId: String, recordKey: String, payload: ByteArray?): String {
|
||||
val writer = MessagePackWriter().array(4).string(operation).string(schemaId).string(recordKey)
|
||||
if (payload == null) writer.nil() else writer.binary(payload)
|
||||
return MessageDigest.getInstance("SHA-256").digest(writer.toByteArray()).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/** Why nothing settled a write. */
|
||||
enum class WriteUnconfirmedReason(val wire: String) { WINDOW_ELAPSED("window_elapsed"), SESSION_ENDED("session_ended"), LOCAL_SEND_FAILED("local_send_failed") }
|
||||
|
||||
/** What a late answer is recorded as on a write that already settled unconfirmed. */
|
||||
enum class WriteRecorded(val wire: String) { ADMITTED_LATE("admitted_late"), REFUSED_LATE("refused_late") }
|
||||
|
||||
/** What the caller was told. Settled once; never changed by a later frame. */
|
||||
sealed class WriteSettlement {
|
||||
object Admitted : WriteSettlement()
|
||||
data class Refused(val code: String?, val error: String, val response: CultNetErrorFrame) : WriteSettlement()
|
||||
data class Unconfirmed(val reason: WriteUnconfirmedReason) : WriteSettlement()
|
||||
}
|
||||
|
||||
/**
|
||||
* The client half of the receipt rules: a pure state machine with an abstract clock, one decision, how each sent write
|
||||
* settles. Transport, timers and decoding belong to the caller; `frame` takes a message and decodes it strictly.
|
||||
*/
|
||||
class WriteTracker(private val windowMs: Long, private val onSettle: (String, WriteSettlement) -> Unit = { _, _ -> }) {
|
||||
private class Entry(val sentAtMs: Long) {
|
||||
var settlement: WriteSettlement? = null
|
||||
var recorded: WriteRecorded? = null
|
||||
}
|
||||
|
||||
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)
|
||||
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. */
|
||||
fun frame(message: CultNetMessage, atMs: Long) {
|
||||
tick(atMs)
|
||||
when (message.schemaVersion) {
|
||||
WRITE_ADMITTED_VERSION -> {
|
||||
answer(decodeWriteAdmitted(message).messageId, WriteSettlement.Admitted, WriteRecorded.ADMITTED_LATE)
|
||||
return
|
||||
}
|
||||
ERROR_V1_VERSION, ERROR_V0_VERSION -> {
|
||||
val error = decodeErrorFrame(message)
|
||||
val refusal = WriteSettlement.Refused(error.code, error.error, error)
|
||||
if (error.messageId != null) {
|
||||
answer(error.messageId, refusal, WriteRecorded.REFUSED_LATE)
|
||||
return
|
||||
}
|
||||
// An error that names no write is attributable only when exactly one write is unanswered.
|
||||
val unanswered = writes.filterValues { it.settlement == null }
|
||||
if (unanswered.size == 1) settle(unanswered.keys.first(), unanswered.values.first(), refusal)
|
||||
}
|
||||
else -> return
|
||||
}
|
||||
}
|
||||
|
||||
/** Every unanswered write whose window, counted from its own send, has run settles unconfirmed. */
|
||||
fun tick(atMs: Long) {
|
||||
for ((id, entry) in writes) {
|
||||
if (entry.settlement == null && atMs - entry.sentAtMs >= windowMs) settle(id, entry, WriteSettlement.Unconfirmed(WriteUnconfirmedReason.WINDOW_ELAPSED))
|
||||
}
|
||||
}
|
||||
|
||||
/** 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))
|
||||
}
|
||||
|
||||
/** This client's own send of [messageId] failed: that write settles unconfirmed; the others are untouched. */
|
||||
fun sendFailed(messageId: String, atMs: Long) {
|
||||
val entry = writes[messageId]
|
||||
if (entry != null && entry.settlement == null) settle(messageId, entry, WriteSettlement.Unconfirmed(WriteUnconfirmedReason.LOCAL_SEND_FAILED))
|
||||
tick(atMs)
|
||||
}
|
||||
|
||||
/** The settlement told to the caller, or null while unanswered or unknown. */
|
||||
fun settlement(messageId: String): WriteSettlement? = writes[messageId]?.settlement
|
||||
|
||||
/** The late answer recorded on an unconfirmed write. */
|
||||
fun recorded(messageId: String): WriteRecorded? = writes[messageId]?.recorded
|
||||
|
||||
private fun answer(messageId: String, settlement: WriteSettlement, late: WriteRecorded) {
|
||||
val entry = writes[messageId] ?: return // names a write that is not in flight: settles nothing
|
||||
val current = entry.settlement
|
||||
if (current == null) settle(messageId, entry, settlement)
|
||||
else if (current is WriteSettlement.Unconfirmed && entry.recorded == null) entry.recorded = late
|
||||
}
|
||||
|
||||
private fun settle(messageId: String, entry: Entry, settlement: WriteSettlement) {
|
||||
entry.settlement = settlement
|
||||
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" }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
package org.gamecult.cultmesh
|
||||
|
||||
// Replays contracts/cultnet/interop/write-receipt-vectors.json against this runtime: constants, contentHash, every
|
||||
// `wire` frame (decode to the vector's fields, re-encode to its bytes), every `schemaInvalid` frame (must be refused),
|
||||
// and every `scenarios` event replay through WriteTracker. `dedupe` and `blankFields` are server inputs and are not
|
||||
// run here. Mode: `java -cp ... org.gamecult.cultmesh.CultMeshKt write-receipt-vectors <vectors.json>`.
|
||||
|
||||
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?>
|
||||
val results = linkedMapOf<String, List<String>>()
|
||||
fun check(name: String, run: () -> List<String>) {
|
||||
results[name] = try { run() } catch (e: Throwable) { listOf("threw: $e") }
|
||||
}
|
||||
|
||||
val constants = obj(vectors["constants"])
|
||||
check("constants") {
|
||||
listOfNotNull(
|
||||
"RECEIPT window".takeIf { constants["RECEIPT_RESULT_WINDOW_MS"] != RECEIPT_RESULT_WINDOW_MS },
|
||||
"LEGACY window".takeIf { constants["LEGACY_RESULT_WINDOW_MS"] != LEGACY_RESULT_WINDOW_MS },
|
||||
)
|
||||
}
|
||||
|
||||
for (case in list(obj(vectors["contentHash"])["cases"])) {
|
||||
val c = obj(case)
|
||||
check("contentHash/${c["name"]}") {
|
||||
val got = writeContentHash(c["operation"] as String, c["schemaId"] as String, c["recordKey"] as String, (c["payloadHex"] as String?)?.let(::unhex))
|
||||
if (got == c["sha256"]) emptyList() else listOf("$got != ${c["sha256"]}")
|
||||
}
|
||||
}
|
||||
|
||||
for (case in list(vectors["wire"])) {
|
||||
val c = obj(case)
|
||||
check("wire/${c["name"]}") {
|
||||
val bytes = unhex(c["messagePackHex"] as String)
|
||||
val problems = mutableListOf<String>()
|
||||
val message = decodeReceiptFrame(parseCultNetMessage(bytes))
|
||||
if (message.toWireMap() != c["message"]) problems += "decoded ${message.toWireMap()} != ${c["message"]}"
|
||||
val again = message.toBytes().joinToString("") { "%02X".format(it) }
|
||||
if (again != (c["messagePackHex"] as String).uppercase()) problems += "re-encoded bytes $again != captured ${c["messagePackHex"]}"
|
||||
problems
|
||||
}
|
||||
}
|
||||
|
||||
for (case in list(vectors["schemaInvalid"])) {
|
||||
val c = obj(case)
|
||||
check("schemaInvalid/${c["name"]}") {
|
||||
try {
|
||||
decodeReceiptFrame(parseCultNetMessage(unhex(c["messagePackHex"] as String)))
|
||||
listOf("accepted a frame that must be refused (${c["why"]})")
|
||||
} catch (_: IOException) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not a vector: `limit_reached` is in error.v1's enum only (write-receipt.md), as the TS and Rust suites also pin.
|
||||
check("error_v0_refuses_the_v1_only_limit_reached_code") {
|
||||
val body = linkedMapOf<String, Any?>("error" to "x", "routingHint" to null, "code" to "limit_reached", "details" to null)
|
||||
val v1 = decodeErrorFrame(CultNetMessage(ERROR_V1_VERSION, body + ("messageId" to null))).code
|
||||
try {
|
||||
decodeErrorFrame(CultNetMessage(ERROR_V0_VERSION, body))
|
||||
listOf("error.v0 accepted limit_reached")
|
||||
} catch (_: IOException) {
|
||||
if (v1 == "limit_reached") emptyList() else listOf("error.v1 lost limit_reached")
|
||||
}
|
||||
}
|
||||
|
||||
for (case in list(obj(vectors["scenarios"])["cases"])) {
|
||||
val scenario = obj(case)
|
||||
check("scenario/${scenario["name"]}") {
|
||||
val problems = mutableListOf<String>()
|
||||
// The fake server states its capability the only way a real one does: in its hello.
|
||||
val hello = cultNetHello(
|
||||
"fake-server", "fake",
|
||||
supportedMessageVersions = if (scenario["server"] == "receipt") CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS else emptyList(),
|
||||
)
|
||||
val windowMs = resultWindowMs(helloAdvertisesWriteReceipts(hello))
|
||||
if (windowMs != constants[scenario["window"] as String]) problems += "window $windowMs != ${scenario["window"]}"
|
||||
val told = linkedMapOf<String, WriteSettlement>()
|
||||
val tracker = WriteTracker(windowMs) { id, settlement ->
|
||||
if (id in told) problems += "$id settled twice"
|
||||
told[id] = settlement
|
||||
}
|
||||
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.
|
||||
}
|
||||
}
|
||||
val expect = obj(scenario["expect"])
|
||||
for ((id, wantAny) in expect) {
|
||||
val want = obj(wantAny)
|
||||
val got = told[id]
|
||||
if (got == null) { problems += "$id: never settled, wanted ${want["outcome"]}"; continue }
|
||||
val (outcome, code, reason) = when (got) {
|
||||
is WriteSettlement.Admitted -> Triple("admitted", null, null)
|
||||
is WriteSettlement.Refused -> Triple("refused", got.code, null)
|
||||
is WriteSettlement.Unconfirmed -> Triple("unconfirmed", null, got.reason.wire)
|
||||
}
|
||||
if (outcome != want["outcome"]) problems += "$id: told $outcome, wanted ${want["outcome"]}"
|
||||
if ("code" in want && code != want["code"]) problems += "$id: code $code, wanted ${want["code"]}"
|
||||
if ("reason" in want && reason != want["reason"]) problems += "$id: reason $reason, wanted ${want["reason"]}"
|
||||
if (tracker.recorded(id)?.wire != want["recorded"]) problems += "$id: recorded ${tracker.recorded(id)?.wire}, wanted ${want["recorded"]}"
|
||||
}
|
||||
for (id in told.keys) if (id !in expect) problems += "$id: settled but the scenario expects nothing for it"
|
||||
problems
|
||||
}
|
||||
}
|
||||
|
||||
// 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") }
|
||||
}
|
||||
val failed = results.values.count { it.isNotEmpty() }
|
||||
println("${results.size - failed}/${results.size} passed")
|
||||
return failed == 0
|
||||
}
|
||||
|
||||
/** Strict decode of whichever receipt-family frame this is, returned as the message its typed form encodes; anything else is refused. */
|
||||
private fun decodeReceiptFrame(message: CultNetMessage): CultNetMessage = when (message.schemaVersion) {
|
||||
WRITE_ADMITTED_VERSION -> decodeWriteAdmitted(message).toMessage()
|
||||
ERROR_V1_VERSION, ERROR_V0_VERSION -> decodeErrorFrame(message).toMessage()
|
||||
else -> throw IOException("not a receipt frame: ${message.schemaVersion}")
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun obj(value: Any?): Map<String, Any?> = value as Map<String, Any?>
|
||||
private fun list(value: Any?): List<Any?> = value as List<Any?>
|
||||
|
||||
private fun unhex(text: String): ByteArray = ByteArray(text.length / 2) { text.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
|
||||
|
||||
/** A small JSON reader for the vector file: objects, arrays, strings, integers, true/false/null. */
|
||||
private class JsonReader(private val text: String) {
|
||||
private var at = 0
|
||||
|
||||
fun readDocument(): Any? = readValue().also { skipSpace(); if (at != text.length) throw IOException("trailing JSON") }
|
||||
|
||||
private fun skipSpace() { while (at < text.length && text[at] in " \t\r\n") at++ }
|
||||
|
||||
private fun readValue(): Any? {
|
||||
skipSpace()
|
||||
return when (val c = text[at]) {
|
||||
'{' -> {
|
||||
at++
|
||||
val map = linkedMapOf<String, Any?>()
|
||||
skipSpace()
|
||||
if (text[at] == '}') { at++; return map }
|
||||
do {
|
||||
skipSpace()
|
||||
val key = readString()
|
||||
skipSpace(); expect(':')
|
||||
map[key] = readValue()
|
||||
skipSpace()
|
||||
} while (closes('}'))
|
||||
map
|
||||
}
|
||||
'[' -> {
|
||||
at++
|
||||
val items = mutableListOf<Any?>()
|
||||
skipSpace()
|
||||
if (text[at] == ']') { at++; return items }
|
||||
do {
|
||||
items += readValue()
|
||||
skipSpace()
|
||||
} while (closes(']'))
|
||||
items
|
||||
}
|
||||
'"' -> readString()
|
||||
't' -> literal("true", true)
|
||||
'f' -> literal("false", false)
|
||||
'n' -> literal("null", null)
|
||||
else -> {
|
||||
if (c != '-' && !c.isDigit()) throw IOException("unexpected '$c' at $at")
|
||||
val start = at++
|
||||
while (at < text.length && text[at].isDigit()) at++
|
||||
text.substring(start, at).toLong()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** After an element: false when [close] ends the container, true after a comma. */
|
||||
private fun closes(close: Char): Boolean {
|
||||
val c = text[at++]
|
||||
if (c == close) return false
|
||||
if (c != ',') throw IOException("expected , or $close at ${at - 1}")
|
||||
return true
|
||||
}
|
||||
|
||||
private fun literal(word: String, value: Any?): Any? {
|
||||
if (!text.startsWith(word, at)) throw IOException("bad literal at $at")
|
||||
at += word.length
|
||||
return value
|
||||
}
|
||||
|
||||
private fun expect(c: Char) { if (text[at++] != c) throw IOException("expected $c at ${at - 1}") }
|
||||
|
||||
private fun readString(): String {
|
||||
expect('"')
|
||||
val out = StringBuilder()
|
||||
while (true) {
|
||||
val c = text[at++]
|
||||
when (c) {
|
||||
'"' -> return out.toString()
|
||||
'\\' -> when (val e = text[at++]) {
|
||||
'n' -> out.append('\n'); 't' -> out.append('\t'); 'r' -> out.append('\r')
|
||||
'b' -> out.append('\b'); 'f' -> out.append('\u000c')
|
||||
'u' -> { out.append(text.substring(at, at + 4).toInt(16).toChar()); at += 4 }
|
||||
'"', '\\', '/' -> out.append(e)
|
||||
else -> throw IOException("bad escape \\$e")
|
||||
}
|
||||
else -> out.append(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue