Compare commits

..
Author SHA1 Message Date
7665df33ae Merge pull request 'TS: write_admitted / error.v1 decoder, WriteTracker, vector replay runner' (#62) from libby/ts-write-receipt-client into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 5m10s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 31s
2026-11-08 19:52:37 +00:00
92b4cb7ce9 Merge pull request 'C#: database server answers writes with write_admitted / error.v1 (write-receipt spec)' (#61) from libby/cs-write-receipt-server into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 19:51:52 +00:00
36494dafb9 Merge pull request 'cultmesh-rs: RUDP document server answers writes with write_admitted / error.v1' (#60) from libby/rs-write-receipt-server into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 19:49:50 +00:00
5bf2428295 Merge pull request 'spec: cultnet write receipt (write_admitted, error id echo, admitted/refused/unconfirmed)' (#59) from libby/cultnet-write-receipt-spec into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 19:49:40 +00:00
Libby
1a228349f1 C#: a put or delete with an empty or blank schemaId or recordKey is refused message_invalid (as an unreadable frame, like the Rust server) and not applied
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 5m35s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 19:46:45 +00:00
Libby
621b86ca15 Spec #59: an empty or blank schemaId or recordKey is message_invalid (details.field message, the write's id), never applied; vector for it
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 5m55s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 46s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 19:45:19 +00:00
Libby
90d252735f cultmesh-rs: a refused reliable-sequenced write is received and acknowledged first, and a refused write counts as session activity
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 6m11s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 52s
Refusing before session.receive left the packet unacknowledged: the client resent it forever and every later ordered write stalled. The test now uses the same peer and proves a later ordered write is admitted.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 19:38:43 +00:00
Libby
dc1ae51e92 cultmesh-ts: publishRudpDocumentOnce builds the put before connecting, so a put that cannot be built keeps its cause on both paths
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 6m13s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 19:37:08 +00:00
Libby
0fbf2baea6 cultnet-ts, cultmesh-ts: Heimdall's five Lows on send-only (spec wording, error.v1 ack-time refusal tests, build errors keep their cause, presence changelog, ack-cut anchors)
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 19:32:49 +00:00
Libby
89ff3d2cbd cultnet-ts test: the Odin presence publisher is send-only, so a refusal after the transport ack is not awaited
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 19:26:25 +00:00
Libby
206dbe142b cultmesh-ts: sendOnly tests use one fake server per publish (the reused one threw on the second connect and hung the run); spec names send-only and the presence publisher
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
The hang was the fake server, not the writer: it is bound to one connection id, and two publishes in one test sent a second connect it rejected from inside the socket handler. The unacknowledged-send test also scripted an admit, whose reply piggybacks the put's ack.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 19:24:52 +00:00
Libby
0caef4d564 WIP (unverified): publishRudpDocumentOnce sendOnly + presence publisher send-only; tests written, cultmesh-ts receipt-client run hung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 19:24:52 +00:00
Libby
c89c4ee39a cultmesh-ts: test kills the flush-cap mutant
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 5m58s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m18s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:07:54 +00:00
Libby
af1ac72359 cultmesh-ts, cultnet-ts: only write_admitted admits a put (no echo admission); the send is bounded by the result window and a timeout is local_send_failed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:05:42 +00:00
Libby
293b4e83bc cultmesh-ts, cultnet-ts: an unanswered put is unconfirmed on every server, session_ended before a result is an error, a failed local send is its own reason
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:03:39 +00:00
Libby
a67faad27c cultnet-ts: tests for two tracker mutants (send expires other windows; non-answer frames settle nothing)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:03:39 +00:00
Libby
6eea66c57f cultnet-ts, cultmesh-ts: write receipt client (frames, WriteTracker, replay runner, opt-in writer)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:03:39 +00:00
Libby
34bf7aa0ee C#: test kills the schema id detail-value mutant
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 5m59s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:03:36 +00:00
Libby
40b34295e3 C#: receipt writes need a reliable, non-sequenced channel; a refusal without a record key or schema id carries a null detail value
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:01:59 +00:00
Libby
e4b67a9e0b cultmesh-rs: a receipt session accepts writes only on a reliable, non-sequenced channel; a test pins that a reconnect starts a fresh session
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 10m5s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m8s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:00:48 +00:00
Libby
74adad916a Spec #59: receipt writes need a reliable, non-sequenced channel; error.v1 schema description names limit_reached; #55-#58 paragraph states which clients still window-succeed
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 9m8s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m14s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:59:39 +00:00
Libby
48cf3b3323 C#: tests kill the replay-details and unreliable non-write mutants
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:45:29 +00:00
Libby
0a67b4d8aa C#: a remembered refusal keeps its code and field, never the write's strings, and replays in fixed text; a write on an unreliable channel of a receipt session is refused message_invalid (channel)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:42:22 +00:00
Libby
1a05df2524 cultmesh-rs: server-wide remembered-write budget is a maintained counter; a session whose refusal set overflowed refuses unseen ids after the budget frees
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 5m35s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m1s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:36:10 +00:00
Libby
2c5203dc82 Spec #59: local_send_failed is its own unconfirmed reason
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 5m31s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:34:52 +00:00
Libby
3443e1dc93 Spec #59: error.v1 code enum names limit_reached; server-wide budget is a conformance column; an overflowed refusal set refuses unseen ids for the session
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:34:19 +00:00
Libby
c8f869e54e cultnet-rs, cultmesh-rs: null or missing document/schemaId refused naming the field; budget tests use literal bytes; unreliable-refusal local failure test
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 5m33s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:32:03 +00:00
Libby
d7b36416c6 cultmesh-rs: pin limit-refusal memory bound and legacy unreliable drop (mutant-driven tests)
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) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:30:36 +00:00
Libby
ef5cd88111 C#: v0 errors for snapshot, shard log and variant refusals carry fixed text, never an exception's message
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 5m35s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 36s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:13:58 +00:00
Libby
d654cb2b99 C#: tests kill the refusal-set bound and abandoned-rehash mutants
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:12:31 +00:00
Libby
1447ccd8dd cultnet-rs, cultmesh-rs: write_admitted in spec field order; server-wide remembered-write budget over digest entries; id size limit; limit refusals remembered; unreliable writes refused; local failure is its own goodbye
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 5m14s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 46s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:09:28 +00:00
Libby
c4d1efe000 C#: receipt ids capped at 256 bytes and held as digests; limit refusals remembered, no slot freed; required fields refused with fixed text; RUDP server advertises no receipt frames
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) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:07:23 +00:00
Libby
734087766a Spec #59: id size, digest memory, limit refusals remembered, required fields, reliable channel, local send failure, per-class conformance table
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 6m0s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 46s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:04:21 +00:00
Libby
541d734675 C#: a forward is retryable only when provably not sent; unconfirmed otherwise; refusals carry fixed text
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultNetWriteNotSentException names the pre-send boundary (connect, hello, no endpoint,
or a send port that proves nothing was written). Every other forwarding failure keeps
the session claim. ForUnownedSchema and the routing refusal no longer carry exception text.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:02:56 +00:00
Libby
b89a883700 spec: C# RUDP schema server is not receipt-capable; advertise only if end to end
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:58:51 +00:00
Libby
2e5df24e96 C#: loopback test for the unreadable-frame answer; Server.Port seam; the answer now reaches the wire
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
An undecodable frame is classed legacy by Decode, so the schema-side guard was unreachable
and a receipt peer was never answered. The guard moves to the legacy decode.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:58:17 +00:00
Libby
74b8a4cbf8 C#: drop the redundant Silent state of a write claim (a silent claim is an unanswered one)
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:53:05 +00:00
Libby
0182a6d40b cultnet-rs, cultmesh-rs: remembered-write cap refusal is error.v1 limit_reached; pin authorize-once with a counting sink
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 5m19s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 41s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:32:49 +00:00
Libby
ed5f9d30d1 C#: receipt server follows Heimdall's review of #61
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 5m30s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 21s
Look the id up first and authorize only on first sighting; remember refusals within the cap; never re-forward an id whose primary stayed silent; end the connection when an answer cannot be queued; fixed refusal texts; limit_reached cap refusal; commit-point doc on ApplyPut/Delete.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:32:39 +00:00
Libby
eafcdcdf7c cultnet: add limit_reached to error.v1 code enum, with captured vector
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 4m51s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:29:18 +00:00
Libby
5f699c27fa cultmesh-rs: a put reusing a delete's id is refused (mutant-driven test)
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:27:54 +00:00
Libby
d4f440ac7c cultmesh-rs: receipt server looks the id up first, remembers refusals, answers deletes and unqueueable refusals
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 4m47s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Follows Heimdall's second review of #60: replay by (session,id) before any
authorization; refusals stored with their hash; delete shares the dedupe
table and settles capability; a refusal the queue cannot take ends the
session; a peer's refusal frames and a receipt session's refused deletes are
not violations; the write limit has its own fixed text; the sink trait says
Ok means committed and visible.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:22:50 +00:00
Libby
b0ea18371f C#: database server answers writes with write_admitted / error.v1 (write-receipt spec)
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 5m46s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 36s
Opt-in per connection by the first hello (or first write); the server answers a
capable hello with its own. A put or delete commits, then gets write_admitted under
its id; every refusal is error.v1 with the id (an unreadable frame carries the id
when it still holds one). (actor, messageId) dedupe is bound to the content hash and
asked only after the write authority grants; same id other content is message_invalid.
The shard write forwarder says hello, waits for the primary's receipt, and the server
relays only what the primary answered under the original id.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:04:37 +00:00
Libby
7a560b1984 spec: dedupe scope is at least the session; a resend after a reconnect is a new write
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 5m52s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 41s
Per Heimdall's review. The dedupe key lives for the life of its session, bounded per
session without forgetting an id the server promised to answer. A longer retention
is optional and never to be relied on. After a reconnect callers resend only writes
that are idempotent by record key and read back before resending any other.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:58:39 +00:00
Libby
8bd31a5d4a cultmesh-rs: receipt server follows Heimdall's review of #60
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 7m11s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
A refused put, a full dedupe table and a reused id refuse the write, not the
session; only a protocol violation ends one. A frame that does not decode but
holds a messageId is refused with that id. The server answers a receipt-capable
hello with its own, listing both frames. The sink's authorization (new
authorize_raw_document, default admit) runs before the session's remembered
writes are consulted. A second hello does not change the capability: it is set
once, by the first hello or the first write. A receipt that cannot be queued, and
a write the payload budget would drop silently, end the session instead of
leaving the write unanswered. Refusals to a receipt-capable session are queued
reliably.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:56:17 +00:00
Libby
45e95984e5 WIP C# write-receipt server (not built, not tested): messages, content hash, per-peer dedupe state, receipt-aware wait
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:41:14 +00:00
Libby
9f73047c29 cultmesh-rs: RUDP document server answers writes with write_admitted / error.v1 (write-receipt spec)
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) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
cultnet-rs gains cultnet.write_admitted.v0, cultnet.error.v1 (v0 + messageId) and
write_content_hash. The server opts a session in only when its hello lists both frames
(framing only; admission rules are unchanged). On an opted-in session an admitted put is
answered with a receipt carrying its id after the sink accepts it, a refusal is an error.v1
naming the id, a resend of the same content is re-receipted without reaching the sink, and the
same id with other content is refused message_invalid (never the old receipt). Other sessions
are answered exactly as before.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:38:46 +00:00
Libby
ab7bc771e3 spec: write receipt per Heimdall's review (dedupe by actor+content hash, admitted-late, 5 s window, capability is framing only)
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 6m28s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 2m19s
- messageId unique per authenticated actor and session; dedupe key (actor, id) bound to SHA-256 of
  [operation, schemaId, recordKey, payload]; same id with other content is message_invalid, never the old receipt.
