Commit graph CultLib/src/GameCult.Networking/CultNetTransport.cs
Author SHA1 Message Date
Meta Vens
e1bdf92321 C#: an unsendable goodbye names the failure by a fixed code
EndUnsendable put the SocketException's message on the wire, the
platform's wording. SendErrorCode names a permanent failure EMSGSIZE,
EINVAL or EAFNOSUPPORT, anything else UNKNOWN, so every runtime's
unsendable goodbye reads "packet could not be sent: CODE" with the same
codes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:04:40 +02:00
Meta Vens
ce63ff88dc C#: a handler's fault ends the session without blaming the peer
The schema server's backstop ended every session with "session refused
a packet", including when the fault was the server's own: for example a
handler's response too large to send, or a refusal the handler did not
type. Decoding and handling are now two catches:
- A message that cannot be decoded is the peer's. The session ends with
  the refusal reason.
- A handler fault ends the session with "server could not handle a
  message".
The transport's EndRefusedPeer becomes EndPeerSession(peer, reason). It
reports whether it ended the session, so the log says when a response
that failed after its first fragment had already ended it over the send.
Before, the log reported that it ended a session that was already gone.

The comment said that ending the session matched cultmesh-py. It does
not: Python silently skips a malformed frame. The honest anchor is the
C# TCP schema server, whose ServePeerAsync ends the one peer whose
message fails to decode or handle. The comment now says so.

Pinned, each by a test that kills its mutant:
- a handler fault's goodbye carries the server's reason, not the refusal
  (mutant: the refusal reason);
- the post-fragment-0 path logs that the session had already ended
  (mutant: a constant outcome);
- EndPeerSession says no goodbye to a replaced peer's endpoint (mutant:
  the Ended check dropped);
- a refused peer's frames still queued are never handled (mutant: the
  queue purge dropped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:19:29 +02:00
Meta Vens
b37f898465 C#: a send failure on an already-ended session says no second goodbye
3b9ffac2 claimed that the generation check subsumed the UnsendableReason
once-guard. It did not. A thread that reads the generation only after
the session has ended carries the ended session's own generation, and the
check lets it through. For example, the polling thread answers a Ping on a
session that a caller's send has just ended but that has not yet been
retired. When that Pong fails permanently, the thread says a second
goodbye and overwrites the first failure's reason (Soul pass 4, PR4).
EndUnsendableSession now also returns when the session has already ended.

Two tests:
- The polling thread answers a Ping on a session already ended, and the
  Pong can never be sent. Exactly one goodbye goes out, and the first
  failure's reason stands. The hook admits the Ping after the session
  ended and before the peer is queued for retirement. The test kills the
  mutant that drops the Ended check.
- A failed send's goodbye is sent under the gate. A polling thread
  admitting a new Connect from the endpoint waits on the gate, so the old
  generation's goodbye never follows the new session's Accept. The test
  kills the mutant that sends the goodbye outside the gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:19:19 +02:00
Meta Vens
a44e189e0c C#: a peer's undecodable message ends that peer's session, never the poll
RudpCultNetSchemaServer decoded each schema frame outside its per-message
backstop, so a malformed payload (0xc1) or a well-formed message with an
unknown schemaVersion threw MessagePackSerializationException out of
PollAvailableAsync and PollOnceAsync. The drain aborted: a good peer's
request in the same drain went unserved, a consumer loop that did not
catch died, and the bad frame was already acknowledged while its peer
stayed connected.

Decoding now runs inside the backstop. A peer whose message cannot be
decoded or handled has its session ended: the transport's new
EndRefusedPeer sends the refusal goodbye under the gate, counts the drop
in PacketsDropped, drops that peer's frames still queued, forgets the
peer and raises PeerDisconnected. A session that already ended (replaced
by a new Connect, or ended by a send failure) gets no goodbye, since the
endpoint may belong to a new session. Other peers in the drain are still
served. Ending the session matches cultmesh-py's server, where one
peer's bad message ends that peer; runtime parity is the reason.

The log names the fault's type only and never its message, because the
decoder's message quotes the peer's input (the unknown schemaVersion).
The dispatch-backstop test now asserts that rule instead of the handler
message it used to require.

