The EMSGSIZE pre-check measures a send's largest datagram, which is one
fragment when maxFragmentBytes is set. Nothing pinned that: a check that
compared the whole payload (Soul's S-TM2) would refuse every large
fragmented send, and large sends are live. A 70,000-byte send with
1,024-byte fragments must not throw and must arrive whole. The test
kills the mutant whose size check ignores maxFragmentBytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Node reports an oversized datagram only to the send's callback, so the
caller's send used to succeed and the session ended later. The transport now
checks the largest datagram of the send against the socket family's UDP
limit (65,507 bytes for IPv4, 65,527 for IPv6) before the session builds
anything: the send throws, nothing is queued, and no sequence is taken, as
the Rust and C# listeners withdraw it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The document servers' refusal tests sent an unreliable fragment, whose
sequence the session never records, so dropping the reset before the goodbye
survived. They now refuse a reliable fragment and check the goodbye does not
acknowledge it.
C#: a BeforeSend seam orders a caller's failing send between the polling
thread's ack and its send, so both threads end the same session; the peer
hears one goodbye.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A permanent failure reported after that generation ended (the client
connected again from the same endpoint) ended the generation that replaced
it and said goodbye to the new client. The callback now acts only while the
generation that sent the datagram is live, which is the one in `sessions`.
That makes two guards unreachable, so they are deleted: `peer.unsendable`
before the goodbye (the first permanent failure ends the generation, so a
second callback from it is already ignored) and the conditional delete (a
live generation is the entry at its key). A refusal's goodbye is sent
best-effort, as the transport and the document server send theirs, instead
of through the failure handling meant for datagrams a session owes.
Tests: late failure after a reconnect, for the transport's generation check
and for the operation server.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The RUDP loop caught only ValueError, so a subscribe frame with
`schemaIds: 5` raised TypeError in _handle_connection_message and ended the
thread for every peer. Any exception from handling one peer's messages now
ends that peer's session with a goodbye, as a refused packet does, and is
logged by its type alone: the error's message can quote what the peer sent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When that generation ends while the handler runs (a goodbye, a refusal, a
timeout, a new Connect from the same endpoint), the response is not sent
and a failure sends no second goodbye: a client that connected again owns
the endpoint.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Node reports a failed send only to the send's callback, never on the socket's
"error" event. Emitting it as "error" from the transport ended any process
with no listener (invokeCultNetOperation among them) once per resend tick.
The transport and the document server now count it in sendFailures, as the
operation server, Rust and Python already do. The tests send to
255.255.255.255 with no error listener.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Import lists in cultnet-ts index.ts and operation-service.ts and the
cultnet-py test block conflicted; both sides are kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Document server: a reset reported by a receive is idle, not an error
(Windows only, where the system reports it; run on Starfire).
- Document server: no ack follows a reply that can never be sent. The
guard is not decoration: without it the ack's success overwrote the
failure and the session that owed the Pong was kept.
- Hub: a send refused at any fragment index delivers nothing of it; at
index 0 the next send arrives whole (Soul's probe, committed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
session.receive raises ValueError on bad fragment metadata, and nothing
in _rudp_loop caught it, so one admitted peer could end the thread for
every peer. The server now ends that peer's session with "session refused
a packet", as the socket transport and the Rust servers do.
Tests pin that, and two D2 rules that were unpinned: a resend loop sends
nothing more to a peer it just ended (P1), and responses after one that
can never be sent are not sent (P3).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every socket send in the RUDP document server goes through
sendRudpDatagram. A Connect from UDP port 0 used to store a session whose
pending Accept threw from the resend interval on every tick, an
uncaughtException that ends the process. Now:
- a Connect whose Accept can never be sent starts no session and leaves a
session already at that endpoint as it was;
- a datagram that can never be sent as built ends only that peer's session
(goodbye, onSessionClosed); any other failure is a lost datagram,
reported through onError, and ends nothing;
- a packet the session refuses (bad fragment metadata) ends that peer's
session with "session refused a packet" instead of leaving a session
that has recorded a sequence it never delivered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Node reports a failed UDP send two ways: it throws for an argument it
refuses (port 0, which any spoofer can claim as a source) and passes a
system failure to the callback later. sendRudpDatagram routes both to one
failure handler, and isPermanentSendError classifies with the permanent set
the other runtimes use (EMSGSIZE, EINVAL, EAFNOSUPPORT, and Node's own
ERR_SOCKET_BAD_PORT for port 0).
- The socket transport's receive and resend paths hand a failure to that
handler: a datagram that can never be sent ends the session (goodbye
names the error code, never its message); any other failure is emitted
on "error" as the socket always reported it. Caller-directed sends still
throw to the caller. A resend loop stops at the generation it ended.
- The operation server starts no session for a Connect whose Accept can
never be sent (counted as dropped), ends a stored peer whose datagram
can never be sent, and counts other failures in sendFailures.
- CultNetRudpSession.endUnsendableSession, as in the other runtimes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A server accepting a fresh Connect can send before its Accept is
acknowledged, so no ack of the new generation prunes the acknowledged
sizes; only `end_session` clearing them keeps the ended generation's bytes
from counting against the new window. Rust, TypeScript, Python and C# each
pin it: 4 MiB acknowledged above a lost packet, a fresh Connect, and the
server's first 1 MiB send of the new generation goes out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`send` checked `window_admits` before `send_many` purged deadline-passed
packets, so the first direct send after the lowest unacked packet expired
was refused for acknowledged bytes that `send_many` would already have
released. `send` now purges first, as `send_many` does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A JVM wildcard socket is dual-stack, so a client bound to 0.0.0.0 reports the
IPv6 wildcard as its local address and the self-test's first check failed on
Linux. Assert that a client for a remote endpoint binds a wildcard, and keep the
exact loopback checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CultMesh RUDP client entry points defaulted bindHost to 127.0.0.1. On
Windows a socket bound to loopback cannot send off the host (os error 10051),
so a default client could not reach a remote endpoint.
cultNetRudpClientBindAddress owns the rule: loopback for a loopback endpoint,
otherwise the unspecified address of the endpoint's family (0.0.0.0 or ::).
bindHost defaults to null on every client entry point, which uses the rule. An
explicit bindHost still wins; a CultMesh server still binds 127.0.0.1 unless
told otherwise. The self-test pins all three.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
create_rudp_schema_transport and CultMesh.create_rudp_client (and create_client
and connect_client above them) defaulted bind_host to 127.0.0.1 on an AF_INET
socket. On Windows a socket bound to loopback cannot send off the host (os
error 10051), so a default client could not reach a remote endpoint.
cultnet-py now owns the rule in rudp_client_bind_host: loopback for a loopback
endpoint, otherwise the unspecified address of the endpoint's family (0.0.0.0
or ::). bind_host defaults to None on the client paths, which use the rule; the
socket family follows the bind host. An explicit bind_host still wins; a
CultMesh server still binds 127.0.0.1 unless told otherwise.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CultMesh.createRudpClient defaulted bindHost to 127.0.0.1 on a udp4 socket, and
invokeCultNetOperation bound 127.0.0.1 unconditionally. On Windows a socket
bound to loopback cannot send off the host (os error 10051), so neither could
reach a remote endpoint.
cultnet-ts now owns the rule in rudpClientBindHost: loopback for a loopback
endpoint, otherwise the unspecified address of the endpoint's family (0.0.0.0
or ::). The operation client and the CultMesh client both use it, and the
CultMesh socket family follows the bind host. An explicit bindHost still wins;
a CultMesh server still binds 127.0.0.1 unless told otherwise.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CultMeshRudpSocketOptions defaulted bind_host to 127.0.0.1 for servers and
clients alike. On Windows a socket bound to loopback cannot send off the host
(os error 10051), so a default client could never reach a remote endpoint such
as rudp://10.77.0.1:17872.
bind_host is now Option<String>. Unset, a server still binds 127.0.0.1; a client
binds loopback for a loopback endpoint and the unspecified address of the
endpoint's family (0.0.0.0 or ::) otherwise. An explicit bind_host wins for both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rust hub and C# listener: 1,100 one-byte fragments, the first can never be
sent, and nothing of the send stays queued.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The injected permanent failure now takes precedence over the injected transient
one, so a test can end a session on a peer that is also losing datagrams and
count exactly which datagrams were attempted afterwards. New tests: a hub
disconnect that can never be sent returns the error; a peer or session ended
in the resend loop is sent nothing more (Rust hub, document server, C#); the
goodbye reaches the peer; the Python server ends the session on a permanent
failure in a resend, an ack and an accept. The oversized-send test compares the
outstanding count with the one the hub's own unacknowledged Accept already
holds.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
D1: on Windows the closed port of a vanished peer is reported by the next
recvfrom as ConnectionResetError (WinError 10054), and the RUDP thread returned
on it. It is now idle, as in the Rust document server, the Rust hub and the C#
listener. Both tests fail on Windows without the fix and pass with it.
D2: is_permanent_send_error (EMSGSIZE, EINVAL, EAFNOSUPPORT and their Winsock
equivalents) separates a datagram that can never be sent as built from a lost
one. The server ends that peer's session with a goodbye whose reason names the
error and never the thread; transient failures stay counted losses.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The hub and the document server swallowed every send failure as a counted
loss, so a 70,000-byte reliable frame returned Ok and was resent forever.
Failures now split in two. Permanent means the datagram can never be sent as
built (EMSGSIZE, EINVAL, EAFNOSUPPORT and their Winsock equivalents,
is_permanent_send_error in cultnet-rs); everything else stays a counted loss.
A caller-directed hub send returns the error and queues nothing: the packets
it created are withdrawn and their sequences reissued, so the peer never sees
a gap (a permanent failure after an earlier fragment left ends the session).
Inside a poll the failure ends that peer's session with a goodbye whose reason
names the error, reported to the hub caller as Disconnected, and never fails
the poll. The document server ends the session the same way and reports a
snapshot response it can never send as an application rejection. Tests pin
each path, the classification per OS, BytesSent after a healthy send and a zero
failure count for healthy sends.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The bytes counted above a lost packet stop counting once it is acknowledged;
every runtime now asserts that another MiB is admitted after the gap closes.
Rust prunes on expiry only when something expired, so the acknowledgement path
owns the pruning it can observe.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The bound summed only packets still awaiting an ack. Once frames above a lost
packet were acknowledged they stopped counting, so a receiver could be made to
hold more than 4 MiB behind one loss, and its hold buffer ended the session.
The sender now keeps the payload size of each acknowledged sequence above the
lowest pending one until the lowest advances past it, in Rust, TypeScript,
Python and C#. Tests per runtime: one lost packet, four acknowledged 1 MiB
frames, the fifth refused until the gap closes; and the floor moving to a new
lowest sequence.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Ack Cut 1c made CultNetRudpSocketTransportOptions.initial_sequence optional; the
new client helper still passed a bare u32.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Cut 1's the_hold_buffer_takes_exactly_1024_frames test held 1,024 frames behind a
sequence the receiver never sees, by sending them all above one unacked packet.
Cut 3's flow window stops a sender 1,023 sequences above its lowest unacked packet,
so the test's sender was refused at frame 1,023. The missing packet is now
acknowledged by hand (the receiver never saw it), so the window slides and what
the test probes is the receiver's hold buffer, not the sender.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
An Accept now counts only while the session's own Connect awaits it, and reliable
data is refused until the handshake seeds the watermark, so the tests' bare Accept
no longer connected the session. The helpers now pair a sender and a receiver
through CreateConnect/AcceptConnect in all four runtimes; the sender's Connect
is acknowledged, so nothing is pending on it when the tests begin. Rust also
takes initial_sequence as an Option.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Cut 1: one watermark owns ordered delivery, seeded only by the handshake.
Cut 1b: every ending drops what the session still owed; flush is bound
to its generation. Cut 1c: a Connect repeats only by its sequence;
stale data and stale Connects are handled as TCP does (challenge Ack);
an unanswered attempt retries at +window-1; the session draws its
initial sequence. Wire bytes unchanged in all four runtimes. Five Soul
passes. Public option types changed: initial_sequence is optional.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While a Connect awaits its Accept, only an Accept the client honours retires it. An Ack that names the Connect (a server's reply to a repeat or a stale copy) no longer wedges the client with nothing to resend. The Connect payload lives on the session, so a fresh attempt can always be built. The fresh attempt uses the abandoned sequence plus the receive window less one (serial), so a late copy of the abandoned Connect stays inside the new one's stale window; only a session's first Connect draws at random.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
While a Connect awaits its Accept, only an Accept the client honours retires it. An Ack that names the Connect (a server's reply to a repeat or a stale copy) no longer wedges the client with nothing to resend. The Connect payload lives on the session, so a fresh attempt can always be built. The fresh attempt uses the abandoned sequence plus the receive window less one (serial), so a late copy of the abandoned Connect stays inside the new one's stale window; only a session's first Connect draws at random.
The cultmesh-ts document server has a test that it does not acknowledge by name a reliable packet its session did not receive.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
While a Connect awaits its Accept, only an Accept the client honours retires it. An Ack that names the Connect (a server's reply to a repeat or a stale copy) no longer wedges the client with nothing to resend. The Connect payload lives on the session, so a fresh attempt can always be built. The fresh attempt uses the abandoned sequence plus the receive window less one (serial), so a late copy of the abandoned Connect stays inside the new one's stale window; only a session's first Connect draws at random.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A frame at or before the seeded watermark is a duplicate of an earlier generation and is acknowledged, never delivered again (serial compare within the receive window). A Connect that precedes the current generation's within the window is a stale copy of an earlier attempt: it is answered like a repeat (Accept owed or Ack), changes no state and does not refresh liveness. A Connect unanswered for 3 s is replaced by a fresh Connect with a newly drawn sequence and the same payload. In server mode a Connect from another endpoint is always a new generation. The initial sequence is unset by default and each session draws its own; the public random helper is removed.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A frame at or before the seeded watermark is a duplicate of an earlier generation and is acknowledged, never delivered again (serial compare within the receive window). A Connect that precedes the current generation's within the window is a stale copy of an earlier attempt: it is answered like a repeat (Accept owed or Ack), changes no state and does not refresh liveness. A Connect unanswered for 3 s is replaced by a fresh Connect with a newly drawn sequence and the same payload. In server mode a Connect from another endpoint is always a new generation. The initial sequence is unset by default and each session draws its own; the public random helper is removed.
The cultmesh-ts document server acknowledges what the session received, not every reliable packet by name.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A frame at or before the seeded watermark is a duplicate of an earlier generation and is acknowledged, never delivered again (serial compare within the receive window). A Connect that precedes the current generation's within the window is a stale copy of an earlier attempt: it is answered like a repeat (Accept owed or Ack), changes no state and does not refresh liveness. A Connect unanswered for 3 s is replaced by a fresh Connect with a newly drawn sequence and the same payload. In server mode a Connect from another endpoint is always a new generation. The initial sequence is unset by default and each session draws its own; the public random helper is removed.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
rs-gf256-v1 (construction documented, independent implementation matches
every KAT); audio parity with checked block arithmetic; every encoded
record must fit the producer's datagram budget (default 1431), and no
media record exceeds 64 KiB on encode, decode, send or reassembly. The
"audio" channel is retired. Muninn and Ratatoskr bump in lockstep at
Cuts 4-5. Three Soul passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mutation run showed the unfragmented receive path of the RUDP session was
undefended (only fragmented reassembly was tested).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The sized parity helpers collided with the existing fixtures of the same name,
so they are now sized_audio_parity and sized_video_parity. Two existing cases
encoded the old base-pts deadline rule: the corruption table expects the new
last pts_ticks message, the edge-of-bound cases admit a deadline at the last
pts, and the 254-shard audio case sets a deadline that covers its last slot.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A producer that builds an oversized record without going through the FEC
policy would otherwise learn only when the receiver silently dropped it.
encode_media_wire_record now refuses anything over
GAMECULT_MEDIA_MAX_WIRE_BYTES, with the decoder's message. The decode ceiling
test pads a valid record by one byte to stand in for a peer that sent one.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Removing the 720-byte shard cap left the decoder with no per-record size
bound: RUDP reassembles up to 16 MiB and the media channel set no cap, so a
4 MiB parity record decoded and recovery work scaled with it.
GAMECULT_MEDIA_MAX_WIRE_BYTES (64 KiB, one maximum UDP datagram, ~7x a jumbo
frame) is now the ceiling. decode_media_wire_record refuses a larger record,
the RUDP session caps the media channel to it on send and on reassembly (and
the profile advertises it), and MediaFecPolicy::validate refuses a
max_wire_bytes above it so no producer can promise a record the decoder drops.
validate_audio_parity_record now checks the deadline against the last slot's
pts, not the base, so recovery can no longer produce a packet that violates
deadline_ticks >= pts_ticks.
Tests: ceiling +-1 at decode for audio and video parity; jumbo record accepted;
session send and reassembly on the media channel only; profile advertisement;
the audio deadline boundary and recovery refusal; a policy over the ceiling
refused; a budget at the ceiling end to end; audio parity larger than every
packet it protects (the budget check relies on it). MediaFecPolicy docs tell
callers to build by struct update from STANDARD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
F1: RecordUnappliedEntry keeps the primary's wire entry on the in-memory log
entry (internal Relayed) and ToLogEntryMessage returns it through the same
normalisation the store uses, so a replica with no store no longer relays a
skipped put as a delete; memory and disk relay identically.
Soul's 4d probes are committed (chained replica keeps the row, key-prefixed
reads serve only owned keys, primary-all serves everything; the two
measurements are explicit) with a memory/disk relay-parity test.
Rust: captured-bytes cases for unowned_schema and variant_unsupported (with and
without a subscription).
The error-code set is stated as a closed enum in the schema description (both
copies) and in cultnet-distributed-database.md: adding a code is one
coordinated change across runtimes, since Rust and TypeScript refuse an unknown
code. The Soul 4c probe file's header says what it guards.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>