- A late answer after the window is recorded on the write (admitted_late / refused_late); no blind resend.
- A forwarder with no primary answer relays nothing; the client reports unconfirmed.
- RECEIPT_RESULT_WINDOW_MS 5000 (default), LEGACY_RESULT_WINDOW_MS 500 (fallback only), DEDUPE_RETENTION_MS as vector constants.
- The hello capability changes framing only and never authorizes.
- Batches can commit partially; the caller owns retry.
- Vectors: constants, worked content hashes, 7 dedupe cases, late-answer and window scenarios.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:31:49 +00:00
Libby
662ae048d8 spec: cultnet write receipt (write_admitted.v0, error.v1 id echo, three-outcome clients)
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) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Specification and shared vectors only, no runtime code. A write is answered
exactly once with cultnet.write_admitted.v0 or cultnet.error.v1 carrying its
messageId; the new frames go only to connections whose hello lists them (today's
TS decoder rejects unknown keys and versions, so "old clients ignore it" is
false). Clients settle admitted | refused | unconfirmed; the result window is the
pre-receipt fallback and ends unconfirmed. Wire vectors, schema-invalid cases and
16 replay scenarios in write-receipt-vectors.json.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:28:46 +00:00
dc5abedffc Merge pull request 'C#: SubmitDocumentAsync and the shard write forwarder complete on the result frame' (#58) from libby/cs-client-ack-finding into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 7m52s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 31s
2026-11-08 13:24:39 +00:00
Libby
09e6a528b8 C#: SubmitDocumentAsync and the shard write forwarder complete on the result frame
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 6m41s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 32s
CultNetWriteCompletion.SendAndAwaitResultAsync registers a refusal handler before
the send, then waits a result window (default 500 ms; CultMeshClientOptions.
SubmitResultWindow, CultNetSchemaWriteForwarderOptions.ResultWindow). A
cultnet.error.v0 inside it rejects with the new CultNetWriteRefusedException
(Code: write_not_authorized, message_invalid, ...) whatever its order against the
send or transport ack; a send returning no longer completes the write. The
forwarder's refusal now throws on the same path as an unreachable primary, so
CultNetDatabaseServer.TryForwardPutAsync/DeleteAsync no longer report a refused
forward as done. Raw send primitives (C# schema client/session, Python, Kotlin)
carry the doc line: fire-and-forget, an ack or a return is not admission, read the
result frame.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:24:27 +00:00
1b3b625474 Merge pull request 'cultmesh-rs: single and batch publish complete on the result frame, not the transport ack' (#57) from libby/rs-client-ack-finding into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 13:18:41 +00:00
Libby
675a6494c1 cultmesh-rs: single and batch publish complete on the result frame, not the transport ack
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 6m10s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m12s
One loop now serves both: every put acknowledged, then a refusal frame (typed
cultnet.error.v0) fails the call in either order against the ack, and a success
frame or the end of the new result_window option (default 500 ms) completes it.
The batch no longer waits on flush_reliable alone, so it cannot hide a refusal.
Raw send primitives (CultNetRudpSocketTransportConnection::send/send_reliable/
flush_reliable, tcp_framed send) carry the doc line: fire-and-forget, an ack or
a return is not admission, read the result frame.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:18:40 +00:00
Libby
3c68d7d19c Merge remote-tracking branch 'origin/main' into libby/rs-client-ack-finding 2026-11-08 13:15:17 +00:00
3162e2ea66 Merge pull request 'cultnet-ts: the Odin presence publisher completes on the result frame, not the ack' (#56) from libby/ts-client-ack-finding-cultnet into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 13:15:08 +00:00
Libby
531fce567d cultnet-ts test: cancel the Odin stub's delayed send when the test ends
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 7m47s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 57s
The stub's 120 ms second packet fired after peer.close() and threw
ERR_SOCKET_DGRAM_NOT_RUNNING as an uncaught exception, failing whichever
publisher test was running when the timer landed (load-dependent). Timers are
now cleared in the test's after hook instead of swallowing the error.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:14:49 +00:00
Libby
0b326fa5ee WIP cultnet-ts: Odin presence publisher completes on the result frame, not the ack (passes alone; two publisher tests fail inside the parallel full run, unexplained)
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 5m32s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:36:23 +00:00
Libby
cdbe5889e0 merge main 2026-11-08 11:35:00 +00:00
ff1c92d2fe Merge pull request 'cultmesh-ts: a put completes on its result frame, not on the transport ack' (#55) from libby/ts-client-ack-finding-cultmesh into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 5m38s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 31s
2026-11-08 11:34:56 +00:00
Libby
7082c9488b cultmesh-ts: a put completes on its result frame, not on the transport ack
publishRudpDocumentOnce resolved when peer.flush returned (ack only) and read no frame, so a
refused put resolved. It now rejects with CultMeshRudpWriteRefusedError (the peer's typed
cultnet.error.v0: write_not_authorized, message_invalid) whether the refusal arrives before the ack,
after it, or within resultWindowMs (default 500 ms) after it; a success reply ends the wait early.
publishRudpDocumentAndWaitForReceipt rejects with the same typed error instead of timing out.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:34:55 +00:00
Libby
dc7eec7e6e merge main 2026-11-08 11:33:16 +00:00
d4e4a2c085 Merge pull request 'GameCult.Networking test: the RUDP schema client delivers a refused put's result frame whatever the order of the ack' (#54) from libby/cs-client-ack-is-not-admission into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 11:30:21 +00:00
8cfc41d665 Merge pull request 'cultmesh-py test: a put the server refused is reported refused after its reliable ack arrived' (#53) from libby/py-client-ack-is-not-admission into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 11:30:18 +00:00
6f2092171f Merge pull request 'cultmesh-rs test: a publish whose refusal arrives before the ack is refused' (#52) from libby/rs-client-ack-is-not-admission into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 11:30:15 +00:00
de1d6b3444 Merge pull request 'cultnet-ts test: the Odin presence publisher does not complete on the ack when the refusal arrives first' (#51) from libby/ts-client-ack-is-not-admission-cultnet into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 11:30:12 +00:00
822d660627 Merge pull request 'cultmesh-ts test: publishRudpDocumentAndWaitForReceipt reports a refused put as refused' (#50) from libby/ts-client-ack-is-not-admission into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 11:30:09 +00:00
Libby
c1d24146db GameCult.Networking test: the RUDP schema client delivers a refused put's result frame whatever the order of the ack
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 8m11s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m10s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:29:50 +00:00
Libby
c6da8fc54a cultmesh-py test: a put the server refused is reported refused after its reliable ack arrived
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 7m26s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 37s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:28:23 +00:00
Libby
9e734447f6 cultmesh-rs test (FAILS, findings): publish completes on the ack when the refusal follows it; the batch publish completes on the ack whatever the refusal
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 7m32s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:27:53 +00:00
Libby
f53930bc13 cultmesh-rs test: a publish whose refusal arrives before the ack is refused
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 5m16s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 51s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:27:48 +00:00
Libby
dfd090dc55 cultmesh-ts test (FAILS, a finding): publishRudpDocumentOnce completes on the ack of a refused put
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 8m38s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m18s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:27:07 +00:00
Libby
4f90ea8156 cultmesh-ts test: publishRudpDocumentAndWaitForReceipt reports a refused put as refused, whether the ack comes before or after the refusal
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 8m50s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m24s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:27:06 +00:00
Libby
7766348c75 cultnet-ts test (FAILS, a finding): the Odin presence publisher completes on the ack when the refusal follows it
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) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:26:22 +00:00
Libby
08cf3b34b5 cultnet-ts test: the Odin presence publisher does not complete on the ack when the refusal arrives first
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 5m59s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:26:22 +00:00
7486f0a326 Merge pull request 'cultmesh-rs: send the capped frame's answer reliably and end the session once it is acknowledged' (#48) from libby/rs-capped-answer-reliable into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 11:21:28 +00:00
Libby
b2077fa2bf cultmesh-rs: a Pong in the capped grace acknowledges nothing; the grace deadline saturates
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 8m57s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m41s
A Ping in the 2 s grace after the capped frame was answered with a Pong built from the
session's ack state, which includes the capped frame. Every reply while the session is
ending now zeroes its ack fields. The deadline no longer wraps at the end of the clock.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:21:08 +00:00
3915a4e5f5 Merge pull request 'GameCult.Networking: resend the capped frame's answer until acknowledged, then end the session' (#49) from libby/cs-capped-answer-reliable into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 11:10:54 +00:00
Libby
0b1a468ddd GameCult.Networking: resend the capped frame's answer until acknowledged, then end the session
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 8m47s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m13s
The schema server answered the 8th undecodable frame and ended the peer's session in the same
call; ending clears the session's pending reliable packets, so a lost datagram was never
resent. The session now stays until the peer has acknowledged every answer (a lost one is
resent by PollResends) or a 2 s grace runs out, serves nothing after the capped frame
meanwhile, and then sends the goodbye.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:10:48 +00:00
Libby
03141bc93b cultmesh-rs: send the capped frame's answer reliably and end the session once it is acknowledged
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
The capped (8th) refusal went out unreliable and unordered with the goodbye at once, so a
lost datagram left the peer without it. It is now queued like any response and resent until
the peer acknowledges it; the session ends then (or after a 2 s grace) and serves nothing
after the capped frame meanwhile. The answer, its resends and the poll that carried the
capped frame acknowledge nothing, as every refusal. A reliable queue that cannot take the
answer ends the session at once with the unreliable refusal, as before.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:04:22 +00:00
337168812e Merge pull request 'cultmesh-ts: resend the capped frame's answer until acknowledged, then end the session' (#47) from libby/ts-capped-answer-reliable into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
2026-11-08 10:59:27 +00:00
Libby
9e5edeca52 cultmesh-ts: resend the capped frame's answer until acknowledged, then end the session
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 7m6s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 51s
endCappedSession deleted the session right after the answer was sent, so the resend
timer never saw it and a lost datagram left the peer without the answer. The session
now stays until the peer has acknowledged every reply (a lost one is resent) or a 2 s
grace runs out, serves nothing after the capped frame meanwhile, and then sends the goodbye.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 10:59:23 +00:00
fe73795d89 Merge pull request 'cultmesh-py: answer an unreadable RUDP frame and end the session after 8 consecutive refusals' (#45) from libby/py-refused-frames-cap into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 6m3s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 57s
2026-11-08 09:54:41 +00:00
Libby
bf81695537 cultmesh-py: answer the capped frame reliably and end the session once the answer is acknowledged
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 5m17s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 52s
The 8th refusal was sent unreliable and unordered with only the first response,
then the goodbye at once, so a lost datagram left the peer without it. All answers
now go the normal reliable path; the session ends when the peer has acknowledged
them (a lost one is resent) or after a 2 s grace, and serves nothing after the
capped frame meanwhile.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:54:37 +00:00
b1d6ec098d Merge pull request 'cultmesh-ts: end a RUDP session after 8 consecutive unreadable frames or authority-refused deletes' (#44) from libby/ts-refused-frames-cap into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 09:52:26 +00:00
Libby
23c7ce3738 cultmesh-ts: count and answer a frame that decodes but is no message the parser takes
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 6m9s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m17s
A map with an unknown schemaVersion was rethrown: unanswered, uncounted toward the
session cap. It is now answered message_invalid, reported, and counted like any other
unreadable frame, so the cap holds for it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:52:22 +00:00
51d2d0cf46 Merge pull request 'GameCult.Networking: answer an undecodable RUDP frame message_invalid and end the session at 8 in a row' (#46) from libby/cs-refused-frames-cap into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 09:47:22 +00:00
Libby
89bbb277bd GameCult.Networking: answer an undecodable RUDP frame message_invalid and end the session at 8 in a row
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 7m10s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m20s
Heimdall's parity ruling. The schema server ended a peer's session on the first
frame it could not decode. It now answers message_invalid (field "message") and
keeps the session; RudpCultNetSchemaServer.MaxConsecutiveRefusedFrames (8)
consecutive undecodable frames end it after the last is answered, and any
decoded frame resets the run. Handler faults still end the session at once.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:47:08 +00:00
Libby
b4979cb65b cultmesh-py: answer an unreadable RUDP frame and end the session after 8 consecutive refusals
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Heimdall's parity ruling. A frame that is no msgpack map used to be dropped
silently; it is now answered message_invalid (field "message") and the session
kept. MAX_CONSECUTIVE_REFUSED_FRAMES (8) consecutive unreadable frames, or
consecutive deletes the write authority refuses, end the session after the
capped frame is answered. The authority's decision is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:43:41 +00:00
Libby
621e05d552 cultmesh-ts: end a RUDP session after 8 consecutive unreadable frames or authority-refused deletes
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Heimdall's parity ruling. The refusal is still answered and the session kept
below the cap; the frame that reaches CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES
is answered too, then the session ends. The write-authority decision is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:41:18 +00:00
4828c41646 Merge pull request 'cultmesh-rs: keep the session through an unreadable frame or refused delete; end at 8 in a row' (#43) from libby/keep-session-invalid-cap into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
2026-11-08 09:36:55 +00:00
Libby
a0d9ed0cc5 cultmesh-rs: keep the session through an unreadable frame or refused delete; end it at 8 in a row
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 3m28s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 56s
Heimdall's parity ruling: the refusal is answered on the live session, and
MAX_CONSECUTIVE_REFUSED_FRAMES (8) consecutive undecodable frames (reset by
any decoded frame) or refused deletes (reset by any decoded non-delete) end
it. The sink gate is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:36:41 +00:00
fb705cbc1d Merge pull request 'cultmesh-rs: RUDP document server refuses unreadable frames and deletes with typed errors' (#42) from libby/rs-rudp-typed-refusals into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 09:33:41 +00:00
Libby
34be64789e cultmesh-rs: RUDP document server refuses unreadable frames and deletes with typed errors
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 5m40s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 32s
An undecodable schema frame was dropped and a DocumentDelete ignored, both
silently. Now the first is refused message_invalid (details.field "message")
and the second write_not_authorized naming the record, each with a fixed
reason, ending the peer's session as the other refusals do. The sink gate is
unchanged. Both bytes are pinned against the shared error-vectors.json.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:33:27 +00:00
a95b43d48f Merge pull request 'cultnet error contract: one message_invalid code replaces malformed_document_put/delete' (#41) from libby/error-message-invalid into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 4m36s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 21s
2026-11-08 09:17:52 +00:00
56 changed files with 9060 additions and 297 deletions

View file

@ -115,6 +115,7 @@ copies), the C# `CultNetErrorMessage` factory, the Rust `CultNetErrorCode`, and
`unowned_schema` (a write, or a remote put or delete, for a schema and key no shard owns),
`write_not_authorized` (a peer's put or delete its server's remote write authority did not grant) and
`unsupported_schema_version` (a message whose `schemaVersion` the peer does not speak; `details` names `schemaVersion` and the offered value) and
`limit_reached` (a receipt-capable server's per-session remembered-write table is full, so a new write is refused; `details` all null) and
`message_invalid` (a request that lacks a required field or carries an ill-typed one: a document-put or delete, or a subscription without an id; `details.field` names the request field, or `message` when the whole frame cannot be read).
A malformed put or delete is `message_invalid`, not a per-operation code: the peer knows which operation it sent.

View file

@ -0,0 +1,122 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.error.v1.schema.json",
"title": "CultNet Error Message v1",
"description": "cultnet.error.v0 plus messageId (write-receipt.md). Sent in place of cultnet.error.v0 only to a connection that declared the write-receipt capability. v0 is unchanged: its schema is closed (additionalProperties false), so an extra key would make a v0 peer reject the frame instead of ignoring it. Map-mode: every key is written, messageId nil when the error answers no readable message. The code enum is the v0 enum plus limit_reached (write-receipt.md, Remembered-write limits).",
"type": "object",
"required": [
"schemaVersion",
"error",
"routingHint",
"code",
"details",
"messageId"
],
"additionalProperties": false,
"properties": {
"schemaVersion": {
"const": "cultnet.error.v1"
},
"error": {
"type": "string",
"minLength": 1
},
"routingHint": {
"description": "Optional shard routing detail for an authority failure. Null for every selection/cursor refusal code (R-N).",
"type": [
"object",
"null"
],
"additionalProperties": false,
"required": [
"shard",
"reason"
],
"properties": {
"shard": {
"type": [
"object",
"null"
]
},
"reason": {
"type": "string"
}
}
},
"code": {
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each). limit_reached: a receipt-capable server refuses a NEW write because its per-session remembered-write table (1024 entries) or its server-wide memory budget is full; messageId is the write's id.",
"type": [
"string",
"null"
],
"enum": [
null,
"selection_invalid",
"cursor_stale",
"cursor_invalid",
"reference_outside_target",
"variant_unsupported",
"unowned_schema",
"write_not_authorized",
"unsupported_schema_version",
"message_invalid",
"limit_reached"
]
},
"details": {
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
"type": [
"object",
"null"
],
"additionalProperties": false,
"required": [
"field",
"value",
"asOf",
"current"
],
"properties": {
"field": {
"description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\". unsupported_schema_version: \"schemaVersion\". message_invalid: the request field that is missing or ill-typed (\"message\" when the whole frame cannot be read). limit_reached: no field; details stay all null. message_invalid also names \"messageId\" (over 256 bytes), \"document\", \"schemaId\" or \"channel\" (write on an unreliable channel).",
"type": [
"string",
"null"
]
},
"value": {
"description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.",
"type": [
"string",
"null"
]
},
"asOf": {
"description": "cursor_stale: the cursor's own minted asOf.",
"type": [
"integer",
"null"
],
"minimum": 0
},
"current": {
"description": "cursor_stale: the answering server's current asOf.",
"type": [
"integer",
"null"
],
"minimum": 0
}
}
},
"messageId": {
"description": "The messageId of the write (or other request) this error answers, echoed byte for byte. Null only when the frame was unreadable or carried no messageId.",
"type": [
"string",
"null"
],
"minLength": 1
}
}
}

View file

@ -0,0 +1,16 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.write-admitted.schema.json",
"title": "CultNet Write Admitted",
"description": "The receipt for one admitted document put or delete (write-receipt.md). Sent only after the write's commit point, only to a connection that declared the write-receipt capability, once per messageId. Map-mode: every key is written, none is nil. A receipt never carries an error, and an error.v1 never carries admission.",
"type": "object",
"required": ["schemaVersion", "messageId", "operation", "schemaId", "recordKey"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.write_admitted.v0" },
"messageId": { "description": "The messageId of the put or delete this admits, echoed byte for byte.", "type": "string", "minLength": 1 },
"operation": { "enum": ["put", "delete"] },
"schemaId": { "description": "The schemaId of the admitted write, echoed.", "type": "string", "minLength": 1 },
"recordKey": { "description": "The recordKey of the admitted write, echoed.", "type": "string", "minLength": 1 }
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,83 @@
# CultNet write receipt (specification)
Status: specification. No runtime implements it yet; the conformance follow-ups below do.
Rules: Heimdall's ruling of 2026-11-08 (a write completes on its result frame; an ack or a return is not admission).
Files here: `cultnet.write-admitted.schema.json`, `cultnet.error.v1.schema.json`, `write-receipt-vectors.json`. They move to `contracts/cultnet/` when the first runtime implements them.
## The problem
A put or delete today has two answers on the wire: nothing (admitted, or the server never read it) and `cultnet.error.v0` (refused, with no id saying which write). A transport ack proves delivery. Clients that completed on the ack, or on a window with no refusal, reported refused writes as done (CultLib #55 to #58 bound this with a result window; this spec replaces the window with an answer).
## Frames
**`cultnet.write_admitted.v0`**: `{ schemaVersion, messageId, operation: "put"|"delete", schemaId, recordKey }`, every key written, none nil. Sent once a put or delete is admitted. The id echoes the write's `messageId` byte for byte; `operation`, `schemaId`, `recordKey` echo the write.
**`cultnet.error.v1`**: `cultnet.error.v0` plus a required `messageId` (string or nil). The code enum is v0's plus `limit_reached` (see Remembered-write limits); `cultnet.error.v1.schema.json` is the authority for the closed set. Sent in place of `error.v0` to a receipt-capable connection. `messageId` is the id of the request the error answers; nil only when the frame was unreadable or carried no id.
Why a new error version and not an extra key on v0: the v0 schema is closed (`additionalProperties: false`) and the TypeScript decoder (Ajv over the closed schemas) rejects unknown keys and unknown `schemaVersion`s, and `parseCultNetMessage` throws on an unknown version; Rust's decoder errors on an unknown version too. Unknown-key tolerance of the Rust, Python and Kotlin error decoders was not checked, so none is assumed, so a surprise key or frame breaks an old client's whole receive path. Old clients are therefore never sent either new frame. This reverses the "old clients ignore it" assumption, which is false for today's decoders.
## Capability: opting in
A connection is receipt-capable when its `cultnet.hello.v0` (sent before its first write; the schema channel is reliable and ordered) lists both `cultnet.write_admitted.v0` and `cultnet.error.v1` in `supportedMessageVersions`. A server MUST NOT send either new frame to any other connection, and answers it exactly as before (no receipt, `error.v0`). A receipt-capable server lists both in its own hello, so a client can tell a legacy server from a receipt server that has not answered yet. A client that sends no hello gets legacy behaviour. A server advertises the two frames in its hello only if it implements the receipt rules end to end.
**The capability changes framing only; it never authorizes.** A hello authenticates nothing and grants nothing: who may write, and which keys, is decided by the server from the transport-authenticated actor exactly as without the capability. Declaring the capability never makes a refused write admitted, never widens an actor's authority, and never changes what a write does; it selects only which frame the answer arrives in.
## Server rules
1. **One answer.** For every put or delete on a receipt-capable connection the server sends exactly one of `write_admitted` or `error.v1`, carrying that write's `messageId`. Never both, never two different answers.
2. **After the commit point.** `write_admitted` is sent only after the write is committed by the same rule that makes it visible to readers. Receipt of the bytes, parse, or authorisation alone is not admission.
3. **Id on every refusal.** Every refusal of a write, whatever its code (`write_not_authorized`, `message_invalid`, `unowned_schema`, `unsupported_schema_version`, shard authority errors, ...), is an `error.v1` whose `messageId` is the write's id. When the frame is readable enough to find the id, it is found; `message_invalid` for a missing or ill-typed field still carries the id. Nil only when no id can be read.
4. **Batches.** There is no batch frame; a batch is N writes, each with its own `messageId`, each answered under rules 1 to 3. A failed batch names its member by that id. A server may not collapse a batch into one answer.
5. **Identity and idempotent resend.** A `messageId` is unique per authenticated actor and session: the client mints it, never reuses it for different content in that session, and a server never accepts the id of one actor as the id of another. The server's dedupe key is `(actor, messageId)`, where *actor* is the transport-authenticated identity, never a name the hello or the frame claims. Alongside the key it keeps the **content hash** of the write and the answer it gave. The content hash is SHA-256 of the MessagePack array `[operation, schemaId, recordKey, payload]`, where `payload` is the raw document payload as a MessagePack `bin` for a put and nil for a delete (the vectors give worked hashes). **Scope: at least the session.** The key lives for the life of the session it was made in, and a server may bound how many writes it remembers per session (see Remembered-write limits; a server never forgets an id it promised to answer). A server MAY keep the key longer for the same actor, up to `DEDUPE_RETENTION_MS` (10 minutes), but a client MUST NOT rely on it: **a reconnect is a new session, and a resend on the new session is a new write.**
- Same key, same content hash, answer was `write_admitted`: answer `write_admitted` again with the same body. The write is not applied twice.
- Same key, same content hash, answer was a refusal: the same refusal again.
- **Same key, different content hash: refuse `message_invalid` (details `field: "messageId"`) and never re-send the old receipt.** Old receipts are for the old content; showing one for new content would report an unapplied write as admitted. The original write's state is unchanged.
- **Id size.** A write's `messageId` is at most 256 bytes of UTF-8. A longer id is refused `message_invalid` (`details.field: "messageId"`, the refusal's `messageId` the id as received, text fixed per Fixed texts) BEFORE anything is remembered: no table entry, no digest, no budget charge. Such a refusal is therefore not replayed from the table; a resend is refused afresh, identically.
- **What is remembered.** Per entry a server keeps only fixed-size digests (SHA-256) of the `messageId` and of the content hash, plus the answer (`admitted`, or the refusal's code and details). Memory per entry is bounded independent of id length and payload size. Lookup compares digests.
- **Remembered-write limits.** The per-session table holds at most 1024 entries. A server MAY also enforce a server-wide memory budget across all sessions (reference: 32 MiB of remembered entries). A NEW write (a key not already in the table) that would exceed either bound is refused `limit_reached` for that write alone, `messageId` the write's id, `details` all null; the write is not applied and the session continues. A resend of a remembered key is still answered from the table.
- **Limit refusals are remembered.** A `limit_reached` refusal is remembered like any other: a resend of the same id and content hash replays `limit_reached`. No slot is freed mid-session, so an id refused at a bound never becomes admitted later in that session (a server at the cap keeps the refused digest pair in a bounded refusal set of at most 1024 per session, outside the entry budget; past it, a further new write is refused `limit_reached` without being remembered, and the session refuses every id it has not seen for the rest of its life, even if a server-wide budget has since freed, so a refusal that was not remembered is never followed by an admission of the same id). The client mints a new id on a new session.
- **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. A `schemaId` or `recordKey` that is empty or only whitespace (present but names nothing) is not "missing": the write is refused `message_invalid` exactly as a frame that does not read, with `details.field` `"message"` and the write's id (vector `error_v1_empty_schema_id_is_unreadable_and_names_the_write`), and is never applied; a server must not hand it to its store.
- **Fixed texts.** The `error` string of these refusals is fixed, so vectors can compare it: `message_invalid: messageId exceeds 256 bytes`, `message_invalid: document is required`, `message_invalid: schemaId is required`, `message_invalid: receipt sessions require the reliable channel`, `limit_reached: the session's remembered-write limit is full`.
- **Reliable channel only.** A write is accepted on a receipt session only on a channel that is reliable and not sequenced (reliable ordered and reliable unordered both qualify; a sequenced channel drops superseded packets, so a write on it can vanish without trace). A write arriving on any other channel is refused `message_invalid` with `details.field: "channel"` (the transport profile's word for it) and fixed text. Receipts and refusals travel the reliable channel; a write that could be lost without trace cannot be answered once.
- Past the session (or a server's longer retention, if it keeps one) a resend is a new write.
6. **Forwarded writes.** A server that forwards a non-primary write answers the original peer only with what the primary answered: the primary's receipt becomes `write_admitted` with the original id, its refusal becomes `error.v1` with the original id. If the forward gets no answer the server relays nothing: no receipt, no error, no synthesized refusal. The client's own rule below then reports the write as unconfirmed. A forwarder never turns an absent or ambiguous primary answer into an admission.
7. **Looks alike.** An admitted and a refused write differ in `schemaVersion`; a receipt has no `error`, an `error.v1` has no admission. A transport ack is neither.
## Client rules: three outcomes
A write settles as exactly one of:
- **admitted**: a `write_admitted` whose `messageId` is the write's.
- **refused**: an `error.v1` whose `messageId` is the write's, carrying its `code` and `details` as the typed error.
- **unconfirmed**: nothing settled it. Reasons: `window_elapsed` (the result window ran from the *send* and passed with no answer; see Result window), `session_ended` (the peer closed the session, or it ended, before the write was answered) and `local_send_failed` (the client's own send of this write failed). A local send failure is never reported as refused: no server answer exists, and the write may or may not have left the machine. Unconfirmed is not success and not refusal. APIs that return a value return the tri-state; APIs that throw throw a distinct refused error and a distinct unconfirmed error and return normally only for admitted. A caller that needs admission treats unconfirmed as not admitted. Resend with the same `messageId` and unchanged content, **on the same session**, is safe against a receipt-capable server (rule 5). After a reconnect the resend is a new write, and a legacy server may apply a resend twice: the caller resends only writes that are idempotent by record key (a put of the same content to the same key, a delete of a key), and reads the record back before resending any other.
**Result window.** Two named constants (also in the vectors): `RECEIPT_RESULT_WINDOW_MS = 5000`, the default against a receipt-capable server (its hello lists both frames); `LEGACY_RESULT_WINDOW_MS = 500`, used only as the fallback against a server whose hello does not list them (or that sent none). The window runs from the send. A receipt-capable server that has not answered within its window is reported as unconfirmed like any other; clients log it as a server that missed its window.
**Late answers.** An answer for a write that already settled `unconfirmed` does not change what the caller was told, but the client records it on the write: a late `write_admitted` makes the write's recorded state `admitted_late`, a late refusal makes it `refused_late`. The write handle exposes that state, and a caller that got `unconfirmed` MUST consult it (and the record) before resending: it must not blind-resend a write that was admitted late. A resend on the same session with the same id and content is harmless on a receipt-capable server (rule 5); across a reconnect the caller follows the idempotence rule above.
**Legacy answers.** An `error.v0`, or an `error.v1` with nil `messageId`, names no write. It is attributed to a write only when exactly one write on the connection is unanswered; it then refuses that write. With two or more unanswered writes it settles none of them and each ends unconfirmed at its window. A frame naming a `messageId` that is not in flight settles nothing.
**Batches can commit partially.** A batch is N independent writes with no atomicity: some members may be admitted while others are refused or unanswered, and nothing is rolled back. The client reports each member's outcome and a batch result is only their set: admitted if every member is admitted, refused if any member is refused (naming the members), otherwise unconfirmed (naming the unanswered members). The caller owns retry: it resends exactly the members that are not admitted, with their original ids.
## Changes to what #55 to #58 shipped
Those clients complete *successfully* when the window ends with no refusal. Under this spec that case is `unconfirmed`. The TypeScript one-shot writer (#62) already reports it so: it resolves `admitted` only on a `write_admitted` carrying the put's id, and no other frame, a put echoed under the same id included, ever counts as admission. A caller whose server cannot answer a write yet (Odin, until it is receipt-capable) opts into the explicit send-only mode per call (`sendOnly: true`), which resolves the distinct outcome `sent` once the transport has acknowledged the put and never `admitted`. The Idunn presence publisher is send-only by its own function (`sendPresenceDocument`, not the `sendOnly` option): the same `sent` result, plus a rejection if a refusal naming the put (or naming nothing) arrives by the transport ack. Receipts and send-only are both opt-in, neither is the default. The C# and Rust clients of #55 to #58 keep the window-success until their conformance work lands, and callers of those must not read it as admission.
## Vectors
`write-receipt-vectors.json`: `constants` (the named windows and retention), `contentHash` (worked dedupe hashes), `dedupe` (server replay cases for rule 5), `wire` (MessagePack hex plus the decoded message for each new frame; the bytes are spec-authored with msgpack-python and every runtime's first conformance PR re-captures them with its own encoder and must match), `schemaInvalid` (frames that must be refused by a validator, each with why), and `scenarios` (event replays with the outcome each write must settle as; the clock is abstract; each case names its window constant). A conformant client passes every scenario; a conformant server's answers for the same inputs satisfy server rules 1 to 7.
## Conformance status
| Server class | Advertises both frames | Server-wide memory budget (MAY, Remembered-write limits) |
|---|---|---|
| `CultNetDatabaseServer` | yes | not implemented: only the per-session limit of 1024 applies |
| Shard write forwarder | yes | not implemented: only the per-session limit of 1024 applies |
| Rust RUDP document server | yes | implemented (32 MiB default, `max_remembered_write_bytes`) |
| C# RUDP schema server (`CultNetRudpSchemaServer`, `src/GameCult.Networking/CultNetRudpSchemaServer.cs`) | no | n/a |
Advertising is per server class, not per transport. A class advertises both frames in its hello only if it implements the receipt rules end to end; the C# RUDP schema server stays "no" until it answers writes with receipts.
## Conformance follow-ups (not in this PR)
C# server and client, Rust, TypeScript, Python, Kotlin: emit and parse the two frames; hello capability; tri-state client outcome; replace the #55 to #58 window-success; re-capture the wire vectors in each runtime. Server write paths include the shard write forwarder (rule 6) and the Odin catalog.

View file

@ -441,8 +441,8 @@ consumer that waits wants delivered, which is now just Acknowledged.
| `cultmesh-rs/src/lib.rs:781-838` `request_raw_snapshot_from_rudp_catalog` (Idunn `src/drivers.rs:5527` uses it) | a hand-rolled loop until `Acknowledged`, then the response | Cut 4: `wait_acknowledged` |
| `cultmesh-rs/src/lib.rs:870-900` `publish_cultnet_message_to_rudp_catalog` (Odin `odin-daemon`) | a hand-rolled loop until `Acknowledged`, then disconnect. The doc at `:869` claims "application admission" | Cut 4: `wait_acknowledged`; the doc says delivered to the document server's sink (`rudp_document_server.rs:343-372`) |
| `cultmesh-rs/src/lib.rs:918-938` `publish_cultnet_messages_to_rudp_catalog` (Ghostlight `mesh.rs:323`) | `flush_reliable`, then drop. **Several ordered frames, then close** | None; flush now means it |
| `cultmesh-ts/src/index.ts:6070-6098` `publishRudpDocumentOnce` (Heimdall, StreamPixels, Stonks, weksa, Bifrost, VoidBot, Hermodr) | `peer.flush` (`cultnet-ts/src/peer.ts:109-110`), then close | None. Cut 5 moves its connect-and-wait helper (`index.ts:6603-6650`) into cultnet-ts |
| `cultnet-ts/src/idunn-odin-presence-publisher.ts:89-215` `publishDocument`, behind `createIdunnRuntimePresencePublisher`. **StreamPixels' service runs this**: `apps/service/scripts/idunn-entry.mjs` runs `deployment/idunn/runtime-presence.mjs`, which calls `createIdunnRuntimePresencePublisher` (`:46`) from its vendored CultLib `542ddd3` (StreamPixels `a49f7d8`; pass 2's `30ee8b9` is stale), where it lives under its older name `cultnet-ts/src/idunn-runtime-authority.ts:206` | a hand-rolled session loop (`:186-208`) until `outstandingReliablePacketCount === 0`. It does resend (`:205`). It drops `readyToSend` (`:198`), so promoted queued packets wait for the resend timer. It pins `initialSequence: 1` (`:103`) and never sends a Disconnect, so Odin's hub keeps each publish's peer until it times out (each publish binds a new port, so each is a new peer) | Cut 5: the socket transport, `connectAndWait`, `send("schema")`, `flush`, `close` (which sends the Disconnect, `rudp.ts:956-969`) |
| `cultmesh-ts/src/index.ts:6211-6303` `publishRudpDocumentOnce` (Heimdall, StreamPixels, Stonks, weksa, Bifrost, VoidBot, Hermodr) | `peer.flush` (`cultnet-ts/src/peer.ts:109-110`), then close | None. Cut 5 moves its connect-and-wait helper (`index.ts:6603-6650`) into cultnet-ts |
| `cultnet-ts/src/idunn-odin-presence-publisher.ts:97-136` `sendPresenceDocument` (formerly `publishDocument`), behind `createIdunnRuntimePresencePublisher`. **StreamPixels' service runs this**: `apps/service/scripts/idunn-entry.mjs` runs `deployment/idunn/runtime-presence.mjs`, which calls `createIdunnRuntimePresencePublisher` (`:46`) from its vendored CultLib `542ddd3` (StreamPixels `a49f7d8`; pass 2's `30ee8b9` is stale), where it lives under its older name `cultnet-ts/src/idunn-runtime-authority.ts:206` | a hand-rolled session loop (`:186-208`) until `outstandingReliablePacketCount === 0`. It does resend (`:205`). It drops `readyToSend` (`:198`), so promoted queued packets wait for the resend timer. It pins `initialSequence: 1` (`:103`) and never sends a Disconnect, so Odin's hub keeps each publish's peer until it times out (each publish binds a new port, so each is a new peer) | Cut 5: the socket transport, `connectAndWait`, `send("schema")`, `flush`, `close` (which sends the Disconnect, `rudp.ts:956-969`) |
| `cultnet-ts/src/signed-daemon-health.ts:104-162` `publishSignedDaemonHealth` (Hermodr, Odin `idunn-rudp.cjs`) | a hand-rolled loop: sends the packets `sendMany` admitted, waits up to 500 ms for the **first** Ack packet of any content (`:146-153`), swallows the timeout, closes. Resends run inside that wait (`:303`) but promoted queued packets are never sent | Cut 5, as above. Today a write not acked within 500 ms, or any payload over 32 packets, is reported published |
| `cultnet-ts/src/operation-service.ts` | the operation response | none (the response is the proof) |
| C# `FlushReliable` callers: Gjallar `Program.cs:3055-3080` (two ordered frames, flush, dispose) | flush | None |

View file

@ -808,6 +808,7 @@ interface CultNetSchemaMessageTransport : AutoCloseable {
val profile: CultNetTransportProfile
val stats: CultNetTransportStats
/** Fire-and-forget: an ack or a return is not admission; read the result frame. */
fun sendSchemaMessage(message: CultNetMessage)
fun receiveSchemaMessage(timeoutMs: Long = 1_000, pollIntervalMs: Long = 5): CultNetMessage?
@ -3167,13 +3168,16 @@ class CultNetRudpSocketTransportConnection(
return connected
}
/** Fire-and-forget: an ack or a return is not admission; read the result frame. */
fun send(channelId: String, payload: ByteArray) {
session.sendMany(channelId, payload, channelSendOptions(channelId, nowMs()), maxFragmentBytes).forEach { sendPacket(it) }
framesSent += 1
}
/** Fire-and-forget: an ack or a return is not admission; read the result frame. */
fun send(channelId: String, payload: String) = send(channelId, payload.toByteArray(StandardCharsets.UTF_8))
/** Fire-and-forget: an ack or a return is not admission; read the result frame. */
fun sendSchema(payload: ByteArray) = send("schema", payload)
fun sendSchema(payload: String) = sendSchema(payload.toByteArray(StandardCharsets.UTF_8))

View file

@ -59,6 +59,16 @@ from cultnet_py.cultmesh_contracts import (
)
_LOGGER = logging.getLogger(__name__)
# How many consecutive refused frames of one kind a RUDP session may send before it is ended: undecodable
# frames (reset by any frame that decodes) and deletes the write authority refuses (reset by any decoded
# frame that is not a refused delete). Fewer are answered with a typed refusal and the session is kept; the
# frame that reaches this number is answered too, then the session ends. The same number is the cap in every
# runtime.
MAX_CONSECUTIVE_REFUSED_FRAMES = 8
# How long a session whose capped frame was answered waits for the peer to acknowledge the answer before it ends anyway.
_RUDP_CAPPED_ANSWER_GRACE_MS = 2000
_EPHEMERAL_PAIR_ATTEMPTS = 64
@ -90,6 +100,10 @@ class _DatabaseSubscription:
class _RudpPeerConnection:
session: CultNetRudpSession
subscriptions: dict[str, _DatabaseSubscription] = field(default_factory=dict)
undecodable_frames_in_a_row: int = 0
refused_deletes_in_a_row: int = 0
# Set once the peer's capped frame has been answered: the session ends when the answers are acknowledged.
ending_deadline_ms: int | None = None
@dataclass
@ -346,6 +360,7 @@ class CultMeshLocalServer:
if packet.connection_id != self.rudp_connection_id:
continue
self._end_answered_peers(rudp_socket, peers)
now_ms = _now_ms()
peer = peers.get(remote_addr)
if packet.packet_type == CultNetRudpPacketType.CONNECT:
@ -394,18 +409,31 @@ class CultMeshLocalServer:
for delivered in result.delivered:
if delivered.channel_id != "schema":
continue
if peer.ending_deadline_ms is not None:
# The capped frame was answered; nothing after it is served while the answer is delivered.
break
try:
message = msgpack.unpackb(delivered.payload, raw=False)
except (msgpack.ExtraData, msgpack.FormatError, msgpack.StackError, ValueError):
continue
if not isinstance(message, dict):
continue
responses = self._handle_connection_message(
message,
peer.subscriptions,
over_rudp=True,
peer=CultMeshServerPeer(f"{remote_addr[0]}:{remote_addr[1]}", "rudp"),
)
message = None
if isinstance(message, dict):
peer.undecodable_frames_in_a_row = 0
responses = self._handle_connection_message(
message,
peer.subscriptions,
over_rudp=True,
peer=CultMeshServerPeer(f"{remote_addr[0]}:{remote_addr[1]}", "rudp"),
)
refused_delete = message.get("schemaVersion") == "cultnet.document_delete.v0" and any(
response.get("code") == "write_not_authorized" for response in responses
)
peer.refused_deletes_in_a_row = peer.refused_deletes_in_a_row + 1 if refused_delete else 0
refused, run = refused_delete, peer.refused_deletes_in_a_row
else:
# A frame that is no CultNet message is answered, not dropped, and the session kept.
peer.undecodable_frames_in_a_row += 1
refused, run = True, peer.undecodable_frames_in_a_row
responses = [self._message_invalid({}, "message", "CultMesh RUDP server could not read the message.")]
for response in responses:
if unsendable is None:
unsendable = self._send_rudp_schema_frame(
@ -415,6 +443,11 @@ class CultMeshLocalServer:
msgpack.packb(response, use_bin_type=True),
message_id=str(response.get("messageId") or ""),
)
if refused and run >= MAX_CONSECUTIVE_REFUSED_FRAMES:
# The capped frame is answered like any other, reliably. The session ends once the peer
# has acknowledged every answer (or the grace runs out): see _end_answered_peers.
peer.ending_deadline_ms = now_ms + _RUDP_CAPPED_ANSWER_GRACE_MS
break
except Exception as error: # noqa: BLE001 - one peer's message never ends the thread
# Whatever a peer's message makes the handling raise is that peer's failure.
# The session recorded the frame's sequence, so it cannot be kept: a retransmit
@ -445,11 +478,26 @@ class CultMeshLocalServer:
if unsendable is not None:
self._end_unsendable_peer(rudp_socket, peers, remote_addr, unsendable)
def _end_answered_peers(
self,
rudp_socket: socket.socket,
peers: dict[tuple[str, int], _RudpPeerConnection],
) -> None:
"""Ends each session whose capped frame was answered once the peer has acknowledged every answer (a lost
answer is resent first), or when the grace has run out."""
now_ms = _now_ms()
for remote_addr, peer in list(peers.items()):
if peer.ending_deadline_ms is None:
continue
if peer.session.outstanding_reliable_packet_count == 0 or now_ms >= peer.ending_deadline_ms:
self._end_refused_peer(rudp_socket, peers, remote_addr)
def _poll_rudp_resends(
self,
rudp_socket: socket.socket,
peers: dict[tuple[str, int], _RudpPeerConnection],
) -> None:
self._end_answered_peers(rudp_socket, peers)
now_ms = _now_ms()
for remote_addr, peer in list(peers.items()):
for packet in peer.session.due_resends(now_ms):

View file

@ -0,0 +1,52 @@
"""Heimdall's invariant: no client treats a reliable-transport ack as write admission. A put completes only on
its result frame (write_not_authorized, or the success reply).
The Python write path is a subscription over RUDP: `send` queues the put, `read_next` returns the result frame
and raises the peer's refusal. The server here refuses the put; its reliable ack of the put arrives (the test
waits for it) and the client still reports the refusal, never success. The transport's `receive` yields data
frames only, so the order of the ack against the refusal cannot change what `read_next` reports.
"""
from __future__ import annotations
import unittest
from cultcache_py import define_database_entry_type
from cultmesh_py import CultMesh, create_node
from cultmesh_py.server import CultMeshLocalServer
from cultnet_py import CultNetDatabaseSubscription, CultNetPeerError, create_rudp_schema_transport
from test_remote_write_authority import KEY_A, NOTE, SCHEMA_ID, put_of
class ClientWriteCompletionTests(unittest.TestCase):
def test_a_put_the_server_refused_is_reported_refused_after_its_ack_arrived(self) -> None:
node = create_node(runtime_id="completion-server")
node.database.register_document(NOTE)
server = CultMeshLocalServer(node=node, port=0, write_authority=None)
server.start()
try:
subscription = CultNetDatabaseSubscription(
host="127.0.0.1",
port=server.port,
timeout_seconds=4.0,
subscription_id="completion",
schema_ids=[SCHEMA_ID],
include_snapshot=False,
create_transport=lambda: create_rudp_schema_transport(
host="127.0.0.1", port=server.port, connection_id=server.rudp_connection_id, timeout_seconds=4.0
),
)
with subscription as sub:
sub.send(put_of(KEY_A, "peer"))
sub._transport.flush_reliable(2.0) # the put's reliable ack has arrived
with self.assertRaises(CultNetPeerError) as raised:
sub.read_next()
finally:
server.stop()
self.assertEqual(raised.exception.response["code"], "write_not_authorized")
self.assertIsNone(server.node.cache.get(NOTE, KEY_A))
if __name__ == "__main__":
unittest.main()

View file

@ -24,7 +24,9 @@ from cultcache_py import (
define_document_type,
)
from cultcache_py.interop import INTEROP_SCHEMA_VERSION, interop_note_document
from cultmesh_py.server import MAX_CONSECUTIVE_REFUSED_FRAMES
from cultmesh_py.verify import verify
from cultmesh_py.write_authority import CultMeshRemoteWriteDecision
from cultnet_py import (
compute_simulation_claim_hash,
CultNetClientAuthorityScope,
@ -4889,6 +4891,140 @@ class CultMeshTests(unittest.TestCase):
self.assertEqual(after["documents"], [])
self.assertTrue(alive)
def _rudp_open(self, server: Any) -> Any:
return create_rudp_schema_transport(
host="127.0.0.1",
port=server.port,
connection_id=server.rudp_connection_id,
timeout_seconds=4.0,
max_fragment_bytes=1200,
)
def _rudp_answer(self, transport: Any, payload: bytes) -> dict[str, Any]:
transport.send("schema", payload)
return msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
def _assert_rudp_session_ended(self, transport: Any, server: Any) -> None:
transport.send("schema", msgpack.packb(snapshot_request(message_id="after-cap").to_wire(), use_bin_type=True))
with self.assertRaises(Exception):
transport.receive(timeout_seconds=1.0)
self.assertTrue(server._rudp_thread.is_alive())
def test_cultmesh_local_server_answers_unreadable_frames_and_ends_the_session_at_eight_in_a_row(self) -> None:
# An undecodable frame (not a msgpack map) is answered message_invalid and the session kept;
# eight in a row end it (the eighth is answered too); any decoded frame resets the run.
self.assertEqual(MAX_CONSECUTIVE_REFUSED_FRAMES, 8)
node = CultMesh.create_node(runtime_id="mesh-rudp-unreadable")
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
garbage = b"\xc1"
snapshot = msgpack.packb(snapshot_request(message_id="ok").to_wire(), use_bin_type=True)
transport = self._rudp_open(server)
try:
for _ in range(2):
for _ in range(MAX_CONSECUTIVE_REFUSED_FRAMES - 1):
reply = self._rudp_answer(transport, garbage)
self.assertEqual(reply["code"], "message_invalid")
self.assertEqual(reply["details"]["field"], "message")
self.assertEqual(self._rudp_answer(transport, snapshot)["messageId"], "ok")
for _ in range(MAX_CONSECUTIVE_REFUSED_FRAMES - 1):
self._rudp_answer(transport, garbage)
transport.send("schema", garbage)
eighth = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
self.assertEqual(eighth["code"], "message_invalid")
self._assert_rudp_session_ended(transport, server)
finally:
transport.close()
server.stop()
def test_cultmesh_local_server_resends_a_lost_capped_answer_before_it_ends_the_session(self) -> None:
# The eighth refusal is answered reliably: the datagram carrying it is lost on the wire, and the
# peer still gets the answer (by resend) before the goodbye. An unreliable final answer, or a
# goodbye sent at once, would leave the peer without it.
node = CultMesh.create_node(runtime_id="mesh-rudp-lost-answer")
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
send = server._send_rudp_packet
dropped: list[Any] = []
armed = threading.Event()
def lossy(rudp_socket: Any, remote_addr: Any, packet: Any) -> Any:
if armed.is_set() and not dropped and packet.packet_type == CultNetRudpPacketType.DATA and b"message_invalid" in (packet.payload or b""):
dropped.append(packet)
return None
return send(rudp_socket, remote_addr, packet)
server._send_rudp_packet = lossy
transport = self._rudp_open(server)
try:
for _ in range(MAX_CONSECUTIVE_REFUSED_FRAMES - 1):
self._rudp_answer(transport, b"\xc1")
armed.set()
reply = self._rudp_answer(transport, b"\xc1")
self.assertEqual(len(dropped), 1, "fixture: the first transmission of the last answer was lost")
self.assertEqual(reply["code"], "message_invalid")
self._assert_rudp_session_ended(transport, server)
finally:
transport.close()
server.stop()
def test_cultmesh_local_server_serves_nothing_after_the_capped_frame(self) -> None:
node = CultMesh.create_node(runtime_id="mesh-rudp-after-cap")
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
transport = self._rudp_open(server)
try:
for _ in range(MAX_CONSECUTIVE_REFUSED_FRAMES - 1):
self._rudp_answer(transport, b"\xc1")
transport.send("schema", b"\xc1")
transport.send("schema", msgpack.packb(snapshot_request(message_id="behind-the-cap").to_wire(), use_bin_type=True))
first = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
self.assertEqual(first["code"], "message_invalid")
with self.assertRaises(Exception):
transport.receive(timeout_seconds=1.0)
finally:
transport.close()
server.stop()
def test_cultmesh_local_server_ends_the_session_at_eight_refused_deletes_in_a_row(self) -> None:
class GrantOnlyKeyB:
def decide(self, request: Any) -> Any:
return CultMeshRemoteWriteDecision.grant() if request.record_key == "note:b" else CultMeshRemoteWriteDecision.refuse("no")
document = self._rudp_bound_note()
node = CultMesh.create_node(runtime_id="mesh-rudp-deletes")
node.database.register_document(document)
server = CultMesh.serve_node(node, write_authority=GrantOnlyKeyB())
def delete(key: str) -> bytes:
return msgpack.packb({
"schemaVersion": "cultnet.document_delete.v0",
"messageId": f"del-{key}",
"schemaId": document.schema_id,
"recordKey": key,
}, use_bin_type=True)
snapshot = msgpack.packb(snapshot_request(message_id="ok").to_wire(), use_bin_type=True)
transport = self._rudp_open(server)
try:
def refused_run(count: int) -> None:
for _ in range(count):
reply = self._rudp_answer(transport, delete("note:a"))
self.assertEqual(reply["code"], "write_not_authorized")
self.assertEqual(reply["details"]["value"], "note:a")
refused_run(MAX_CONSECUTIVE_REFUSED_FRAMES - 1)
# A granted delete resets the run (nothing is stored under it, so the store answers that).
self.assertEqual(self._rudp_answer(transport, delete("note:b"))["code"], "unregistered_document_delete")
refused_run(MAX_CONSECUTIVE_REFUSED_FRAMES - 1)
# So does any decoded non-delete frame.
self.assertEqual(self._rudp_answer(transport, snapshot)["messageId"], "ok")
refused_run(MAX_CONSECUTIVE_REFUSED_FRAMES - 1)
transport.send("schema", delete("note:a"))
eighth = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
self.assertEqual(eighth["code"], "write_not_authorized")
self._assert_rudp_session_ended(transport, server)
finally:
transport.close()
server.stop()
def test_cultmesh_local_server_refuses_a_put_its_store_cannot_take_and_serves_on(self) -> None:
# Well-formed puts the store refuses: a blank key, a payload that is not MessagePack, a
# payload its schema cannot decode, a global document under another key. Each is refused

View file

@ -82,7 +82,10 @@ pub struct CultMeshRudpDocumentPublishOptions {
pub target: SocketAddr,
pub runtime_id: String,
pub connect_timeout: Duration,
/// How long the acknowledgement of every put may take.
pub flush_timeout: Duration,
/// How long after the acknowledgement a refusal may still arrive; a success frame ends the wait early.
pub result_window: Duration,
pub poll_interval: Duration,
pub resend_delay_ms: u64,
pub source_agent_id: Option<String>,
@ -149,6 +152,7 @@ impl Default for CultMeshRudpDocumentPublishOptions {
runtime_id: "cultmesh-rudp-document-publisher".to_string(),
connect_timeout: Duration::from_secs(3),
flush_timeout: Duration::from_millis(300),
result_window: Duration::from_millis(500),
poll_interval: Duration::from_millis(10),
resend_delay_ms: 50,
source_agent_id: None,
@ -882,77 +886,30 @@ fn unix_millis() -> u128 {
/// Publish one raw CultNet schema message to a RUDP catalog.
///
/// Each call owns a fresh connection epoch and returns only after the catalog
/// acknowledges reliable application admission. Against a Rust CultMesh
/// catalog, which never acknowledges a message it refuses, a refusal is
/// returned as an error, naming the refusal when it is one of the fixed texts
/// such a catalog sends. A catalog that acknowledges a message on receipt and
/// refuses it afterwards (the TypeScript document server does) is reported as
/// having admitted it: the acknowledgement arrives first.
/// A put completes on the catalog's result frame, never on the transport
/// acknowledgement: an ack proves delivery, not admission. A refusal (a
/// `cultnet.error.v0` frame) returns an error in either order against the ack,
/// naming the refusal when it is one of the fixed texts a catalog sends. A
/// catalog that admits a put sends no success frame, so after the ack the call
/// waits `result_window` for a refusal and reports the put as admitted when
/// none comes (interim until the catalog sends a success frame; a success
/// frame ends the wait early). Each call owns a fresh connection epoch.
pub fn publish_cultnet_message_to_rudp_catalog(
message: &cultnet_rs::CultNetMessage,
options: CultMeshRudpDocumentPublishOptions,
) -> Result<()> {
if options.runtime_id.trim().is_empty() {
anyhow::bail!("runtime_id must be non-empty");
}
let mut client = connect_rudp_client(
options.target,
&options.runtime_id,
options.connect_timeout,
options.poll_interval,
options.resend_delay_ms,
"cultmesh-rudp-document-publisher",
)?;
let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?;
let receipt = client.send_reliable("schema", payload)?;
let flush_deadline = Instant::now() + options.flush_timeout;
while Instant::now() < flush_deadline {
if let Some(frame) = client.receive_once()? {
if frame.channel_id == "schema" {
if let Ok(cultnet_rs::CultNetMessage::Error { error, .. }) =
decode_cultnet_message_from_slice(
&frame.payload,
CultNetWireContract::CultNetSchemaV0,
)
{
let _ = client.disconnect(b"document-refused".to_vec());
anyhow::bail!(
"CultMesh RUDP catalog {} refused the put{}",
options.target,
recognised_refusal(&error)
);
}
}
}
match client.reliable_send_status(&receipt) {
cultnet_rs::CultNetRudpReliableSendStatus::Acknowledged => {
let _ = client.disconnect(b"document-acknowledged".to_vec());
return Ok(());
}
cultnet_rs::CultNetRudpReliableSendStatus::Invalidated => {
anyhow::bail!("CultMesh RUDP document receipt was invalidated")
}
cultnet_rs::CultNetRudpReliableSendStatus::Pending => {}
}
client.poll_resends()?;
thread::sleep(options.poll_interval);
}
let _ = client.disconnect(b"document-unacknowledged".to_vec());
anyhow::bail!(
"timed out waiting for reliable acknowledgement from CultMesh RUDP catalog {}",
options.target
)
publish_cultnet_messages_to_rudp_catalog(std::slice::from_ref(message), options)
}
/// Publish a batch of raw CultNet schema messages to a RUDP catalog.
/// Publish a batch of raw CultNet schema messages to a RUDP catalog on one
/// connection.
///
/// Unlike [`publish_cultnet_message_to_rudp_catalog`], which polls for
/// per-message application-level acknowledgement, this sends every message on
/// one connection and waits only for a transport-level `flush_reliable`. It
/// trades per-receipt confirmation for batch throughput; callers that need a
/// stronger delivery guarantee should use the singular publish path instead.
/// Completes under the same rule as [`publish_cultnet_message_to_rudp_catalog`]:
/// every message acknowledged, then no refusal frame within `result_window`
/// (or one success frame per message). A refusal of any message in the batch
/// fails the whole call; the batch cannot hide it. The catalog's answers do not
/// name the message they answer, so a failed batch does not say which message
/// was refused or whether the others were admitted.
pub fn publish_cultnet_messages_to_rudp_catalog(
messages: &[cultnet_rs::CultNetMessage],
options: CultMeshRudpDocumentPublishOptions,
@ -969,11 +926,62 @@ pub fn publish_cultnet_messages_to_rudp_catalog(
"cultmesh-rudp-document-publisher",
)?;
let mut receipts = Vec::with_capacity(messages.len());
for message in messages {
let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?;
client.send("schema", payload)?;
receipts.push(client.send_reliable("schema", payload)?);
}
let ack_deadline = Instant::now() + options.flush_timeout;
let mut acknowledged_at: Option<Instant> = None;
let mut successes = 0;
loop {
while let Some(frame) = client.receive_once()? {
if frame.channel_id != "schema" {
continue;
}
match decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0) {
Ok(cultnet_rs::CultNetMessage::Error { error, .. }) => {
let _ = client.disconnect(b"document-refused".to_vec());
anyhow::bail!(
"CultMesh RUDP catalog {} refused the put{}",
options.target,
recognised_refusal(&error)
);
}
Ok(_) => successes += 1,
Err(_) => {}
}
}
if acknowledged_at.is_none() {
let mut all_acknowledged = true;
for receipt in &receipts {
match client.reliable_send_status(receipt) {
cultnet_rs::CultNetRudpReliableSendStatus::Acknowledged => {}
cultnet_rs::CultNetRudpReliableSendStatus::Invalidated => {
anyhow::bail!("CultMesh RUDP document receipt was invalidated")
}
cultnet_rs::CultNetRudpReliableSendStatus::Pending => all_acknowledged = false,
}
}
if all_acknowledged {
acknowledged_at = Some(Instant::now());
} else if Instant::now() >= ack_deadline {
let _ = client.disconnect(b"document-unacknowledged".to_vec());
anyhow::bail!(
"timed out waiting for reliable acknowledgement from CultMesh RUDP catalog {}",
options.target
)
}
}
if let Some(acknowledged_at) = acknowledged_at {
if successes >= messages.len() || acknowledged_at.elapsed() >= options.result_window {
let _ = client.disconnect(b"document-completed".to_vec());
return Ok(());
}
}
client.poll_resends()?;
thread::sleep(options.poll_interval);
}
client.flush_reliable(options.flush_timeout)
}
fn connect_rudp_client(
@ -1451,6 +1459,141 @@ mod tests {
Ok((target, stop, catalog))
}
/// A catalog that acknowledges every reliable message it receives and refuses it with a typed
/// write_not_authorized, the acknowledgement either before the refusal or after it (the second packet
/// follows the first by 100 ms). The refusal carries no acknowledgement of its own.
/// What the stub catalog sends for a put, and in which order against the transport ack.
#[derive(Clone, Copy, PartialEq, Eq)]
enum CatalogReply {
RefusalThenAck,
AckThenRefusal,
AckThenSuccess,
}
fn acknowledging_refusing_catalog(
reply: CatalogReply,
) -> Result<(
SocketAddr,
std::sync::Arc<std::sync::atomic::AtomicBool>,
thread::JoinHandle<Result<()>>,
)> {
use cultnet_rs::{
CultNetRudpPacketType, CultNetRudpSendOptions, CultNetRudpSession,
CultNetRudpSessionOptions, decode_rudp_packet, encode_rudp_packet,
};
let socket = UdpSocket::bind("127.0.0.1:0")?;
socket.set_read_timeout(Some(Duration::from_millis(5)))?;
let target = socket.local_addr()?;
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let catalog_stop = stop.clone();
let catalog = thread::spawn(move || -> Result<()> {
let mut session: Option<CultNetRudpSession> = None;
let mut wire = vec![0_u8; 65_535];
while !catalog_stop.load(std::sync::atomic::Ordering::SeqCst) {
let Ok((bytes, peer)) = socket.recv_from(&mut wire) else {
continue;
};
let Ok(packet) = decode_rudp_packet(&wire[..bytes]) else {
continue;
};
if packet.packet_type == CultNetRudpPacketType::Connect {
let mut accepted = CultNetRudpSession::new(CultNetRudpSessionOptions {
connection_id: packet.connection_id,
initial_sequence: None,
resend_delay_ms: 10_000,
max_pending_reliable_packets: Some(64),
});
let accept = accepted.accept_connect(&packet, 0, Vec::new())?;
socket.send_to(&encode_rudp_packet(&accept)?, peer)?;
session = Some(accepted);
continue;
}
let Some(session) = session.as_mut() else {
continue;
};
let Ok(received) = session.receive(&packet, 1) else {
continue;
};
if !packet.reliable || received.delivered.is_empty() {
continue;
}
let ack = session.create_ack_for_received(packet.sequence);
let refusal = encode_cultnet_message_to_vec(
&if reply == CatalogReply::AckThenSuccess {
cultnet_rs::CultNetMessage::SnapshotResponseRaw {
message_id: "completion".into(),
documents: Vec::new(),
}
} else {
cultnet_rs::CultNetMessage::Error {
error: "write_not_authorized: the catalog refused the document".into(),
code: Some(cultnet_rs::CultNetErrorCode::WriteNotAuthorized),
details: None,
}
},
CultNetWireContract::CultNetSchemaV0,
)?;
let options = CultNetRudpSendOptions {
reliable: false,
ordered: false,
sequenced: false,
now_ms: 2,
reliable_expire_after_ms: None,
};
let mut refusals = session.send_many("schema", refusal, options, Some(1200))?;
for refusal in &mut refusals {
refusal.ack = 0;
refusal.ack_mask = 0;
}
let send_refusals = |socket: &UdpSocket| -> Result<()> {
for refusal in &refusals {
socket.send_to(&encode_rudp_packet(refusal)?, peer)?;
}
Ok(())
};
if reply == CatalogReply::RefusalThenAck {
send_refusals(&socket)?;
thread::sleep(Duration::from_millis(100));
socket.send_to(&encode_rudp_packet(&ack)?, peer)?;
} else {
socket.send_to(&encode_rudp_packet(&ack)?, peer)?;
thread::sleep(Duration::from_millis(100));
send_refusals(&socket)?;
}
}
Ok(())
});
Ok((target, stop, catalog))
}
fn publish_options(target: SocketAddr) -> CultMeshRudpDocumentPublishOptions {
CultMeshRudpDocumentPublishOptions {
target,
connect_timeout: Duration::from_secs(2),
flush_timeout: Duration::from_secs(3),
poll_interval: Duration::from_millis(2),
resend_delay_ms: 5,
..CultMeshRudpDocumentPublishOptions::default()
}
}
fn refused_note() -> cultnet_rs::CultNetMessage {
cultnet_rs::CultNetMessage::DocumentPutRaw {
message_id: "completion".into(),
document: cultnet_rs::CultNetRawDocumentRecord {
schema_id: "cultmesh.test.raw.v0".into(),
record_key: "tests:completion:a".into(),
stored_at: "2026-11-08T00:00:00Z".into(),
payload_encoding: cultnet_rs::CultNetRawPayloadEncoding::Messagepack,
payload: vec![0xc0],
source_runtime_id: None,
source_agent_id: None,
source_role: None,
tags: None,
},
}
}
/// A catalog's refusal text is quoted only when it is one of the fixed
/// refusals a CultMesh catalog sends. Any other text, which could carry
/// anything, stays out of the publisher's error.
@ -1901,4 +2044,63 @@ mod tests {
assert_eq!(lease.bytes(), &[1, 2, 3]);
Ok(())
}
// Heimdall's invariant: no client treats a reliable-transport ack as write admission. The catalog here
// acknowledges the put and refuses it, the refusal arriving before or after the ack.
#[test]
fn a_publish_whose_refusal_arrives_before_the_ack_is_refused() -> Result<()> {
let (target, stop, catalog) = acknowledging_refusing_catalog(CatalogReply::RefusalThenAck)?;
let result = publish_cultnet_message_to_rudp_catalog(&refused_note(), publish_options(target));
stop.store(true, std::sync::atomic::Ordering::SeqCst);
catalog.join().expect("catalog thread should not panic")?;
let error = result.expect_err("a refused put is never reported as admitted");
assert!(error.to_string().contains("refused the put"), "{error:#}");
Ok(())
}
#[test]
fn a_publish_whose_ack_arrives_before_the_refusal_is_refused() -> Result<()> {
let (target, stop, catalog) = acknowledging_refusing_catalog(CatalogReply::AckThenRefusal)?;
let result = publish_cultnet_message_to_rudp_catalog(&refused_note(), publish_options(target));
stop.store(true, std::sync::atomic::Ordering::SeqCst);
catalog.join().expect("catalog thread should not panic")?;
let error = result.expect_err("a refused put is never reported as admitted");
assert!(error.to_string().contains("refused the put"), "{error:#}");
Ok(())
}
#[test]
fn a_batch_publish_is_not_complete_on_the_ack_of_refused_puts() -> Result<()> {
for reply in [CatalogReply::AckThenRefusal, CatalogReply::RefusalThenAck] {
let (target, stop, catalog) = acknowledging_refusing_catalog(reply)?;
let result = publish_cultnet_messages_to_rudp_catalog(&[refused_note()], publish_options(target));
stop.store(true, std::sync::atomic::Ordering::SeqCst);
catalog.join().expect("catalog thread should not panic")?;
assert!(result.is_err(), "a refused put was reported as published");
}
Ok(())
}
#[test]
fn a_success_frame_completes_the_publish_without_waiting_out_the_result_window() -> Result<()> {
for batch in [false, true] {
let (target, stop, catalog) = acknowledging_refusing_catalog(CatalogReply::AckThenSuccess)?;
let options = CultMeshRudpDocumentPublishOptions {
result_window: Duration::from_secs(30),
..publish_options(target)
};
let started = Instant::now();
let result = if batch {
publish_cultnet_messages_to_rudp_catalog(&[refused_note()], options)
} else {
publish_cultnet_message_to_rudp_catalog(&refused_note(), options)
};
stop.store(true, std::sync::atomic::Ordering::SeqCst);
catalog.join().expect("catalog thread should not panic")?;
result?;
assert!(started.elapsed() < Duration::from_secs(10), "batch={batch}: waited out the window");
}
Ok(())
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -5,8 +5,13 @@ All notable changes to `@gamecult/cultmesh-ts` are recorded here, following
## Unreleased
### Added
- `publishRudpDocumentOnce` takes `writeReceipts: true`: it sends a hello listing `cultnet.write_admitted.v0` and `cultnet.error.v1` and, against a server that lists both back, resolves only on that write's `write_admitted`, rejects with `CultMeshRudpWriteRefusedError` (now also carrying an `error.v1`) on its refusal and with the new `CultMeshRudpWriteUnconfirmedError` (`reason`: `window_elapsed` after 5000 ms, `session_ended`, or `local_send_failed`) when nothing settles it. Without the option, or against a server that did not advertise, there is no hello and the window is 500 ms; a put nothing settled in it is unconfirmed too, no longer "published" (**behaviour change**: a server that applies a put but answers nothing now yields `CultMeshRudpWriteUnconfirmedError`, as the spec requires). A legacy server's echo of the put's id is not admission: only `write_admitted` is. The flush that sends the put is bounded by the result window, and a send not acknowledged by then is `local_send_failed`. `resultWindowMs` overrides either window. `publishRudpDocumentAndWaitForReceipt` is not changed.
### Breaking
- `publishRudpDocumentOnce` no longer treats a window with no refusal as published: a put nothing settled in its result window (500 ms, or 5000 ms against a receipt-capable server) rejects `CultMeshRudpWriteUnconfirmedError`, and a legacy server's echo of the put's id is not admission. It now resolves `"admitted"` (only on a `write_admitted`) or `"sent"`. **Migration:** a caller whose server cannot answer a write yet (for example Odin, until it is receipt-capable) passes `sendOnly: true` per call and treats `"sent"` as "sent", never as admitted. Receipts stay opt-in (`writeReceipts: true`); neither mode is the default.
- The package is renamed `cultmesh-ts` to `@gamecult/cultmesh-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-ts` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`.
- `asSchemaAlias` is removed from `CultMeshDocumentHandle`, `CultMeshCollectionHandle` and their bound forms. Read the handle by the schema you mean.
- `CultMeshDocumentCatalog` (`document`, `tryDocument`, `latest`, `replace`, `submitPrediction`, `authoritativeWriter`, `predictionWriter`, `watch`) and `CultMeshCollectionCatalog` (`collection`, `tryCollection`, `latest`, `watchChanges`) take `<TDocument = never, TSchema>` and yield `CultMeshCatalogValue<TDocument, TSchema>`: an explicit type argument wins, a CultCache document definition names its own value type, and a bare schema descriptor yields `unknown`. A value type is no longer inferred from a value or an expected result. The `parse` option is removed from these calls, and a lookup by type answers with that type's handle or none, not an alias of a compatible schema.
@ -16,6 +21,7 @@ All notable changes to `@gamecult/cultmesh-ts` are recorded here, following
### Added
- `createRudpDocumentServer` ends a session after `CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES` (8) consecutive undecodable frames or consecutive deletes the write authority refuses; fewer are answered and the session is kept. Any decoded frame resets the undecodable run, any decoded non-refused-delete frame resets the delete run. The same cap holds in every runtime.
- `CultMeshCatalogValue<TDocument, TSchema>`, exported.
- `documentById` and `tryDocumentById` on `CultMeshDocumentCatalog`, which address one document by its document id when a catalog holds several documents of one type.

View file

@ -20,6 +20,10 @@ import {
CultNetRudpSocketTransportConnection,
rudpClientBindHost,
CultNetSchemaCatalog,
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
WriteTracker,
helloAdvertisesWriteReceipts,
resultWindowMs,
cultNetMessageInvalidError,
cultNetTextError,
cultNetWriteNotAuthorizedError,
@ -44,6 +48,8 @@ import {
type CultNetSchemaCatalogOptions,
type CultNetSnapshotResponseRawMessage,
type CultNetWireContract,
type WriteSettlement,
type WriteUnconfirmedReason,
} from "@gamecult/cultnet-ts";
import {
@ -4121,6 +4127,16 @@ export interface CultMeshRudpDocumentServerOptions extends CultMeshRudpSocketOpt
/** A write authority that has not answered in this long has refused the write. */
export const CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_DECIDE_TIMEOUT_MS = 5_000;
/**
* How many consecutive refused frames of one kind a `createRudpDocumentServer` session may send before it is
* ended: undecodable frames (reset by any frame that decodes) and deletes the write authority refuses (reset by any
* decoded frame that is not a refused delete). Fewer are answered with a typed refusal and the session is kept; the
* frame that reaches this number is answered too, then the session ends. The same number is the cap in every runtime.
*/
export const CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES = 8;
/** How long a session whose capped frame was answered waits for the peer to acknowledge the answers before it ends anyway. */
const CULTMESH_RUDP_DOCUMENT_SERVER_CAPPED_ANSWER_GRACE_MS = 2000;
/** Replies larger than one fragment are split into fragments of at most this many bytes. */
export const CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_MAX_FRAGMENT_BYTES = 1200;
@ -4173,7 +4189,64 @@ export interface CultMeshRudpDocumentServer {
close(): void;
}
/**
* A write the peer refused: the typed `cultnet.error.v0` it answered with (`write_not_authorized`,
* `message_invalid`, ...), whichever of the transport ack and the result frame came first.
*/
export class CultMeshRudpWriteRefusedError extends Error {
readonly code: string | undefined;
readonly response: Extract<CultNetMessage, { schemaVersion: "cultnet.error.v0" | "cultnet.error.v1" }>;
constructor(response: Extract<CultNetMessage, { schemaVersion: "cultnet.error.v0" | "cultnet.error.v1" }>) {
super(response.error);
this.name = "CultMeshRudpWriteRefusedError";
this.code = response.code ?? undefined;
this.response = response;
}
}
/**
* Nothing settled the write (write-receipt.md, "unconfirmed"): the result window ran out with no answer, the
* session ended first, or the client's own send failed. Not success and not refusal; a caller that needs admission treats it as not admitted.
*/
export class CultMeshRudpWriteUnconfirmedError extends Error {
readonly reason: WriteUnconfirmedReason;
readonly messageId: string;
constructor(messageId: string, reason: WriteUnconfirmedReason) {
super(`Write ${messageId} was not confirmed: ${reason}.`);
this.name = "CultMeshRudpWriteUnconfirmedError";
this.reason = reason;
this.messageId = messageId;
}
}
export interface CultMeshRudpDocumentPublishOptions extends CultMeshRudpPeerOptions {
/**
* How long, counted from the send, the put may wait for its result frame. A reliable-transport ack is not
* admission; the outcome is the peer's result frame. Default: 5000 ms against a server whose hello lists the
* write-receipt frames, 500 ms otherwise. A put nothing settled within the window is unconfirmed, never
* published, whatever the server advertised: only a `write_admitted` is admission. The send itself is also
* bounded by the window (`flushTimeoutMs` is the upper bound); a send not acknowledged by then is
* unconfirmed as `local_send_failed`.
*/
resultWindowMs?: number;
/**
* Declare the write-receipt capability: send a hello listing `cultnet.write_admitted.v0` and `cultnet.error.v1`
* before the put. A server that lists both back is receipt-capable: the put then resolves only on its
* `write_admitted`, rejects with `CultMeshRudpWriteRefusedError` on its `error.v1`, and rejects with
* `CultMeshRudpWriteUnconfirmedError` when nothing settles it. Unset, no hello is sent and the server answers
* as it always did.
*/
writeReceipts?: boolean;
/** With `writeReceipts`, how long to wait for the server's hello before treating it as legacy. Default 500 ms. */
helloWaitMs?: number;
/**
* Send the put and report only that it was sent: no hello, no wait for a result frame. Resolves `"sent"` once the
* transport has acknowledged the put (`flushTimeoutMs`), rejects `CultMeshRudpWriteUnconfirmedError`
* (`local_send_failed`) if it cannot be sent, and never resolves `"admitted"`: whether the server applied or
* refused it is unknown. For callers whose server cannot answer a write yet. Opt-in per call; not combinable with
* `writeReceipts`.
*/
sendOnly?: boolean;
messageId?: string;
sourceRuntimeId?: string;
sourceAgentId?: string;
@ -5537,6 +5610,11 @@ export class CultMesh {
closed: boolean;
/** A packet it owed could never be sent, so the session ended. */
unsendable: boolean;
/** Consecutive undecodable frames, and consecutive deletes the authority refused. */
unreadableInARow: number;
refusedDeletesInARow: number;
/** Set once the peer's capped frame is answered: the session ends when the answers are acknowledged, or at this time. */
endingDeadlineMs?: number;
work: Promise<void>;
};
const sessions = new Map<string, SessionRecord>();
@ -5591,6 +5669,8 @@ export class CultMesh {
connectPayload,
closed: false,
unsendable: false,
unreadableInARow: 0,
refusedDeletesInARow: 0,
session: new CultNetRudpSession({
connectionId,
initialSequence: options.initialSequence,
@ -5655,7 +5735,8 @@ export class CultMesh {
record: SessionRecord,
payload: Uint8Array,
): Promise<void> {
if (record.closed) return;
// Nothing after the capped frame is served while its answer is delivered.
if (record.closed || record.endingDeadlineMs !== undefined) return;
let decoded: unknown;
let message: CultNetMessage;
try {
@ -5665,12 +5746,21 @@ export class CultMesh {
// A frame the server cannot read is judged as the write it looks like (a put when it does not even decode), so
// an ungranted peer hears the same refusal for it as for any other write, not a parse error.
const write = readableWrite(decoded);
if (decoded !== undefined && !write) throw error;
if (await writeRefused(record, write?.operation ?? "put", write?.schemaId ?? "", write?.recordKey ?? "", null, null)) return;
reportError(error);
sendSchemaMessage(record, cultNetMessageInvalidError("message", "CultMesh RUDP document server could not read the write."));
record.unreadableInARow += 1;
if (decoded !== undefined && !write) {
// It decodes but is no message the parser takes (an unknown schemaVersion, a non-map) and is no write the
// authority could judge: answered and counted like any other unreadable frame.
reportError(error);
sendSchemaMessage(record, cultNetMessageInvalidError("message", "CultMesh RUDP document server could not read the message."));
} else if (!await writeRefused(record, write?.operation ?? "put", write?.schemaId ?? "", write?.recordKey ?? "", null, null)) {
reportError(error);
sendSchemaMessage(record, cultNetMessageInvalidError("message", "CultMesh RUDP document server could not read the write."));
}
if (record.unreadableInARow >= CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES) endCappedSession(record);
return;
}
record.unreadableInARow = 0;
if (message.schemaVersion !== "cultnet.document_delete.v0") record.refusedDeletesInARow = 0;
switch (message.schemaVersion) {
case "cultnet.snapshot_request.v0": {
if (!options.getCache) {
@ -5744,7 +5834,12 @@ export class CultMesh {
}
return;
case "cultnet.document_delete.v0": {
if (await writeRefused(record, "delete", message.schemaId, message.recordKey, null, null)) return;
if (await writeRefused(record, "delete", message.schemaId, message.recordKey, null, null)) {
record.refusedDeletesInARow += 1;
if (record.refusedDeletesInARow >= CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES) endCappedSession(record);
return;
}
record.refusedDeletesInARow = 0;
if (!options.onDocumentDeleteRaw) {
sendSchemaMessage(record, cultNetTextError("CultMesh RUDP document server has no delete handler."));
return;
@ -5895,6 +5990,17 @@ export class CultMesh {
sendRudpDatagram(socket, goodbye, record.remote.port, record.remote.address, () => {});
}
/**
* A session that reached the cap of consecutive refused frames: its last refusal went out like any reply
* (reliable, so a lost datagram is resent), and the session ends once the peer has acknowledged every reply or
* the grace runs out. The resend timer ends it.
*/
function endCappedSession(record: SessionRecord): void {
const key = `${record.remote.address}:${record.remote.port}`;
if (sessions.get(key) !== record) return;
record.endingDeadlineMs ??= Date.now() + CULTMESH_RUDP_DOCUMENT_SERVER_CAPPED_ANSWER_GRACE_MS;
}
/** The session owes its peer a packet that can never be sent as built. */
function endUnsendableSession(record: SessionRecord, error: Error): void {
if (record.unsendable) return;
@ -6028,6 +6134,11 @@ export class CultMesh {
closeSession(record);
continue;
}
if (record.endingDeadlineMs !== undefined
&& (record.session.outstandingReliablePacketCount === 0 || nowMs >= record.endingDeadlineMs)) {
endRefusedSession(key, record);
continue;
}
for (const packet of record.session.dueResends(nowMs)) {
sendPacket(record, packet);
}
@ -6105,25 +6216,88 @@ export class CultMesh {
recordKey: string,
value: CultCacheDocumentValue<TDefinition>,
options: CultMeshRudpDocumentPublishOptions = {},
): Promise<void> {
): Promise<"admitted" | "sent"> {
requireNonEmpty(runtimeId, "runtimeId");
requireNonEmpty(recordKey, "recordKey");
if (options.sendOnly && options.writeReceipts) throw new TypeError("sendOnly and writeReceipts cannot be combined.");
const registry = new CultNetDocumentRegistry([binding]);
const messageId = options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`;
// Built before any connection and outside every try: a put that cannot be built is the caller's error, with its cause.
const put = registry.createRawDocumentPutMessage(
binding,
messageId,
recordKey,
value,
{
sourceRuntimeId: options.sourceRuntimeId ?? runtimeId,
sourceAgentId: options.sourceAgentId,
sourceRole: options.sourceRole,
tags: options.tags,
},
);
const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options);
try {
peer.send(registry.createRawDocumentPutMessage(
binding,
options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`,
recordKey,
value,
{
sourceRuntimeId: options.sourceRuntimeId ?? runtimeId,
sourceAgentId: options.sourceAgentId,
sourceRole: options.sourceRole,
tags: options.tags,
},
));
await peer.flush(options.flushTimeoutMs ?? 30_000);
if (options.sendOnly) {
try {
peer.send(put);
await peer.flush(options.flushTimeoutMs ?? 30_000);
} catch {
throw new CultMeshRudpWriteUnconfirmedError(messageId, "local_send_failed");
}
return "sent";
}
// A transport ack is not admission: the put completes on its result frame, and the WriteTracker alone decides
// how it settles (write-receipt.md, "Client rules"). This function only maps the settlement to resolve/throw.
let receiptCapable = false;
let tracker: WriteTracker | undefined;
let settlement: WriteSettlement | undefined;
let wake: (() => void) | undefined;
let serverHello: (() => void) | undefined;
peer.on("message", (message: CultNetMessage) => {
if (message.schemaVersion === "cultnet.hello.v0") {
receiptCapable = helloAdvertisesWriteReceipts(message);
serverHello?.();
} else {
tracker?.frame(message, Date.now());
}
});
peer.on("close", () => tracker?.sessionEnd(Date.now()));
if (options.writeReceipts) {
const answered = new Promise<void>((resolve) => { serverHello = resolve; });
peer.sendHello({
schemaVersion: "cultnet.hello.v0", runtimeId, runtimeKind: "cultmesh-ts",
supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS],
});
let helloTimer: NodeJS.Timeout | undefined;
await Promise.race([answered, new Promise<void>((resolve) => { helloTimer = setTimeout(resolve, options.helloWaitMs ?? 500); })]);
clearTimeout(helloTimer);
}
const writeTracker = new WriteTracker({
windowMs: options.resultWindowMs ?? resultWindowMs(receiptCapable),
onSettle: (_id, result) => { settlement = result; wake?.(); },
});
tracker = writeTracker;
writeTracker.send(messageId, Date.now());
try {
peer.send(put);
// The flush may not outlast the result window: a send nobody acknowledged by then is a local send failure.
const windowRemaining = writeTracker.nextDeadlineMs(Date.now()) ?? 0;
await peer.flush(Math.min(options.flushTimeoutMs ?? 30_000, Math.max(windowRemaining, 1)));
} catch {
// Our own send failed or was never acknowledged: the put may or may not have left. Never a refusal.
writeTracker.sendFailed(messageId, Date.now());
}
while (!settlement) {
const remaining = writeTracker.nextDeadlineMs(Date.now()) ?? 0;
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, remaining);
wake = () => { clearTimeout(timer); resolve(); };
});
writeTracker.tick(Date.now());
}
if (settlement.outcome === "refused") throw new CultMeshRudpWriteRefusedError(settlement.response);
if (settlement.outcome === "unconfirmed") throw new CultMeshRudpWriteUnconfirmedError(messageId, settlement.reason);
return "admitted";
} finally {
peer.close();
}
@ -6142,6 +6316,7 @@ export class CultMesh {
const receipt = new Promise<CultCacheDocumentValue<TReceiptDefinition>>((resolve, reject) => {
const timeout = setTimeout(() => { cleanup(); reject(new Error(`Timed out waiting for RUDP application receipt for ${recordKey}.`)); }, options.receiptTimeoutMs ?? 5_000);
const onMessage = (message: CultNetMessage): void => {
if (message.schemaVersion === "cultnet.error.v0") { cleanup(); reject(new CultMeshRudpWriteRefusedError(message)); return; }
if (message.schemaVersion !== "cultnet.document_put_raw.v0" || message.messageId !== messageId || message.document.schemaId !== receiptBinding.definition.schemaId) return;
try {
const decoded = decodeDocumentPayload(receiptBinding.definition, message.document.payload);

View file

@ -0,0 +1,120 @@
// Heimdall's invariant: no client may treat a reliable-transport ack as write admission. A put completes only
// on its result frame (write_not_authorized, or the success reply). Each test serves a client a peer that
// acknowledges the put and refuses it, the ack arriving before the refusal and after it.
import assert from "node:assert/strict";
import { createSocket, type Socket } from "node:dgram";
import test from "node:test";
import { z } from "zod";
import { defineDocumentType } from "@gamecult/cultcache-ts";
import {
CultNetDocumentRegistry,
CultNetRudpSession,
cultNetWriteNotAuthorizedError,
decodeRudpPacket,
defineCultNetDocumentBinding,
encodeCultNetMessageForWire,
encodeRudpPacket,
} from "@gamecult/cultnet-ts";
import { encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpWriteRefusedError, CultMeshRudpWriteUnconfirmedError } from "../src/index";
const isTheRefusal = (error: unknown): boolean =>
error instanceof CultMeshRudpWriteRefusedError && error.code === "write_not_authorized";
const note = defineDocumentType({
type: "cultmesh.completion_note",
schemaId: "cultmesh.completion_note.v0",
schema: z.object({ noteId: z.string(), text: z.string() }),
name: "noteId",
});
const binding = defineCultNetDocumentBinding({ definition: note });
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
type Order = "ack-then-refusal" | "refusal-then-ack";
/** A peer that acknowledges every reliable put and refuses it with write_not_authorized, in the given order. */
async function refusingPeer(connectionId: number, order: Order) {
const socket: Socket = createSocket("udp4");
await new Promise<void>((resolve) => socket.bind(0, "127.0.0.1", resolve));
const session = new CultNetRudpSession({ connectionId, resendDelayMs: 50 });
const timers = new Set<NodeJS.Timeout>();
let closed = false;
const send = (packet: Parameters<typeof encodeRudpPacket>[0], port: number, address: string) => {
if (!closed) socket.send(encodeRudpPacket(packet), port, address);
};
const later = (action: () => void) => { const timer = setTimeout(() => { timers.delete(timer); action(); }, 120); timers.add(timer); };
socket.on("message", (wire, remote) => {
const packet = decodeRudpPacket(wire);
const now = Date.now();
if (packet.packetType === "connect") {
send(session.acceptConnect(packet, now), remote.port, remote.address);
return;
}
const result = session.receive(packet, now);
if (result.reply) send(result.reply, remote.port, remote.address);
for (const frame of result.delivered) {
if (frame.channelId !== "schema") continue;
const ack = session.createAckForReceived(packet.sequence);
const refusal = session.sendMany(
"schema",
encodeWireValue(encodeCultNetMessageForWire(cultNetWriteNotAuthorizedError("tests:completion:a"), "cultnet.schema.v0")),
{ reliable: true, ordered: true, nowMs: now },
);
if (order === "ack-then-refusal") {
send(ack, remote.port, remote.address);
later(() => refusal.forEach((p) => send(p, remote.port, remote.address)));
} else {
// The refusal carries no acknowledgement of its own; the ack follows it.
refusal.forEach((p) => send({ ...p, ack: 0, ackMask: 0 }, remote.port, remote.address));
later(() => send(ack, remote.port, remote.address));
}
}
if (packet.reliable) send(session.createAckForReceived(packet.sequence), remote.port, remote.address);
});
return { endpoint: `rudp://127.0.0.1:${socket.address().port}`, close: () => { closed = true; timers.forEach(clearTimeout); socket.close(); } };
}
const orders: Order[] = ["ack-then-refusal", "refusal-then-ack"];
let nextConnection = 0x10205000;
for (const order of orders) {
test(`publishRudpDocumentOnce does not complete on the ack of a refused put (${order})`, async () => {
const peer = await refusingPeer(nextConnection, order);
try {
await assert.rejects(
CultMesh.publishRudpDocumentOnce("completion-once", nextConnection++, peer.endpoint, binding, "tests:completion:a", { noteId: "a", text: "x" }, { resendDelayMs: 50, resendPollMs: 5, flushTimeoutMs: 2_000 }),
isTheRefusal,
);
} finally { peer.close(); }
});
test(`publishRudpDocumentAndWaitForReceipt reports a refused put as refused, never as success (${order})`, async () => {
const peer = await refusingPeer(nextConnection, order);
try {
await assert.rejects(
CultMesh.publishRudpDocumentAndWaitForReceipt("completion-receipt", nextConnection++, peer.endpoint, binding, "tests:completion:a", { noteId: "a", text: "x" }, binding, { resendDelayMs: 50, resendPollMs: 5, receiptTimeoutMs: 800 }),
isTheRefusal,
);
} finally { peer.close(); }
await sleep(10);
});
}
test("a put the server applied but did not answer is unconfirmed, never published", async () => {
const connectionId = nextConnection++;
const puts: string[] = [];
const server = CultMesh.createRudpDocumentServer("completion-admit", connectionId, {
documents: new CultNetDocumentRegistry([binding]),
writeAuthority: new CultMeshGrantAllWrites(),
bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 50, resendPollMs: 5,
onDocumentPutRaw: (put) => { puts.push(put.recordKey); },
});
await server.start();
try {
await assert.rejects(
CultMesh.publishRudpDocumentOnce("completion-admit-client", connectionId, `rudp://127.0.0.1:${server.bind.port}`, binding, "tests:completion:a", { noteId: "a", text: "x" }, { resendDelayMs: 50, resendPollMs: 5, resultWindowMs: 200 }),
(error: unknown) => error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed",
);
assert.deepEqual(puts, ["tests:completion:a"], "the server did apply it; this server just does not answer");
} finally { server.close(); }
});

View file

@ -21,7 +21,7 @@ import {
type CultNetMessage,
type CultNetSnapshotResponseRawMessage,
} from "@gamecult/cultnet-ts";
import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpUnservableDocumentError } from "../src/index";
import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpUnservableDocumentError, CultMeshRudpWriteUnconfirmedError } from "../src/index";
// A record's payload as the bytes a peer would put on the wire.
const payloadOf = (value: unknown) => documentPayloadFromBytes(encodeWireValue(value));
@ -2968,25 +2968,30 @@ test("CultMesh TS publishes one registered document to a RUDP catalog", async ()
try {
await server.start();
await CultMesh.publishRudpDocumentOnce(
"cultmesh-ts-muninn",
connectionId,
`rudp://127.0.0.1:${server.bind.port}`,
defineCultNetDocumentBinding({ definition: noteDocument }),
"note:odin",
{
noteId: "note:odin",
body: "pay respects once",
},
{
resendDelayMs: 25,
resendPollMs: 5,
maxFragmentBytes: 1024,
maxPendingReliablePackets: 16,
connectTimeoutMs: 1_000,
flushTimeoutMs: 25,
sourceRole: "test-provider",
},
// This server answers a put with nothing, so the writer cannot know the put landed: unconfirmed, though it did.
await assert.rejects(
CultMesh.publishRudpDocumentOnce(
"cultmesh-ts-muninn",
connectionId,
`rudp://127.0.0.1:${server.bind.port}`,
defineCultNetDocumentBinding({ definition: noteDocument }),
"note:odin",
{
noteId: "note:odin",
body: "pay respects once",
},
{
resendDelayMs: 25,
resendPollMs: 5,
maxFragmentBytes: 1024,
maxPendingReliablePackets: 16,
connectTimeoutMs: 1_000,
flushTimeoutMs: 25,
resultWindowMs: 100,
sourceRole: "test-provider",
},
),
(error: unknown) => error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed",
);
await waitFor(() => received !== undefined, "published RUDP document");

View file

@ -1,11 +1,13 @@
// A peer's put or delete is applied only when the server's write authority grants it. Ports the behaviours of
// cultmesh-py test_remote_write_authority.py (no forwarding: this server does not forward non-primary writes).
import assert from "node:assert/strict";
import { createSocket, type Socket } from "node:dgram";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import { z } from "zod";
import { documentPayloadFromBytes, defineDocumentType } from "@gamecult/cultcache-ts";
import { decodeRudpPacket } from "@gamecult/cultnet-ts";
import { encodeWireValue } from "@gamecult/cultnet-ts/wire";
import {
CultNetDocumentRegistry,
@ -16,6 +18,7 @@ import {
} from "@gamecult/cultnet-ts";
import {
CultMesh,
CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES,
CultMeshGrantAllWrites,
type CultMeshRemoteWriteAuthority,
type CultMeshRemoteWriteDecision,
@ -505,3 +508,178 @@ test("a granted peer's unreadable write reaches onError and gets a malformed ref
} finally { r.close(); }
}
});
const CAP = 8; // the cap every runtime shares; the exported constant must stay this number
test("the consecutive-refused-frames cap is the number every runtime shares", () => {
assert.equal(CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES, CAP);
});
test("unreadable frames are answered and the session kept until eight in a row; any decoded frame resets the run", async () => {
const r = await rawRig(new Recording(grant));
try {
const batch = async (frames: number) => {
for (let i = 0; i < frames; i++) r.send(Uint8Array.from([0xc1]));
await sleep(400);
};
await batch(CAP - 1);
assert.equal(r.replies.length, CAP - 1, "each is answered");
r.send(encodeWireValue(put(KEY_A)));
await sleep(250);
assert.deepEqual(r.puts.map((p) => p.recordKey), [KEY_A], "the session was kept through seven");
await batch(CAP - 1);
assert.equal(r.replies.length, 2 * (CAP - 1), "the valid frame reset the run");
r.send(Uint8Array.from([0xc1]));
await sleep(300);
assert.equal(r.replies.length, 2 * (CAP - 1) + 1, "the eighth consecutive one is answered too");
assert.ok(r.replies.every((reply) => (reply as { code?: string }).code === "message_invalid"));
// The goodbye reached the peer: its client no longer takes a send.
assert.throws(() => r.send(encodeWireValue(put(KEY_B))), /before the session is connected/, "then the session ended");
assert.deepEqual(r.puts.map((p) => p.recordKey), [KEY_A]);
} finally { r.close(); }
});
test("deletes the authority refuses are answered and the session kept until eight in a row; any other decoded frame resets the run", async () => {
const authority = new Recording((request) => request.recordKey === KEY_B ? { granted: true } : { granted: false });
await using(await rig(authority), async (r) => {
const refuse = async (deletes: number) => {
for (let i = 0; i < deletes; i++) r.peer.send(del(KEY_A));
await sleep(400);
};
await refuse(CAP - 1);
assert.equal(r.replies.length, CAP - 1);
r.peer.send(del(KEY_B)); // granted: resets the run
await sleep(250);
assert.deepEqual(r.deletes.map((d) => d.recordKey), [KEY_B]);
await refuse(CAP - 1);
assert.equal(r.replies.length, 2 * (CAP - 1));
r.peer.send(put(KEY_B)); // a decoded non-delete resets too
await sleep(250);
await refuse(CAP - 1);
assert.equal(r.replies.length, 3 * (CAP - 1), "a put reset the run");
r.peer.send(del(KEY_A));
await sleep(300);
assert.equal(r.replies.length, 3 * (CAP - 1) + 1, "the eighth consecutive refused delete is answered too");
for (const reply of r.replies) assertRefused(reply, KEY_A);
assert.throws(() => r.peer.send(del(KEY_B)), /before the session is connected/, "then the session ended");
assert.equal(r.deletes.length, 1);
});
});
test("a frame that decodes but is no message the parser takes is answered, counted, and ended at eight in a row like any unreadable frame", async () => {
const r = await rawRig(new Recording(grant));
const unknown = () => encodeWireValue({ schemaVersion: "cultnet.not-a-message.v0", messageId: "x" });
try {
const batch = async (frames: number) => {
for (let i = 0; i < frames; i++) r.send(unknown());
await sleep(400);
};
await batch(CAP - 1);
assert.equal(r.replies.length, CAP - 1, "each is answered");
assert.equal(r.errors.length, CAP - 1, "and reported");
r.send(encodeWireValue(put(KEY_A)));
await sleep(250);
assert.deepEqual(r.puts.map((p) => p.recordKey), [KEY_A], "the session was kept through seven");
await batch(CAP - 1);
assert.equal(r.replies.length, 2 * (CAP - 1), "the decoded put reset the run");
r.send(unknown());
await sleep(300);
assert.equal(r.replies.length, 2 * (CAP - 1) + 1, "the eighth is answered too");
for (const reply of r.replies) {
assert.equal((reply as { code?: string }).code, "message_invalid");
assert.equal((reply as { details: { field: string } }).details.field, "message");
}
assert.throws(() => r.send(unknown()), /before the session is connected/, "then the session ended");
} finally { r.close(); }
});
/** A UDP relay between a client and a server that drops the first (or, with `dropEvery`, every) server datagram carrying `payloadContains` once armed. */
async function lossyRelay(serverPort: number, payloadContains: string, dropEvery = false) {
const dropped: unknown[] = [];
let armed = false;
let clientAddress: { port: number; address: string } | undefined;
const toClient: Socket = createSocket("udp4");
const toServer: Socket = createSocket("udp4");
toServer.on("message", (wire) => {
if (!clientAddress) return;
if (armed && (dropEvery || dropped.length === 0)) {
const packet = decodeRudpPacket(wire);
if (packet.packetType === "data" && Buffer.from(packet.payload ?? []).includes(payloadContains)) {
dropped.push(packet);
return;
}
}
toClient.send(wire, clientAddress.port, clientAddress.address);
});
toClient.on("message", (wire, remote) => {
clientAddress = remote;
toServer.send(wire, serverPort, "127.0.0.1");
});
await new Promise<void>((resolve) => toClient.bind(0, "127.0.0.1", resolve));
await new Promise<void>((resolve) => toServer.bind(0, "127.0.0.1", resolve));
return {
port: toClient.address().port,
dropped,
arm: () => { armed = true; },
close: () => { toClient.close(); toServer.close(); },
};
}
async function cappedOverRelay(dropEvery: boolean, body: (x: { relay: Awaited<ReturnType<typeof lossyRelay>>; client: { send(channel: string, payload: Uint8Array): void }; replies: CultNetMessage[] }) => Promise<void>) {
const connectionId = nextConnection++;
const server = CultMesh.createRudpDocumentServer("authority-lossy", connectionId, {
documents: new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: note })]),
writeAuthority: new Recording(grant), bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5,
onDocumentPutRaw: () => {}, onError: () => {},
});
await server.start();
const relay = await lossyRelay(server.bind.port, "message_invalid", dropEvery);
const client = await CultMesh.createRudpClient("lossy", connectionId, `rudp://127.0.0.1:${relay.port}`, { resendDelayMs: 25, resendPollMs: 5 });
try {
client.connect();
const started = Date.now();
while (!client.connected) {
if (Date.now() - started > 2_000) throw new Error("not connected");
await sleep(5);
}
const replies: CultNetMessage[] = [];
new CultNetPeer(client, { wireContract: "cultnet.schema.v0" }).on("message", (m) => replies.push(m));
for (let i = 0; i < CAP - 1; i++) client.send("schema", Uint8Array.from([0xc1]));
await sleep(300);
assert.equal(replies.length, CAP - 1);
relay.arm();
client.send("schema", Uint8Array.from([0xc1]));
await body({ relay, client, replies });
} finally { client.close(); server.close(); relay.close(); }
}
test("the capped frame's answer is resent when its datagram is lost, and the goodbye follows only after it arrived", async () => {
await cappedOverRelay(false, async ({ relay, client, replies }) => {
await sleep(600);
assert.equal(relay.dropped.length, 1, "fixture: the first transmission of the last answer was lost");
assert.equal(replies.length, CAP, "the lost answer was resent and arrived");
assert.equal((replies[CAP - 1] as { code?: string }).code, "message_invalid");
assert.throws(() => client.send("schema", Uint8Array.from([0xc1])), /before the session is connected/, "then the session ended");
});
});
test("a capped session whose answer is never acknowledged still ends, after the grace", async () => {
await cappedOverRelay(true, async ({ client, replies }) => {
await sleep(1200);
assert.equal(replies.length, CAP - 1, "fixture: the last answer never arrives");
client.send("schema", Uint8Array.from([0xc1])); // still connected: the server is waiting for the acknowledgement
await sleep(1500);
assert.throws(() => client.send("schema", Uint8Array.from([0xc1])), /before the session is connected/, "the grace ran out and the goodbye went out");
});
});
test("nothing behind the capped frame is served while its answer is delivered", async () => {
const r = await rawRig(new Recording(grant));
try {
for (let i = 0; i < CAP; i++) r.send(Uint8Array.from([0xc1]));
r.send(encodeWireValue(put(KEY_A)));
await sleep(500);
assert.equal(r.replies.length, CAP);
assert.deepEqual(r.puts, [], "the put behind the cap was not served");
} finally { r.close(); }
});

View file

@ -0,0 +1,243 @@
// Client side of contracts/cultnet/interop/write-receipt.md in the one-shot writer: with `writeReceipts` the put
// completes only on a write_admitted carrying its id; a refusal, a silent window and a legacy server each end as the
// spec says. Each test serves the client a fake RUDP peer that acknowledges the put and answers as scripted.
import assert from "node:assert/strict";
import { createSocket, type Socket } from "node:dgram";
import test from "node:test";
import { z } from "zod";
import { defineDocumentType } from "@gamecult/cultcache-ts";
import {
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
CultNetRudpSession,
decodeRudpPacket,
defineCultNetDocumentBinding,
encodeCultNetMessageForWire,
encodeRudpPacket,
type CultNetMessage,
} from "@gamecult/cultnet-ts";
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { CultMesh, CultMeshRudpWriteRefusedError, CultMeshRudpWriteUnconfirmedError } from "../src/index";
const note = defineDocumentType({
type: "cultmesh.receipt_note",
schemaId: "cultmesh.receipt_note.v0",
schema: z.object({ noteId: z.string(), text: z.string() }),
name: "noteId",
});
const binding = defineCultNetDocumentBinding({ definition: note });
let nextConnection = 0x10206000;
interface Script {
/** Does the server's hello list both receipt frames? */
advertise: boolean;
/** What the server answers a put with, `delayMs` after it arrives. */
answer: "admit" | "refuse" | "wrong-id" | "silence" | "goodbye" | "echo";
delayMs?: number;
/** Acknowledge the reliable packets it receives? Default true. */
ack?: boolean;
}
async function fakeServer(connectionId: number, script: Script) {
const socket: Socket = createSocket("udp4");
await new Promise<void>((resolve) => socket.bind(0, "127.0.0.1", resolve));
const session = new CultNetRudpSession({ connectionId, resendDelayMs: 50 });
const timers = new Set<NodeJS.Timeout>();
const seen: string[] = [];
let closed = false;
const send = (packet: Parameters<typeof encodeRudpPacket>[0], port: number, address: string) => {
if (!closed) socket.send(encodeRudpPacket(packet), port, address);
};
const say = (message: CultNetMessage, port: number, address: string) => {
const packets = session.sendMany("schema", encodeWireValue(encodeCultNetMessageForWire(message)), { reliable: true, ordered: true, nowMs: Date.now() });
packets.forEach((p) => send(p, port, address));
};
socket.on("message", (wire, remote) => {
const packet = decodeRudpPacket(wire);
const now = Date.now();
if (packet.packetType === "connect") {
send(session.acceptConnect(packet, now), remote.port, remote.address);
return;
}
const result = session.receive(packet, now);
if (result.reply) send(result.reply, remote.port, remote.address);
for (const frame of result.delivered) {
if (frame.channelId !== "schema") continue;
const message = decodeWireValue(frame.payload) as { schemaVersion: string; messageId?: string };
seen.push(message.schemaVersion);
if (message.schemaVersion === "cultnet.hello.v0") {
say({
schemaVersion: "cultnet.hello.v0", runtimeId: "fake", runtimeKind: "fake",
...(script.advertise ? { supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] } : {}),
}, remote.port, remote.address);
continue;
}
if (message.schemaVersion !== "cultnet.document_put_raw.v0" || script.answer === "silence") continue;
const timer = setTimeout(() => {
timers.delete(timer);
if (script.answer === "echo") {
say({ schemaVersion: "cultnet.document_put_raw.v0", messageId: message.messageId!, document: { schemaId: "cultmesh.receipt_note.v0", recordKey: "tests:receipt:a", storedAt: "t", payloadEncoding: "messagepack", payload: new Uint8Array([1]) } } as unknown as CultNetMessage, remote.port, remote.address);
} else if (script.answer === "goodbye") {
send(session.endUnsendableSession(new Error("scripted goodbye")), remote.port, remote.address);
} else if (script.answer === "refuse") {
say({ schemaVersion: "cultnet.error.v1", error: "write_not_authorized", routingHint: null, code: "write_not_authorized", details: null, messageId: message.messageId! }, remote.port, remote.address);
} else {
const messageId = script.answer === "wrong-id" ? "someone-else" : message.messageId!;
say({ schemaVersion: "cultnet.write_admitted.v0", messageId, operation: "put", schemaId: "cultmesh.receipt_note.v0", recordKey: "tests:receipt:a" }, remote.port, remote.address);
}
}, script.delayMs ?? 20);
timers.add(timer);
}
if (packet.reliable && script.ack !== false) send(session.createAckForReceived(packet.sequence), remote.port, remote.address);
});
return { endpoint: `rudp://127.0.0.1:${socket.address().port}`, seen, close: () => { closed = true; timers.forEach(clearTimeout); socket.close(); } };
}
const transport = { resendDelayMs: 50, resendPollMs: 5, flushTimeoutMs: 2_000 };
const publish = (server: { endpoint: string }, options: Record<string, unknown>) =>
CultMesh.publishRudpDocumentOnce("receipt-client", nextConnection++, server.endpoint, binding, "tests:receipt:a", { noteId: "a", text: "x" }, { ...transport, ...options });
test("a receipt-capable server's write_admitted completes the put, past the legacy 500 ms window", async () => {
const server = await fakeServer(nextConnection, { advertise: true, answer: "admit", delayMs: 700 });
try {
await publish(server, { writeReceipts: true });
assert.deepEqual(server.seen.slice(0, 2), ["cultnet.hello.v0", "cultnet.document_put_raw.v0"]);
} finally { server.close(); }
});
test("a receipt-capable server's error.v1 refuses the put with its code", async () => {
const server = await fakeServer(nextConnection, { advertise: true, answer: "refuse" });
try {
await assert.rejects(publish(server, { writeReceipts: true }), (error: unknown) =>
error instanceof CultMeshRudpWriteRefusedError && error.code === "write_not_authorized" && error.response.schemaVersion === "cultnet.error.v1");
} finally { server.close(); }
});
test("a receipt-capable server that stays silent leaves the put unconfirmed, never published", async () => {
const server = await fakeServer(nextConnection, { advertise: true, answer: "silence" });
try {
await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 250 }), (error: unknown) =>
error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed");
} finally { server.close(); }
});
test("a receipt for another id settles nothing", async () => {
const server = await fakeServer(nextConnection, { advertise: true, answer: "wrong-id" });
try {
await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 250 }), CultMeshRudpWriteUnconfirmedError);
} finally { server.close(); }
});
test("a server whose hello does not advertise leaves a silent put unconfirmed after the 500 ms window, never published", async () => {
const server = await fakeServer(nextConnection, { advertise: false, answer: "silence" });
const started = Date.now();
try {
await assert.rejects(publish(server, { writeReceipts: true }), (error: unknown) =>
error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed");
assert.ok(Date.now() - started >= 500, "the legacy window ran from the send");
} finally { server.close(); }
});
test("a session that ends before any result leaves the put unconfirmed (session_ended), against a legacy server too", async () => {
for (const advertise of [false, true]) {
const server = await fakeServer(nextConnection, { advertise, answer: "goodbye" });
try {
await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 3_000 }), (error: unknown) =>
error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "session_ended");
} finally { server.close(); }
}
});
test("a put whose send is never acknowledged is unconfirmed as local_send_failed, not an untyped error", async () => {
const server = await fakeServer(nextConnection, { advertise: true, answer: "silence", ack: false });
const started = Date.now();
try {
await assert.rejects(publish(server, { writeReceipts: true, flushTimeoutMs: 300 }), (error: unknown) =>
error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "local_send_failed");
assert.ok(Date.now() - started < 2_500, "flushTimeoutMs bounds the send inside the 5 s window");
} finally { server.close(); }
});
test("the flush is bounded by the result window: a send nobody acknowledges is local_send_failed at the window, not at the flush timeout", async () => {
const server = await fakeServer(nextConnection, { advertise: true, answer: "silence", ack: false });
const started = Date.now();
try {
await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 300, flushTimeoutMs: 20_000 }), (error: unknown) =>
error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "local_send_failed");
assert.ok(Date.now() - started < 3_000, "it did not wait out the 20 s flush timeout");
} finally { server.close(); }
});
test("a server that does not advertise receipts and echoes the put's id back has not admitted it", async () => {
const server = await fakeServer(nextConnection, { advertise: false, answer: "echo" });
try {
await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 300 }), (error: unknown) =>
error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed");
} finally { server.close(); }
});
test("a silent server never yields admitted, in either mode: the default is unconfirmed, sendOnly is the distinct outcome sent", async () => {
const dflt = await fakeServer(nextConnection, { advertise: false, answer: "silence" });
try {
await assert.rejects(publish(dflt, { resultWindowMs: 200 }), CultMeshRudpWriteUnconfirmedError);
} finally { dflt.close(); }
const sendOnly = await fakeServer(nextConnection, { advertise: false, answer: "silence" });
try {
const outcome = await publish(sendOnly, { sendOnly: true });
assert.equal(outcome, "sent");
assert.notEqual(outcome, "admitted");
assert.deepEqual(sendOnly.seen, ["cultnet.document_put_raw.v0"], "sendOnly sends no hello");
} finally { sendOnly.close(); }
});
test("only a write_admitted resolves admitted, and sendOnly does not wait for or act on an answer", async () => {
const admitting = await fakeServer(nextConnection, { advertise: true, answer: "admit" });
try {
assert.equal(await publish(admitting, { writeReceipts: true }), "admitted");
} finally { admitting.close(); }
const refusing = await fakeServer(nextConnection, { advertise: true, answer: "refuse", delayMs: 1 });
try {
assert.equal(await publish(refusing, { sendOnly: true }), "sent", "a refusal is not awaited: sendOnly says only that the put was sent");
} finally { refusing.close(); }
});
test("sendOnly fails local_send_failed when the send is never acknowledged", async () => {
const server = await fakeServer(nextConnection, { advertise: true, answer: "silence", ack: false });
try {
await assert.rejects(publish(server, { sendOnly: true, flushTimeoutMs: 300 }), (error: unknown) =>
error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "local_send_failed");
} finally { server.close(); }
});
test("sendOnly keeps the cause of a put that cannot be built: it is not reported as local_send_failed", async () => {
const server = await fakeServer(nextConnection, { advertise: false, answer: "silence" });
try {
await assert.rejects(
CultMesh.publishRudpDocumentOnce("receipt-client", nextConnection++, server.endpoint, binding, "tests:receipt:a", { noteId: 1 } as unknown as { noteId: string; text: string }, { ...transport, sendOnly: true }),
(error: unknown) => !(error instanceof CultMeshRudpWriteUnconfirmedError) && error instanceof Error,
);
assert.deepEqual(server.seen, [], "nothing was sent");
} finally { server.close(); }
});
test("the receipt path keeps the cause of a put that cannot be built: it is not reported as local_send_failed", async () => {
const server = await fakeServer(nextConnection, { advertise: true, answer: "admit" });
try {
await assert.rejects(
CultMesh.publishRudpDocumentOnce("receipt-client", nextConnection++, server.endpoint, binding, "tests:receipt:a", { noteId: 1 } as unknown as { noteId: string; text: string }, { ...transport, writeReceipts: true }),
(error: unknown) => !(error instanceof CultMeshRudpWriteUnconfirmedError) && error instanceof Error,
);
assert.ok(!server.seen.includes("cultnet.document_put_raw.v0"), "no put was sent");
} finally { server.close(); }
});
test("sendOnly cannot be combined with writeReceipts", async () => {
await assert.rejects(publish({ endpoint: "rudp://127.0.0.1:9" }, { sendOnly: true, writeReceipts: true }), TypeError);
});
test("without writeReceipts no hello is sent and the receipt frames are never asked for", async () => {
const server = await fakeServer(nextConnection, { advertise: true, answer: "silence" });
try {
await assert.rejects(publish(server, { resultWindowMs: 100 }), CultMeshRudpWriteUnconfirmedError);
assert.deepEqual(server.seen, ["cultnet.document_put_raw.v0"]);
} finally { server.close(); }
});

View file

@ -58,6 +58,7 @@ class CultNetRawClient:
create_transport: CultNetSchemaTransportFactory | None = None
def send(self, message: CultNetMessage | dict[str, Any]) -> None:
"""Fire-and-forget: an ack or a return is not admission; read the result frame."""
wire = message.to_wire() if isinstance(message, CultNetMessage) else message
with self.open_transport() as transport:
transport.send("schema", msgpack.packb(wire, use_bin_type=True))
@ -319,6 +320,7 @@ class CultNetDatabaseSubscription:
self._transport = None
def send(self, message: CultNetMessage | dict[str, Any]) -> None:
"""Fire-and-forget: an ack or a return is not admission; read the result frame."""
if self._transport is None:
raise RuntimeError("CultNet database subscription is not open")
wire = message.to_wire() if isinstance(message, CultNetMessage) else message

View file

@ -1256,6 +1256,7 @@ class CultNetRudpSocketTransportConnection:
self._send_packet(self.session.create_connect(_now_ms(), payload))
def send(self, channel_id: str, payload: bytes) -> None:
"""Fire-and-forget: an ack or a return is not admission; read the result frame."""
packets = self.session.send_many(
channel_id,
payload,
@ -1567,6 +1568,7 @@ class TcpFramedTransportConnection:
)
def send(self, channel_id: str, payload: bytes) -> None:
"""Fire-and-forget: an ack or a return is not admission; read the result frame."""
if channel_id != "schema":
raise ValueError(f"tcp_framed transport only supports the schema channel, got {channel_id!r}")
before = len(payload) + 4

View file

@ -249,6 +249,9 @@ pub enum CultNetErrorCode {
WriteNotAuthorized,
UnsupportedSchemaVersion,
MessageInvalid,
/// `cultnet.error.v1` only: the session's remembered-write limit is full. `cultnet.error.v0` neither
/// encodes nor decodes it, so its enum stays the schema's.
LimitReached,
}
impl CultNetErrorCode {
@ -263,6 +266,7 @@ impl CultNetErrorCode {
Self::WriteNotAuthorized => "write_not_authorized",
Self::UnsupportedSchemaVersion => "unsupported_schema_version",
Self::MessageInvalid => "message_invalid",
Self::LimitReached => "limit_reached",
}
}
@ -277,6 +281,7 @@ impl CultNetErrorCode {
"write_not_authorized" => Ok(Self::WriteNotAuthorized),
"unsupported_schema_version" => Ok(Self::UnsupportedSchemaVersion),
"message_invalid" => Ok(Self::MessageInvalid),
"limit_reached" => Ok(Self::LimitReached),
_ => Err(anyhow!("ErrorMessage.Code is not a recognized cultnet.error.v0 code")),
}
}
@ -359,6 +364,26 @@ pub enum CultNetMessage {
#[serde(default, skip_serializing_if = "Option::is_none")]
details: Option<CultNetErrorDetails>,
},
/// `cultnet.error.v1` (contracts/cultnet/interop/write-receipt.md): `cultnet.error.v0` plus the id of the
/// request it answers. Sent only to a connection that declared the write-receipt capability.
#[serde(skip_deserializing)]
#[serde(rename = "cultnet.error.v1", rename_all = "camelCase")]
ErrorV1 {
error: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
code: Option<CultNetErrorCode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
details: Option<CultNetErrorDetails>,
message_id: Option<String>,
},
/// `cultnet.write_admitted.v0`: the receipt for one admitted put or delete, sent after its commit point.
#[serde(rename = "cultnet.write_admitted.v0", rename_all = "camelCase")]
WriteAdmitted {
message_id: String,
operation: String,
schema_id: String,
record_key: String,
},
#[serde(rename = "cultnet.sample.change_name.v0", rename_all = "camelCase")]
SampleChangeName { name: String },
#[serde(rename = "cultnet.sample.chat.v0", rename_all = "camelCase")]
@ -543,7 +568,9 @@ pub fn encode_cultnet_message_for_wire(
| CultNetMessage::SnapshotResponseRawV1 { .. }
| CultNetMessage::ContentChunkRequest { .. }
| CultNetMessage::ContentChunkResponse { .. }
| CultNetMessage::Error { .. } => encode_raw_cultnet_schema_message(message),
| CultNetMessage::Error { .. }
| CultNetMessage::ErrorV1 { .. }
| CultNetMessage::WriteAdmitted { .. } => encode_raw_cultnet_schema_message(message),
_ => Ok(cultcache_rs::decode_payload(&rmp_serde::to_vec(
&serde_json::to_value(message)?,
)?)?),
@ -573,7 +600,8 @@ pub fn decode_cultnet_message_from_slice(
| "cultnet.snapshot_response_raw.v0"
| "cultmesh.content_chunk_request.v1"
| "cultmesh.content_chunk_response.v1"
| "cultnet.error.v0",
| "cultnet.error.v0"
| "cultnet.error.v1",
) => parse_raw_cultnet_schema_message(&wire_value)
.with_context(|| received_version_label(&wire_value))?,
_ => cultcache_rs::decode_payload::<CultNetMessage>(bytes)
@ -645,6 +673,20 @@ fn validate_message(message: &CultNetMessage) -> Result<()> {
require_non_empty(session, "session")?;
}
CultNetMessage::Error { error, code: _, details: _ } => require_non_empty(error, "error")?,
CultNetMessage::ErrorV1 { error, message_id, .. } => {
require_non_empty(error, "error")?;
if let Some(message_id) = message_id {
require_non_empty(message_id, "messageId")?;
}
}
CultNetMessage::WriteAdmitted { message_id, operation, schema_id, record_key } => {
require_non_empty(message_id, "messageId")?;
if operation != "put" && operation != "delete" {
return Err(anyhow!("operation must be put or delete"));
}
require_non_empty(schema_id, "schemaId")?;
require_non_empty(record_key, "recordKey")?;
}
CultNetMessage::SampleChangeName { name } => require_non_empty(name, "name")?,
CultNetMessage::SampleChat { text } => require_non_empty(text, "text")?,
CultNetMessage::DocumentDelete {
@ -1021,7 +1063,7 @@ fn parse_raw_cultnet_schema_message(input: &rmpv::Value) -> Result<CultNetMessag
.to_vec(),
error: require_legacy_string(get("error"), "error")?,
}),
"cultnet.error.v0" => {
"cultnet.error.v0" | "cultnet.error.v1" => {
let code = get("code")
.filter(|value| !value.is_nil())
.map(|value| {
@ -1031,12 +1073,23 @@ fn parse_raw_cultnet_schema_message(input: &rmpv::Value) -> Result<CultNetMessag
.and_then(CultNetErrorCode::from_wire_str)
})
.transpose()?;
if schema_version == "cultnet.error.v0" && code == Some(CultNetErrorCode::LimitReached) {
return Err(anyhow!("ErrorMessage.Code is not a recognized cultnet.error.v0 code"));
}
let details = require_error_details(get("details"), "ErrorMessage.Details")?;
Ok(CultNetMessage::Error {
error: require_legacy_string(get("error"), "ErrorMessage.Error")?,
code,
details,
})
let error = require_legacy_string(get("error"), "ErrorMessage.Error")?;
if schema_version == "cultnet.error.v1" {
return Ok(CultNetMessage::ErrorV1 {
error,
code,
details,
message_id: match get("messageId") {
None | Some(rmpv::Value::Nil) => None,
other => Some(require_legacy_string(other, "messageId")?),
},
});
}
Ok(CultNetMessage::Error { error, code, details })
}
_ => Err(anyhow!(
"Unsupported raw cultnet.schema.v0 message"
@ -1090,7 +1143,18 @@ fn require_legacy_optional_u64(
}
fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result<rmpv::Value> {
if let CultNetMessage::Error { code: Some(CultNetErrorCode::LimitReached), .. } = message {
return Err(anyhow!("limit_reached is a cultnet.error.v1 code; cultnet.error.v0 cannot carry it"));
}
Ok(rmpv::Value::Map(match message {
// Spec field order (write-receipt.md), not serde_json's sorted map.
CultNetMessage::WriteAdmitted { message_id, operation, schema_id, record_key } => vec![
(rmpv::Value::from("schemaVersion"), rmpv::Value::from("cultnet.write_admitted.v0")),
(rmpv::Value::from("messageId"), rmpv::Value::from(message_id.as_str())),
(rmpv::Value::from("operation"), rmpv::Value::from(operation.as_str())),
(rmpv::Value::from("schemaId"), rmpv::Value::from(schema_id.as_str())),
(rmpv::Value::from("recordKey"), rmpv::Value::from(record_key.as_str())),
],
CultNetMessage::DocumentPutRaw {
message_id,
document,
@ -1255,6 +1319,36 @@ fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result<rmpv::V
.unwrap_or(rmpv::Value::Nil),
),
],
CultNetMessage::ErrorV1 {
error,
code,
details,
message_id,
} => vec![
(rmpv::Value::from("schemaVersion"), rmpv::Value::from("cultnet.error.v1")),
(rmpv::Value::from("error"), rmpv::Value::from(error.as_str())),
(rmpv::Value::from("routingHint"), rmpv::Value::Nil),
(
rmpv::Value::from("code"),
code.as_ref()
.map(|code| rmpv::Value::from(code.as_wire_str()))
.unwrap_or(rmpv::Value::Nil),
),
(
rmpv::Value::from("details"),
details
.as_ref()
.map(encode_error_details)
.unwrap_or(rmpv::Value::Nil),
),
(
rmpv::Value::from("messageId"),
message_id
.as_deref()
.map(rmpv::Value::from)
.unwrap_or(rmpv::Value::Nil),
),
],
_ => {
return Err(anyhow!(
"Message is not a raw cultnet.schema.v0 binary replication message"
@ -1884,3 +1978,71 @@ fn legacy_schema_descriptor(schema: &CultNetSchemaDescriptor) -> Result<rmpv::Va
),
]))
}
/// The content hash a write-receipt server binds to a `(actor, messageId)` dedupe key
/// (contracts/cultnet/interop/write-receipt.md, rule 5): SHA-256 of the MessagePack array
/// `[operation, schemaId, recordKey, payload]`, `payload` a `bin` for a put and nil for a delete.
pub fn write_content_hash(
operation: &str,
schema_id: &str,
record_key: &str,
payload: Option<&[u8]>,
) -> Result<[u8; 32]> {
use sha2::{Digest, Sha256};
let value = rmpv::Value::Array(vec![
rmpv::Value::from(operation),
rmpv::Value::from(schema_id),
rmpv::Value::from(record_key),
payload.map_or(rmpv::Value::Nil, |payload| rmpv::Value::Binary(payload.to_vec())),
]);
let mut bytes = Vec::new();
rmpv::encode::write_value(&mut bytes, &value)?;
Ok(Sha256::digest(&bytes).into())
}
/// SHA-256 of a write's `messageId`: what a receipt server remembers in place of the id itself, so an
/// entry's memory does not depend on the id's length (write-receipt.md, "What is remembered").
pub fn message_id_digest(message_id: &str) -> [u8; 32] {
use sha2::{Digest, Sha256};
Sha256::digest(message_id.as_bytes()).into()
}
/// The `messageId` of a schema frame that does not decode as a message (write-receipt.md, rule 3: a refusal
/// carries the write's id whenever the frame is readable enough to find it). `None` when the bytes are not
/// a MessagePack map or hold no non-empty string `messageId`.
pub fn readable_message_id(bytes: &[u8]) -> Option<String> {
let value = rmpv::decode::read_value(&mut &*bytes).ok()?;
let rmpv::Value::Map(entries) = value else {
return None;
};
entries.into_iter().find_map(|(key, value)| match (key.as_str(), value) {
(Some("messageId"), rmpv::Value::String(id)) => id.into_str().filter(|id| !id.is_empty()),
_ => None,
})
}
/// The first required field (`document`, then `schemaId`) that a put or delete frame lacks or holds as nil
/// (write-receipt.md, "Required fields"), with the write's operation (`"put"` or `"delete"`). `None` when the
/// bytes are not such a frame, or both fields are present. A put's `schemaId` is its document's.
pub fn missing_required_write_field(bytes: &[u8]) -> Option<(&'static str, &'static str)> {
let rmpv::Value::Map(entries) = rmpv::decode::read_value(&mut &*bytes).ok()? else {
return None;
};
let field = |entries: &[(rmpv::Value, rmpv::Value)], name: &str| {
entries
.iter()
.find(|(key, _)| key.as_str() == Some(name))
.map(|(_, value)| value.clone())
.filter(|value| !value.is_nil())
};
match field(&entries, "schemaVersion")?.as_str()? {
"cultnet.document_put_raw.v0" => match field(&entries, "document") {
Some(rmpv::Value::Map(document)) => {
field(&document, "schemaId").is_none().then_some(("put", "schemaId"))
}
_ => Some(("put", "document")),
},
"cultnet.document_delete.v0" => field(&entries, "schemaId").is_none().then_some(("delete", "schemaId")),
_ => None,
}
}

View file

@ -468,6 +468,14 @@ impl CultNetRudpSession {
self.create_disconnect(RUDP_REFUSED_PACKET_REASON.to_vec())
}
/// Ends a session because the server itself could not answer a write it owes an answer to (its answer
/// could not be encoded, queued or sent). The peer did nothing wrong: this goodbye is not the
/// "session refused a packet" of a protocol refusal.
pub fn end_local_failure_session(&mut self) -> CultNetRudpPacket {
self.reset_peer_state();
self.create_disconnect(RUDP_LOCAL_FAILURE_REASON.to_vec())
}
/// Ends a session that owes its peer a packet that can never be sent as
/// built (see `is_permanent_send_error`). Resending it would fail forever,
/// and dropping it would leave a reliable sequence the peer waits on for
@ -2198,6 +2206,7 @@ impl CultNetRudpSocketTransportConnection {
self.send_packet(&packet)
}
/// Fire-and-forget: an ack or a return is not admission; read the result frame.
pub fn send(&mut self, channel_id: &str, payload: Vec<u8>) -> Result<()> {
let options =
channel_send_options(
@ -2216,6 +2225,8 @@ impl CultNetRudpSocketTransportConnection {
/// Sends one frame on a reliable channel whose packets cannot expire and
/// returns the transport receipt used to observe its exact acknowledgement.
///
/// Fire-and-forget: an ack or a return is not admission; read the result frame.
pub fn send_reliable(
&mut self,
channel_id: &str,
@ -2309,6 +2320,9 @@ impl CultNetRudpSocketTransportConnection {
self.session.outstanding_reliable_packet_count()
}
/// Waits until every reliable write is acknowledged by the transport.
///
/// Fire-and-forget: an ack or a return is not admission; read the result frame.
pub fn flush_reliable(&mut self, timeout: Duration) -> Result<()> {
let original_timeout = self.socket.read_timeout()?;
let poll_timeout = original_timeout
@ -2603,6 +2617,8 @@ where
/// Sent to a peer whose session refused one of its packets, and reported to
/// the local caller as the disconnect reason.
const RUDP_REFUSED_PACKET_REASON: &[u8] = b"session refused a packet";
/// Goodbye reason of a session whose server could not answer a write (`end_local_failure_session`).
const RUDP_LOCAL_FAILURE_REASON: &[u8] = b"server could not answer a write";
const SESSION_TIMED_OUT_REASON: &[u8] = b"session timed out";
/// Prefix of the goodbye reason for a session that owed a packet which can
/// never be sent; the failure's fixed name (`send_error_code`) follows it.

View file

@ -252,6 +252,7 @@ impl<TStream> TcpFramedTransportConnection<TStream>
where
TStream: Write,
{
/// Fire-and-forget: an ack or a return is not admission; read the result frame.
pub fn send(&mut self, channel_id: &str, payload: &[u8]) -> Result<()> {
if channel_id != "schema" {
return Err(anyhow!(

View file

@ -0,0 +1,155 @@
//! Wire parity with contracts/cultnet/interop/write-receipt-vectors.json: the content hash, and the two new frames.
use cultnet_rs::{
CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetWireContract, decode_cultnet_message_from_slice,
encode_cultnet_message_to_vec, write_content_hash,
};
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
#[test]
fn the_content_hash_matches_the_spec_vectors() {
let a = [0x81, 0xA2, 0x69, 0x64, 0xA1, 0x31];
let b = [0x81, 0xA2, 0x69, 0x64, 0xA1, 0x32];
let cases = [
(write_content_hash("put", "note.v0", "note:1", Some(&a)), "7051c10071123b6ade50fbd5075e3859cae4ea70ce358a24a0b0c6980090e95d"),
(write_content_hash("put", "note.v0", "note:1", Some(&b)), "58d3fc6b858d2605a68550c9d4811ccdca6b9db7a0c3bfebf304d279257f6f9f"),
(write_content_hash("put", "note.v0", "note:2", Some(&a)), "a248cd920e58d41bc6ccca9e108030b1d491239000047fef088122364a32b6f8"),
(write_content_hash("delete", "note.v0", "note:1", None), "232b0f96651b28aa8ab7673a6106944a9f6a8d66852b6e21291a23ed084b8b37"),
];
for (hash, expected) in cases {
assert_eq!(hex(&hash.unwrap()), expected);
}
}
#[test]
fn the_new_frames_decode_back_equal() {
let admitted = CultNetMessage::WriteAdmitted {
message_id: "put-1".into(),
operation: "put".into(),
schema_id: "note.v0".into(),
record_key: "note:1".into(),
};
let bytes = encode_cultnet_message_to_vec(&admitted, CultNetWireContract::CultNetSchemaV0).unwrap();
assert_eq!(
decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0).unwrap(),
admitted
);
// error.v1 with a nil id round-trips as nil, and the code enum is v0's.
let error = CultNetMessage::ErrorV1 {
error: "message_invalid: unreadable frame".into(),
code: Some(CultNetErrorCode::MessageInvalid),
details: None,
message_id: None,
};
let bytes = encode_cultnet_message_to_vec(&error, CultNetWireContract::CultNetSchemaV0).unwrap();
assert_eq!(
decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0).unwrap(),
error
);
}
fn unhex(text: &str) -> Vec<u8> {
(0..text.len()).step_by(2).map(|i| u8::from_str_radix(&text[i..i + 2], 16).unwrap()).collect()
}
/// The spec's wire vectors (write-receipt-vectors.json `wire`): the bytes decode to the vector's fields, not
/// merely to something that encodes back to itself.
#[test]
fn the_spec_wire_vectors_decode_to_their_expected_fields() {
let admitted = decode_cultnet_message_from_slice(
&unhex("85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31"),
CultNetWireContract::CultNetSchemaV0,
)
.unwrap();
assert_eq!(
admitted,
CultNetMessage::WriteAdmitted {
message_id: "put-1".into(),
operation: "put".into(),
schema_id: "note.v0".into(),
record_key: "note:1".into(),
}
);
let refused = decode_cultnet_message_from_slice(
&unhex("86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D93677726974655F6E6F745F617574686F72697A65643A2074686520636174616C6F6720726566757365642074686520646F63756D656E74AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A66E6F74653A32A461734F66C0A763757272656E74C0A96D6573736167654964A57075742D32"),
CultNetWireContract::CultNetSchemaV0,
)
.unwrap();
let CultNetMessage::ErrorV1 { error, code, details, message_id } = refused else {
panic!("expected error.v1");
};
assert_eq!(error, "write_not_authorized: the catalog refused the document");
assert_eq!(code, Some(CultNetErrorCode::WriteNotAuthorized));
assert_eq!(message_id.as_deref(), Some("put-2"));
let details = details.unwrap();
assert_eq!(details.field.as_deref(), Some("recordKey"));
assert_eq!(details.value.as_deref(), Some("note:2"));
assert_eq!((details.as_of, details.current), (None, None));
}
/// Spec vector `error_v1_limit_reached`: the bytes the server must send, and what they decode to.
#[test]
fn error_v1_limit_reached_is_the_spec_vector() {
let vector = "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D93B6C696D69745F726561636865643A207468652073657373696F6E27732072656D656D62657265642D7772697465206C696D69742069732066756C6CAB726F7574696E6748696E74C0A4636F6465AD6C696D69745F72656163686564A764657461696C7384A56669656C64C0A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964A87075742D31303235";
let message = CultNetMessage::ErrorV1 {
error: "limit_reached: the session's remembered-write limit is full".into(),
code: Some(CultNetErrorCode::LimitReached),
details: Some(CultNetErrorDetails::default()),
message_id: Some("put-1025".into()),
};
let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).unwrap();
assert_eq!(bytes, unhex(vector), "encoded bytes must equal the spec vector");
let CultNetMessage::ErrorV1 { error, code, details, message_id } =
decode_cultnet_message_from_slice(&unhex(vector), CultNetWireContract::CultNetSchemaV0).unwrap()
else {
panic!("expected error.v1");
};
assert_eq!(error, "limit_reached: the session's remembered-write limit is full");
assert_eq!(code, Some(CultNetErrorCode::LimitReached));
assert_eq!(message_id.as_deref(), Some("put-1025"));
assert_eq!(details, Some(CultNetErrorDetails::default()));
}
/// Spec vectors `write_admitted_put` and `write_admitted_delete`: the encoder emits the spec's field order.
#[test]
fn write_admitted_encodes_to_the_spec_vector_bytes() {
let cases = [
("put-1", "put", "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31"),
("del-1", "delete", "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A564656C2D31A96F7065726174696F6EA664656C657465A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31"),
];
for (id, operation, vector) in cases {
let message = CultNetMessage::WriteAdmitted {
message_id: id.into(),
operation: operation.into(),
schema_id: "note.v0".into(),
record_key: "note:1".into(),
};
let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).unwrap();
assert_eq!(bytes, unhex(vector));
}
}
/// `limit_reached` is in the error.v1 enum only: a v0 error neither encodes nor decodes it.
#[test]
fn error_v0_refuses_the_v1_only_limit_reached_code() {
let v0 = CultNetMessage::Error {
error: "x".into(),
code: Some(CultNetErrorCode::LimitReached),
details: None,
};
assert!(encode_cultnet_message_to_vec(&v0, CultNetWireContract::CultNetSchemaV0).is_err());
let v1 = CultNetMessage::ErrorV1 {
error: "x".into(),
code: Some(CultNetErrorCode::LimitReached),
details: None,
message_id: None,
};
let v1_bytes = encode_cultnet_message_to_vec(&v1, CultNetWireContract::CultNetSchemaV0).unwrap();
let at = v1_bytes.windows(16).position(|w| w == b"cultnet.error.v1").unwrap();
let mut as_v0 = v1_bytes.clone();
as_v0[at + 15] = b'0';
assert!(decode_cultnet_message_from_slice(&as_v0, CultNetWireContract::CultNetSchemaV0).is_err());
}

View file

@ -7,6 +7,7 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following
### Added
- Write receipt, client side (`contracts/cultnet/interop/write-receipt.md`): `cultnet.write_admitted.v0` and `cultnet.error.v1` (v0 plus `messageId`; its closed code set adds `limit_reached`, `CULTNET_ERROR_V1_CODES`) parse and encode, and `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS` / `helloAdvertisesWriteReceipts` name the hello capability. `cultnet.error.v0` and its code set are unchanged. `WriteTracker` settles each write as admitted, refused or unconfirmed (windows `RECEIPT_RESULT_WINDOW_MS` 5000 and `LEGACY_RESULT_WINDOW_MS` 500, counted from the send; late answers recorded as `admitted_late` / `refused_late`), and `tools/replay-write-receipt-vectors.mjs` replays the vectors against it (see README, "Write receipt replay runner"). The interop schemas are read from `contracts/cultnet/interop/`.
- `message_invalid` joins the closed `cultnet.error.v0` code set, with `cultNetMessageInvalidError(field, reason)` (`details.field` names the request field at fault), byte-identical to the C# `ForMessageInvalid`.
- `cultNetWriteNotAuthorizedError(recordKey, reason?)`: the typed `write_not_authorized` refusal (code and `details.recordKey`), byte-identical to the C# `ForRemoteWriteRefused` vector in `error-vectors.json`. `CultNetDocumentRegistry.canonicalSchemaId(schemaId)` resolves the id a put or delete writes under, for a server's write authority.
- `cultNetTextError(error)`: the text-only `cultnet.error.v0` refusal, with `routingHint`, `code` and `details` null. The legacy-union error decode uses it, so there is one place that spells the null members.
@ -16,6 +17,7 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following
### Breaking
- The Idunn runtime presence publisher is send-only (`sendPresenceDocument`): it returns `"sent"` once Odin's transport has acknowledged the put and no longer waits up to 500 ms for a success reply, which was never admission. It rejects `IdunnPresenceRefusedError` for a `cultnet.error.v0`, or a `cultnet.error.v1` naming the put or nothing, that arrives by the ack; a refusal after the ack is not awaited, and no other frame, one carrying the put's id included, counts as success.
- The package is renamed `cultnet-ts` to `@gamecult/cultnet-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultnet-ts` and its subpaths; the API is unchanged.
- `CultNetErrorMessage` carries `routingHint`, `code` and `details` (null when absent), as the reference writes them and `cultnet.error.schema.json` requires; the interface previously dropped them. New `CultNetErrorCode`, `CultNetErrorDetails` and `CultNetShardRoutingHint` types.

View file

@ -289,6 +289,33 @@ maps them back to the exact C# union payload shape at the wire boundary.
serve and dial commands also exercise schema-v0 over the shared RUDP socket
transport and advertise both transport profiles.
## Write receipt replay runner
`WriteTracker` is the client half of `contracts/cultnet/interop/write-receipt.md`: a pure state machine on an abstract
clock that owns one decision, how each sent write settles. `tools/replay-write-receipt-vectors.mjs` replays
`write-receipt-vectors.json` against it (build first, then `node tools/replay-write-receipt-vectors.mjs`): it prints
`PASS`/`FAIL` per case and exits nonzero on any failure. Rust and C# port the same runner by implementing this interface
and replaying the same file.
Tracker interface (times are integer milliseconds on the vector's clock; the constructor takes the window):
| Call | Meaning |
| --- | --- |
| `new WriteTracker({ windowMs, onSettle })` | `windowMs` = 5000 when the server's hello lists both `cultnet.write_admitted.v0` and `cultnet.error.v1`, else 500. `onSettle(id, settlement)` fires once per write. |
| `send(id, atMs)` | A write with that `messageId` was sent. Its window runs from here, not from any transport ack. |
| `frame(message, atMs)` | A decoded frame arrived. `write_admitted` and `error.v1` settle the write they name; `error.v0`, or `error.v1` with nil `messageId`, refuses a write only when exactly one is unanswered; an id not in flight settles nothing. |
| `tick(atMs)` | Advance the clock. Every unanswered write with `atMs - sentAt >= windowMs` settles `unconfirmed(window_elapsed)`. `send`, `frame` and `sessionEnd` tick first. |
| `sessionEnd(atMs)` | Every unanswered write settles `unconfirmed(session_ended)`. |
| `state(id)` | `pending`, or the settlement plus `recorded`: `admitted_late` / `refused_late`, set by the first answer that arrives after `unconfirmed`. A settlement never changes. |
Runner, per case in `scenarios.cases` (the transport ack is not an input: delivery is not an answer):
1. The fake server states its capability the only way a real one does, in a hello (`server: "receipt"` lists both frames, `"legacy"` lists none); the window comes from that hello and must equal `constants[case.window]`.
2. Events run in `atMs` order: `send` -> `send(id)`, `frame` -> encode the frame to MessagePack, decode and validate it as the client would, then `frame(message)`, `tick` -> `tick`, `sessionEnd` -> `sessionEnd`, `transportAck` -> nothing.
3. For each id in `expect`, the settlement given to the caller has the expected `outcome` (and `code` or `reason` when given), `state(id).recorded` equals the expected `recorded` (absent when absent), and nothing settled twice or outside `expect`.
The runner also decodes each `wire[].messagePackHex` and compares the fields and the re-encoded bytes to the vector, and requires every `schemaInvalid[]` frame to be refused by the decoder. `dedupe.cases` and `contentHash` are server vectors and not replayed here.
## Development
```bash

View file

@ -9,6 +9,8 @@ import registerSchema from "../contracts/cultnet/cultnet.register.schema.json";
import verifySchema from "../contracts/cultnet/cultnet.verify.schema.json";
import loginSuccessSchema from "../contracts/cultnet/cultnet.login-success.schema.json";
import errorSchema from "../contracts/cultnet/cultnet.error.schema.json";
import errorV1Schema from "../contracts/cultnet/interop/cultnet.error.v1.schema.json";
import writeAdmittedSchema from "../contracts/cultnet/interop/cultnet.write-admitted.schema.json";
import sampleChangeNameSchema from "../contracts/cultnet/cultnet.sample-change-name.schema.json";
import sampleChatSchema from "../contracts/cultnet/cultnet.sample-chat.schema.json";
import documentPutSchema from "../contracts/cultnet/cultnet.document-put.schema.json";
@ -62,6 +64,8 @@ export type CultNetSchemaVersion =
| "cultnet.verify.v0"
| "cultnet.login_success.v0"
| "cultnet.error.v0"
| "cultnet.error.v1"
| "cultnet.write_admitted.v0"
| "cultnet.sample.change_name.v0"
| "cultnet.sample.chat.v0"
| "cultnet.document_put.v0"
@ -201,6 +205,11 @@ export const CULTNET_ERROR_CODES = [
export type CultNetErrorCode = (typeof CULTNET_ERROR_CODES)[number];
/** The closed refusal-code set of cultnet.error.v1 (contracts/cultnet/interop/cultnet.error.v1.schema.json): v0's codes plus `limit_reached`. */
export const CULTNET_ERROR_V1_CODES = [...CULTNET_ERROR_CODES, "limit_reached"] as const;
export type CultNetErrorV1Code = (typeof CULTNET_ERROR_V1_CODES)[number];
/** A refusal that carries text only: `routingHint`, `code` and `details` are written as null, as the reference writes an absent member. */
export function cultNetTextError(error: string): CultNetErrorMessage {
return { schemaVersion: "cultnet.error.v0", error, routingHint: null, code: null, details: null };
@ -255,6 +264,34 @@ export interface CultNetErrorMessage {
details: CultNetErrorDetails | null;
}
/**
* cultnet.error.v0 plus the `messageId` of the request it answers (nil when no id could be read). Sent only to a
* connection whose hello listed both receipt frames (contracts/cultnet/interop/write-receipt.md).
*/
export interface CultNetErrorV1Message extends Omit<CultNetErrorMessage, "schemaVersion" | "code"> {
schemaVersion: "cultnet.error.v1";
code: CultNetErrorV1Code | null;
messageId: string | null;
}
/** The receipt for one admitted put or delete: every key written, the write's `messageId` echoed byte for byte. */
export interface CultNetWriteAdmittedMessage {
schemaVersion: "cultnet.write_admitted.v0";
messageId: string;
operation: "put" | "delete";
schemaId: string;
recordKey: string;
}
/** The two frames a receipt-capable hello lists in `supportedMessageVersions`; a peer lists both or neither. */
export const CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS = ["cultnet.write_admitted.v0", "cultnet.error.v1"] as const;
/** True when a hello lists both receipt frames: the only thing that makes a connection receipt-capable. */
export function helloAdvertisesWriteReceipts(hello: Pick<CultNetHelloMessage, "supportedMessageVersions">): boolean {
const listed = hello.supportedMessageVersions ?? [];
return CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS.every((version) => listed.includes(version));
}
export interface CultNetSampleChangeNameMessage {
schemaVersion: "cultnet.sample.change_name.v0";
name: string;
@ -770,6 +807,8 @@ export interface GhostlightAgentStateDocument {
export type CultNetMessage =
| CultNetHelloMessage
| CultNetErrorV1Message
| CultNetWriteAdmittedMessage
| CultNetLoginMessage
| CultNetRegisterMessage
| CultNetVerifyMessage
@ -812,6 +851,8 @@ const CULTNET_MESSAGE_SCHEMAS = [
verifySchema,
loginSuccessSchema,
errorSchema,
errorV1Schema,
writeAdmittedSchema,
sampleChangeNameSchema,
sampleChatSchema,
documentPutSchema,
@ -1535,6 +1576,8 @@ export const cultNetSchemas = {
verifySchema,
loginSuccessSchema,
errorSchema,
errorV1Schema,
writeAdmittedSchema,
sampleChangeNameSchema,
sampleChatSchema,
documentPutSchema,

View file

@ -52,7 +52,7 @@ export function createIdunnRuntimePresencePublisher(
const publish = async (detail: string) => {
const signed = signer.sign(detail);
messageSequence += 1;
await publishDocument(endpoint, connectionId, {
await sendPresenceDocument(endpoint, connectionId, {
schemaVersion: "cultnet.document_put_raw.v0",
messageId: `runtime-presence:${signer.authority.expected.target}:${signer.runtimeInstanceId}:${messageSequence}`,
document: signed.document,
@ -86,11 +86,19 @@ export function createIdunnRuntimePresencePublisher(
};
}
async function publishDocument(
/**
* Sends one presence put and says only that it was sent (write-receipt.md: the explicit send-only mode). Odin does not
* answer a write with a receipt yet, so no frame, whatever id it carries, is admission; this never claims it. It returns
* once the transport has acknowledged the put, rejects `IdunnPresenceRefusedError` if a refusal arrives by then, and
* rejects if the put is never acknowledged.
*
* @internal exported for tests
*/
export async function sendPresenceDocument(
endpoint: { host: string; port: number },
connectionId: number,
message: Record<string, unknown>,
): Promise<void> {
): Promise<"sent"> {
const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4");
await new Promise<void>((resolve, reject) => {
socket.once("error", reject);
@ -107,26 +115,34 @@ async function publishDocument(
const wirePayload = encode(encodeCultNetMessageForWire(message as never, "cultnet.schema.v0"));
const packets = session.sendMany("schema", wirePayload, { reliable: true, ordered: true, nowMs: Date.now() });
for (const packet of packets) await sendPacket(socket, endpoint, packet);
await receiveUntil(
receiver,
session,
endpoint,
() => session.outstandingReliablePacketCount === 0,
2000,
(frame) => {
if (frame.channelId !== "schema") return;
const response = parseCultNetMessage(decode(frame.payload));
if (response.schemaVersion === "cultnet.error.v0") {
throw new Error(`Odin rejected runtime presence: ${response.error}`);
}
},
);
// The transport ack is delivery, not admission. A refusal that arrives by then rejects; no other frame means anything.
const onFrame = (frame: { channelId: string; payload: Uint8Array }) => {
if (frame.channelId !== "schema") return;
const response = parseCultNetMessage(decode(frame.payload));
if (response.schemaVersion === "cultnet.error.v0") throw new IdunnPresenceRefusedError(response);
// An error.v1 refuses this put only when it names it (or names nothing).
if (response.schemaVersion === "cultnet.error.v1" && (response.messageId === null || response.messageId === message.messageId)) {
throw new IdunnPresenceRefusedError(response);
}
};
await receiveUntil(receiver, session, endpoint, () => session.outstandingReliablePacketCount === 0, 2000, onFrame);
return "sent";
} finally {
receiver.close();
socket.close();
}
}
/** Odin refused the presence put: the typed `cultnet.error.v0` it answered with. */
export class IdunnPresenceRefusedError extends Error {
readonly code: string | undefined;
constructor(readonly response: { error: string; code?: string | null }) {
super(`Odin rejected runtime presence: ${response.error}`);
this.name = "IdunnPresenceRefusedError";
this.code = response.code ?? undefined;
}
}
type PacketReceiver = {
socket: dgram.Socket;
next(timeoutMs: number): Promise<CultNetRudpPacket>;

View file

@ -17,6 +17,12 @@ export {
type CultNetRawDocumentRecord,
type CultNetRawPayloadEncoding,
CULTNET_ERROR_CODES,
CULTNET_ERROR_V1_CODES,
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
helloAdvertisesWriteReceipts,
type CultNetErrorV1Code,
type CultNetErrorV1Message,
type CultNetWriteAdmittedMessage,
cultNetTextError,
cultNetMessageInvalidError,
cultNetWriteNotAuthorizedError,
@ -249,3 +255,15 @@ export {
type IdunnRuntimePresencePublisher,
type IdunnRuntimePresencePublisherOptions,
} from "./idunn-odin-presence-publisher";
export {
LEGACY_RESULT_WINDOW_MS,
RECEIPT_RESULT_WINDOW_MS,
WriteTracker,
resultWindowMs,
type WriteRecorded,
type WriteSettlement,
type WriteState,
type WriteTrackerOptions,
type WriteUnconfirmedReason,
} from "./write-tracker";

View file

@ -95,6 +95,10 @@ export class CultNetPeer extends EventEmitter {
return this.#transport?.profile;
}
/**
* Fire-and-forget: an ack or a return is not admission; read the result frame (a write is refused with
* `cultnet.error.v0`, `write_not_authorized` or `message_invalid`, as a `message` event).
*/
send(message: CultNetMessage): void {
const wireValue = encodeCultNetMessageForWire(message, this.#wireContract);
const payload = encode(wireValue);
@ -106,6 +110,7 @@ export class CultNetPeer extends EventEmitter {
this.#stream?.write(encodeFrame(payload));
}
/** Resolves when the transport has acknowledged what was sent. An ack is not admission; read the result frame. */
async flush(timeoutMs?: number): Promise<void> {
await this.#transport?.flush?.(timeoutMs);
}

View file

@ -0,0 +1,144 @@
// The client half of the write receipt (contracts/cultnet/interop/write-receipt.md, "Client rules"): a pure state
// machine with an abstract clock. It owns exactly one decision, how each sent write settles. Transport, timers and
// frame decoding belong to the caller; the replay runner (tools/replay-write-receipt-vectors.mjs) drives this same
// interface from write-receipt-vectors.json.
import type { CultNetErrorMessage, CultNetErrorV1Message, CultNetMessage } from "./contracts";
/** The result window against a server whose hello lists both receipt frames. */
export const RECEIPT_RESULT_WINDOW_MS = 5000;
/** The result window against a server whose hello does not list them (or that sent none). */
export const LEGACY_RESULT_WINDOW_MS = 500;
export function resultWindowMs(serverAdvertisesReceipts: boolean): number {
return serverAdvertisesReceipts ? RECEIPT_RESULT_WINDOW_MS : LEGACY_RESULT_WINDOW_MS;
}
/**
* Why nothing settled a write: its window ran out, the session ended first, or the client's own send of it failed (it may or
* may not have left the machine, and is never a refusal).
*/
export type WriteUnconfirmedReason = "window_elapsed" | "session_ended" | "local_send_failed";
/** What a late answer is recorded as on a write that already settled unconfirmed. */
export type WriteRecorded = "admitted_late" | "refused_late";
/** What the caller was told. Settled once; never changed by a later frame. */
export type WriteSettlement =
| { outcome: "admitted" }
| { outcome: "refused"; code: string | null; error: string; response: CultNetErrorMessage | CultNetErrorV1Message }
| { outcome: "unconfirmed"; reason: WriteUnconfirmedReason };
/** A write's state: unanswered, or its settlement plus (after unconfirmed only) the late answer. */
export type WriteState =
| { outcome: "pending"; recorded?: undefined }
| (WriteSettlement & { recorded?: WriteRecorded });
export interface WriteTrackerOptions {
/** The result window, from the send. Use `resultWindowMs(serverAdvertisesReceipts)`. */
windowMs: number;
/** Called once per write, at the moment it settles. */
onSettle?: (messageId: string, settlement: WriteSettlement) => void;
}
interface Entry {
sentAtMs: number;
state: WriteState;
}
export class WriteTracker {
readonly #windowMs: number;
readonly #onSettle?: (messageId: string, settlement: WriteSettlement) => void;
readonly #writes = new Map<string, Entry>();
constructor(options: WriteTrackerOptions) {
this.#windowMs = options.windowMs;
this.#onSettle = options.onSettle;
}
/** Registers a write sent at `atMs`. A messageId is minted once per session: re-sending a known id is the same write. */
send(messageId: string, atMs: number): void {
this.tick(atMs);
if (!this.#writes.has(messageId)) this.#writes.set(messageId, { sentAtMs: atMs, state: { outcome: "pending" } });
}
/** Feeds one decoded frame received at `atMs`. Frames that are not answers to a write are ignored. */
frame(message: CultNetMessage, atMs: number): void {
this.tick(atMs);
switch (message.schemaVersion) {
case "cultnet.write_admitted.v0":
this.#answer(message.messageId, { outcome: "admitted" }, "admitted_late");
return;
case "cultnet.error.v1":
if (message.messageId !== null) {
this.#answer(message.messageId, refusalOf(message), "refused_late");
return;
}
break;
case "cultnet.error.v0":
break;
default:
return;
}
// An error that names no write is attributable only when exactly one write is unanswered.
const unanswered = [...this.#writes].filter(([, entry]) => entry.state.outcome === "pending");
if (unanswered.length === 1) this.#settle(unanswered[0][0], unanswered[0][1], refusalOf(message));
}
/** Advances the clock: every unanswered write whose window, counted from its send, has run settles unconfirmed. */
tick(atMs: number): void {
for (const [messageId, entry] of this.#writes) {
if (entry.state.outcome === "pending" && atMs - entry.sentAtMs >= this.#windowMs) {
this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "window_elapsed" });
}
}
}
/** The session ended: every unanswered write settles unconfirmed. */
sessionEnd(atMs: number): void {
this.tick(atMs);
for (const [messageId, entry] of this.#writes) {
if (entry.state.outcome === "pending") this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "session_ended" });
}
}
/** This client's own send of `messageId` failed: that write settles unconfirmed; the others are untouched. */
sendFailed(messageId: string, atMs: number): void {
const entry = this.#writes.get(messageId);
if (entry?.state.outcome === "pending") this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "local_send_failed" });
this.tick(atMs);
}
state(messageId: string): WriteState | undefined {
return this.#writes.get(messageId)?.state;
}
/** Milliseconds from `atMs` until the next unanswered write's window runs out; undefined when none is unanswered. */
nextDeadlineMs(atMs: number): number | undefined {
let next: number | undefined;
for (const entry of this.#writes.values()) {
if (entry.state.outcome !== "pending") continue;
const remaining = Math.max(0, entry.sentAtMs + this.#windowMs - atMs);
if (next === undefined || remaining < next) next = remaining;
}
return next;
}
#answer(messageId: string, settlement: WriteSettlement, late: WriteRecorded): void {
const entry = this.#writes.get(messageId);
if (!entry) return; // names a write that is not in flight: settles nothing
if (entry.state.outcome === "pending") {
this.#settle(messageId, entry, settlement);
} else if (entry.state.outcome === "unconfirmed" && entry.state.recorded === undefined) {
entry.state = { ...entry.state, recorded: late };
}
}
#settle(messageId: string, entry: Entry, settlement: WriteSettlement): void {
entry.state = settlement;
this.#onSettle?.(messageId, settlement);
}
}
function refusalOf(message: CultNetErrorMessage | CultNetErrorV1Message): WriteSettlement {
return { outcome: "refused", code: message.code, error: message.error, response: message };
}

View file

@ -0,0 +1,94 @@
// The presence publisher is send-only (write-receipt.md): Odin answers no write with a receipt yet, so no frame, whatever
// id it carries, is admission. Each test serves it a fake Odin that acknowledges the put and answers as scripted.
import assert from "node:assert/strict";
import { createSocket, type Socket } from "node:dgram";
import test from "node:test";
import { encode, decode } from "@msgpack/msgpack";
import { encodeCultNetMessageForWire, type CultNetMessage } from "../src/contracts";
import { IdunnPresenceRefusedError, sendPresenceDocument } from "../src/idunn-odin-presence-publisher";
import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "../src/rudp";
interface Script {
answer: "silence" | "echo" | "admitted" | "refuse-v0" | "refuse-v1-other" | "refuse-v1-same" | "refuse-v1-none";
/** Acknowledge the put? Default true. */
ack?: boolean;
}
async function fakeOdin(connectionId: number, script: Script) {
const socket: Socket = createSocket("udp4");
await new Promise<void>((resolve) => socket.bind(0, "127.0.0.1", resolve));
const session = new CultNetRudpSession({ connectionId, resendDelayMs: 50 });
const send = (packet: Parameters<typeof encodeRudpPacket>[0], port: number, address: string) => {
try { socket.send(encodeRudpPacket(packet), port, address); } catch { /* closed */ }
};
const say = (message: CultNetMessage, port: number, address: string) => {
const packets = session.sendMany("schema", encode(encodeCultNetMessageForWire(message, "cultnet.schema.v0")), { reliable: true, ordered: true, nowMs: Date.now() });
packets.forEach((p) => send(p, port, address));
};
socket.on("message", (wire, remote) => {
const packet = decodeRudpPacket(wire);
const now = Date.now();
if (packet.packetType === "connect") {
send(session.acceptConnect(packet, now), remote.port, remote.address);
return;
}
const result = session.receive(packet, now);
if (result.reply) send(result.reply, remote.port, remote.address);
for (const frame of result.delivered) {
if (frame.channelId !== "schema") continue;
const put = decode(frame.payload) as { messageId: string; document: unknown };
if (script.answer === "echo") say({ schemaVersion: "cultnet.document_put_raw.v0", messageId: put.messageId, document: put.document } as unknown as CultNetMessage, remote.port, remote.address);
else if (script.answer === "admitted") say({ schemaVersion: "cultnet.write_admitted.v0", messageId: put.messageId, operation: "put", schemaId: "s", recordKey: "k" }, remote.port, remote.address);
else if (script.answer === "refuse-v0") say({ schemaVersion: "cultnet.error.v0", error: "no", routingHint: null, code: "write_not_authorized", details: null }, remote.port, remote.address);
else if (script.answer === "refuse-v1-other") say({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code: "write_not_authorized", details: null, messageId: "someone-else" }, remote.port, remote.address);
else if (script.answer === "refuse-v1-same") say({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code: "write_not_authorized", details: null, messageId: put.messageId }, remote.port, remote.address);
else if (script.answer === "refuse-v1-none") say({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code: "write_not_authorized", details: null, messageId: null }, remote.port, remote.address);
}
if (packet.reliable && script.ack !== false) send(session.createAckForReceived(packet.sequence), remote.port, remote.address);
});
return { port: socket.address().port, close: () => socket.close() };
}
let nextConnection = 0x0d1d1000;
const put = (id: string) => ({ schemaVersion: "cultnet.document_put_raw.v0", messageId: id, document: { schemaId: "s", recordKey: "k", storedAt: "t", payloadEncoding: "messagepack", payload: new Uint8Array([1]) } });
const sendTo = (server: { port: number }, id: string, connectionId: number) =>
sendPresenceDocument({ host: "127.0.0.1", port: server.port }, connectionId, put(id));
for (const answer of ["silence", "echo", "admitted", "refuse-v1-other"] as const) {
test(`a ${answer} Odin yields sent, never admitted: no frame carrying the put's id is success`, async () => {
const connectionId = nextConnection++;
const server = await fakeOdin(connectionId, { answer });
try {
const outcome = await sendTo(server, "presence-1", connectionId);
assert.equal(outcome, "sent");
assert.notEqual(outcome as string, "admitted");
} finally { server.close(); }
});
}
test("a refusal that arrives by the transport ack rejects the put", async () => {
const connectionId = nextConnection++;
const server = await fakeOdin(connectionId, { answer: "refuse-v0" });
try {
await assert.rejects(sendTo(server, "presence-1", connectionId), IdunnPresenceRefusedError);
} finally { server.close(); }
});
for (const answer of ["refuse-v1-same", "refuse-v1-none"] as const) {
test(`an error.v1 refusal that ${answer === "refuse-v1-same" ? "names this put" : "names no put"} rejects the put`, async () => {
const connectionId = nextConnection++;
const server = await fakeOdin(connectionId, { answer });
try {
await assert.rejects(sendTo(server, "presence-1", connectionId), IdunnPresenceRefusedError);
} finally { server.close(); }
});
}
test("a put the transport never acknowledges is an error, not sent", async () => {
const connectionId = nextConnection++;
const server = await fakeOdin(connectionId, { answer: "silence", ack: false });
try {
await assert.rejects(sendTo(server, "presence-1", connectionId), /Timed out/);
} finally { server.close(); }
});

View file

@ -276,9 +276,14 @@ async function startOdinPeer(context: TestContext, endpoint: string) {
peer.once("error", reject);
peer.bind(port, "127.0.0.1", resolve);
});
context.after(() => peer.close());
// The second packet of a split delivery is a timer; the test's end cancels it rather than racing the closed socket.
const timers = new Set<NodeJS.Timeout>();
const later = (send: () => void) => { timers.add(setTimeout(send, 120)); };
context.after(() => { for (const timer of timers) clearTimeout(timer); peer.close(); });
const state = {
reject: false,
// How a refusal and the ack of the put reach the publisher: in one packet, or the ack first, or the refusal first.
order: "piggyback" as "piggyback" | "ack-then-refusal" | "refusal-then-ack",
received: [] as Array<{ schemaVersion?: string; document: { payload: DocumentPayload } }>,
responseAck: undefined as number | undefined,
failure: undefined as Error | undefined,
@ -305,7 +310,15 @@ async function startOdinPeer(context: TestContext, endpoint: string) {
});
assert.ok(response);
state.responseAck = response.ack;
peer.send(encodeRudpPacket(response), remote.port, remote.address);
if (state.reject && state.order === "ack-then-refusal") {
peer.send(encodeRudpPacket(serverSession.createAckForReceived(packet.sequence)), remote.port, remote.address);
later(() => peer.send(encodeRudpPacket(response), remote.port, remote.address));
} else if (state.reject && state.order === "refusal-then-ack") {
peer.send(encodeRudpPacket({ ...response, ack: 0, ackMask: 0 }), remote.port, remote.address);
later(() => peer.send(encodeRudpPacket(serverSession!.createAckForReceived(packet.sequence)), remote.port, remote.address));
} else {
peer.send(encodeRudpPacket(response), remote.port, remote.address);
}
} catch (error) {
state.failure = error as Error;
}
@ -332,6 +345,28 @@ test("the Odin publisher publishes the signer's document to the endpoint Expecte
assert.equal(peer.failure, undefined, peer.failure?.message);
});
test("the Odin publisher rejects a presence whose refusal arrives before the transport ack", async (context) => {
const endpoint = expectedFacts("service").odinEndpoint!;
const peer = await startOdinPeer(context, endpoint);
peer.reject = true;
peer.order = "refusal-then-ack";
const signer = signerFor(context, "service");
signer.reportHealth("active");
const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint });
await assert.rejects(publisher.publish("denied presence"), /Odin rejected runtime presence: test admission denied/);
});
test("the Odin publisher is send-only: a refusal that follows the transport ack is not awaited", async (context) => {
const endpoint = expectedFacts("service").odinEndpoint!;
const peer = await startOdinPeer(context, endpoint);
peer.reject = true;
peer.order = "ack-then-refusal";
const signer = signerFor(context, "service");
signer.reportHealth("active");
const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint });
await publisher.publish("denied presence");
});
test("the Odin publisher leaves a capability shortfall to the Rust correlation instead of refusing to publish", async (context) => {
const endpoint = expectedFacts("service").odinEndpoint!;
const peer = await startOdinPeer(context, endpoint);

View file

@ -0,0 +1,229 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { join } from "node:path";
import {
CULTNET_ERROR_CODES,
CULTNET_ERROR_V1_CODES,
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
LEGACY_RESULT_WINDOW_MS,
RECEIPT_RESULT_WINDOW_MS,
WriteTracker,
cultNetSchemas,
helloAdvertisesWriteReceipts,
parseCultNetMessage,
resultWindowMs,
type CultNetErrorV1Message,
type CultNetMessage,
type CultNetWriteAdmittedMessage,
type WriteSettlement,
} from "../src";
const admitted = (messageId: string): CultNetWriteAdmittedMessage =>
({ schemaVersion: "cultnet.write_admitted.v0", messageId, operation: "put", schemaId: "note.v0", recordKey: "note:1" });
const refusedV1 = (messageId: string | null, code: CultNetErrorV1Message["code"] = "write_not_authorized"): CultNetErrorV1Message =>
({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code, details: null, messageId });
const errorV0 = (): CultNetMessage =>
({ schemaVersion: "cultnet.error.v0", error: "no", routingHint: null, code: "write_not_authorized", details: null });
test("error.v1 accepts every code of its closed enum, limit_reached included, and refuses any other", () => {
const schemaCodes = ((cultNetSchemas.errorV1Schema.properties.code as { enum: (string | null)[] }).enum).filter((c) => c !== null);
assert.deepEqual([...CULTNET_ERROR_V1_CODES].sort(), [...schemaCodes].sort());
for (const code of CULTNET_ERROR_V1_CODES) parseCultNetMessage({ ...refusedV1("w1"), code });
assert.throws(() => parseCultNetMessage({ ...refusedV1("w1"), code: "invented" }));
});
test("error.v1 echoes messageId byte for byte, allows nil, and requires the key", () => {
assert.equal((parseCultNetMessage(refusedV1("w-é 1")) as CultNetErrorV1Message).messageId, "w-é 1");
assert.equal((parseCultNetMessage(refusedV1(null)) as CultNetErrorV1Message).messageId, null);
const { messageId: _omitted, ...withoutId } = refusedV1("w1");
assert.throws(() => parseCultNetMessage(withoutId));
assert.throws(() => parseCultNetMessage(refusedV1("")));
});
test("error.v0 is unaffected: still closed, no messageId, no limit_reached", () => {
assert.deepEqual([...CULTNET_ERROR_CODES].includes("limit_reached" as never), false);
assert.throws(() => parseCultNetMessage({ ...errorV0(), code: "limit_reached" }));
assert.throws(() => parseCultNetMessage({ ...errorV0(), messageId: "w1" }));
assert.equal(parseCultNetMessage(errorV0()).schemaVersion, "cultnet.error.v0");
});
test("write_admitted needs all five keys, a known operation and no error key", () => {
parseCultNetMessage(admitted("w1"));
parseCultNetMessage({ ...admitted("w1"), operation: "delete" });
assert.throws(() => parseCultNetMessage({ ...admitted("w1"), operation: "upsert" }));
assert.throws(() => parseCultNetMessage({ ...admitted("w1"), error: "nope" }));
const { recordKey: _omitted, ...short } = admitted("w1");
assert.throws(() => parseCultNetMessage(short));
});
test("a hello is receipt-capable only when it lists both frames", () => {
assert.equal(helloAdvertisesWriteReceipts({ supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] }), true);
assert.equal(helloAdvertisesWriteReceipts({ supportedMessageVersions: ["cultnet.write_admitted.v0"] }), false);
assert.equal(helloAdvertisesWriteReceipts({ supportedMessageVersions: ["cultnet.error.v1"] }), false);
assert.equal(helloAdvertisesWriteReceipts({}), false);
const hello = parseCultNetMessage({ schemaVersion: "cultnet.hello.v0", runtimeId: "r", runtimeKind: "k", supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] });
assert.equal(hello.schemaVersion, "cultnet.hello.v0");
});
test("the window is 5000 ms for a receipt-capable server and 500 ms otherwise", () => {
assert.equal(resultWindowMs(true), RECEIPT_RESULT_WINDOW_MS);
assert.equal(resultWindowMs(false), LEGACY_RESULT_WINDOW_MS);
assert.equal(RECEIPT_RESULT_WINDOW_MS, 5000);
assert.equal(LEGACY_RESULT_WINDOW_MS, 500);
});
function tracker(windowMs = 500) {
const told: [string, WriteSettlement][] = [];
return { told, t: new WriteTracker({ windowMs, onSettle: (id, s) => told.push([id, s]) }) };
}
test("tracker: the window is inclusive at exactly windowMs and open one ms before", () => {
const { t, told } = tracker();
t.send("w1", 100);
t.tick(599);
assert.equal(t.state("w1")?.outcome, "pending");
t.tick(600);
assert.deepEqual(told, [["w1", { outcome: "unconfirmed", reason: "window_elapsed" }]]);
});
test("tracker: each write's window runs from its own send", () => {
const { t } = tracker();
t.send("w1", 0);
t.send("w2", 300);
t.tick(500);
assert.equal(t.state("w1")?.outcome, "unconfirmed");
assert.equal(t.state("w2")?.outcome, "pending");
});
test("tracker: a frame arriving after the window cannot admit the write; it is recorded late", () => {
const { t, told } = tracker();
t.send("w1", 0);
t.frame(admitted("w1"), 500);
assert.deepEqual(t.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed", recorded: "admitted_late" });
assert.equal(told.length, 1);
});
test("tracker: a write settles once; later frames neither change it nor flip a late record", () => {
const { t, told } = tracker();
t.send("w1", 0);
t.frame(refusedV1("w1"), 10);
t.frame(admitted("w1"), 20);
t.frame(refusedV1("w1", "message_invalid"), 30);
t.sessionEnd(40);
assert.equal(told.length, 1);
assert.deepEqual(told[0][1], { outcome: "refused", code: "write_not_authorized", error: "no", response: refusedV1("w1") });
t.send("w2", 100);
t.tick(600);
t.frame(refusedV1("w2"), 700);
t.frame(admitted("w2"), 800);
assert.equal(t.state("w2")?.outcome === "unconfirmed" && t.state("w2")?.recorded, "refused_late");
});
test("tracker: an id-less error refuses the single unanswered write only, counting answered writes out", () => {
const { t } = tracker();
t.send("w1", 0);
t.send("w2", 1);
t.frame(admitted("w1"), 5);
t.frame(refusedV1(null), 6);
assert.equal(t.state("w1")?.outcome, "admitted");
assert.equal(t.state("w2")?.outcome, "refused");
});
test("tracker: an id-less error never reaches a write that already ended unconfirmed", () => {
const { t } = tracker();
t.send("w1", 0);
t.tick(500);
t.frame(errorV0(), 501);
assert.deepEqual(t.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" });
});
test("tracker: sessionEnd settles unanswered writes only, and an expired window keeps its own reason", () => {
const { t } = tracker();
t.send("w1", 0);
t.send("w2", 0);
t.send("w3", 400);
t.frame(admitted("w2"), 10);
t.sessionEnd(600);
assert.deepEqual(t.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" });
assert.equal(t.state("w2")?.outcome, "admitted");
assert.deepEqual(t.state("w3"), { outcome: "unconfirmed", reason: "session_ended" });
});
test("tracker: re-sending a known id is the same write and does not restart its window", () => {
const { t } = tracker();
t.send("w1", 0);
t.send("w1", 400);
t.tick(500);
assert.equal(t.state("w1")?.outcome, "unconfirmed");
});
test("tracker: a send past another write's window settles that write at once", () => {
const { t, told } = tracker();
t.send("w1", 0);
t.send("w2", 600);
assert.deepEqual(told, [["w1", { outcome: "unconfirmed", reason: "window_elapsed" }]]);
});
test("tracker: only receipt and error frames answer; any other frame naming the id settles nothing", () => {
const { t } = tracker();
t.send("w1", 0);
t.frame({ schemaVersion: "cultnet.hello.v0", runtimeId: "r", runtimeKind: "k" }, 1);
t.frame({ schemaVersion: "cultnet.document_put_raw.v0", messageId: "w1" } as unknown as CultNetMessage, 2);
assert.equal(t.state("w1")?.outcome, "pending");
});
test("tracker: nextDeadlineMs reports the nearest unanswered window, none when all settled", () => {
const { t } = tracker();
assert.equal(t.nextDeadlineMs(0), undefined);
t.send("w1", 0);
t.send("w2", 100);
assert.equal(t.nextDeadlineMs(50), 450);
assert.equal(t.nextDeadlineMs(900), 0);
t.frame(admitted("w1"), 60);
assert.equal(t.nextDeadlineMs(150), 450);
t.frame(admitted("w2"), 160);
assert.equal(t.nextDeadlineMs(170), undefined);
});
test("the replay runner passes every vector against this runtime", () => {
const script = join(__dirname, "..", "..", "tools", "replay-write-receipt-vectors.mjs");
const run = spawnSync(process.execPath, [script], { encoding: "utf8" });
assert.equal(run.status, 0, run.stdout + run.stderr);
assert.match(run.stdout, /^PASS scenario\/receipt_admits$/m);
assert.doesNotMatch(run.stdout, /^FAIL/m);
});
test("a failed local send settles only that write unconfirmed as local_send_failed, and an answer after it is recorded late", () => {
const settled: [string, WriteSettlement][] = [];
const tracker = new WriteTracker({ windowMs: 1000, onSettle: (id, settlement) => settled.push([id, settlement]) });
tracker.send("w1", 0);
tracker.send("w2", 0);
tracker.sendFailed("w1", 10);
assert.deepEqual(settled, [["w1", { outcome: "unconfirmed", reason: "local_send_failed" }]]);
assert.equal(tracker.state("w2")?.outcome, "pending");
tracker.frame(admitted("w1"), 20);
assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "local_send_failed", recorded: "admitted_late" });
tracker.sendFailed("w2", 30);
tracker.sendFailed("w2", 31);
assert.equal(settled.length, 2, "a write settles once");
tracker.sendFailed("unknown", 40);
assert.equal(tracker.state("unknown"), undefined);
});
test("a failed local send advances the clock: other writes whose windows have run settle window_elapsed", () => {
const tracker = new WriteTracker({ windowMs: 1000 });
tracker.send("w1", 0);
tracker.send("w2", 500);
tracker.sendFailed("w2", 1200);
assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" });
assert.deepEqual(tracker.state("w2"), { outcome: "unconfirmed", reason: "local_send_failed" });
});
test("a write whose own send failed settles local_send_failed even when its window has also run", () => {
const tracker = new WriteTracker({ windowMs: 1000 });
tracker.send("w1", 0);
tracker.sendFailed("w1", 1000);
assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "local_send_failed" });
});

View file

@ -0,0 +1,116 @@
#!/usr/bin/env node
// Replays contracts/cultnet/interop/write-receipt-vectors.json against this runtime: every `scenarios.cases` event
// replay through a fake server and the WriteTracker, and every `wire` / `schemaInvalid` frame through the real
// decoder. Prints PASS/FAIL per case; exit 1 on any failure. Runs against the built package (`npm run build` first).
//
// node tools/replay-write-receipt-vectors.mjs [path/to/write-receipt-vectors.json]
//
// The runner interface a port (Rust, C#) must reproduce is documented in README.md, "Write receipt replay runner".
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const here = path.dirname(fileURLToPath(import.meta.url));
const { decode, encode } = require("@msgpack/msgpack");
const {
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS, LEGACY_RESULT_WINDOW_MS, RECEIPT_RESULT_WINDOW_MS, WriteTracker,
encodeCultNetMessageForWire, helloAdvertisesWriteReceipts, parseCultNetMessage, resultWindowMs,
} = require(path.join(here, "..", "dist", "index.js"));
const vectorsPath = process.argv[2] ?? path.join(here, "..", "..", "..", "contracts", "cultnet", "interop", "write-receipt-vectors.json");
const vectors = JSON.parse(readFileSync(vectorsPath, "utf8"));
const results = [];
const check = (name, run) => {
try {
const problems = run();
results.push({ name, problems });
} catch (error) {
results.push({ name, problems: [`threw: ${error instanceof Error ? error.message : String(error)}`] });
}
};
const bytesOf = (hex) => Uint8Array.from(Buffer.from(hex, "hex"));
const hexOf = (bytes) => Buffer.from(bytes).toString("hex").toUpperCase();
// A frame reaches the client as bytes: the fake server encodes it, the client decodes and validates it.
const delivered = (frame) => parseCultNetMessage(decode(encode(frame)));
check("constants", () => [
...(vectors.constants.RECEIPT_RESULT_WINDOW_MS === RECEIPT_RESULT_WINDOW_MS ? [] : [`RECEIPT window ${RECEIPT_RESULT_WINDOW_MS} != ${vectors.constants.RECEIPT_RESULT_WINDOW_MS}`]),
...(vectors.constants.LEGACY_RESULT_WINDOW_MS === LEGACY_RESULT_WINDOW_MS ? [] : [`LEGACY window ${LEGACY_RESULT_WINDOW_MS} != ${vectors.constants.LEGACY_RESULT_WINDOW_MS}`]),
]);
for (const vector of vectors.wire) {
check(`wire/${vector.name}`, () => {
const problems = [];
const parsed = parseCultNetMessage(decode(bytesOf(vector.messagePackHex)));
if (JSON.stringify(sorted(parsed)) !== JSON.stringify(sorted(vector.message))) problems.push(`decoded ${JSON.stringify(parsed)} != ${JSON.stringify(vector.message)}`);
const reencoded = hexOf(encode(encodeCultNetMessageForWire(parsed)));
if (reencoded !== vector.messagePackHex.toUpperCase()) problems.push(`re-encoded bytes ${reencoded} != captured ${vector.messagePackHex}`);
return problems;
});
}
for (const vector of vectors.schemaInvalid) {
check(`schemaInvalid/${vector.name}`, () => {
try {
parseCultNetMessage(decode(bytesOf(vector.messagePackHex)));
} catch {
return [];
}
return [`accepted a frame that must be refused (${vector.why})`];
});
}
for (const scenario of vectors.scenarios.cases) {
check(`scenario/${scenario.name}`, () => {
// The fake server states its capability the only way a real one does: in its hello.
const serverHello = { schemaVersion: "cultnet.hello.v0", runtimeId: "fake-server", runtimeKind: "fake",
...(scenario.server === "receipt" ? { supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] } : {}) };
const windowMs = resultWindowMs(helloAdvertisesWriteReceipts(serverHello));
const problems = [];
if (windowMs !== vectors.constants[scenario.window]) problems.push(`window ${windowMs} != ${scenario.window}`);
const told = new Map(); // what the caller was told, at settle time
const tracker = new WriteTracker({ windowMs, onSettle: (id, settlement) => {
if (told.has(id)) problems.push(`${id} settled twice`);
told.set(id, settlement);
} });
for (const event of [...scenario.events].sort((a, b) => a.atMs - b.atMs)) {
if ("send" in event) tracker.send(event.send, event.atMs);
else if ("frame" in event) tracker.frame(delivered(event.frame), event.atMs);
else if ("tick" in event) tracker.tick(event.atMs);
else if ("sessionEnd" in event) tracker.sessionEnd(event.atMs);
// transportAck: delivery is not an answer; the tracker has no such input and must not need one.
}
for (const [id, want] of Object.entries(scenario.expect)) {
const got = told.get(id);
if (!got) { problems.push(`${id}: never settled, wanted ${want.outcome}`); continue; }
if (got.outcome !== want.outcome) problems.push(`${id}: told ${got.outcome}, wanted ${want.outcome}`);
if (want.code !== undefined && got.code !== want.code) problems.push(`${id}: code ${got.code}, wanted ${want.code}`);
if (want.reason !== undefined && got.reason !== want.reason) problems.push(`${id}: reason ${got.reason}, wanted ${want.reason}`);
const recorded = tracker.state(id)?.recorded;
if (recorded !== want.recorded) problems.push(`${id}: recorded ${recorded}, wanted ${want.recorded}`);
}
for (const id of told.keys()) if (!(id in scenario.expect)) problems.push(`${id}: settled but the scenario expects nothing for it`);
return problems;
});
}
function sorted(value) {
if (Array.isArray(value)) return value.map(sorted);
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sorted(value[k])]));
return value;
}
let failed = 0;
for (const { name, problems } of results) {
if (problems.length === 0) { console.log(`PASS ${name}`); continue; }
failed++;
console.log(`FAIL ${name}`);
for (const problem of problems) console.log(` ${problem}`);
}
console.log(`${results.length - failed}/${results.length} passed`);
process.exit(failed === 0 ? 0 : 1);

View file

@ -44,6 +44,12 @@ namespace GameCult.Mesh
/// <summary>Gets or sets the deadline for one correlated typed operation response.</summary>
public TimeSpan OperationResponseTimeout { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Gets or sets how long after a submitted document the provider's refusal may still arrive. The provider
/// sends no success frame for an admitted submission, so this window bounds <c>SubmitDocumentAsync</c>.
/// </summary>
public TimeSpan SubmitResultWindow { get; set; } = CultNetWriteCompletion.DefaultResultWindow;
}
/// <summary>
@ -149,6 +155,7 @@ namespace GameCult.Mesh
private readonly CultMeshSessionManager _sessions;
private readonly TimeSpan _subscriptionResponseTimeout;
private readonly TimeSpan _operationResponseTimeout;
private readonly TimeSpan _submitResultWindow;
private readonly ConcurrentDictionary<string, SharedResource> _documents = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, SharedResource> _collections = new(StringComparer.Ordinal);
private bool _disposed;
@ -191,6 +198,7 @@ namespace GameCult.Mesh
options.Sessions);
_subscriptionResponseTimeout = options.SubscriptionResponseTimeout;
_operationResponseTimeout = options.OperationResponseTimeout;
_submitResultWindow = options.SubmitResultWindow;
}
/// <summary>Connects to one stable Verse/provider target using one application protocol.</summary>
@ -319,7 +327,10 @@ namespace GameCult.Mesh
/// <summary>
/// Submits one typed document to a provider by stable identity. Submission is uncommitted input;
/// only a provider-authored receipt or resulting state can establish acceptance.
/// only a provider-authored receipt or resulting state can establish acceptance. The task completes on
/// the provider's result frame: a refusal (<c>write_not_authorized</c>, <c>message_invalid</c>, ...) throws
/// <see cref="CultNetWriteRefusedException"/>, and a submission not refused within
/// <see cref="CultMeshClientOptions.SubmitResultWindow"/> completes.
/// </summary>
public async Task SubmitDocumentAsync<TDocument>(
CultMeshSessionTarget target,
@ -351,7 +362,11 @@ namespace GameCult.Mesh
var session = await ConnectAsync(target, CultMeshProtocols.Documents, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
session.OpenSchemaClient().SendCultNet(message);
await CultNetWriteCompletion.SendAndAwaitResultAsync(
refuse => session.OnCultNet(refuse),
() => session.SendCultNet(message),
_submitResultWindow,
cancellationToken).ConfigureAwait(false);
}
/// <summary>

View file

@ -352,6 +352,7 @@ namespace GameCult.Mesh
internal long PhysicalGeneration => Interlocked.Read(ref _physicalGeneration);
public Observable<CultMeshSessionState> WatchState() => _states;
public ICultNetSchemaClient OpenSchemaClient() => new SessionSchemaClient(this);
/// <summary>Fire-and-forget: an ack or a return is not admission; read the result frame.</summary>
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage => Channel.SendCultNet(message);
public IDisposable OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage
{

View file

@ -1017,6 +1017,8 @@ namespace GameCult.Networking
/// <summary>
/// Applies a raw document mutation after checking shard authority. This is the host's own door (and replication's):
/// it asks no remote write authority. A peer's put goes through <see cref="CultNetDatabaseServer"/>, which does.
/// Returning normally means the put is committed and visible to readers: a receipt-capable peer is told
/// <c>write_admitted</c> on the strength of it, so a write that has only been queued must not return.
/// </summary>
/// <exception cref="CultNetUnownedSchemaException">No shard owns the schema and key; nothing was written.</exception>
/// <exception cref="CultNetShardLogException">The change committed and was published, but the shard log could not record it.</exception>
@ -1047,6 +1049,7 @@ namespace GameCult.Networking
/// <summary>
/// Applies a raw document delete after checking shard authority. This is the host's own door: it asks no remote
/// write authority. A peer's delete goes through <see cref="CultNetDatabaseServer"/>, which does.
/// Returning normally means the delete is committed and visible to readers (see <see cref="ApplyPutAsync(CultNetDocumentPutRawMessage)"/>).
/// </summary>
/// <exception cref="CultNetUnownedSchemaException">No shard owns the schema and key; nothing was written.</exception>
/// <exception cref="CultNetShardLogException">The change committed and was published, but the shard log could not record it.</exception>

View file

@ -22,6 +22,7 @@ namespace GameCult.Networking
private readonly Func<CultNetSnapshotRequestV1Message, CultNetServerPeer, Task> _snapshotHandlerV1;
private readonly Func<CultNetDocumentPutRawMessage, CultNetServerPeer, Task> _putHandler;
private readonly Func<CultNetDocumentDeleteMessage, CultNetServerPeer, Task> _deleteHandler;
private readonly Func<CultNetHelloMessage, CultNetServerPeer, Task> _helloHandler;
private readonly Func<CultNetShardCatalogRequestMessage, CultNetServerPeer, Task> _shardCatalogHandler;
private readonly Func<CultNetShardLogRequestMessage, CultNetServerPeer, Task> _shardLogHandler;
private readonly Func<CultNetDatabaseSubscribeMessage, CultNetServerPeer, Task> _subscribeHandler;
@ -45,6 +46,7 @@ namespace GameCult.Networking
_snapshotHandlerV1 = HandleSnapshotRequestV1Async;
_putHandler = HandlePutAsync;
_deleteHandler = HandleDeleteAsync;
_helloHandler = HandleHelloAsync;
_shardCatalogHandler = HandleShardCatalogRequestAsync;
_shardLogHandler = HandleShardLogRequestAsync;
_subscribeHandler = HandleSubscribeAsync;
@ -55,6 +57,7 @@ namespace GameCult.Networking
_server.OnCultNet(_snapshotHandlerV1);
_server.OnCultNet(_putHandler);
_server.OnCultNet(_deleteHandler);
_server.OnCultNet(_helloHandler);
_server.OnCultNet(_shardCatalogHandler);
_server.OnCultNet(_shardLogHandler);
_server.OnCultNet(_subscribeHandler);
@ -217,6 +220,7 @@ namespace GameCult.Networking
_server.RemoveCultNetMessageListener<CultNetSnapshotRequestV1Message>(_snapshotHandlerV1);
_server.RemoveCultNetMessageListener<CultNetDocumentPutRawMessage>(_putHandler);
_server.RemoveCultNetMessageListener<CultNetDocumentDeleteMessage>(_deleteHandler);
_server.RemoveCultNetMessageListener<CultNetHelloMessage>(_helloHandler);
_server.RemoveCultNetMessageListener<CultNetShardCatalogRequestMessage>(_shardCatalogHandler);
_server.RemoveCultNetMessageListener<CultNetShardLogRequestMessage>(_shardLogHandler);
_server.RemoveCultNetMessageListener<CultNetDatabaseSubscribeMessage>(_subscribeHandler);
@ -262,7 +266,7 @@ namespace GameCult.Networking
catch (Exception ex)
{
_server.Logger.LogError($"CultNet snapshot v1 request failed: {ex.Message}");
peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message });
peer.SendCultNet(new CultNetErrorMessage { Error = "the snapshot request could not be answered" });
}
return Task.CompletedTask;
@ -282,7 +286,8 @@ namespace GameCult.Networking
}
catch (Exception ex)
{
peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message });
_server.Logger.LogError($"CultNet shard log request failed: {ex.Message}");
peer.SendCultNet(new CultNetErrorMessage { Error = "the shard log request could not be answered" });
}
return Task.CompletedTask;
@ -323,74 +328,256 @@ namespace GameCult.Networking
};
}
private async Task HandlePutAsync(CultNetDocumentPutRawMessage message, CultNetServerPeer peer)
// A hello decides the connection's write framing if it is the first thing to (write-receipt.md, "Capability"). The
// server answers a receipt-capable hello with its own, so the client can tell a receipt server from a legacy one.
private Task HandleHelloAsync(CultNetHelloMessage hello, CultNetServerPeer peer)
{
if (peer.Transport != null && peer.Transport.SettleWriteFraming(hello))
{
Answer(peer, new CultNetHelloMessage
{
RuntimeId = "gamecult-networking-database-server",
RuntimeKind = "dotnet",
SupportedMessageVersions = [CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1]
});
}
return Task.CompletedTask;
}
// The write's receipt state, or null when its connection did not opt in (it is then answered exactly as before).
private CultNetReceiptedWrite? BeginReceipted(
CultNetServerPeer peer,
string operation,
string? messageId,
string? schemaId,
string? recordKey,
byte[]? payload)
{
var transport = peer.Transport;
if (transport == null) return null;
transport.SettleWriteFraming(null);
var session = transport.WriteReceipts;
return session == null
? null
: new CultNetReceiptedWrite(session, message => Answer(peer, message), operation, messageId, schemaId, recordKey, payload);
}
// Every frame a write can be answered with. Mock point for tests: the answer leaves through the peer's transport. An
// answer that cannot be queued ends the connection: the peer settles its write as session_ended, never as silence.
private void Answer(CultNetServerPeer peer, ICultNetSchemaMessage message)
{
try
{
await _database.ApplyPutAsync(message, Authorize(peer, CultNetRemoteWriteOperation.Put, message.ShardId, message.ShardEpoch))
.ConfigureAwait(false);
Send(peer, message);
}
catch (Exception ex)
{
_server.Logger.LogWarning($"CultNet write answer could not be sent; ending the connection: {ex.Message}");
peer.Transport?.End();
}
}
private static void Send(CultNetServerPeer peer, ICultNetSchemaMessage message)
{
switch (message)
{
case CultNetWriteAdmittedMessage admitted: peer.SendCultNet(admitted); break;
case CultNetErrorV1Message error: peer.SendCultNet(error); break;
case CultNetErrorMessage error: peer.SendCultNet(error); break;
case CultNetHelloMessage hello: peer.SendCultNet(hello); break;
default: throw new ArgumentException($"{message.GetType().Name} is not a write answer.", nameof(message));
}
}
// A refusal: error.v1 carrying the write's id for a receipt-capable connection, the v0 error for any other.
// An empty or blank schemaId or recordKey names no record: refused as a frame that does not read, exactly as the Rust
// server's decoder refuses it (write-receipt.md, "Required fields"), never handed to the store.
private static bool IsUnreadableWriteTarget(string? schemaId, string? recordKey) =>
string.IsNullOrWhiteSpace(schemaId) || string.IsNullOrWhiteSpace(recordKey);
private static CultNetErrorMessage UnreadableWriteTarget() =>
CultNetErrorMessage.ForMessageInvalid("message", "the message could not be read");
private static void Refuse(CultNetServerPeer peer, CultNetReceiptedWrite? write, CultNetErrorMessage error)
{
if (write != null) write.Refused(error);
else peer.SendCultNet(error);
}
// Rule 6: a forward is answered with what the primary answered, under the original id. A refusal the primary sent is
// relayed; silence or any other failure relays nothing, so the client settles it unconfirmed.
private void RelayForwardFailure(CultNetReceiptedWrite write, Exception failure)
{
if (failure is CultNetWriteRefusedException refused)
{
write.Refused(refused.Refusal);
return;
}
_server.Logger.LogWarning($"CultNet forwarded write was not answered by its primary: {failure.Message}");
// Only a forward that provably never left (not sent) may be forwarded again. Every other failure, silence or not, is
// unconfirmed: the primary may have applied it, so the id is never forwarded again on this session.
if (failure is CultNetWriteNotSentException) write.NotSent();
else write.LeftSilent();
}
private async Task HandlePutAsync(CultNetDocumentPutRawMessage message, CultNetServerPeer peer)
{
var write = BeginReceipted(peer, "put", message.MessageId, message.Document?.SchemaId, message.Document?.RecordKey, message.Document?.Payload);
var idRefusal = write?.IdRefusal;
if (idRefusal != null)
{
write!.Refused(idRefusal);
return;
}
var missing = message.Document == null ? "document" : message.Document.SchemaId == null ? "schemaId" : null;
if (missing != null)
{
Refuse(peer, write, CultNetErrorMessage.ForMessageInvalid(missing, $"{missing} is required"));
return;
}
if (IsUnreadableWriteTarget(message.Document!.SchemaId, message.Document.RecordKey))
{
Refuse(peer, write, UnreadableWriteTarget());
return;
}
if (write != null && !write.Begin()) return;
var authorize = Authorize(peer, CultNetRemoteWriteOperation.Put, message.ShardId, message.ShardEpoch);
try
{
await _database.ApplyPutAsync(message, authorize).ConfigureAwait(false);
write?.Admitted();
}
catch (CultNetRemoteWriteRefusedException ex)
{
_server.Logger.LogWarning($"CultNet raw put refused: {ex.Message}");
peer.SendCultNet(CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason));
Refuse(peer, write, CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason));
}
catch (CultNetShardAuthorityException ex)
{
if (!await TryForwardPutAsync(ex, message).ConfigureAwait(false))
bool forwarded;
try
{
forwarded = await TryForwardPutAsync(ex, message).ConfigureAwait(false);
}
catch (CultNetWriteUnconfirmedException) when (write == null)
{
// A peer that did not opt in is told nothing of a forward its primary left unanswered.
return;
}
catch (Exception failure) when (write != null)
{
RelayForwardFailure(write, failure);
return;
}
if (forwarded)
{
write?.Admitted();
}
else
{
_server.Logger.LogError($"CultNet raw put authority failed: {ex.Message}");
peer.SendCultNet(CreateRoutingError(ex));
Refuse(peer, write, CreateRoutingError(ex));
}
}
catch (CultNetUnownedSchemaException ex)
{
_server.Logger.LogWarning($"CultNet raw put refused: {ex.Message}");
peer.SendCultNet(CultNetErrorMessage.ForUnownedSchema(ex));
Refuse(peer, write, CultNetErrorMessage.ForUnownedSchema(ex));
}
catch (Exception ex) when (CommittedWithLogFailure(ex))
{
// The write committed and was published; only its shard log failed, so the peer is not told it was refused.
_server.Logger.LogWarning($"CultNet raw put committed but its shard log failed: {ex.Message}");
write?.Admitted();
}
catch (Exception ex)
{
_server.Logger.LogError($"CultNet raw put failed: {ex.Message}");
peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message });
Refuse(peer, write, new CultNetErrorMessage { Error = CultNetWriteReceiptSession.WriteFailedText });
}
}
private async Task HandleDeleteAsync(CultNetDocumentDeleteMessage message, CultNetServerPeer peer)
{
var write = BeginReceipted(peer, "delete", message.MessageId, message.SchemaId, message.RecordKey, null);
var idRefusal = write?.IdRefusal;
if (idRefusal != null)
{
write!.Refused(idRefusal);
return;
}
if (message.SchemaId == null)
{
Refuse(peer, write, CultNetErrorMessage.ForMessageInvalid("schemaId", "schemaId is required"));
return;
}
if (IsUnreadableWriteTarget(message.SchemaId, message.RecordKey))
{
Refuse(peer, write, UnreadableWriteTarget());
return;
}
if (write != null && !write.Begin()) return;
var authorize = Authorize(peer, CultNetRemoteWriteOperation.Delete, message.ShardId, message.ShardEpoch);
try
{
await _database.ApplyDeleteAsync(message, Authorize(peer, CultNetRemoteWriteOperation.Delete, message.ShardId, message.ShardEpoch))
.ConfigureAwait(false);
await _database.ApplyDeleteAsync(message, authorize).ConfigureAwait(false);
write?.Admitted();
}
catch (CultNetRemoteWriteRefusedException ex)
{
_server.Logger.LogWarning($"CultNet raw delete refused: {ex.Message}");
peer.SendCultNet(CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason));
Refuse(peer, write, CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason));
}
catch (CultNetShardAuthorityException ex)
{
if (!await TryForwardDeleteAsync(ex, message).ConfigureAwait(false))
bool forwarded;
try
{
peer.SendCultNet(CreateRoutingError(ex));
forwarded = await TryForwardDeleteAsync(ex, message).ConfigureAwait(false);
}
catch (CultNetWriteUnconfirmedException) when (write == null)
{
return;
}
catch (Exception failure) when (write != null)
{
RelayForwardFailure(write, failure);
return;
}
if (forwarded)
{
write?.Admitted();
}
else
{
Refuse(peer, write, CreateRoutingError(ex));
}
}
catch (CultNetUnownedSchemaException ex)
{
_server.Logger.LogWarning($"CultNet raw delete refused: {ex.Message}");
peer.SendCultNet(CultNetErrorMessage.ForUnownedSchema(ex));
Refuse(peer, write, CultNetErrorMessage.ForUnownedSchema(ex));
}
catch (Exception ex) when (CommittedWithLogFailure(ex))
{
_server.Logger.LogWarning($"CultNet raw delete committed but its shard log failed: {ex.Message}");
write?.Admitted();
}
catch (Exception ex)
{
peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message });
Refuse(peer, write, new CultNetErrorMessage { Error = CultNetWriteReceiptSession.WriteFailedText });
}
}
@ -588,7 +775,7 @@ namespace GameCult.Networking
var refusedKey = change.GetType().GetProperty("Key")?.GetValue(change) is CultRecordKey changed ? changed.Value : string.Empty;
lock (refused)
refused.Add(refusedKey);
sendError(new CultNetErrorMessage { Error = refusal.Message });
sendError(new CultNetErrorMessage { Error = "the record is a variant CultNet cannot carry" });
return null;
}
@ -666,11 +853,14 @@ namespace GameCult.Networking
exception is CultNetShardLogException ||
exception is AggregateException aggregate && aggregate.Flatten().InnerExceptions.Any(inner => inner is CultNetShardLogException);
// Fixed text: the routing refusal never carries an exception's own message (the shard and reason travel in the hint).
internal const string RoutingRefusalText = "the write belongs to another shard primary";
private static CultNetErrorMessage CreateRoutingError(CultNetShardAuthorityException exception)
{
return new CultNetErrorMessage
{
Error = exception.Message,
Error = RoutingRefusalText,
RoutingHint = new CultNetShardRoutingHint
{
Reason = exception.Reason,

View file

@ -1,4 +1,5 @@
using System;
using System.Runtime.CompilerServices;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net;
@ -113,9 +114,24 @@ namespace GameCult.Networking
private readonly CultNetRudpSocketTransportServer _transport;
private readonly ConcurrentDictionary<Type, Delegate> _handlers = new();
private readonly ConcurrentDictionary<Delegate, Delegate> _schemaPeerAdapters = new();
private readonly ConditionalWeakTable<CultNetRudpSocketServerPeer, StrongBox<int>> _undecodableFramesInARow = new();
private bool _disposed;
private ILogger _logger = new NullLogger();
/// <summary>
/// How many consecutive undecodable frames a peer's session may send before it is ended. Fewer are
/// answered with a typed <c>message_invalid</c> and the session is kept; the frame that reaches this
/// number is answered too, then the session ends. Any frame that decodes resets the run. The same
/// number is the cap in every runtime.
/// </summary>
public const int MaxConsecutiveRefusedFrames = 8;
/// <summary>
/// How long a session whose capped frame was answered waits for the peer to acknowledge the answer
/// before it ends anyway.
/// </summary>
internal const long CappedAnswerGraceMs = 2000;
/// <summary>
/// Gets or sets the logger used by the server. R-AM: the dispatch backstop in DispatchAsync
/// logs every untyped fault through this instead of swallowing it silently.
@ -300,6 +316,9 @@ namespace GameCult.Networking
{
if (!string.Equals(delivered.Frame.ChannelId, "schema", StringComparison.Ordinal))
return false;
// The capped frame was answered: nothing after it is served while the answer is delivered.
if (delivered.Peer.EndingReason != null)
return false;
// A peer's message that cannot be decoded (malformed bytes, an unknown schemaVersion) or
// handled ends that peer's session, never the poll: the drain goes on serving every other
@ -317,11 +336,23 @@ namespace GameCult.Networking
}
catch (Exception ex)
{
// The peer sent something no runtime can read: the refusal is the peer's.
var ended = _transport.EndPeerSession(delivered.Peer, CultNetRudpSession.RefusedPacketReason);
Logger.LogError($"CultNet RUDP schema server could not decode a peer's message ({ex.GetType().Name}); {SessionOutcome(ended)}");
// The peer sent something no runtime can read: the refusal is the peer's. It is answered
// message_invalid and the session kept, until MaxConsecutiveRefusedFrames in a row end it.
var run = _undecodableFramesInARow.GetOrCreateValue(delivered.Peer);
run.Value++;
_transport.SendSchemaMessage(delivered.Peer, CultNetErrorMessage.ForMessageInvalid("message", "CultNet RUDP schema server could not read the message."));
if (run.Value < MaxConsecutiveRefusedFrames)
{
Logger.LogError($"CultNet RUDP schema server could not decode a peer's message ({ex.GetType().Name}); it was refused and that peer's session kept.");
return false;
}
// The answer was sent reliably: the session ends once the peer has acknowledged it (a lost
// datagram is resent first), or after the grace.
_transport.EndPeerSessionWhenAnswered(delivered.Peer, CultNetRudpSession.RefusedPacketReason, CappedAnswerGraceMs);
Logger.LogError($"CultNet RUDP schema server could not decode a peer's message ({ex.GetType().Name}); that peer's session ends once its answer is acknowledged.");
return false;
}
_undecodableFramesInARow.GetOrCreateValue(delivered.Peer).Value = 0;
if (!_handlers.TryGetValue(message.GetType(), out var handler) || handler == null)
{

View file

@ -26,7 +26,9 @@ namespace GameCult.Networking
void Connect(string host, int port);
/// <summary>
/// Sends one schema-v0 message through the transport.
/// Sends one schema-v0 message through the transport. Fire-and-forget: an ack or a return is not admission;
/// read the result frame (<see cref="CultNetWriteCompletion"/>). Throws <see cref="CultNetWriteNotSentException"/> only
/// when it can prove no byte was written; any other exception leaves the send ambiguous and a forwarded write unconfirmed.
/// </summary>
void SendCultNet<T>(T message)
where T : ICultNetSchemaMessage;

View file

@ -53,6 +53,8 @@ namespace GameCult.Networking
CultNetSchemaVersions.Verify => Deserialize<CultNetVerifyMessage>(payload),
CultNetSchemaVersions.LoginSuccess => Deserialize<CultNetLoginSuccessMessage>(payload),
CultNetSchemaVersions.Error => Deserialize<CultNetErrorMessage>(payload),
CultNetSchemaVersions.ErrorV1 => Deserialize<CultNetErrorV1Message>(payload),
CultNetSchemaVersions.WriteAdmitted => Deserialize<CultNetWriteAdmittedMessage>(payload),
CultNetSchemaVersions.SampleChangeName => Deserialize<CultNetSampleChangeNameMessage>(payload),
CultNetSchemaVersions.SampleChat => Deserialize<CultNetSampleChatMessage>(payload),
CultNetSchemaVersions.OperationRequest => Deserialize<CultNetOperationRequestMessage>(payload),

View file

@ -48,6 +48,14 @@ namespace GameCult.Networking
/// </summary>
public const string Error = "cultnet.error.v0";
/// <summary>
/// error v1 contract identifier: error v0 plus the id of the request it answers (write-receipt.md).
/// </summary>
public const string ErrorV1 = "cultnet.error.v1";
/// <summary>
/// write admitted receipt contract identifier (write-receipt.md).
/// </summary>
public const string WriteAdmitted = "cultnet.write_admitted.v0";
/// <summary>
/// sample change name contract identifier.
/// </summary>
public const string SampleChangeName = "cultnet.sample.change_name.v0";
@ -538,7 +546,7 @@ namespace GameCult.Networking
public static CultNetErrorMessage ForUnownedSchema(CultNetUnownedSchemaException ex) =>
new CultNetErrorMessage
{
Error = $"unowned_schema: {ex.Message}",
Error = "unowned_schema: no shard owns the schema and key",
Code = "unowned_schema",
Details = new CultNetErrorDetails { Field = "schemaId", Value = ex.SchemaId }
};
@ -621,6 +629,80 @@ namespace GameCult.Networking
};
}
/// <summary>
/// <c>cultnet.error.v1</c> (contracts/cultnet/interop/write-receipt.md): the v0 error plus <see cref="MessageId"/>,
/// the id of the request it answers. Sent only to a connection that declared the write-receipt capability.
/// </summary>
[MessagePackObject]
public class CultNetErrorV1Message : ICultNetSchemaMessage
{
/// <summary>Gets or sets the schema version.</summary>
[Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.ErrorV1;
/// <summary>Gets or sets the error.</summary>
[Key("error")] public string Error { get; set; } = string.Empty;
/// <summary>Gets or sets optional shard routing details for authority failures.</summary>
[Key("routingHint")] public CultNetShardRoutingHint? RoutingHint { get; set; }
/// <summary>Gets or sets the machine-readable refusal code (the v0 enum).</summary>
[Key("code")] public string? Code { get; set; }
/// <summary>Gets or sets the code-specific structured detail.</summary>
[Key("details")] public CultNetErrorDetails? Details { get; set; }
/// <summary>Gets or sets the id of the request this answers; null only when no id could be read.</summary>
[Key("messageId")] public string? MessageId { get; set; }
/// <summary>Builds the v1 form of <paramref name="error"/> answering <paramref name="messageId"/>.</summary>
public static CultNetErrorV1Message From(CultNetErrorMessage error, string? messageId)
{
if (error == null) throw new ArgumentNullException(nameof(error));
return new CultNetErrorV1Message
{
Error = error.Error,
RoutingHint = error.RoutingHint,
Code = error.Code,
Details = error.Details,
MessageId = string.IsNullOrEmpty(messageId) ? null : messageId
};
}
/// <summary>
/// The refusal of a write a session has no room to remember (write-receipt.md, rule 5): code <c>limit_reached</c>,
/// details present and all nil. Exists only as error.v1; v0 has no such code.
/// </summary>
public static CultNetErrorV1Message LimitReached(string? messageId) => new CultNetErrorV1Message
{
Error = "limit_reached: the session's remembered-write limit is full",
Code = "limit_reached",
Details = new CultNetErrorDetails(),
MessageId = string.IsNullOrEmpty(messageId) ? null : messageId
};
/// <summary>Gets the v0 form, dropping the id.</summary>
public CultNetErrorMessage ToV0() => new CultNetErrorMessage
{
Error = Error,
RoutingHint = RoutingHint,
Code = Code,
Details = Details
};
}
/// <summary>
/// <c>cultnet.write_admitted.v0</c>: the receipt for one admitted put or delete, sent after its commit point.
/// </summary>
[MessagePackObject]
public class CultNetWriteAdmittedMessage : ICultNetSchemaMessage
{
/// <summary>Gets or sets the schema version.</summary>
[Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.WriteAdmitted;
/// <summary>Gets or sets the messageId of the admitted write, echoed.</summary>
[Key("messageId")] public string MessageId { get; set; } = string.Empty;
/// <summary>Gets or sets <c>put</c> or <c>delete</c>.</summary>
[Key("operation")] public string Operation { get; set; } = string.Empty;
/// <summary>Gets or sets the schemaId of the admitted write, echoed.</summary>
[Key("schemaId")] public string SchemaId { get; set; } = string.Empty;
/// <summary>Gets or sets the recordKey of the admitted write, echoed.</summary>
[Key("recordKey")] public string RecordKey { get; set; } = string.Empty;
}
/// <summary>
/// Code-specific structured detail carried on <see cref="CultNetErrorMessage"/> (R-N). Fields are
/// populated only for the code that defines them; every field is otherwise omitted (nil on the wire).

View file

@ -10,12 +10,16 @@ namespace GameCult.Networking
public interface ICultNetShardWriteForwarder
{
/// <summary>
/// Forwards a raw document put to the shard owner.
/// Forwards a raw document put to the shard owner. Completes only when the owner answered that it admitted the write;
/// throws <see cref="CultNetWriteRefusedException"/> when it refused, <see cref="CultNetWriteNotSentException"/> when
/// nothing could have reached it (the only failure after which the id may be forwarded again), and any other exception
/// when the frame may have gone out and was not answered (unconfirmed, including <see cref="CultNetWriteUnconfirmedException"/>). The server relays to the original peer exactly what the owner answered.
/// </summary>
Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message);
/// <summary>
/// Forwards a raw document delete to the shard owner.
/// Forwards a raw document delete to the shard owner; completes, refuses and goes unconfirmed as
/// <see cref="ForwardPutAsync"/> does.
/// </summary>
Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message);
}
@ -68,6 +72,14 @@ namespace GameCult.Networking
/// rudp:// endpoints use the C# RUDP schema client and cultnet:// endpoints use LiteNetLib.
/// </summary>
public Func<ICultNetSchemaClient>? CreateClient { get; set; }
/// <summary>
/// Gets or sets how long the primary has to answer a forwarded write (from the send). Its receipt completes the forward,
/// its refusal fails it with <see cref="CultNetWriteRefusedException"/>, and silence fails it with
/// <see cref="CultNetWriteUnconfirmedException"/>. Defaults to <see cref="CultNetWriteCompletion.ReceiptResultWindow"/>; a
/// primary that predates write receipts never answers, so forwarding to one is unconfirmed after this window.
/// </summary>
public TimeSpan ResultWindow { get; set; } = CultNetWriteCompletion.ReceiptResultWindow;
}
/// <summary>
@ -85,24 +97,30 @@ namespace GameCult.Networking
_options = options ?? new CultNetSchemaWriteForwarderOptions();
}
/// <inheritdoc />
/// <summary>
/// Forwards a put under its original message id and completes on the primary's result frame: a receipt returns, a refusal
/// throws <see cref="CultNetWriteRefusedException"/> and silence throws <see cref="CultNetWriteUnconfirmedException"/>.
/// </summary>
public async Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message)
{
if (message == null) throw new ArgumentNullException(nameof(message));
var endpoint = ResolvePrimaryEndpoint(shard);
if (shard == null) throw new ArgumentNullException(nameof(shard));
message.ShardId ??= shard.ShardId;
message.ShardEpoch ??= shard.Epoch;
await SendAsync(endpoint, message).ConfigureAwait(false);
await SendAsync(shard, message, message.MessageId).ConfigureAwait(false);
}
/// <inheritdoc />
/// <summary>
/// Forwards a delete under its original message id; completes, refuses and goes unconfirmed as
/// <see cref="ForwardPutAsync"/> does.
/// </summary>
public async Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message)
{
if (message == null) throw new ArgumentNullException(nameof(message));
var endpoint = ResolvePrimaryEndpoint(shard);
if (shard == null) throw new ArgumentNullException(nameof(shard));
message.ShardId ??= shard.ShardId;
message.ShardEpoch ??= shard.Epoch;
await SendAsync(endpoint, message).ConfigureAwait(false);
await SendAsync(shard, message, message.MessageId).ConfigureAwait(false);
}
/// <summary>
@ -126,13 +144,51 @@ namespace GameCult.Networking
return (uri.Host, uri.IsDefaultPort ? 3075 : uri.Port);
}
private async Task SendAsync<T>(string endpoint, T message) where T : ICultNetSchemaMessage
// The write frame has not been handed to the transport until this returns: any failure here is CultNetWriteNotSentException,
// the one failure the caller may retry under the same id.
private async Task<ICultNetSchemaClient> ConnectBeforeSendAsync(CultNetShardDescriptor shard, string messageId)
{
var (host, port) = ParseEndpoint(endpoint);
using var client = CreateClient(endpoint);
client.Connect(host, port);
await WaitForConnectionAsync(client, endpoint).ConfigureAwait(false);
client.SendCultNet(message);
ICultNetSchemaClient? client = null;
try
{
var endpoint = ResolvePrimaryEndpoint(shard);
var (host, port) = ParseEndpoint(endpoint);
client = CreateClient(endpoint);
client.Connect(host, port);
await WaitForConnectionAsync(client, endpoint).ConfigureAwait(false);
// The hello comes first on the ordered schema channel: it opts this connection in to the primary's receipts.
client.SendCultNet(new CultNetHelloMessage
{
RuntimeId = "gamecult-networking-shard-forwarder",
RuntimeKind = "dotnet",
SupportedMessageVersions = [CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1]
});
return client;
}
catch (Exception failure)
{
client?.Dispose();
if (failure is CultNetWriteNotSentException) throw;
throw new CultNetWriteNotSentException(messageId, failure);
}
}
private async Task SendAsync<T>(CultNetShardDescriptor shard, T message, string messageId) where T : ICultNetSchemaMessage
{
using var client = await ConnectBeforeSendAsync(shard, messageId).ConfigureAwait(false);
// From the write's send on, every failure (timeout, reset, silence, an exception from the send itself) propagates as it
// is: the frame may have gone out, so the caller treats it as unconfirmed.
await CultNetWriteCompletion.SendAndAwaitReceiptAsync(
(admitted, refuse, refuseV1) =>
{
client.OnCultNet(admitted);
client.OnCultNet(refuse);
client.OnCultNet(refuseV1);
return null;
},
() => client.SendCultNet(message),
messageId,
_options.ResultWindow).ConfigureAwait(false);
}
private ICultNetSchemaClient CreateClient(string endpoint)