Tests, each failing on the pre-fix code: a malformed payload and an
unknown schemaVersion each leave the good peer served without Poll
throwing (also through PollOnceAsync); the bad peer's session ends,
counted and told; the log carries the type and not the canary value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:23:40 +02:00
Meta Vens
3b9ffac2fe C#: a send failure's goodbye belongs to the generation that sent
Send ended an unsendable session after releasing SessionGate, and the
goodbye was built under the gate but sent outside it. If the polling
thread admitted a new Connect from the same endpoint in that window, the
ended generation's goodbye reached the new client: it saw Data, Accept,
Disconnect and believed itself disconnected while the listener held it
connected.

Every path that finds a permanent send failure now reads the session's
generation under the gate at the send, and EndUnsendableSession builds
and sends the goodbye under the gate only while that generation is
unchanged. The generation check replaces the UnsendableReason once-guard:
a second failure in the same generation finds it already ended.

The test orders the new Connect inside the send, before the fragment
that can never be sent, and asserts the new client hears no Disconnect
and stays connected on both sides. It fails on the pre-fix code
(Data, Accept, Disconnect) and kills the mutant that drops the check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:23:30 +02:00
Meta Vens
2d76b8d1b6 Pin the reset before a refusal's goodbye (cultmesh-ts, cultmesh-py) and C#'s once-guard
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>
2026-09-30 16:02:27 +02:00
Meta Vens
e8e8e5bac1 GameCult.Networking: a Connect that replaces a peer ends the old peer's session under its gate
A caller still holding the replaced peer (a schema handler awaiting work for
it) could send into the endpoint the new session owns: a goodbye after a
partial failure disconnected the new client, a fixed initial sequence
delivered the stale frame in place of the real one, and a drawn one stalled
the new session. The replaced session now ends under the SessionGate every
Send takes, so a stale Send throws and nothing of it leaves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:54:33 +02:00
Meta Vens
2e94dc3aa3 Merge origin/main into hands/ack-cutD: Cut 3 flow window, RUDP client bind
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>
2026-09-30 15:49:04 +02:00
Meta Vens
cce4d391b8 Ack Cut D D2 (C#): only the polling thread changes the peer set
A caller-directed Send that failed after a fragment left called
EndUnsendablePeer on the caller's thread, removing from _peers and raising
PeerDisconnected while the polling thread could be changing _peers too.
Send now ends the session and sends the goodbye under the peer's
SessionGate, and queues the peer; TryReceiveOnce and PollResends retire
queued peers before anything else. A peer a new Connect already replaced
is skipped, so a stale peer object cannot remove the peer that replaced it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:03:22 +02:00
Meta Vens
ac5c86033b Ack Cut D D2: pin the disconnect, the resend loop, the Python resend, ack and accept paths
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>
2026-09-30 12:25:04 +02:00
Meta Vens
6cf92329b6 Ack Cut D D2 (C#): a send that can never succeed throws from a caller-directed send and ends only that peer's session inside a poll
The listener swallowed every send failure as a counted loss. A send now splits
into permanent (MessageSize, InvalidArgument, AddressFamilyNotSupported: the
datagram can never be sent as built) and transient (everything else, still a
counted loss). SendSchema throws the SocketException and queues nothing, with
the packets it created withdrawn and their sequences reissued; a permanent
failure after an earlier fragment left ends the session. Inside a poll the
failure ends that peer's session, PeerDisconnected carries a reason naming the
error, and the poll is never failed. Tests pin each path, the classification,
BytesSent after a healthy send and a zero failure count.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 12:19:46 +02:00
Meta Vens
d7eacd1011 Ack Cut 3 F1: the 4 MiB flow bound counts acknowledged bytes above the lowest unacknowledged sequence
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>
2026-09-30 12:04:19 +02:00
Meta Vens
b8c00117b3 Merge origin/main into hands/ack-cutD 2026-09-30 10:58:23 +02:00
Meta Vens
8c71b5c6dd Merge origin/main into hands/ack-cut3
# Conflicts:
#	packages/cultnet-ts/src/rudp.ts
2026-09-30 10:52:49 +02:00
Meta Vens
4da8bbe291 Ack Cut 1d batch 2 (C#): an Ack does not retire a Connect awaiting its Accept; the fresh attempt jumps by the receive window less one
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>
2026-09-30 09:53:06 +02:00
Meta Vens
1569f013c6 Ack Cut 1d (C#): stale generations are duplicates, a stale Connect is answered not obeyed, sessions draw their own sequence
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.

An accepted peer now stamps its liveness on accept, so one that goes silent times out.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 09:11:52 +02:00
Meta Vens
0abb8d4380 Ack Cut 1c: a new generation drops what the old one owed before the queue is asked for room
A bounded session whose queue still held an unacknowledged Accept for a peer
that vanished refused the next Connect; the old server-mode path reset before
accepting, so this would have been a regression. accept_connect and
create_connect now end the generation first in every runtime, and a test per
runtime holds a one-slot queue to it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 06:50:23 +02:00
Meta Vens
5ff1ee3222 Ack Cut 1c (C#): a Connect's sequence says whether it repeats; the handshake alone seeds the watermark
The session records the sequence of the Connect that started its generation. A
Connect repeats exactly when the session is connected and carries that
sequence; anything else ends the generation, forgets the peer and accepts a new
one. AcceptConnect answers both, ConnectRepeats is the predicate for servers
that keep one session per peer, and AnswerRepeatedConnect is private. Server
mode admits a Connect from a new endpoint and the session decides; the listener
no longer compares payloads (the payload stays application evidence).

CreateConnect on a session that has had a peer starts a new generation. An
Accept counts only while the Connect is pending and only if it names it. The
watermark is seeded by the Connect or Accept alone; reliable data before that
is neither remembered, acknowledged nor delivered. The default initial sequence
is random in [1, 2^31) in every options type. An ICMP connection reset no
longer sets DisconnectReason: nothing ended. The interop peer flushes before it
disconnects.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 05:51:14 +02:00
Meta Vens
eeda6d7fd6 Ack Cut 3: the sender's flow window (1,023 sequences and 4 MiB above the lowest unacked)
Admission in Rust, C#, TypeScript and Python goes through one predicate,
window_admits: the 32-packet slot, a sequence at most 1,023 above the lowest
unacked one, and at most 4 MiB of payload above it. With nothing pending any
packet is admissible. Admission is FIFO: nothing overtakes a queued packet.
The single-packet send refuses on the same predicate instead of queueing a
packet and then reporting that it produced none. The receive window is stated
in docs/cultnet-transport-parity.md. Kotlin trails (Cut K3).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 00:55:14 +02:00
Meta Vens
8e609284d2 Ack Cut D (C#): a failed send to one peer is that peer's lost datagram
CultNetRudpSocketTransportServer.SendPacket let a SocketException escape, so
the first failed SendTo ended PollResends and TryReceiveOnce for every peer.
SendPacket now counts the failure in CultNetTransportStats.SendFailures and
returns; only an encode failure throws. The refusal goodbye's own catch is
redundant and goes.

The tests inject the failure at SendPacket through an internal FailingSendPeers
set, exposed to GameCult.Networking.Tests by InternalsVisibleTo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 00:51:29 +02:00
Meta Vens
ebc7080165 Ack Cut 1b: every ending drops what it owed; a flush is bound to its generation
Each session gets one private primitive that ends its current generation:
not connected, pending and queued writes dropped, generation advanced,
"ended" raised until the next Connect or Accept begins one. A peer's
Disconnect, a local disconnect, a timeout and a reset (refusal included) all
call it, and it leaves what was received from the peer alone. The socket
transports' flush captures the session generation and fails when it changes
or the session has ended, in all four runtimes; the disconnect reason and,
in TypeScript, the transport's generation pair are report-only or gone. Rust
receipts carry the generation in place of the session scope UUID, so a
receipt reads Invalidated after any ending and stays so after a reconnect.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 00:13:22 +02:00
Meta Vens
385123277f Ack Cut 1: one watermark owns ordered delivery in all four runtimes
The per-channel next pointer, its first-use initialisation, the skip over
other channels' sequences, and the per-channel drain are deleted. Each
session keeps one contiguous received-through watermark, seeded by the
peer's Connect or Accept (or the first packet of an unhandshaken session),
advanced in the received-sequence bookkeeping. Ordered frames wait in one
map keyed by first sequence, and every reliable receipt drains the frames at
or below watermark + 1 in sequence order, after the unordered frame of the
same call. A frame is held only while a sequence below it is missing, so a
5 MiB ordered frame with no gap is never held. Rust bounds what is still
held after the drain (frames, bytes, channels with held frames).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 00:00:32 +02:00
Meta Vens
afd52e97fa GameCult.Networking: the replaced-peer reason belongs to the listener that reports it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:44:32 +02:00
Meta Vens
ca18dfac55 GameCult.Networking: an ended session fails its flush; a repeated Connect owes one Accept
FlushReliable throws for writes an ended session forgot. A server-mode
transport and the listener answer a Connect from the peer they already hold
with AnswerRepeatedConnect: the Accept still owed, or an Ack, queueing
nothing. The listener replaced the peer session on every Connect, reusing
sequences and never reporting PeerDisconnected; it now replaces only on a
different connect payload and reports the old peer, as the Rust hub does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:41:23 +02:00
Meta Vens
9dba01082c GameCult.Networking: the listener refusal local no longer shadows the datagram length
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:42:45 +02:00
Meta Vens
b579075dd5 GameCult.Networking: a declared length past int.MaxValue is malformed; server mode is claimed only by a Connect; a refusing session ends
CultNetRudpPacketCodec.Decode did checked((int)length), so a datagram declaring
0x80000000 threw OverflowException, which no receive loop catches; the length
is now compared as a long and takes the ordinary malformed-packet drop.
Server mode claimed its endpoint on the first datagram of any type; only a
Connect claims it now. A session that refuses a packet has already recorded
its reliable sequence, so the socket transport and the listener end it: the
session forgets the peer (so the goodbye does not acknowledge the refused
frame), the peer gets a Disconnect, the listener drops the peer and raises
PeerDisconnected, and a server-mode transport forgets its endpoint.
CultNetRudpSession gains ResetPeerState, and the session and the listener
reject an InitialSequence of uint.MaxValue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:41:56 +02:00
Meta Vens
b8dbcfe40c GameCult.Networking: RUDP socket transports drop and count strays
A malformed frame, a foreign connection id or a packet the session refuses
threw out of TryReceiveOnce. They are now counted in Stats.PacketsDropped and
the read reports progress. The client-side transport checks the connection
id before it claims the peer endpoint; the server listener drops refused
packets instead of throwing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 17:51:32 +02:00
Meta Vens
0736ae4824 Separate RUDP reliable and lossy sequence domains 2026-09-04 00:33:16 +02:00
Meta Vens
75c180782a Acknowledge retransmits across RUDP horizons 2026-08-23 19:15:22 +02:00
Meta Vens
fda51fad50 Acknowledge reliable RUDP windows cumulatively 2026-08-23 19:00:33 +02:00
Meta Vens
a2e3d3fa35 Bind reliable windows to the RUDP ACK horizon 2026-08-23 18:54:18 +02:00
Meta Vens
f4dabfefb0 Bound RUDP reliable transmission windows 2026-08-23 18:35:49 +02:00
Meta Vens
2afec5c5dc Remove scheduler sleeps from RUDP body bursts 2026-07-19 16:28:06 +01:00
Meta Vens
fd070cc8b2 Serialize concurrent RUDP session publication 2026-07-19 08:36:09 +01:00
Meta Vens
4f816a4926 Withdraw reactive state when peers disconnect 2026-07-19 06:40:05 +01:00
Meta Vens
743fa3889e Stream exact hot state over negotiated body planes 2026-07-19 05:54:24 +01:00
Meta Vens
bb7e06ba1e Synchronize RUDP resend acknowledgements 2026-07-17 13:17:09 +01:00
Meta Vens
25231429c1 Drain RUDP content traffic without per-packet sleeps 2026-07-16 02:36:48 +01:00
Meta Vens
a311cbe8cb Improve managed content throughput 2026-07-14 03:26:43 +01:00
Meta Vens
fbee58eba1 Prevent RUDP snapshot traffic from starving peers 2026-07-12 19:42:16 +01:00
Meta Vens
71dcfc11e8 Publish RUDP connection state across threads 2026-07-12 11:05:47 +01:00
Meta Vens
c398bad186 Deliver large scoped snapshots reliably 2026-07-12 08:42:08 +01:00
Meta Vens
905c5254b4 Reuse CultMesh snapshot connections 2026-07-10 06:37:10 +01:00
Meta Vens
81b01ec41e Harden CultNet snapshot fetches 2026-06-27 14:35:54 +01:00
Meta Vens
1c85eabefb Add C# multi-peer RUDP listener 2026-06-17 14:28:18 +01:00
Meta Vens
4335ab10bd Fix RUDP ordered control sequence parity 2026-06-16 13:18:39 +01:00
Meta Vens
bcdcc8035f Route LiteNetLib service sends through adapter 2026-06-16 11:15:41 +01:00
Meta Vens
c6f1435242 Keep RUDP socket receive polling nonblocking 2026-06-16 09:49:00 +01:00
Meta Vens
f909c77c89 Route LiteNetLib receives through transport adapter 2026-06-16 07:56:11 +01:00
Meta Vens
6f2c52170c Route LiteNetLib sends through transport adapter 2026-06-16 07:41:29 +01:00