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>
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>
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>
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>
HandleSnapshotRequestV1Async only caught the three typed selection
exceptions, unlike its siblings HandleShardLogRequestAsync/HandlePutAsync,
which catch Exception and answer a CultNetErrorMessage. An untyped fault
(e.g. a null Selection) escaped into the RUDP dispatch backstop, which
swallowed it with no log - the same backstop that hid SM-6's
ArgumentNullException regression for a whole batch.
- HandleSnapshotRequestV1Async now catches Exception like its siblings and
answers a CultNetErrorMessage.
- RudpCultNetSchemaServer gets a Logger (ILogger/NullLogger, same pattern as
Server/Client), and DispatchAsync's backstop always logs the fault it
swallows, unwrapping DynamicInvoke's TargetInvocationException wrapper so
the logged message is the handler's real exception, not the generic
invocation-wrapper text.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
R-AG. ReadLengthPrefixedFields tested pos + length > bytes.Length in int
math; a length prefix near int.MaxValue overflowed the unchecked add,
slipped past the guard, and Encoding.UTF8.GetString threw
ArgumentOutOfRangeException instead of this method returning null the
way every other malformed shape already does. Promotes to long before
the add, which cannot itself overflow (both operands stay far under
long's range), so the guard sees the true sum.
CultNetRudpSchemaServer.DispatchAsync gains a catch around the handler
invocation: HandleSnapshotRequestV1Async already answers
cursor_invalid/selection_invalid/reference_outside_target for its three
typed exceptions, but nothing stopped an untyped one from an
unanticipated bug reaching DynamicInvoke's caller and stalling the poll
loop for every other peer. Answers nothing on the wire (an exception
this generic carries no typed refusal to report) and drops the one
malformed dispatch, the same as an unregistered handler already does.
Hostile-cursor tests (CultNetSelectionFixBatchTests.cs) feed
CultNetSelectionCursor.Parse a length prefix near int.MaxValue (C#'s own
proven overflow), near usize::MAX (Rust's proven overflow, fed here too
per the ruling), wider than any integer type, and negative-looking -
each asserted to throw the typed CultNetSelectionCursorException.
R-AK. Deletes ReferenceMembers's `!member.IsReference` guard.
CultCache.ReferencesOf (CultCache.cs:262-278) already searches only its
own reference members for a role match and returns empty for anything
else; index aliases are unique per descriptor, so a data member's own
role string can never coincide with a reference member's. Verified
before deleting: traced ReferencesOf's role lookup directly rather than
trusting the ruling's premise. A second copy of the same exclusion here
was duplicate authority, not a second decision.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>