View file

@ -2759,6 +2759,11 @@ namespace GameCult.Networking
// under SessionGate by whichever thread's send found the failure; the polling thread reads
// it when it retires the peer.
internal byte[]? UnsendableReason { get; set; }
// Set once the peer's capped frame has been answered: the session ends with this reason when the
// peer has acknowledged every answer, or at the deadline. Under SessionGate.
internal byte[]? EndingReason { get; set; }
internal long EndingDeadlineMs { get; set; }
}
/// <summary>
@ -2928,6 +2933,7 @@ namespace GameCult.Networking
public bool TryReceiveOnce(out CultNetRudpSocketServerFrame? delivered)
{
RetireUnsendablePeers();
EndAnsweredPeers();
if (_deliveredFrames.Count > 0)
{
delivered = _deliveredFrames.Dequeue();
@ -3146,6 +3152,7 @@ namespace GameCult.Networking
public void PollResends()
{
RetireUnsendablePeers();
EndAnsweredPeers();
if (_socket.Poll(0, SelectMode.SelectRead))
return;
foreach (var peer in _peers.Values.ToArray())
@ -3244,6 +3251,44 @@ namespace GameCult.Networking
return true;
}
// Polling thread only. The application answered a peer's capped frame: the answer is reliable, so a
// lost datagram is resent, and the session ends with `reason` once the peer has acknowledged every
// answer or `graceMs` have passed (EndAnsweredPeers). Frames of the peer still queued are not delivered.
internal void EndPeerSessionWhenAnswered(CultNetRudpSocketServerPeer peer, byte[] reason, long graceMs)
{
if (peer == null) throw new ArgumentNullException(nameof(peer));
if (reason == null) throw new ArgumentNullException(nameof(reason));
lock (peer.SessionGate)
{
if (peer.Session.Ended || peer.EndingReason != null)
return;
peer.EndingReason = reason;
peer.EndingDeadlineMs = NowMs() + graceMs;
}
var kept = _deliveredFrames.Where(frame => !ReferenceEquals(frame.Peer, peer)).ToArray();
_deliveredFrames.Clear();
foreach (var frame in kept)
_deliveredFrames.Enqueue(frame);
}
// Polling thread only.
private void EndAnsweredPeers()
{
foreach (var peer in _peers.Values.ToArray())
{
byte[]? reason;
lock (peer.SessionGate)
{
reason = peer.EndingReason != null
&& (peer.Session.OutstandingReliablePacketCount == 0 || NowMs() >= peer.EndingDeadlineMs)
? peer.EndingReason
: null;
}
if (reason != null)
EndPeerSession(peer, reason);
}
}
// Polling thread only. A peer a new Connect already replaced was reported then, and its key now
// names the peer that replaced it.
private void RetireUnsendablePeers()
@ -3488,7 +3533,8 @@ namespace GameCult.Networking
/// </summary>
public sealed class LiteNetLibTransportConnection
{
private readonly NetPeer? _peer;
private readonly Action<byte[]>? _deliver;
private readonly Action? _end;
private readonly CultNetTransportStats _stats = new CultNetTransportStats();
/// <summary>
@ -3496,10 +3542,99 @@ namespace GameCult.Networking
/// </summary>
public LiteNetLibTransportConnection(NetPeer peer, CultNetTransportProfile? profile = null)
{
_peer = peer ?? throw new ArgumentNullException(nameof(peer));
if (peer == null) throw new ArgumentNullException(nameof(peer));
_deliver = payload => peer.Send(payload, DeliveryMethod.ReliableOrdered);
_end = peer.Disconnect;
Profile = profile ?? CultNetTransportProfiles.CreateLiteNetLib("csharp-litenetlib-peer");
}
// The mock point for what leaves the connection: the peer's reliable ordered send.
internal LiteNetLibTransportConnection(Action<byte[]> deliver, Action? end = null)
{
_deliver = deliver ?? throw new ArgumentNullException(nameof(deliver));
_end = end;
Profile = CultNetTransportProfiles.CreateLiteNetLib("csharp-litenetlib-peer");
}
/// <summary>
/// Ends the connection. A write that can be neither answered nor refused is answered by this goodbye: the peer sees
/// its session end and settles the write as unconfirmed (<c>session_ended</c>), never as silence.
/// </summary>
internal void End() => _end?.Invoke();
/// <summary>
/// Answers a schema frame that did not decode with a <c>message_invalid</c> error.v1 naming its id when it holds one;
/// when that answer cannot be sent the connection ends instead.
/// </summary>
internal void AnswerUnreadableSchemaFrame(byte[] payload)
{
try
{
SendSchema(CultNetErrorV1Message.From(
CultNetErrorMessage.ForMessageInvalid("message", "the message could not be read"),
CultNetReadableMessageId.From(payload)));
}
catch (Exception)
{
End();
}
}
/// <summary>
/// A put or delete that arrived on an unreliable or sequenced channel of a receipt session (write-receipt.md, Reliable channel only):
/// answered <c>message_invalid</c>, details <c>channel</c>, fixed text, carrying the write's id, and not applied. True when
/// the frame was such a write and has been answered here.
/// </summary>
internal bool RefuseUnreliableWrite(ICultNetSchemaMessage message, DeliveryMethod method)
{
if (WriteReceipts == null) return false;
if (method is DeliveryMethod.ReliableOrdered or DeliveryMethod.ReliableUnordered) return false;
var messageId = message switch
{
CultNetDocumentPutRawMessage put => put.MessageId,
CultNetDocumentDeleteMessage delete => delete.MessageId,
_ => null
};
if (messageId == null && message is not (CultNetDocumentPutRawMessage or CultNetDocumentDeleteMessage)) return false;
try
{
SendSchema(CultNetErrorV1Message.From(
CultNetErrorMessage.ForMessageInvalid("channel", "receipt sessions require the reliable channel"), messageId));
}
catch (Exception)
{
End();
}
return true;
}
private readonly object _writeFramingGate = new();
private bool _writeFramingSettled;
/// <summary>
/// The write-receipt state of a connection that opted in (write-receipt.md); null for every other connection, which is
/// answered exactly as before.
/// </summary>
internal CultNetWriteReceiptSession? WriteReceipts { get; private set; }
/// <summary>
/// Decides this connection's write framing once: by its first hello, or by its first write if no hello came first. A
/// later hello changes nothing. The capability changes framing only; it never authorizes. Returns true when this call
/// opted the connection in.
/// </summary>
internal bool SettleWriteFraming(CultNetHelloMessage? hello)
{
lock (_writeFramingGate)
{
if (_writeFramingSettled) return false;
_writeFramingSettled = true;
if (hello == null || !CultNetWriteReceiptCapability.Declares(hello)) return false;
WriteReceipts = new CultNetWriteReceiptSession();
return true;
}
}
/// <summary>
/// Initializes a receive-only channel-aware adapter for LiteNetLib payload classification.
/// </summary>
@ -3535,14 +3670,14 @@ namespace GameCult.Networking
public void Send(string channelId, byte[] payload)
{
if (payload == null) throw new ArgumentNullException(nameof(payload));
if (_peer == null) throw new InvalidOperationException("A LiteNetLib peer is required to send through this transport.");
if (_deliver == null) throw new InvalidOperationException("A LiteNetLib peer is required to send through this transport.");
if (!string.Equals(channelId, "schema", StringComparison.Ordinal)
&& !string.Equals(channelId, "legacy", StringComparison.Ordinal))
{
throw new InvalidOperationException($"litenetlib transport only supports schema and legacy channels, got \"{channelId}\".");
}
_peer.Send(payload, DeliveryMethod.ReliableOrdered);
_deliver(payload);
_stats.BytesSent += payload.Length;
_stats.FramesSent++;
}

View file

@ -0,0 +1,141 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace GameCult.Networking
{
/// <summary>
/// A peer refused a write: the typed <c>cultnet.error.v0</c> it answered with.
/// </summary>
public sealed class CultNetWriteRefusedException : Exception
{
/// <summary>Creates the exception from the refusal frame.</summary>
public CultNetWriteRefusedException(CultNetErrorMessage refusal)
: base(refusal?.Error ?? throw new ArgumentNullException(nameof(refusal)))
{
Code = refusal.Code;
Refusal = refusal;
}
/// <summary>Gets the machine-readable refusal code, e.g. <c>write_not_authorized</c> or <c>message_invalid</c>.</summary>
public string? Code { get; }
/// <summary>Gets the refusal frame as received.</summary>
public CultNetErrorMessage Refusal { get; }
}
/// <summary>
/// A write nothing answered within its window: neither admitted nor refused (write-receipt.md, "unconfirmed").
/// </summary>
public sealed class CultNetWriteUnconfirmedException : Exception
{
/// <summary>Creates the exception for the write <paramref name="messageId"/>.</summary>
public CultNetWriteUnconfirmedException(string messageId)
: base($"No admission or refusal arrived for write '{messageId}'.")
{
MessageId = messageId;
}
/// <summary>Gets the id of the unconfirmed write.</summary>
public string MessageId { get; }
}
/// <summary>
/// A forwarded write that provably never left this process: the connection failed, or the send failed before any byte was
/// written. It is the only forwarding failure after which the same id may be forwarded again. A send port that cannot prove
/// nothing was written must not throw this; every other failure of a forward is unconfirmed.
/// </summary>
public sealed class CultNetWriteNotSentException : Exception
{
/// <summary>Creates the exception for the write <paramref name="messageId"/>; <paramref name="cause"/> is for logs only.</summary>
public CultNetWriteNotSentException(string messageId, Exception? cause = null)
: base($"Write '{messageId}' was not sent.", cause)
{
MessageId = messageId;
}
/// <summary>Gets the id of the write that was not sent.</summary>
public string MessageId { get; }
}
/// <summary>
/// Completes a write on the peer's result frame, never on a send's return or a transport acknowledgement:
/// an ack proves delivery, not admission.
/// </summary>
public static class CultNetWriteCompletion
{
/// <summary>How long after a send a refusal may still arrive when the caller names no window.</summary>
public static readonly TimeSpan DefaultResultWindow = TimeSpan.FromMilliseconds(500);
/// <summary>How long a receipt-capable peer has to answer a write (write-receipt.md, <c>RECEIPT_RESULT_WINDOW_MS</c>).</summary>
public static readonly TimeSpan ReceiptResultWindow = TimeSpan.FromMilliseconds(5000);
/// <summary>
/// Sends one write to a receipt-capable peer and waits <paramref name="resultWindow"/> for its answer: a
/// <c>write_admitted</c> naming <paramref name="messageId"/> returns, an <c>error.v1</c> naming it throws
/// <see cref="CultNetWriteRefusedException"/>, and an id-less error (v0, or v1 with no id) is attributed to this write
/// because it is the only one in flight on the connection. Silence throws <see cref="CultNetWriteUnconfirmedException"/>.
/// </summary>
public static async Task SendAndAwaitReceiptAsync(
Func<Action<CultNetWriteAdmittedMessage>, Action<CultNetErrorMessage>, Action<CultNetErrorV1Message>, IDisposable?> subscribe,
Action send,
string messageId,
TimeSpan resultWindow,
CancellationToken cancellationToken = default)
{
if (subscribe == null) throw new ArgumentNullException(nameof(subscribe));
if (send == null) throw new ArgumentNullException(nameof(send));
var answer = new TaskCompletionSource<CultNetErrorMessage?>(TaskCreationOptions.RunContinuationsAsynchronously);
using var registration = subscribe(
admitted =>
{
if (string.Equals(admitted.MessageId, messageId, StringComparison.Ordinal)) answer.TrySetResult(null);
},
error => answer.TrySetResult(error),
error =>
{
if (error.MessageId == null || string.Equals(error.MessageId, messageId, StringComparison.Ordinal))
answer.TrySetResult(error.ToV0());
});
send();
var window = Task.Delay(resultWindow, cancellationToken);
await Task.WhenAny(answer.Task, window).ConfigureAwait(false);
if (!answer.Task.IsCompleted)
{
await window.ConfigureAwait(false);
throw new CultNetWriteUnconfirmedException(messageId);
}
var refusal = await answer.Task.ConfigureAwait(false);
if (refusal != null) throw new CultNetWriteRefusedException(refusal);
}
/// <summary>
/// Sends one write and waits <paramref name="resultWindow"/> for the peer's answer. A refusal frame rejects with
/// <see cref="CultNetWriteRefusedException"/> whatever its order against the transport acknowledgement. A peer
/// that admits a write sends no success frame, so the end of the window is the interim bound on a refusal
/// (until peers send one); the error frame carries no message id, so any refusal inside the window is this
/// write's.
/// </summary>
/// <param name="subscribe">Registers the refusal handler and returns its registration; called before the send.</param>
/// <param name="send">Sends the write.</param>
/// <param name="resultWindow">How long a refusal may take to arrive.</param>
/// <param name="cancellationToken">Cancels the wait.</param>
public static async Task SendAndAwaitResultAsync(
Func<Action<CultNetErrorMessage>, IDisposable?> subscribe,
Action send,
TimeSpan resultWindow,
CancellationToken cancellationToken = default)
{
if (subscribe == null) throw new ArgumentNullException(nameof(subscribe));
if (send == null) throw new ArgumentNullException(nameof(send));
var refused = new TaskCompletionSource<CultNetErrorMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
using var registration = subscribe(error => refused.TrySetResult(error));
send();
var window = Task.Delay(resultWindow, cancellationToken);
await Task.WhenAny(refused.Task, window).ConfigureAwait(false);
if (refused.Task.IsCompleted) throw new CultNetWriteRefusedException(await refused.Task.ConfigureAwait(false));
await window.ConfigureAwait(false);
}
}
}

View file

@ -0,0 +1,396 @@
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using GameCult.Caching;
using MessagePack;
namespace GameCult.Networking
{
/// <summary>
/// The content hash a write-receipt server binds to a (peer, messageId) dedupe key
/// (contracts/cultnet/interop/write-receipt.md, rule 5).
/// </summary>
public static class CultNetWriteContentHash
{
/// <summary>
/// SHA-256 of the MessagePack array <c>[operation, schemaId, recordKey, payload]</c>, <c>payload</c> a <c>bin</c>
/// for a put and nil for a delete.
/// </summary>
public static byte[] Compute(string operation, string schemaId, string recordKey, byte[]? payload)
{
var buffer = new System.Buffers.ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(buffer);
writer.WriteArrayHeader(4);
writer.Write(operation);
writer.Write(schemaId);
writer.Write(recordKey);
if (payload == null) writer.WriteNil();
else writer.Write(payload);
writer.Flush();
using var sha = SHA256.Create();
return sha.ComputeHash(buffer.WrittenSpan.ToArray());
}
}
/// <summary>
/// Whether a peer's hello opted in to write receipts: it lists both <c>cultnet.write_admitted.v0</c> and
/// <c>cultnet.error.v1</c>. The capability changes framing only; it never authorizes.
/// </summary>
internal static class CultNetWriteReceiptCapability
{
public static bool Declares(CultNetHelloMessage hello)
{
var versions = hello.SupportedMessageVersions;
return versions != null
&& Array.IndexOf(versions, CultNetSchemaVersions.WriteAdmitted) >= 0
&& Array.IndexOf(versions, CultNetSchemaVersions.ErrorV1) >= 0;
}
}
/// <summary>A SHA-256 digest held by value: the fixed-size stand-in for an id or a content hash.</summary>
internal readonly struct CultNetDigest : IEquatable<CultNetDigest>
{
private readonly ulong _a, _b, _c, _d;
private CultNetDigest(ulong a, ulong b, ulong c, ulong d) { _a = a; _b = b; _c = c; _d = d; }
public static CultNetDigest Of(byte[] bytes)
{
using var sha = SHA256.Create();
var hash = sha.ComputeHash(bytes);
return new CultNetDigest(
BitConverter.ToUInt64(hash, 0), BitConverter.ToUInt64(hash, 8),
BitConverter.ToUInt64(hash, 16), BitConverter.ToUInt64(hash, 24));
}
public bool Equals(CultNetDigest other) => _a == other._a && _b == other._b && _c == other._c && _d == other._d;
public override bool Equals(object? obj) => obj is CultNetDigest other && Equals(other);
public override int GetHashCode() => unchecked((int)_a ^ (int)(_a >> 32));
public static bool operator ==(CultNetDigest left, CultNetDigest right) => left.Equals(right);
public static bool operator !=(CultNetDigest left, CultNetDigest right) => !left.Equals(right);
}
/// <summary>
/// What a session keeps of a refusal (write-receipt.md, What is remembered): its code, the field its details name, and its
/// routing hint (a shard descriptor the server owns). Never the write's strings or an authority's reason: the replay is built
/// from fixed text and the fields of the resend, which carries the same content.
/// </summary>
internal readonly struct CultNetRememberedRefusal
{
public string? Code { get; }
public bool HasDetails { get; }
public string? Field { get; }
public CultNetShardRoutingHint? Hint { get; }
public CultNetRememberedRefusal(string? code, bool hasDetails, string? field, CultNetShardRoutingHint? hint)
{
Code = code; HasDetails = hasDetails; Field = field; Hint = hint;
}
public static CultNetRememberedRefusal Of(CultNetErrorMessage error) =>
new(error.Code, error.Details != null, error.Details?.Field, error.RoutingHint);
private static string? NullIfEmpty(string value) => value.Length == 0 ? null : value;
/// <summary>The refusal, in fixed text, naming the write's own record key or schema id where its details do.</summary>
public CultNetErrorMessage ToError(string schemaId, string recordKey) => new()
{
Error = Code switch
{
"unowned_schema" => "unowned_schema: no shard owns the schema and key",
null => Hint != null ? CultNetDatabaseServer.RoutingRefusalText : CultNetWriteReceiptSession.WriteFailedText,
_ => Code
},
Code = Code,
RoutingHint = Hint,
Details = HasDetails
? new CultNetErrorDetails
{
Field = Field,
Value = Field switch { "recordKey" => NullIfEmpty(recordKey), "schemaId" => NullIfEmpty(schemaId), _ => null }
}
: null
};
}
/// <summary>
/// One receipt-capable peer's writes: message id to content hash and the answer given, refusals included, so a resend of the
/// same content is answered again without being decided again and the same id with other content is refused. Ids and content
/// hashes are held as fixed-size digests; the answer is held as its kind (and, for a refusal, its code and the field its details name),
/// never the strings of the write.
/// </summary>
internal sealed class CultNetWriteReceiptSession
{
/// <summary>The most writes a peer is remembered for, refused ones included; a peer at the limit has new writes refused.</summary>
internal const int MaxRememberedWrites = 1024;
/// <summary>The most ids refused at the limit that are remembered, so their resend is judged the same way.</summary>
internal const int MaxRememberedLimitRefusals = 1024;
/// <summary>The longest messageId a write may carry, in UTF-8 bytes (write-receipt.md, Id size).</summary>
internal const int MaxMessageIdBytes = 256;
/// <summary>What a write is refused with when it failed for a reason no typed refusal names. Never an exception's own text.</summary>
internal const string WriteFailedText = "the write could not be applied";
private readonly object _gate = new();
private readonly Dictionary<CultNetDigest, Entry> _writes = new();
private readonly Dictionary<CultNetDigest, CultNetDigest> _limitRefusals = new();
private sealed class Entry
{
public Entry(CultNetDigest hash) { Hash = hash; }
public CultNetDigest Hash { get; set; }
public bool Abandoned { get; set; }
public bool Admitted { get; set; }
public CultNetRememberedRefusal? Refusal { get; set; }
}
public enum Begin { New, ReplayAdmitted, ReplayRefusal, Unanswered, DifferentContent, Full }
private static CultNetDigest IdDigest(string messageId) => CultNetDigest.Of(System.Text.Encoding.UTF8.GetBytes(messageId));
/// <summary>
/// Looks <paramref name="messageId"/> up first. An id not seen is claimed for this content (<see cref="Begin.New"/>);
/// otherwise the outcome says what already holds it, and <paramref name="refusal"/> is the refusal to repeat.
/// </summary>
public Begin TryBegin(string messageId, byte[] hash, out CultNetRememberedRefusal? refusal)
{
var id = IdDigest(messageId);
var content = CultNetDigest.Of(hash);
lock (_gate)
{
refusal = null;
if (_writes.TryGetValue(id, out var entry))
{
if (entry.Abandoned)
{
// Not sent, so judged afresh in the slot it already holds; no slot is ever freed.
entry.Abandoned = false;
entry.Hash = content;
return Begin.New;
}
if (entry.Hash != content) return Begin.DifferentContent;
if (entry.Admitted) return Begin.ReplayAdmitted;
if (entry.Refusal != null)
{
refusal = entry.Refusal;
return Begin.ReplayRefusal;
}
// No answer yet: the first attempt is still being applied, or its primary left it unanswered for good.
return Begin.Unanswered;
}
if (_writes.Count >= MaxRememberedWrites)
{
if (_limitRefusals.TryGetValue(id, out var refused)) return refused == content ? Begin.Full : Begin.DifferentContent;
if (_limitRefusals.Count < MaxRememberedLimitRefusals) _limitRefusals[id] = content;
return Begin.Full;
}
_writes[id] = new Entry(content);
return Begin.New;
}
}
/// <summary>The write was admitted: remember it so a resend repeats the receipt.</summary>
public void Admitted(string messageId)
{
lock (_gate)
{
if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Admitted = true;
}
}
/// <summary>The write was refused: remember the refusal so a resend repeats it.</summary>
public void Refused(string messageId, CultNetRememberedRefusal refusal)
{
lock (_gate)
{
if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Refusal = refusal;
}
}
/// <summary>
/// The write never reached anyone who could have applied it: a repeat is judged afresh. The entry keeps its slot, so
/// forgetting a write never makes room for an id that was refused at the limit.
/// </summary>
public void Abandoned(string messageId)
{
lock (_gate)
{
if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Abandoned = true;
}
}
}
/// <summary>
/// The messageId of a schema frame that does not decode (write-receipt.md, rule 3: a refusal carries the write's id whenever
/// the frame is readable enough to find it).
/// </summary>
internal static class CultNetReadableMessageId
{
/// <summary>The frame's top-level string <c>messageId</c>, or null when the bytes are not a map holding one.</summary>
public static string? From(byte[] payload)
{
try
{
var reader = new MessagePackReader(payload);
var entries = reader.ReadMapHeader();
for (var index = 0; index < entries; index++)
{
var key = reader.NextMessagePackType == MessagePackType.String ? reader.ReadString() : null;
if (key == "messageId" && reader.NextMessagePackType == MessagePackType.String)
{
var id = reader.ReadString();
return string.IsNullOrEmpty(id) ? null : id;
}
if (key == null) reader.Skip();
reader.Skip();
}
}
catch (Exception)
{
// Not a readable map: no id.
}
return null;
}
}
/// <summary>
/// One write on a receipt-capable connection: its dedupe claim and its single answer (write-receipt.md, rules 1 to 5).
/// </summary>
internal sealed class CultNetReceiptedWrite
{
private readonly CultNetWriteReceiptSession _session;
private readonly Action<ICultNetSchemaMessage> _send;
private readonly string _operation;
private readonly string _messageId;
private readonly string _schemaId;
private readonly string _recordKey;
private readonly byte[]? _payload;
private bool _claimed;
private bool _answered;
public CultNetReceiptedWrite(
CultNetWriteReceiptSession session,
Action<ICultNetSchemaMessage> send,
string operation,
string? messageId,
string? schemaId,
string? recordKey,
byte[]? payload)
{
_session = session;
_send = send;
_operation = operation;
_messageId = messageId ?? string.Empty;
_schemaId = schemaId ?? string.Empty;
_recordKey = recordKey ?? string.Empty;
_payload = payload;
}
/// <summary>
/// Looks the write's id up before anything else is decided (rule 5). True when the id is new and the write should go on
/// to authorization and the commit; false when it was answered here (a replay, a reused id, a full table) or is owed no
/// further answer (still in flight, or left silent by its primary).
/// </summary>
public bool Begin()
{
var outcome = _session.TryBegin(_messageId, CultNetWriteContentHash.Compute(_operation, _schemaId, _recordKey, _payload), out var refusal);
switch (outcome)
{
case CultNetWriteReceiptSession.Begin.New:
_claimed = true;
return true;
case CultNetWriteReceiptSession.Begin.ReplayAdmitted:
_answered = true;
_send(AdmittedReceipt());
return false;
case CultNetWriteReceiptSession.Begin.ReplayRefusal:
_answered = true;
_send(CultNetErrorV1Message.From(refusal!.Value.ToError(_schemaId, _recordKey), _messageId));
return false;
case CultNetWriteReceiptSession.Begin.Unanswered:
// The first attempt is still being applied and will answer this id, or its primary left it unanswered:
// then the claim stays, so the id is never sent again on this session, and the write stays unconfirmed.
_answered = true;
return false;
case CultNetWriteReceiptSession.Begin.DifferentContent:
Refused(CultNetErrorMessage.ForMessageInvalid("messageId", "the message id was already used for different content"));
return false;
default:
_answered = true;
_send(CultNetErrorV1Message.LimitReached(_messageId));
return false;
}
}
private CultNetWriteAdmittedMessage AdmittedReceipt() => new()
{
MessageId = _messageId,
Operation = _operation,
SchemaId = _schemaId,
RecordKey = _recordKey
};
/// <summary>The write committed: remember the receipt and send it.</summary>
public void Admitted()
{
if (_answered) return;
_answered = true;
if (_claimed) _session.Admitted(_messageId);
_send(AdmittedReceipt());
}
/// <summary>The write was refused: remember the refusal (a resend repeats it) and send its one error.v1.</summary>
public void Refused(CultNetErrorMessage error)
{
if (_answered) return;
_answered = true;
if (_claimed)
{
// A remembered refusal is answered from what is remembered, so the replay is the refusal again.
var kept = CultNetRememberedRefusal.Of(error);
_session.Refused(_messageId, kept);
error = kept.ToError(_schemaId, _recordKey);
}
_send(CultNetErrorV1Message.From(error, _messageId));
}
/// <summary>
/// A forward that may have reached its primary and is unconfirmed (silence, reset, timeout, an ambiguous send): no answer is
/// owed or possible, and the claim stays so the id is never forwarded again on this session. Sends nothing.
/// </summary>
public void LeftSilent()
{
if (_answered) return;
_answered = true;
}
/// <summary>A forward that was provably not sent (connect failed, or the send failed before any byte was written): forget the claim, send nothing.</summary>
public void NotSent()
{
if (_answered) return;
_answered = true;
if (_claimed) _session.Abandoned(_messageId);
_claimed = false;
}
/// <summary>
/// Why the write's id cannot be used at all, before anything is remembered: it is missing (refused with no id) or longer
/// than <see cref="CultNetWriteReceiptSession.MaxMessageIdBytes"/> UTF-8 bytes. Null when the id is usable.
/// </summary>
public CultNetErrorMessage? IdRefusal =>
_messageId.Length == 0
? CultNetErrorMessage.ForMessageInvalid("messageId", "a write needs a messageId")
: System.Text.Encoding.UTF8.GetByteCount(_messageId) > CultNetWriteReceiptSession.MaxMessageIdBytes
? CultNetErrorMessage.ForMessageInvalid("messageId", "messageId exceeds 256 bytes")
: null;
}
}

View file

@ -23,7 +23,7 @@ namespace GameCult.Networking
private const string EmailPattern =
@"^([0-9a-zA-Z]([\+\-_\.][0-9a-zA-Z]+)*)+@(([0-9a-zA-Z][-\w]*[0-9a-zA-Z]*\.)+[a-zA-Z0-9]{2,17})$";
private const string UsernamePattern = @"^[A-Za-z0-9]+(?:[ _-][A-Za-z0-9]+)*$";
private const int ServerPort = 3075;
private const int DefaultPort = 3075;
private const float SessionTimeoutSeconds = 1800; // 30 minutes
private const float SessionRefreshThresholdSeconds = 300; // 5 minutes
private const int MaxConnectionAttemptsPerMinute = 30;
@ -63,6 +63,12 @@ namespace GameCult.Networking
/// </summary>
public bool LogSensitivePayloads { get; set; }
/// <summary>
/// Gets or sets the UDP port <see cref="Start"/> binds (default 3075; 0 asks the OS for a free one). After
/// <see cref="Start"/> it holds the port actually bound.
/// </summary>
public int Port { get; set; } = DefaultPort;
private float Time => (float)(_timer?.Elapsed.TotalSeconds ?? 0d);
/// <summary>
@ -74,7 +80,7 @@ namespace GameCult.Networking
{
TransportId = "litenetlib-server",
Host = "0.0.0.0",
Port = ServerPort
Port = Port
});
/// <summary>
@ -357,7 +363,8 @@ namespace GameCult.Networking
UnsyncedEvents = true,
NatPunchEnabled = true
};
_netManager.Start(ServerPort);
_netManager.Start(Port);
Port = _netManager.LocalPort;
listener.NetworkErrorEvent += (point, code) => Logger.LogInfo($"{point.Address}: Error {code}");
@ -408,11 +415,25 @@ namespace GameCult.Networking
{
var cultNetMessage = LiteNetLibTransportConnection.DecodeSchema(frame);
Logger.LogDebug($"Received CultNet schema message {cultNetMessage.SchemaVersion}");
if (transport.RefuseUnreliableWrite(cultNetMessage, method)) return;
await HandleCultNetSchemaMessageAsync(peer, user, cultNetMessage).ConfigureAwait(false);
return;
}
var message = LiteNetLibTransportConnection.DecodeLegacy(frame);
Message message;
try
{
message = LiteNetLibTransportConnection.DecodeLegacy(frame);
}
catch (Exception ex) when (transport.WriteReceipts != null)
{
// A frame that decodes on neither channel is classed legacy, so this is where an unreadable frame lands.
// A receipt-capable peer is told which frame was unreadable (by its id, if the frame still holds one).
Logger.LogWarning($"CultNet frame unreadable: {ex.Message}");
transport.AnswerUnreadableSchemaFrame(frame.Payload);
return;
}
if (LogSensitivePayloads)
{
Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory<byte>(frame.Payload))}");
@ -507,7 +528,7 @@ namespace GameCult.Networking
}
});
Logger.LogInfo($"Server started on port {ServerPort}.");
Logger.LogInfo($"Server started on port {Port}.");
}
private async Task HandleCultNetSchemaMessageAsync(NetPeer peer, User user, ICultNetSchemaMessage message)

