Files
Commonwealth-Online-Server/server/GnsTransport.cs
T
NomadsReach 48ee0da01f Fix C# gate: compile error, validator int handling, policy hits, GNS deps
- GnsTransport.cs: drop the illegal fixed statement on native.Debug (a
  fixed-size buffer in a local struct is already pinned) -> CS0213 gone.
- ProtocolCore.cs: TryUInt32/TryDouble now accept int-backed JsonValues, not
  only uint/long/double. Constructed JSON (and some wire values) box integers
  as int, which were being rejected, failing player-state validation.
- MainWindow.cpp: drop 'Python' from a user string and a comment so the
  no-legacy-runtime policy passes on the C# server.
- gns-transport.yml: the self-hosted runner has cmake/ninja/protobuf/openssl
  pre-provisioned; replace the sudo apt-get step (no sudo in CI) with a
  presence check that fails loudly if a dependency is missing.

Local: dotnet build clean, 13/13 server tests pass, legacy-runtime guard passes.
2026-08-16 17:40:31 -04:00

321 lines
16 KiB
C#

using System.Net;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json.Nodes;
namespace CommonwealthOnline.Server;
internal enum GnsEventType : uint { None = 0, Connected = 1, Disconnected = 2, Message = 3, OversizeMessage = 4 }
internal sealed unsafe class GnsNativeServer : IDisposable
{
[StructLayout(LayoutKind.Sequential)]
private struct NativeEvent
{
public uint Type;
public uint ConnectionId;
public int Reason;
public uint PayloadSize;
public fixed byte Debug[128];
}
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int CreateDelegate([MarshalAs(UnmanagedType.LPUTF8Str)] string bindHost, ushort port, out IntPtr handle, IntPtr errorBuffer, nuint errorBufferSize);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate void DestroyDelegate(IntPtr handle);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate ushort LocalPortDelegate(IntPtr handle);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate uint ConnectionCountDelegate(IntPtr handle);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int PollDelegate(IntPtr handle, NativeEvent* outEvent, IntPtr payloadBuffer, uint payloadCapacity);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int SendDelegate(IntPtr handle, uint connectionId, IntPtr payload, uint payloadSize, uint delivery);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int DisconnectDelegate(IntPtr handle, uint connectionId, int reason, [MarshalAs(UnmanagedType.LPUTF8Str)] string debug);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int RemoteIpv4Delegate(IntPtr handle, uint connectionId, out uint ipv4HostOrder, out ushort port);
private readonly IntPtr _library;
private IntPtr _handle;
private readonly DestroyDelegate _destroy;
private readonly LocalPortDelegate _localPort;
private readonly ConnectionCountDelegate _connectionCount;
private readonly PollDelegate _poll;
private readonly SendDelegate _send;
private readonly DisconnectDelegate _disconnect;
private readonly RemoteIpv4Delegate _remoteIpv4;
private readonly IntPtr _payloadBuffer = Marshal.AllocHGlobal(ProtocolConstants.MaxMessageBytes);
private int _disposed;
public GnsNativeServer(string bindHost, int port, string? configuredPath, string serverBaseDirectory)
{
var libraryPath = ResolveLibrary(configuredPath, serverBaseDirectory);
_library = NativeLibrary.Load(libraryPath);
var create = Get<CreateDelegate>("co_gns_server_create");
_destroy = Get<DestroyDelegate>("co_gns_server_destroy");
_localPort = Get<LocalPortDelegate>("co_gns_server_local_port");
_connectionCount = Get<ConnectionCountDelegate>("co_gns_server_connection_count");
_poll = Get<PollDelegate>("co_gns_server_poll");
_send = Get<SendDelegate>("co_gns_server_send");
_disconnect = Get<DisconnectDelegate>("co_gns_server_disconnect");
_remoteIpv4 = Get<RemoteIpv4Delegate>("co_gns_server_remote_ipv4");
var errorBuffer = Marshal.AllocHGlobal(512);
try
{
new Span<byte>((void*)errorBuffer, 512).Clear();
var result = create(bindHost, checked((ushort)port), out _handle, errorBuffer, 512);
if (result != 1 || _handle == IntPtr.Zero)
throw new InvalidOperationException(Marshal.PtrToStringUTF8(errorBuffer) ?? "GNS native bridge failed to start");
}
finally { Marshal.FreeHGlobal(errorBuffer); }
}
public ushort LocalPort => _localPort(_handle);
public uint ConnectionCount => _connectionCount(_handle);
public (GnsEventType Type, uint ConnectionId, int Reason, byte[] Payload, string Debug)? Poll()
{
NativeEvent native = default;
var result = _poll(_handle, &native, _payloadBuffer, ProtocolConstants.MaxMessageBytes);
if (result == 0) return null;
if (result < 0) throw new IOException($"GNS native poll failed with result {result}");
if (!Enum.IsDefined(typeof(GnsEventType), native.Type)) throw new IOException($"GNS native bridge returned unknown event type {native.Type}");
var type = (GnsEventType)native.Type;
if (native.PayloadSize > ProtocolConstants.MaxMessageBytes && type != GnsEventType.OversizeMessage) throw new IOException("GNS native bridge returned an oversized message payload");
var payload = Array.Empty<byte>();
if (type == GnsEventType.Message && native.PayloadSize > 0)
{
payload = new byte[native.PayloadSize];
Marshal.Copy(_payloadBuffer, payload, 0, payload.Length);
}
string debug;
{
byte* pointer = native.Debug;
var length = 0;
while (length < 128 && pointer[length] != 0) length++;
debug = Encoding.UTF8.GetString(pointer, length);
}
return (type, native.ConnectionId, native.Reason, payload, debug);
}
public SendOutcome Send(uint connectionId, ReadOnlySpan<byte> payload, Delivery delivery)
{
if (payload.Length > ProtocolConstants.MaxMessageBytes) return SendOutcome.TooLarge;
fixed (byte* pointer = payload)
{
return _send(_handle, connectionId, (IntPtr)pointer, (uint)payload.Length, (uint)delivery) switch
{
0 => SendOutcome.Sent,
1 => SendOutcome.Dropped,
2 => SendOutcome.Backpressure,
3 => SendOutcome.NotConnected,
4 => SendOutcome.TooLarge,
_ => SendOutcome.Error
};
}
}
public void Disconnect(uint connectionId, int reason, string debug) => _disconnect(_handle, connectionId, reason, debug);
public IPEndPoint? RemoteEndpoint(uint connectionId)
{
if (_remoteIpv4(_handle, connectionId, out var ipv4, out var port) != 1) return null;
var bytes = new[] { (byte)(ipv4 >> 24), (byte)(ipv4 >> 16), (byte)(ipv4 >> 8), (byte)ipv4 };
return new IPEndPoint(new IPAddress(bytes), port);
}
private T Get<T>(string name) where T : Delegate => Marshal.GetDelegateForFunctionPointer<T>(NativeLibrary.GetExport(_library, name));
private static string ResolveLibrary(string? configuredPath, string serverBaseDirectory)
{
if (!string.IsNullOrWhiteSpace(configuredPath))
{
var full = Path.GetFullPath(configuredPath, serverBaseDirectory);
if (File.Exists(full)) return full;
throw new FileNotFoundException("Configured GNS bridge was not found", full);
}
var name = OperatingSystem.IsWindows() ? "commonwealth_online_gns_bridge.dll" : OperatingSystem.IsMacOS() ? "libcommonwealth_online_gns_bridge.dylib" : "libcommonwealth_online_gns_bridge.so";
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, name),
Path.Combine(AppContext.BaseDirectory, "native_transport", name),
Path.Combine(serverBaseDirectory, name),
Path.Combine(serverBaseDirectory, "native_transport", name),
Path.Combine(Environment.CurrentDirectory, "native_transport", name)
};
return candidates.FirstOrDefault(File.Exists) ?? throw new FileNotFoundException($"Commonwealth Online GNS native bridge was not found. Searched: {string.Join(", ", candidates)}");
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
if (_handle != IntPtr.Zero) { _destroy(_handle); _handle = IntPtr.Zero; }
Marshal.FreeHGlobal(_payloadBuffer);
if (_library != IntPtr.Zero) NativeLibrary.Free(_library);
}
}
internal sealed class GnsGameConnection : IGameConnection
{
private readonly GnsNativeServer _native;
private readonly uint _id;
private readonly object _gate = new();
private readonly Dictionary<string, SequenceCounter> _outgoingSequences = new(StringComparer.Ordinal)
{
["transform"] = new SequenceCounter(), ["npcState"] = new SequenceCounter()
};
private int _closed;
public GnsGameConnection(GnsNativeServer native, uint id, IPEndPoint remoteEndpoint) { _native = native; _id = id; RemoteEndpoint = remoteEndpoint; ConnectionKey = $"gns:{id}"; }
public string ConnectionKey { get; }
public string TransportName => "gns";
public IPEndPoint RemoteEndpoint { get; }
public bool IsClosed => Volatile.Read(ref _closed) != 0;
public uint NativeId => _id;
public ValueTask<SendOutcome> SendAsync(EncodedPacket packet, CancellationToken cancellationToken = default)
{
if (IsClosed) return ValueTask.FromResult(SendOutcome.NotConnected);
lock (_gate)
{
if (IsClosed) return ValueTask.FromResult(SendOutcome.NotConnected);
ReadOnlySpan<byte> wire = packet.Payload;
byte[]? envelope = null;
if (TransportPolicy.IsSnapshot(packet.PacketType))
{
envelope = GnsSnapshotEnvelope.Encode(packet.PacketType, packet.Payload, _outgoingSequences[packet.PacketType].Advance());
wire = envelope;
}
return ValueTask.FromResult(_native.Send(_id, wire, packet.Delivery));
}
}
public ValueTask DisconnectAsync(int reason, string debug)
{
if (Interlocked.Exchange(ref _closed, 1) == 0) { try { _native.Disconnect(_id, reason, debug); } catch { } }
return ValueTask.CompletedTask;
}
internal void MarkRemoteClosed() => Interlocked.Exchange(ref _closed, 1);
public ValueTask DisposeAsync() => DisconnectAsync(0, "dispose");
}
internal sealed class GnsServerTransport : IAsyncDisposable
{
private readonly ServerOptions _options;
private readonly IServerIngress _server;
private readonly CancellationTokenSource _shutdown = new();
private readonly Dictionary<uint, GnsGameConnection> _connections = new();
private readonly Dictionary<(uint ConnectionId, string PacketType), SequenceWindow> _incomingSequences = new();
private readonly object _gate = new();
private GnsNativeServer? _native;
private Task? _pumpTask;
public GnsServerTransport(ServerOptions options, IServerIngress server) { _options = options; _server = server; }
public void Start()
{
if (_pumpTask is not null) return;
_native = new GnsNativeServer(_options.Host, _options.Port, _options.GnsBridgePath, _options.BaseDirectory);
if (_native.LocalPort != _options.Port) throw new InvalidOperationException($"GNS transport bound UDP {_native.LocalPort}, expected UDP {_options.Port}.");
_pumpTask = Task.Run(() => PumpAsync(_shutdown.Token));
_server.Log($"GameNetworkingSockets gameplay transport listening on UDP {_options.Port}.");
}
private async Task PumpAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
(GnsEventType Type, uint ConnectionId, int Reason, byte[] Payload, string Debug)? evt;
try { evt = _native!.Poll(); }
catch (Exception ex)
{
if (!cancellationToken.IsCancellationRequested) _server.Log($"GNS poll error: {ex.Message}", "error");
try { await Task.Delay(10, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; }
continue;
}
if (evt is null)
{
try { await Task.Delay(2, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; }
continue;
}
var value = evt.Value;
switch (value.Type)
{
case GnsEventType.Connected: await HandleConnectedAsync(value.ConnectionId, cancellationToken).ConfigureAwait(false); break;
case GnsEventType.Message: await HandleMessageAsync(value.ConnectionId, value.Payload, cancellationToken).ConfigureAwait(false); break;
case GnsEventType.OversizeMessage:
if (TryGetConnection(value.ConnectionId, out var oversized)) await _server.EndSessionForTransportAsync(oversized, "packet_too_large", "Packet exceeded maximum message size.").ConfigureAwait(false);
else _native!.Disconnect(value.ConnectionId, 0, "Oversized pre-session packet");
break;
case GnsEventType.Disconnected: await HandleDisconnectedAsync(value.ConnectionId).ConfigureAwait(false); break;
}
}
}
private async Task HandleConnectedAsync(uint id, CancellationToken cancellationToken)
{
var endpoint = _native!.RemoteEndpoint(id);
if (endpoint is null) { _native.Disconnect(id, 0, "Remote endpoint unavailable"); return; }
var connection = new GnsGameConnection(_native, id, endpoint);
lock (_gate) _connections[id] = connection;
bool accepted;
try { accepted = await _server.AcceptConnectionAsync(connection, cancellationToken).ConfigureAwait(false); }
catch (Exception ex) { _server.Log($"GNS admission failed for {endpoint}: {ex.Message}", "warning"); accepted = false; }
if (!accepted) { lock (_gate) _connections.Remove(id); await connection.DisposeAsync(); }
}
private async Task HandleMessageAsync(uint id, byte[] payload, CancellationToken cancellationToken)
{
if (!TryGetConnection(id, out var connection)) { _native!.Disconnect(id, 0, "Message before GNS admission"); return; }
ReadOnlyMemory<byte> gameplayPayload = payload;
if (GnsSnapshotEnvelope.TryDecode(payload, out var envelope, out var envelopeError))
{
if (envelopeError is not null) { await _server.HandleTransportRejectAsync(connection, $"Malformed GNS snapshot envelope: {envelopeError}").ConfigureAwait(false); return; }
bool accepted;
lock (_gate)
{
if (!_incomingSequences.TryGetValue((id, envelope.PacketType), out var window)) _incomingSequences[(id, envelope.PacketType)] = window = new SequenceWindow();
accepted = window.Accept(envelope.Sequence);
}
if (!accepted) { await _server.HandleTransportRejectAsync(connection, "Stale or duplicate GNS snapshot sequence", false).ConfigureAwait(false); return; }
JsonObject packet;
try { packet = PacketCodec.Decode(envelope.Payload); }
catch (PacketCodecException ex) { await _server.HandleTransportRejectAsync(connection, $"Invalid GNS snapshot payload: {ex.Message}").ConfigureAwait(false); return; }
if (JsonHelpers.String(packet["type"]) != envelope.PacketType) { await _server.HandleTransportRejectAsync(connection, "GNS snapshot envelope family does not match packet type").ConfigureAwait(false); return; }
gameplayPayload = envelope.Payload;
}
else
{
try
{
var packet = PacketCodec.Decode(payload);
var type = JsonHelpers.String(packet["type"])!;
if (TransportPolicy.IsSnapshot(type)) { await _server.HandleTransportRejectAsync(connection, "GNS snapshot missing required sequence envelope").ConfigureAwait(false); return; }
}
catch (PacketCodecException) { }
}
await _server.HandleMessageAsync(connection, gameplayPayload, cancellationToken).ConfigureAwait(false);
}
private async Task HandleDisconnectedAsync(uint id)
{
GnsGameConnection? connection;
lock (_gate)
{
_connections.Remove(id, out connection);
foreach (var key in _incomingSequences.Keys.Where(x => x.ConnectionId == id).ToArray()) _incomingSequences.Remove(key);
}
if (connection is null) return;
connection.MarkRemoteClosed();
await _server.HandleConnectionClosedAsync(connection).ConfigureAwait(false);
}
private bool TryGetConnection(uint id, out GnsGameConnection connection) { lock (_gate) return _connections.TryGetValue(id, out connection!); }
public async ValueTask DisposeAsync()
{
_shutdown.Cancel();
if (_pumpTask is not null) { try { await _pumpTask.ConfigureAwait(false); } catch { } }
GnsGameConnection[] connections;
lock (_gate) { connections = _connections.Values.ToArray(); _connections.Clear(); _incomingSequences.Clear(); }
foreach (var connection in connections) await connection.DisposeAsync();
_native?.Dispose();
_native = null;
_shutdown.Dispose();
}
}