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>
352 lines
14 KiB
C#
352 lines
14 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Threading.Tasks;
|
|
using GameCult.Logging;
|
|
|
|
namespace GameCult.Networking
|
|
{
|
|
/// <summary>
|
|
/// Result of a bounded non-blocking RUDP schema-server drain.
|
|
/// </summary>
|
|
public readonly struct RudpCultNetSchemaPollResult
|
|
{
|
|
public RudpCultNetSchemaPollResult(int transportItemsConsumed, int messagesDispatched)
|
|
{
|
|
TransportItemsConsumed = transportItemsConsumed;
|
|
MessagesDispatched = messagesDispatched;
|
|
}
|
|
|
|
/// <summary>Gets transport work consumed, including ACK and connection control packets.</summary>
|
|
public int TransportItemsConsumed { get; }
|
|
/// <summary>Gets application schema messages dispatched.</summary>
|
|
public int MessagesDispatched { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Options for hosting schema-v0 messages over the native CultNet RUDP listener.
|
|
/// </summary>
|
|
public sealed class RudpCultNetSchemaServerOptions
|
|
{
|
|
/// <summary>
|
|
/// Gets or sets the runtime id advertised by the RUDP listener.
|
|
/// </summary>
|
|
public string RuntimeId { get; set; } = "csharp-rudp-schema-server";
|
|
/// <summary>
|
|
/// Gets or sets the UDP socket used by the listener. When omitted, a loopback socket is created.
|
|
/// </summary>
|
|
public Socket? Socket { get; set; }
|
|
/// <summary>
|
|
/// Gets or sets the accepted RUDP connection id.
|
|
/// </summary>
|
|
public uint ConnectionId { get; set; } = 0x43554c54;
|
|
/// <summary>
|
|
/// Gets or sets the first local packet sequence for each accepted peer. Unset, each peer's
|
|
/// session draws its own at random.
|
|
/// </summary>
|
|
public uint? InitialSequence { get; set; }
|
|
/// <summary>
|
|
/// Gets or sets the resend delay in milliseconds.
|
|
/// </summary>
|
|
public long ResendDelayMs { get; set; } = 25;
|
|
/// <summary>
|
|
/// Gets or sets the advertised transport id.
|
|
/// </summary>
|
|
public string TransportId { get; set; } = "schema-rudp";
|
|
/// <summary>
|
|
/// Gets or sets the maximum fragment size for schema-channel payloads.
|
|
/// </summary>
|
|
public int? MaxFragmentBytes { get; set; } = 1024;
|
|
/// <summary>
|
|
/// Gets or sets the maximum pending reliable packet count.
|
|
/// </summary>
|
|
public int? MaxPendingReliablePackets { get; set; }
|
|
/// <summary>
|
|
/// Gets or sets the payload sent with accept packets.
|
|
/// </summary>
|
|
public byte[]? AcceptPayload { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Peer context for a schema-v0 RUDP server.
|
|
/// </summary>
|
|
public sealed class RudpCultNetSchemaServerPeer : ICultNetSchemaServerPeer, ICultNetSchemaServerPeerLocation
|
|
{
|
|
private readonly RudpCultNetSchemaServer _server;
|
|
|
|
internal RudpCultNetSchemaServerPeer(
|
|
RudpCultNetSchemaServer server,
|
|
CultNetRudpSocketServerPeer transportPeer)
|
|
{
|
|
_server = server;
|
|
TransportPeer = transportPeer;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the underlying RUDP transport peer.
|
|
/// </summary>
|
|
public CultNetRudpSocketServerPeer TransportPeer { get; }
|
|
/// <summary>
|
|
/// Gets the UDP endpoint for this peer.
|
|
/// </summary>
|
|
public EndPoint RemoteEndPoint => TransportPeer.RemoteEndPoint;
|
|
|
|
/// <summary>
|
|
/// Sends a schema-v0 response to this peer.
|
|
/// </summary>
|
|
public void SendCultNet<TMessage>(TMessage message)
|
|
where TMessage : ICultNetSchemaMessage
|
|
{
|
|
_server.SendCultNet(TransportPeer, message);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Multi-peer schema-v0 host over the native CultNet RUDP transport.
|
|
/// </summary>
|
|
public sealed class RudpCultNetSchemaServer : ICultNetSchemaServer, ICultNetSchemaServerPeerLifecycle, IDisposable
|
|
{
|
|
private readonly Socket _socket;
|
|
private readonly CultNetRudpSocketTransportServer _transport;
|
|
private readonly ConcurrentDictionary<Type, Delegate> _handlers = new();
|
|
private readonly ConcurrentDictionary<Delegate, Delegate> _schemaPeerAdapters = new();
|
|
private bool _disposed;
|
|
private ILogger _logger = new NullLogger();
|
|
|
|
/// <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.
|
|
/// </summary>
|
|
public ILogger Logger
|
|
{
|
|
get => _logger;
|
|
set => _logger = value ?? new NullLogger();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a schema-v0 RUDP server.
|
|
/// </summary>
|
|
public RudpCultNetSchemaServer(RudpCultNetSchemaServerOptions? options = null)
|
|
{
|
|
options ??= new RudpCultNetSchemaServerOptions();
|
|
var socket = options.Socket;
|
|
if (socket == null)
|
|
{
|
|
socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
|
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
|
}
|
|
|
|
_socket = socket;
|
|
_transport = new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions
|
|
{
|
|
RuntimeId = options.RuntimeId,
|
|
Socket = socket,
|
|
ConnectionId = options.ConnectionId,
|
|
InitialSequence = options.InitialSequence,
|
|
ResendDelayMs = options.ResendDelayMs,
|
|
TransportId = options.TransportId,
|
|
MaxFragmentBytes = options.MaxFragmentBytes,
|
|
MaxPendingReliablePackets = options.MaxPendingReliablePackets,
|
|
AcceptPayload = options.AcceptPayload
|
|
});
|
|
_transport.PeerDisconnected += OnTransportPeerDisconnected;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the underlying RUDP listener profile.
|
|
/// </summary>
|
|
public CultNetTransportProfile Profile => _transport.Profile;
|
|
|
|
/// <summary>
|
|
/// Gets the local UDP endpoint.
|
|
/// </summary>
|
|
public IPEndPoint LocalEndPoint => (IPEndPoint)_socket.LocalEndPoint!;
|
|
|
|
/// <summary>
|
|
/// Gets a snapshot of the current transfer counters.
|
|
/// </summary>
|
|
public CultNetTransportStats Stats => _transport.Stats;
|
|
|
|
/// <summary>
|
|
/// Gets the currently tracked RUDP peers.
|
|
/// </summary>
|
|
public IReadOnlyCollection<CultNetRudpSocketServerPeer> Peers => _transport.Peers;
|
|
|
|
/// <inheritdoc />
|
|
public event Action<ICultNetSchemaServerPeer>? PeerDisconnected;
|
|
|
|
/// <summary>
|
|
/// Registers a schema-v0 handler.
|
|
/// </summary>
|
|
public void OnCultNet<TMessage>(Func<TMessage, RudpCultNetSchemaServerPeer, Task> callback)
|
|
where TMessage : ICultNetSchemaMessage
|
|
{
|
|
if (callback == null) throw new ArgumentNullException(nameof(callback));
|
|
_handlers.AddOrUpdate(
|
|
typeof(TMessage),
|
|
_ => callback,
|
|
(type, current) =>
|
|
{
|
|
var combined = Delegate.Combine(current, callback) as Func<TMessage, RudpCultNetSchemaServerPeer, Task>;
|
|
return combined ?? throw new InvalidOperationException($"Failed to combine delegates for {type.Name}.");
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers a schema-v0 handler.
|
|
/// </summary>
|
|
public void OnCultNet<TMessage>(Action<TMessage, RudpCultNetSchemaServerPeer> callback)
|
|
where TMessage : ICultNetSchemaMessage
|
|
{
|
|
if (callback == null) throw new ArgumentNullException(nameof(callback));
|
|
OnCultNet<TMessage>((message, peer) =>
|
|
{
|
|
callback(message, peer);
|
|
return Task.CompletedTask;
|
|
});
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void OnCultNet<TMessage>(Func<TMessage, ICultNetSchemaServerPeer, Task> callback)
|
|
where TMessage : ICultNetSchemaMessage
|
|
{
|
|
if (callback == null) throw new ArgumentNullException(nameof(callback));
|
|
Func<TMessage, RudpCultNetSchemaServerPeer, Task> adapter = (message, peer) => callback(message, peer);
|
|
_schemaPeerAdapters[callback] = adapter;
|
|
OnCultNet(adapter);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void RemoveCultNetMessageListener<TMessage>(Delegate callback)
|
|
where TMessage : ICultNetSchemaMessage
|
|
{
|
|
if (_schemaPeerAdapters.TryRemove(callback, out var adapter))
|
|
{
|
|
callback = adapter;
|
|
}
|
|
|
|
if (!_handlers.TryGetValue(typeof(TMessage), out var current))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var next = Delegate.Remove(current, callback);
|
|
if (next == null)
|
|
{
|
|
_handlers.TryRemove(typeof(TMessage), out _);
|
|
}
|
|
else
|
|
{
|
|
_handlers[typeof(TMessage)] = next;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends a schema-v0 message to a transport peer.
|
|
/// </summary>
|
|
public void SendCultNet<TMessage>(CultNetRudpSocketServerPeer peer, TMessage message)
|
|
where TMessage : ICultNetSchemaMessage
|
|
{
|
|
_transport.SendSchemaMessage(peer, message);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Polls one packet/frame and dispatches at most one schema-v0 message.
|
|
/// </summary>
|
|
public async Task<bool> PollOnceAsync()
|
|
{
|
|
if (!_transport.TryReceiveOnce(out var delivered) || delivered == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return await DispatchAsync(delivered).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drains up to <paramref name="maxTransportItems"/> immediately available transport work items.
|
|
/// Control packets count as progress so callers only idle when the transport is actually empty.
|
|
/// </summary>
|
|
public async Task<RudpCultNetSchemaPollResult> PollAvailableAsync(int maxTransportItems)
|
|
{
|
|
if (maxTransportItems <= 0) throw new ArgumentOutOfRangeException(nameof(maxTransportItems));
|
|
var consumed = 0;
|
|
var dispatched = 0;
|
|
while (consumed < maxTransportItems && _transport.TryReceiveOnce(out var delivered))
|
|
{
|
|
consumed++;
|
|
if (delivered != null && await DispatchAsync(delivered).ConfigureAwait(false))
|
|
dispatched++;
|
|
}
|
|
return new RudpCultNetSchemaPollResult(consumed, dispatched);
|
|
}
|
|
|
|
private async Task<bool> DispatchAsync(CultNetRudpSocketServerFrame delivered)
|
|
{
|
|
if (!string.Equals(delivered.Frame.ChannelId, "schema", StringComparison.Ordinal))
|
|
return false;
|
|
|
|
var message = CultNetSchemaMessageSerialization.Deserialize(delivered.Frame.Payload);
|
|
if (!_handlers.TryGetValue(message.GetType(), out var handler) || handler == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// R-AG: a handler is expected to catch its own typed refusals (HandleSnapshotRequestV1Async
|
|
// catches the three selection exceptions and answers cursor_invalid/selection_invalid/
|
|
// reference_outside_target on the wire, R-AM) - this catch is the backstop for whatever a
|
|
// handler does not, so an untyped exception from a malformed/hostile message can never
|
|
// reach the poll loop and stall the server for every other peer. It still answers nothing
|
|
// on the wire (an exception this generic carries no typed refusal to report) and drops the
|
|
// one malformed dispatch, exactly as an unregistered handler already does just above -
|
|
// R-AM: but it always logs. A backstop that swallows silently is a compensator, and an
|
|
// untested, unlogged one hid the SM-6 ArgumentNullException regression for a whole batch.
|
|
try
|
|
{
|
|
var result = handler.DynamicInvoke(message, new RudpCultNetSchemaServerPeer(this, delivered.Peer));
|
|
if (result is Task task)
|
|
{
|
|
await task.ConfigureAwait(false);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// DynamicInvoke wraps a synchronously-thrown handler fault in TargetInvocationException,
|
|
// whose own .Message is the generic "Exception has been thrown by the target of an
|
|
// invocation." - log the real fault, not that wrapper.
|
|
var reported = ex is System.Reflection.TargetInvocationException { InnerException: { } inner } ? inner : ex;
|
|
Logger.LogError($"CultNet RUDP schema dispatch failed for {message.GetType().Name}: {reported.Message}");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends any reliable packets whose resend timers are due.
|
|
/// </summary>
|
|
public void PollResends()
|
|
{
|
|
_transport.PollResends();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_disposed = true;
|
|
_transport.PeerDisconnected -= OnTransportPeerDisconnected;
|
|
_transport.Dispose();
|
|
}
|
|
|
|
private void OnTransportPeerDisconnected(CultNetRudpSocketServerPeer peer)
|
|
{
|
|
PeerDisconnected?.Invoke(new RudpCultNetSchemaServerPeer(this, peer));
|
|
}
|
|
}
|
|
}
|