View file

@ -291,6 +291,51 @@ public sealed class CultMeshClientTests
submitted.Document.SourceRole.Should().Be("pilot");
}
[TestCase("write_not_authorized", 0)]
[TestCase("message_invalid", 0)]
[TestCase("write_not_authorized", 150)]
public async Task SubmitDocument_ARefusalRejectsWithTheTypedError_WhateverItsOrderAgainstTheSend(string code, int delayMs)
{
var connector = new DocumentConnector
{
RefuseSubmissionsWith = code,
RefusalDelay = TimeSpan.FromMilliseconds(delayMs)
};
using var mesh = new CultMeshClient(new CultMeshClientOptions
{
RendezvousEndpoints = new[] { "rudp://odin:3076" },
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
Sessions = CultMeshTestTrust.LocalSessions,
Connectors = new[] { connector }
});
var request = new ClientTestDocument { Id = "command:1", Text = "move" };
var act = () => mesh.SubmitDocumentAsync(Target, request.Id, request, "pilot-one", "pilot");
(await act.Should().ThrowAsync<CultNetWriteRefusedException>()).Which.Code.Should().Be(code);
}
[Test]
public async Task SubmitDocument_CompletesOnlyAfterTheResultWindow_NotOnTheSendReturning()
{
var connector = new DocumentConnector();
using var mesh = new CultMeshClient(new CultMeshClientOptions
{
RendezvousEndpoints = new[] { "rudp://odin:3076" },
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
Sessions = CultMeshTestTrust.LocalSessions,
Connectors = new[] { connector },
SubmitResultWindow = TimeSpan.FromMilliseconds(400)
});
var request = new ClientTestDocument { Id = "command:1", Text = "move" };
await mesh.ConnectAsync(Target, CultMeshProtocols.Documents);
var started = DateTime.UtcNow;
await mesh.SubmitDocumentAsync(Target, request.Id, request, "pilot-one", "pilot");
(DateTime.UtcNow - started).Should().BeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(380));
}
[Test]
public async Task InvokeOperation_CorrelatesProviderReceiptThroughStableIdentitySession()
{
@ -496,6 +541,8 @@ public sealed class CultMeshClientTests
public bool SuppressFirstSnapshot { get; set; }
public bool SuppressFirstSubscriptionSnapshot { get; set; }
public bool RejectOperations { get; set; }
public string? RefuseSubmissionsWith { get; set; }
public TimeSpan RefusalDelay { get; set; }
public bool FailFirstOperationBeforeResponse { get; set; }
public string ResponseSourceRuntimeId { get; set; } = "aetheria-daemon";
public string ConnectorId => "document-test";
@ -515,6 +562,8 @@ public sealed class CultMeshClientTests
rejectOperations: RejectOperations,
failOperationBeforeResponse: FailFirstOperationBeforeResponse && Clients.Count == 0,
responseSourceRuntimeId: ResponseSourceRuntimeId);
client.RefuseSubmissionsWith = RefuseSubmissionsWith;
client.RefusalDelay = RefusalDelay;
Clients.Add(client);
return Task.FromResult<ICultNetSchemaClient>(client);
}
@ -522,6 +571,9 @@ public sealed class CultMeshClientTests
private sealed class DocumentClient : ICultNetSchemaClient, ICultNetSchemaClientHealth, ICultMeshVerifiedSchemaClient
{
private readonly List<Action<CultNetErrorMessage>> _errors = new();
public string? RefuseSubmissionsWith { get; set; }
public TimeSpan RefusalDelay { get; set; }
private readonly List<Action<CultNetSnapshotResponseRawMessage>> _snapshots = new();
private readonly List<Action<CultNetOperationResponseMessage>> _operationResponses = new();
private readonly CultNetDocumentRegistry _documents;
@ -558,6 +610,15 @@ public sealed class CultMeshClientTests
if (message is CultNetDocumentPutRawMessage submitted)
{
Submitted.Add(submitted);
if (RefuseSubmissionsWith != null)
{
var refusal = new CultNetErrorMessage { Error = "not allowed", Code = RefuseSubmissionsWith };
// A refusal that follows the send's return (and any transport ack) must still reject the submit.
Task.Delay(RefusalDelay).ContinueWith(_ =>
{
foreach (var handler in _errors.ToArray()) handler(refusal);
});
}
return;
}
if (message is CultNetOperationRequestMessage operation)
@ -642,6 +703,8 @@ public sealed class CultMeshClientTests
_snapshots.Add(response => callback((T)(object)response));
if (typeof(T) == typeof(CultNetOperationResponseMessage))
_operationResponses.Add(response => callback((T)(object)response));
if (typeof(T) == typeof(CultNetErrorMessage))
_errors.Add(error => callback((T)(object)error));
}
public void Dispose() { }
public void Fail(Exception error) => _failure.TrySetResult(error);

