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>
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>
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>
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>
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 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>
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>
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>
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>
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>