View file

@ -0,0 +1,100 @@
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
namespace GameCult.Networking.Tests
{
/// <summary>
/// Heimdall's invariant: no client treats a reliable-transport ack as write admission. A put completes
/// only on its result frame (write_not_authorized, or the success reply). The C# schema client has no
/// completion call: SendCultNet returns once the put is queued, and the outcome reaches the caller only
/// as the result frame, through OnCultNet. These tests serve it a peer that acknowledges the put and
/// refuses it, the ack before the refusal and after it, and require the refusal to arrive as the outcome.
/// </summary>
public class CultNetClientWriteCompletionTests
{
private const uint ConnectionId = 0x10203197;
// Acknowledges the first reliable schema frame it receives and answers it with write_not_authorized,
// the second packet 150 ms after the first. The refusal carries no acknowledgement of its own.
private static Task RefusingPeer(Socket socket, bool refusalFirst, CancellationToken stop)
{
return Task.Run(() =>
{
var session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = ConnectionId, ResendDelayMs = 25 });
var buffer = new byte[65535];
EndPoint from = new IPEndPoint(IPAddress.Any, 0);
var answered = false;
while (!stop.IsCancellationRequested)
{
if (!socket.Poll(10_000, SelectMode.SelectRead))
continue;
var received = socket.ReceiveFrom(buffer, ref from);
var packet = CultNetRudpPacketCodec.Decode(buffer.AsSpan(0, received).ToArray());
void Send(CultNetRudpPacket p) => socket.SendTo(CultNetRudpPacketCodec.Encode(p), from);
if (packet.PacketType == CultNetRudpPacketType.Connect)
{
Send(session.AcceptConnect(packet));
continue;
}
var result = session.Receive(packet, 1);
if (result.Reply != null)
Send(result.Reply);
if (answered || result.Delivered.Count == 0)
continue;
answered = true;
var ack = session.CreateAckForReceived(packet.Sequence);
var refusal = session.Send("schema", CultNetSchemaMessageSerialization.Serialize(new CultNetErrorMessage
{
Error = "write_not_authorized: the catalog refused the document",
Code = "write_not_authorized"
}), new CultNetRudpSendOptions { Reliable = true, Ordered = true });
refusal.Ack = 0;
refusal.AckMask = 0;
if (refusalFirst)
{
Send(refusal);
Thread.Sleep(150);
Send(ack);
}
else
{
Send(ack);
Thread.Sleep(150);
Send(refusal);
}
}
});
}
[TestCase(false, TestName = "AckThenRefusal")]
[TestCase(true, TestName = "RefusalThenAck")]
public async Task ARefusedPutIsReportedRefusedWhateverTheOrderOfItsAck(bool refusalFirst)
{
using var serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
serverSocket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
using var stop = new CancellationTokenSource();
var peer = RefusingPeer(serverSocket, refusalFirst, stop.Token);
using var client = new RudpCultNetSchemaClient("completion", ConnectionId);
var outcomes = new BlockingCollection<CultNetErrorMessage>();
client.OnCultNet<CultNetErrorMessage>(outcomes.Add);
client.Connect("127.0.0.1", ((IPEndPoint)serverSocket.LocalEndPoint!).Port);
var deadline = DateTime.UtcNow.AddSeconds(3);
while (!client.Connected && DateTime.UtcNow < deadline)
await Task.Delay(5);
Assert.That(client.Connected, "fixture: the client connected");
client.SendCultNet(new CultNetDocumentPutRawMessage { MessageId = "completion" });
Assert.That(outcomes.TryTake(out var refusal, 3000), Is.True, "the result frame reached the caller");
Assert.That(refusal!.Code, Is.EqualTo("write_not_authorized"));
stop.Cancel();
await peer;
}
}
}

View file

@ -418,6 +418,7 @@ namespace GameCult.Networking.Tests
CultNetDatabaseChangeKind.Added, VariantKey, schemaId, database.Shards[0], cache.Get<VariantWireFixture>(VariantKey)!, previousDocument: null);
Assert.That(Deliver(databaseServer, added, selection, refused, errors), Is.Null);
Assert.That(errors, Has.Count.EqualTo(1));
Assert.That(errors[0].Error, Is.EqualTo("the record is a variant CultNet cannot carry"));
var removed = new CultNetDatabaseChange<VariantWireFixture>(
CultNetDatabaseChangeKind.Removed, VariantKey, schemaId, database.Shards[0], document: null, previousDocument: cache.Get<VariantWireFixture>(VariantKey)!);

View file

@ -14,8 +14,9 @@ namespace GameCult.Networking.Tests
{
/// <summary>
/// A schema server's poll serves every peer whatever one peer sends. A message that cannot be
/// decoded or handled is that peer's failure: its session ends, the drop is counted and logged,
/// and the drain goes on to the other peers.
/// decoded is answered message_invalid and its session kept until MaxConsecutiveRefusedFrames in a
/// row end it; one that cannot be handled ends that peer's session. The fault is logged, and the
/// drain goes on to the other peers.
/// </summary>
public class CultNetRudpSchemaServerPeerInputTests
{
@ -161,30 +162,205 @@ namespace GameCult.Networking.Tests
}
[Test]
public async Task APeerWhoseMessageCannotBeDecodedHasItsSessionEnded()
public async Task APeerWhoseMessageCannotBeDecodedIsAnsweredAndKept()
{
using var scene = await BadThenGood(Malformed());
var dropped = scene.Server.Stats.PacketsDropped;
try { await scene.Server.PollAvailableAsync(16); }
catch (MessagePackSerializationException) { Assert.Fail("the poll threw for a peer's input"); }
// The frame was already acknowledged, so the session cannot be kept: the peer is told,
// the listener forgets it, and the drop is counted.
Assert.That(scene.Ended, Is.EqualTo(new[] { scene.Bad.EndPoint }));
Assert.That(scene.Server.Peers.Select(peer => peer.RemoteEndPoint), Is.EqualTo(new[] { scene.Good.EndPoint }));
Assert.That(scene.Server.Stats.PacketsDropped, Is.EqualTo(dropped + 1));
var goodbye = Drain(scene.Bad.Socket).Single(packet => packet.PacketType == CultNetRudpPacketType.Disconnect);
Assert.That(scene.Bad.Session.Receive(goodbye, 1).Disconnected, Is.True);
Assert.That(scene.Ended, Is.Empty);
Assert.That(scene.Server.Peers, Has.Count.EqualTo(2));
var refusal = ErrorsReceivedBy(scene.Bad).Single();
Assert.That(refusal.Code, Is.EqualTo("message_invalid"));
Assert.That(refusal.Details!.Field, Is.EqualTo("message"));
Assert.That(Drain(scene.Good.Socket).Select(packet => packet.PacketType), Has.None.EqualTo(CultNetRudpPacketType.Disconnect));
Assert.That(scene.Logger.Errors, Has.Count.EqualTo(1));
}
// The peer's malformed message and a well-formed one after it arrive in one datagram's
// delivery: the second is held for order, and both are released together. The session ends
// on the first, so the second belongs to no session and is never handled.
// The RUDP schema server implements no receipt rules, so it must not advertise them (write-receipt.md, Capability and the
// conformance table): whatever hello it sends lists neither frame.
[Test]
public async Task ARefusedPeersFramesStillQueuedAreNotHandled()
public async Task TheRudpSchemaServerNeverAdvertisesTheReceiptFrames()
{
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "csharp-rudp-no-receipts",
ConnectionId = ConnectionId
});
var client = await Connect(server);
try
{
SendSchema(server, client, CultNetSchemaMessageSerialization.Serialize(new CultNetHelloMessage
{
RuntimeId = "receipt-client",
RuntimeKind = "test",
SupportedMessageVersions = [CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1]
}));
await server.PollAvailableAsync(16);
var hellos = new List<CultNetHelloMessage>();
foreach (var packet in Drain(client.Socket))
foreach (var frame in client.Session.Receive(packet, 1).Delivered)
if (CultNetSchemaMessageSerialization.Deserialize(frame.Payload) is CultNetHelloMessage hello)
hellos.Add(hello);
foreach (var hello in hellos)
{
var listed = hello.SupportedMessageVersions ?? Array.Empty<string>();
Assert.That(listed, Has.None.EqualTo(CultNetSchemaVersions.WriteAdmitted));
Assert.That(listed, Has.None.EqualTo(CultNetSchemaVersions.ErrorV1));
}
}
finally
{
client.Socket.Dispose();
}
}
private static List<CultNetErrorMessage> ErrorsReceivedBy(Client client)
{
var errors = new List<CultNetErrorMessage>();
foreach (var packet in Drain(client.Socket))
{
var received = client.Session.Receive(packet, 1);
foreach (var frame in received.Delivered)
if (CultNetSchemaMessageSerialization.Deserialize(frame.Payload) is CultNetErrorMessage error)
errors.Add(error);
}
return errors;
}
// The bad peer acknowledges what it received and the server polls on.
private static async Task Acknowledge(Scene scene)
{
scene.Bad.Socket.SendTo(CultNetRudpPacketCodec.Encode(scene.Bad.Session.CreateAck()), scene.Server.LocalEndPoint);
await scene.Server.PollAvailableAsync(16);
}
// The bad peer has sent seven undecodable frames (BadThenGood sent the first), each answered.
private static async Task<Scene> AtTheCap()
{
var scene = await BadThenGood(Malformed());
await scene.Server.PollAvailableAsync(16);
ErrorsReceivedBy(scene.Bad);
for (var i = 0; i < 6; i++)
{
SendSchema(scene.Server, scene.Bad, Malformed());
await scene.Server.PollAvailableAsync(16);
ErrorsReceivedBy(scene.Bad);
}
Assert.That(scene.Ended, Is.Empty);
return scene;
}
// The capped frame's answer is lost on the wire: the server resends it, and the goodbye follows only
// once the peer has acknowledged it.
[Test]
public async Task ALostCappedAnswerIsResentAndTheGoodbyeWaitsForItsAcknowledgement()
{
using var scene = await AtTheCap();
SendSchema(scene.Server, scene.Bad, Malformed());
await scene.Server.PollAvailableAsync(16);
var lost = Drain(scene.Bad.Socket);
Assert.That(lost.Where(p => p.PacketType == CultNetRudpPacketType.Data), Is.Not.Empty, "fixture: the answer was sent and is lost here");
Assert.That(lost.Select(p => p.PacketType), Has.None.EqualTo(CultNetRudpPacketType.Disconnect), "no goodbye before the answer is acknowledged");
Assert.That(scene.Ended, Is.Empty);
var resent = new List<CultNetRudpPacket>();
for (var attempt = 0; attempt < 40 && resent.Count == 0; attempt++)
{
await Task.Delay(50);
scene.Server.PollResends();
resent = Drain(scene.Bad.Socket);
}
Assert.That(resent.Select(p => p.PacketType), Has.None.EqualTo(CultNetRudpPacketType.Disconnect), "still no goodbye");
var answers = new List<CultNetErrorMessage>();
foreach (var packet in resent)
foreach (var frame in scene.Bad.Session.Receive(packet, 1).Delivered)
if (CultNetSchemaMessageSerialization.Deserialize(frame.Payload) is CultNetErrorMessage error)
answers.Add(error);
Assert.That(answers.Select(e => e.Code), Is.EqualTo(new[] { "message_invalid" }), "the lost answer arrived by resend");
Assert.That(scene.Ended, Is.Empty);
await Acknowledge(scene);
Assert.That(scene.Ended, Is.EqualTo(new[] { scene.Bad.EndPoint }), "acknowledged: the session ends");
Assert.That(Drain(scene.Bad.Socket).Select(p => p.PacketType), Has.Some.EqualTo(CultNetRudpPacketType.Disconnect));
}
// A peer that never acknowledges the answer is not kept for ever: the session ends after the grace.
[Test]
public async Task ACappedSessionWhoseAnswerIsNeverAcknowledgedEndsAfterTheGrace()
{
using var scene = await AtTheCap();
SendSchema(scene.Server, scene.Bad, Malformed());
await scene.Server.PollAvailableAsync(16);
Drain(scene.Bad.Socket);
await Task.Delay(1500); // the grace is two seconds
scene.Server.PollResends();
Assert.That(scene.Ended, Is.Empty, "inside the grace");
await Task.Delay(700);
scene.Server.PollResends();
Assert.That(scene.Ended, Is.EqualTo(new[] { scene.Bad.EndPoint }), "the grace ran out");
}
// Nothing behind the capped frame is served while its answer is delivered.
[Test]
public async Task NothingBehindTheCappedFrameIsServed()
{
using var scene = await AtTheCap();
SendSchema(scene.Server, scene.Bad, Malformed());
SendSchema(scene.Server, scene.Bad, CultNetSchemaMessageSerialization.Serialize(
new CultNetShardCatalogRequestMessage { MessageId = "behind-the-cap" }));
await scene.Server.PollAvailableAsync(16);
await scene.Server.PollAvailableAsync(16);
Assert.That(scene.Served.Select(s => s.MessageId), Does.Not.Contain("behind-the-cap"));
}
// The numbers are the shared cross-runtime cap, spelled out so changing the constant fails here.
[Test]
public async Task EightConsecutiveUndecodableFramesEndTheSessionAfterTheEighthIsAnswered()
{
Assert.That(RudpCultNetSchemaServer.MaxConsecutiveRefusedFrames, Is.EqualTo(8));
using var scene = await BadThenGood(Malformed());
await scene.Server.PollAvailableAsync(16);
ErrorsReceivedBy(scene.Bad);
async Task Refuse(int count)
{
for (var i = 0; i < count; i++)
{
SendSchema(scene.Server, scene.Bad, Malformed());
await scene.Server.PollAvailableAsync(16);
}
}
await Refuse(6); // with the first, seven in a row
Assert.That(ErrorsReceivedBy(scene.Bad), Has.Count.EqualTo(6));
Assert.That(scene.Ended, Is.Empty);
SendSchema(scene.Server, scene.Bad, CultNetSchemaMessageSerialization.Serialize(
new CultNetShardCatalogRequestMessage { MessageId = "resets-the-run" }));
await scene.Server.PollAvailableAsync(16);
Assert.That(scene.Served.Select(s => s.MessageId), Does.Contain("resets-the-run"));
await Refuse(7);
Assert.That(ErrorsReceivedBy(scene.Bad), Has.Count.EqualTo(7));
Assert.That(scene.Ended, Is.Empty, "a decoded frame reset the run");
await Refuse(1);
Assert.That(ErrorsReceivedBy(scene.Bad).Select(e => e.Code), Is.EqualTo(new[] { "message_invalid" }), "the eighth is answered too");
Assert.That(scene.Ended, Is.Empty, "the session ends once the answer is acknowledged");
await Acknowledge(scene);
Assert.That(scene.Ended, Is.EqualTo(new[] { scene.Bad.EndPoint }));
Assert.That(scene.Server.Peers.Select(peer => peer.RemoteEndPoint), Is.EqualTo(new[] { scene.Good.EndPoint }));
}
// The peer's malformed message and a well-formed one after it arrive in one datagram's
// delivery: the second is held for order, and both are released together. The session is
// kept through the first, so the second is handled.
[Test]
public async Task AFrameQueuedBehindAnUndecodableOneIsStillHandled()
{
var logger = new CapturingLogger();
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
@ -205,8 +381,8 @@ namespace GameCult.Networking.Tests
await server.PollAvailableAsync(16);
Assert.That(served, Is.Empty);
Assert.That(server.Peers, Is.Empty);
Assert.That(served, Is.EqualTo(new[] { "after-the-refused-one" }));
Assert.That(server.Peers, Has.Count.EqualTo(1));
Assert.That(logger.Errors, Has.Count.EqualTo(1));
bad.Socket.Dispose();
}

View file

@ -0,0 +1,290 @@
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Caching;
using LiteNetLib;
using NUnit.Framework;
using static GameCult.Networking.Tests.NetworkingTests;
namespace GameCult.Networking.Tests
{
// The receipt server on a real socket: Server.Start on an ephemeral loopback port and real LiteNetLib clients. The
// handler-level tests (CultNetWriteReceiptServerTests) hand frames to the handlers; only a running server decides what
// a frame that does not decode is answered with.
public sealed class CultNetWriteReceiptLoopbackTests
{
private static readonly TimeSpan Cap = TimeSpan.FromSeconds(10);
private static readonly CultRecordKey KeyA = new("tests:loopback:a");
private static readonly CultRecordKey KeyB = new("tests:loopback:b");
private const string ShardId = "loopback-shard";
private sealed class Client : IDisposable
{
private readonly NetManager _manager;
private readonly NetPeer _peer;
private readonly BlockingCollection<ICultNetSchemaMessage> _received = new();
public int Closed;
public Client(int port, string key)
{
var listener = new EventBasedNetListener();
var connected = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var decoder = new LiteNetLibTransportConnection();
listener.PeerConnectedEvent += _ => connected.TrySetResult(true);
listener.PeerDisconnectedEvent += (_, _) => Interlocked.Increment(ref Closed);
listener.NetworkReceiveEvent += (_, reader, _, _) =>
_received.Add(LiteNetLibTransportConnection.DecodeSchema(decoder.Receive(reader.GetRemainingBytes())));
_manager = new NetManager(listener) { UnsyncedEvents = true };
_manager.Start(0);
_peer = _manager.Connect("127.0.0.1", port, key);
if (!connected.Task.Wait(Cap)) Assert.Fail("the client did not connect");
}
public void Send<T>(T message) where T : ICultNetSchemaMessage =>
_peer.Send(CultNetSchemaMessageSerialization.Serialize(message), DeliveryMethod.ReliableOrdered);
public void SendUnreliable<T>(T message) where T : ICultNetSchemaMessage =>
_peer.Send(CultNetSchemaMessageSerialization.Serialize(message), DeliveryMethod.Unreliable);
public void Send<T>(T message, DeliveryMethod method) where T : ICultNetSchemaMessage =>
_peer.Send(CultNetSchemaMessageSerialization.Serialize(message), method);
public void SendRaw(byte[] bytes) => _peer.Send(bytes, DeliveryMethod.ReliableOrdered);
public T Next<T>() where T : ICultNetSchemaMessage
{
if (!_received.TryTake(out var message, Cap)) Assert.Fail($"no {typeof(T).Name} arrived within {Cap}");
Assert.That(message, Is.InstanceOf<T>());
return (T)message!;
}
public void ExpectNothingMore(string because)
{
// A reliable ordered channel: anything the server sent before the probe's answer has already arrived.
Assert.That(_received.Count, Is.Zero, because);
}
public void Dispose()
{
_manager.Stop();
_received.Dispose();
}
}
private sealed class Authority : ICultNetRemoteWriteAuthority
{
public CultNetRemoteWriteDecision Decide(CultNetRemoteWriteRequest request) =>
request.RecordKey.Equals(KeyB) ? CultNetRemoteWriteDecision.Refuse("denied") : CultNetRemoteWriteDecision.Grant();
}
private sealed class Running : IDisposable
{
public Server Server = null!;
public CultCache Cache = null!;
public CultNetDatabase Database = null!;
public string SchemaId = string.Empty;
private CultNetDatabaseServer _databaseServer = null!;
public static Running Start()
{
var cache = new CultCache();
var schemaId = cache.Registry.GetRequired<NetworkSchemaNote>().SchemaId;
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
Shards = [new CultNetShardDescriptor(ShardId, "primary", epoch: 1, isPrimary: true, schemaIds: [schemaId])]
});
var server = new Server(cache, ServerSecurityOptions.Development()) { Port = 0 };
var databaseServer = new CultNetDatabaseServer(server, database, new CultNetDatabaseServerOptions
{
RemoteWriteAuthority = new Authority()
});
server.Start();
return new Running { Server = server, Cache = cache, Database = database, SchemaId = schemaId, _databaseServer = databaseServer };
}
public Client Connect() => new(Server.Port, ServerSecurityOptions.Development().ConnectionKey);
public CultNetDocumentPutRawMessage PutOf(string id, CultRecordKey key, string text)
{
var message = Database.Documents.CreateRawDocumentPutMessage(id, new CultRecordHandle<NetworkSchemaNote>(key),
new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = text });
message.ShardId = ShardId;
message.ShardEpoch = 1;
return message;
}
public void Dispose()
{
_databaseServer.Dispose();
Server.Dispose();
}
}
private static CultNetHelloMessage HelloOf(params string[] versions) =>
new() { RuntimeId = "loopback-client", RuntimeKind = "test", SupportedMessageVersions = versions };
private static readonly string[] BothFrames = [CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1];
[Test]
public void StartBindsTheRequestedPortAndReportsTheOneBound()
{
using var running = Running.Start();
Assert.That(running.Server.Port, Is.Not.Zero.And.Not.EqualTo(3075));
}
// contracts/cultnet/interop/write-receipt-vectors.json, error_v1_unreadable_frame_has_null_message_id: code
// message_invalid, details.field "message", messageId null.
[TestCase(new byte[] { 0xC1 }, TestName = "AnUnreadableFrameFromAReceiptPeerIsAnsweredWithMessageInvalidAndNoId_NeverUsedByte")]
[TestCase(new byte[] { 0x92, 0x01, 0x02 }, TestName = "AnUnreadableFrameFromAReceiptPeerIsAnsweredWithMessageInvalidAndNoId_NotAMap")]
public void AnUnreadableFrameFromAReceiptPeerIsAnsweredWithMessageInvalidAndNoId(byte[] garbage)
{
using var running = Running.Start();
using var client = running.Connect();
client.Send(HelloOf(BothFrames));
client.Next<CultNetHelloMessage>();
client.SendRaw(garbage);
var error = client.Next<CultNetErrorV1Message>();
Assert.That(error.Code, Is.EqualTo("message_invalid"));
Assert.That(error.Details!.Field, Is.EqualTo("message"));
Assert.That(error.MessageId, Is.Null);
}
[Test]
public async Task AReceiptPeerIsStillServedAfterAnUnreadableFrameAndALegacyPeerIsAnsweredAsBefore()
{
using var running = Running.Start();
using var receipt = running.Connect();
receipt.Send(HelloOf(BothFrames));
receipt.Next<CultNetHelloMessage>();
receipt.SendRaw(new byte[] { 0xC1 });
receipt.Next<CultNetErrorV1Message>();
receipt.Send(running.PutOf("r1", KeyA, "one"));
Assert.That(receipt.Next<CultNetWriteAdmittedMessage>().MessageId, Is.EqualTo("r1"));
using var legacy = running.Connect();
legacy.Send(HelloOf()); // a hello that lists neither frame is answered with nothing
legacy.SendRaw(new byte[] { 0xC1 }); // unanswered: were it answered, error.v1 would arrive ahead of the refusal
legacy.Send(running.PutOf("l1", KeyB, "two"));
var refusal = legacy.Next<CultNetErrorMessage>();
Assert.That(refusal, Is.TypeOf<CultNetErrorMessage>());
Assert.That(refusal.Code, Is.EqualTo("write_not_authorized"));
legacy.Send(running.PutOf("l2", KeyA, "three"));
await WaitFor(() => running.Cache.Get<NetworkSchemaNote>(KeyA)?.Text == "three");
legacy.ExpectNothingMore("a legacy peer is never sent a receipt or error.v1");
Assert.That(running.Cache.Get<NetworkSchemaNote>(KeyB), Is.Null);
}
// write-receipt.md, Reliable channel only: a write on an unreliable channel of a receipt session is refused
// message_invalid, details.field "channel", fixed text, carrying the write's id; it is not applied.
[Test]
public async Task AWriteOnAnUnreliableChannelOfAReceiptPeerIsRefusedAndNotApplied()
{
using var running = Running.Start();
using var client = running.Connect();
client.Send(HelloOf(BothFrames));
client.Next<CultNetHelloMessage>();
client.SendUnreliable(running.PutOf("u1", KeyA, "lost"));
var error = client.Next<CultNetErrorV1Message>();
Assert.That(error.Code, Is.EqualTo("message_invalid"));
Assert.That(error.Details!.Field, Is.EqualTo("channel"));
Assert.That(error.Error, Is.EqualTo("message_invalid: receipt sessions require the reliable channel"));
Assert.That(error.MessageId, Is.EqualTo("u1"));
// The session goes on, and the same id on the reliable channel is a new write, not a replayed refusal.
client.Send(running.PutOf("u1", KeyA, "kept"));
Assert.That(client.Next<CultNetWriteAdmittedMessage>().MessageId, Is.EqualTo("u1"));
await WaitFor(() => running.Cache.Get<NetworkSchemaNote>(KeyA)?.Text == "kept");
}
// write-receipt.md, Reliable channel only: reliable and not sequenced. Reliable ordered and unordered qualify; reliable
// sequenced does not, and nor does any unreliable method.
[TestCase(DeliveryMethod.ReliableSequenced, true)]
[TestCase(DeliveryMethod.Sequenced, true)]
[TestCase(DeliveryMethod.Unreliable, true)]
[TestCase(DeliveryMethod.ReliableUnordered, false)]
[TestCase(DeliveryMethod.ReliableOrdered, false)]
public async Task OnlyAReliableNonSequencedChannelCarriesAWriteOfAReceiptPeer(DeliveryMethod method, bool refused)
{
using var running = Running.Start();
using var client = running.Connect();
client.Send(HelloOf(BothFrames));
client.Next<CultNetHelloMessage>();
client.Send(running.PutOf("c1", KeyA, "text"), method);
if (refused)
{
var error = client.Next<CultNetErrorV1Message>();
Assert.That(error.Details!.Field, Is.EqualTo("channel"));
Assert.That(error.MessageId, Is.EqualTo("c1"));
Assert.That(running.Cache.Get<NetworkSchemaNote>(KeyA), Is.Null);
}
else
{
Assert.That(client.Next<CultNetWriteAdmittedMessage>().MessageId, Is.EqualTo("c1"));
await WaitFor(() => running.Cache.Get<NetworkSchemaNote>(KeyA)?.Text == "text");
}
}
[Test]
public void ADeleteOnAnUnreliableChannelOfAReceiptPeerIsRefusedWithItsId()
{
using var running = Running.Start();
using var client = running.Connect();
client.Send(HelloOf(BothFrames));
client.Next<CultNetHelloMessage>();
client.SendUnreliable(new CultNetDocumentDeleteMessage
{
MessageId = "ud1", SchemaId = running.SchemaId, RecordKey = KeyA.Value, ShardId = ShardId, ShardEpoch = 1
});
var error = client.Next<CultNetErrorV1Message>();
Assert.That(error.Code, Is.EqualTo("message_invalid"));
Assert.That(error.Details!.Field, Is.EqualTo("channel"));
Assert.That(error.MessageId, Is.EqualTo("ud1"));
}
[Test]
public async Task OnlyWritesAreRefusedOnTheUnreliableChannelAndALegacyPeerIsLeftAsBefore()
{
using var running = Running.Start();
using var receipt = running.Connect();
receipt.Send(HelloOf(BothFrames));
receipt.Next<CultNetHelloMessage>();
receipt.SendUnreliable(HelloOf(BothFrames)); // not a write: answered with nothing on the unreliable channel
receipt.Send(running.PutOf("r1", KeyA, "one"));
Assert.That(receipt.Next<CultNetWriteAdmittedMessage>().MessageId, Is.EqualTo("r1"), "nothing else was sent ahead of the receipt");
receipt.Send(running.PutOf("r2", KeyA, "two"));
Assert.That(receipt.Next<CultNetWriteAdmittedMessage>().MessageId, Is.EqualTo("r2"));
receipt.ExpectNothingMore("the unreliable hello was answered with nothing");
using var legacy = running.Connect();
legacy.Send(HelloOf());
legacy.SendUnreliable(running.PutOf("l1", KeyB, "two")); // refused by the authority, answered as v0 as before
Assert.That(legacy.Next<CultNetErrorMessage>().Code, Is.EqualTo("write_not_authorized"));
legacy.SendUnreliable(running.PutOf("l2", KeyA, "three"));
await WaitFor(() => running.Cache.Get<NetworkSchemaNote>(KeyA)?.Text == "three");
legacy.ExpectNothingMore("a legacy peer is never sent a receipt, error.v1 or a channel refusal");
}
private static async Task WaitFor(Func<bool> condition)
{
var deadline = DateTime.UtcNow + Cap;
while (!condition())
{
if (DateTime.UtcNow > deadline) Assert.Fail("the condition did not hold within the cap");
await Task.Delay(5);
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -5189,7 +5189,9 @@ namespace GameCult.Networking.Tests
[Test]
public async Task CultNetSchemaWriteForwarder_Uses_SchemaClientPort()
{
var fake = new CapturingSchemaClient(_ => null);
var fake = new CapturingSchemaClient(message => message is CultNetDocumentDeleteMessage delete
? new CultNetWriteAdmittedMessage { MessageId = delete.MessageId, Operation = "delete", SchemaId = delete.SchemaId, RecordKey = delete.RecordKey }
: null);
var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions
{
CreateClient = () => fake
@ -5203,16 +5205,107 @@ namespace GameCult.Networking.Tests
await forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage
{
MessageId = "fwd-del",
SchemaId = "note.v0",
RecordKey = "note:1"
});
var sent = (CultNetDocumentDeleteMessage)fake.SentMessages.Single();
var hello = (CultNetHelloMessage)fake.SentMessages[0];
Assert.That(hello.SupportedMessageVersions, Is.EquivalentTo(new[] { CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1 }),
"the hello opts the forwarding connection in before the write");
var sent = (CultNetDocumentDeleteMessage)fake.SentMessages[1];
Assert.That(sent.MessageId, Is.EqualTo("fwd-del"), "the write goes under its original id");
Assert.That(fake.ConnectedHost, Is.EqualTo("primary.example.test"));
Assert.That(sent.ShardId, Is.EqualTo("notes"));
Assert.That(sent.ShardEpoch, Is.EqualTo(3));
}
[TestCase("write_not_authorized", 0)]
[TestCase("message_invalid", 0)]
[TestCase("write_not_authorized", 150)]
public void CultNetSchemaWriteForwarder_RejectsWithTheTypedRefusal_WhateverItsOrderAgainstTheSend(string code, int delayMs)
{
var fake = new CapturingSchemaClient(_ => null);
var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions
{
CreateClient = () => fake,
ResultWindow = TimeSpan.FromSeconds(2)
});
var shard = new CultNetShardDescriptor(
"notes", "primary", 3, isPrimary: false,
primaryEndpoints: ["cultnet://primary.example.test:4075"]);
fake.OnSend = () =>
{
var refusal = new CultNetErrorMessage { Error = "refused", Code = code };
if (delayMs == 0) fake.Deliver(refusal);
else Task.Delay(delayMs).ContinueWith(_ => fake.Deliver(refusal));
};
var put = Assert.ThrowsAsync<CultNetWriteRefusedException>(() => forwarder.ForwardPutAsync(shard, new CultNetDocumentPutRawMessage()));
Assert.That(put!.Code, Is.EqualTo(code));
var delete = Assert.ThrowsAsync<CultNetWriteRefusedException>(() => forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { SchemaId = "note.v0", RecordKey = "note:1" }));
Assert.That(delete!.Code, Is.EqualTo(code));
}
[Test]
public void CultNetSchemaWriteForwarder_SilenceIsUnconfirmedNotSuccess()
{
var fake = new CapturingSchemaClient(_ => null);
var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions
{
CreateClient = () => fake,
ResultWindow = TimeSpan.FromMilliseconds(400)
});
var shard = new CultNetShardDescriptor(
"notes", "primary", 3, isPrimary: false,
primaryEndpoints: ["cultnet://primary.example.test:4075"]);
var started = DateTime.UtcNow;
var unconfirmed = Assert.ThrowsAsync<CultNetWriteUnconfirmedException>(() =>
forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { MessageId = "fwd-del", SchemaId = "note.v0", RecordKey = "note:1" }));
Assert.That(unconfirmed!.MessageId, Is.EqualTo("fwd-del"));
Assert.That(DateTime.UtcNow - started, Is.GreaterThanOrEqualTo(TimeSpan.FromMilliseconds(380)));
}
[Test]
public void CultNetSchemaWriteForwarder_AnswersNamingAnotherWriteSettleNothing()
{
var fake = new CapturingSchemaClient(_ => null);
var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions
{
CreateClient = () => fake,
ResultWindow = TimeSpan.FromMilliseconds(300)
});
var shard = new CultNetShardDescriptor(
"notes", "primary", 3, isPrimary: false,
primaryEndpoints: ["cultnet://primary.example.test:4075"]);
fake.OnSend = () =>
{
fake.Deliver(new CultNetWriteAdmittedMessage { MessageId = "someone-else", Operation = "delete", SchemaId = "note.v0", RecordKey = "note:1" });
fake.Deliver(new CultNetErrorV1Message { Error = "refused", Code = "write_not_authorized", MessageId = "someone-else" });
};
Assert.ThrowsAsync<CultNetWriteUnconfirmedException>(() =>
forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { MessageId = "fwd-del", SchemaId = "note.v0", RecordKey = "note:1" }));
}
[Test]
public void CultNetSchemaWriteForwarder_RejectsWithTheErrorV1NamingItsWrite()
{
var fake = new CapturingSchemaClient(_ => null);
var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions { CreateClient = () => fake });
var shard = new CultNetShardDescriptor(
"notes", "primary", 3, isPrimary: false,
primaryEndpoints: ["cultnet://primary.example.test:4075"]);
fake.OnSend = () => fake.Deliver(new CultNetErrorV1Message { Error = "refused", Code = "write_not_authorized", MessageId = "fwd-del" });
var refused = Assert.ThrowsAsync<CultNetWriteRefusedException>(() =>
forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { MessageId = "fwd-del", SchemaId = "note.v0", RecordKey = "note:1" }));
Assert.That(refused!.Code, Is.EqualTo("write_not_authorized"));
}
[Test]
public void CultMeshVerseCatalog_Finds_CompatibleTransferTargets()
{
@ -7458,6 +7551,14 @@ namespace GameCult.Networking.Tests
_respond = respond;
}
public Action? OnSend { get; set; }
public void Deliver(ICultNetSchemaMessage message)
{
if (!_handlers.TryGetValue(message.GetType(), out var handlers)) return;
foreach (var handler in handlers.ToArray()) handler.DynamicInvoke(message);
}
public bool Connected { get; private set; }
public string? ConnectedHost { get; private set; }
public int ConnectedPort { get; private set; }
@ -7474,6 +7575,7 @@ namespace GameCult.Networking.Tests
where T : ICultNetSchemaMessage
{
SentMessages.Add(message);
OnSend?.Invoke();
var response = _respond(message);
if (response == null)
{